From f9589ef17ab495aaefc3b333317ff43075dea44f Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sun, 9 Aug 2026 21:18:56 +0100 Subject: [PATCH] feat(dashboard): payment links list and create form (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merchant could sign up, get a settlement wallet provisioned, and then had no way to create a payment link except curl. This closes that. The `/links` route was rendering the *detail* page: it called `useParams<{ id }>()` on a route with no `[id]` segment, so `id` was always undefined and the page requested `/payment-links/undefined`. There was no list view and no create form anywhere. The detail page moves to `/links/[id]`, where its param actually exists, and `/links` becomes the list it always claimed to be. Nearly every piece already existed — LinkCard, CreateLinkModal, LinkCreatedModal, QRCodeModal, usePaymentLinks — but nothing rendered them. This wires them up and fixes what did not survive contact with the API: - `PaymentLinksResponse` declared `total`, `page` and `limit` as siblings of `data`, a shape the API has never sent. It answers `{ data, meta }`, and the client deliberately skips unwrapping when `meta` is present, so both halves arrive intact. The one consumer only ever read `.data`, so pagination had nothing to render from and nothing caught the mismatch. - LinkCard had no route to the detail page. The ID is now the link, rather than the whole card: the footer holds copy, QR and deactivate buttons, and nesting those inside an anchor is invalid markup that would navigate on every click. Search filters the loaded page client-side, because the API has no search parameter for links yet — filtering server-side would silently return nothing. Marked in the code for a move once the endpoint supports it. Verified in the browser against the running API: list renders 5 links, filter chips drive `?status=` (All 5 / Deactivated 1), create posts and the count goes 4 to 5 with the URL auto-copied, deactivate flips the badge in place, and `/links/[id]` loads the link and its stats with real ids. No console errors. Dashboard: typecheck clean, 0 lint errors, 38/38 tests. Co-Authored-By: Claude Opus 5 --- .../app/(dashboard)/links/[id]/loading.tsx | 18 + .../src/app/(dashboard)/links/[id]/page.tsx | 286 +++++++++++ .../src/app/(dashboard)/links/page.tsx | 476 ++++++++---------- .../src/components/links/LinkCard.tsx | 14 +- apps/dashboard/src/hooks/usePaymentLinks.ts | 13 +- packages/types/src/payment.types.ts | 26 +- 6 files changed, 567 insertions(+), 266 deletions(-) create mode 100644 apps/dashboard/src/app/(dashboard)/links/[id]/loading.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/links/[id]/page.tsx diff --git a/apps/dashboard/src/app/(dashboard)/links/[id]/loading.tsx b/apps/dashboard/src/app/(dashboard)/links/[id]/loading.tsx new file mode 100644 index 0000000..bf59252 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/links/[id]/loading.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from "@useroutr/ui"; + +export default function Loading() { + return ( +
+ + + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ + +
+ ); +} \ No newline at end of file diff --git a/apps/dashboard/src/app/(dashboard)/links/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/links/[id]/page.tsx new file mode 100644 index 0000000..a3c097d --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/links/[id]/page.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Button, Skeleton } from "@useroutr/ui"; +import { useToast } from "@useroutr/ui"; +import Link from "next/link"; +import { LinkStatusBadge } from "@/components/links/LinkStatusBadge"; +import { api } from "@/lib/api"; +import { formatCurrency } from "@/lib/utils"; + +import type { PaymentLink } from "@useroutr/types"; + +interface LinkStats { + totalViews?: number; + totalPayments?: number; + conversionRate?: number; + totalRevenue?: number; + currency?: string; +} + +interface LinkPayment { + id: string; + payer?: string; + amount: number; + currency: string; + status: string; +} + +export default function LinkDetailPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { toast } = useToast(); + + const [link, setLink] = useState(null); + const [stats, setStats] = useState(null); + const [payments, setPayments] = useState([]); + const [loading, setLoading] = useState(true); + + async function fetchData() { + try { + setLoading(true); + + // allSettled, not all: a missing link is "not found" (no toast), while a + // failing stats/payments call should still leave the link itself usable. + const [linkResult, statsResult, paymentsResult] = await Promise.allSettled( + [ + api.get(`/payment-links/${id}`), + api.get(`/payment-links/${id}/stats`), + // `/payments` is paginated: the envelope is { items, meta }, and the + // api client has already unwrapped the outer { data }. Reading + // `.data` here would always be undefined and the table would render + // empty with no error to explain why. + api.get<{ items?: LinkPayment[] }>(`/payments`, { + params: { linkId: id }, + }), + ] + ); + + if (linkResult.status !== "fulfilled") { + setLink(null); + return; + } + + setLink(linkResult.value); + setStats(statsResult.status === "fulfilled" ? statsResult.value : null); + setPayments( + paymentsResult.status === "fulfilled" + ? (paymentsResult.value?.items ?? []) + : [] + ); + } catch { + toast(`Failed to load link`, "error"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + fetchData(); + }, [id]); + + async function copyUrl() { + if (!link) return; + await navigator.clipboard.writeText(link.url); + toast("Copied link", "success"); + } + + function downloadQr() { + if (!link?.qrCodeUrl) return; + + const a = document.createElement("a"); + a.href = link.qrCodeUrl; + a.download = `${link.id}.png`; + a.click(); + } + + async function deactivate() { + if (!link) return; + + const ok = window.confirm( + "Are you sure you want to deactivate this link?" + ); + + if (!ok) return; + + try { + await api.delete(`/payment-links/${link.id}`); + } catch (error) { + toast( + error instanceof Error ? error.message : "Failed to deactivate link", + "error" + ); + return; + } + + toast("Link deactivated", "success"); + + fetchData(); + } + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (!link) { + return ( +
+

Link not found

+ + + +
+ ); + } + + return ( +
+ {/* BACK */} + + ← Back to links + + + {/* HERO */} +
+
+ {link.id} + +
+ +
+ {link.amount + ? formatCurrency(link.amount, link.currency) + : "Open amount"} +
+ +

+ {link.description} +

+ + {/* ACTIONS */} +
+ + + +
+
+ + {/* STATS */} +
+ + + + +
+ + {/* PAYMENTS */} +
+

Recent Payments

+ + {payments.length === 0 ? ( +

+ No payments yet +

+ ) : ( +
+ {payments.slice(0, 10).map((p) => ( + + {p.payer ?? "—"} + + {formatCurrency(p.amount, p.currency)} + + {p.status} + + ))} +
+ )} + + + View all payments → + +
+ + {/* SIDEBAR */} +
+

+ Created:{" "} + {new Date(link.createdAt).toLocaleString()} +

+ +

+ Updated:{" "} + {new Date(link.updatedAt).toLocaleString()} +

+ +

+ Type: {link.type} +

+ +

+ Expiry:{" "} + {link.expiresAt + ? new Date(link.expiresAt).toLocaleDateString() + : "Never"} +

+ + {link.status === "active" && ( + + )} +
+
+ ); +} + +/* ---------- helper ---------- */ +function Stat({ + label, + value, +}: { + label: string; + value: string | number; +}) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} \ No newline at end of file diff --git a/apps/dashboard/src/app/(dashboard)/links/page.tsx b/apps/dashboard/src/app/(dashboard)/links/page.tsx index a3c097d..c4b50f6 100644 --- a/apps/dashboard/src/app/(dashboard)/links/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/links/page.tsx @@ -1,286 +1,246 @@ "use client"; -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { Button, Skeleton } from "@useroutr/ui"; -import { useToast } from "@useroutr/ui"; -import Link from "next/link"; -import { LinkStatusBadge } from "@/components/links/LinkStatusBadge"; -import { api } from "@/lib/api"; -import { formatCurrency } from "@/lib/utils"; - -import type { PaymentLink } from "@useroutr/types"; - -interface LinkStats { - totalViews?: number; - totalPayments?: number; - conversionRate?: number; - totalRevenue?: number; - currency?: string; -} - -interface LinkPayment { - id: string; - payer?: string; - amount: number; - currency: string; - status: string; -} - -export default function LinkDetailPage() { - const { id } = useParams<{ id: string }>(); - const router = useRouter(); +import { useMemo, useState } from "react"; +import { Button, Pagination, Skeleton, useToast } from "@useroutr/ui"; +import { Plus } from "@phosphor-icons/react"; +import { + usePaymentLinks, + useCreatePaymentLink, + useDeactivatePaymentLink, +} from "@/hooks/usePaymentLinks"; +import { LinkCard } from "@/components/links/LinkCard"; +import { CreateLinkModal } from "@/components/links/CreateLinkModal"; +import { LinkCreatedModal } from "@/components/links/LinkCreatedModal"; +import { QRCodeModal } from "@/components/links/QRCodeModal"; +import { SearchInput } from "@/components/payments/SearchInput"; +import { PageHeader } from "@/components/brand/PageHeader"; +import { EmptyState } from "@/components/brand/EmptyState"; +import type { CreatePaymentLinkInput, PaymentLink } from "@useroutr/types"; + +/** + * The merchant's payment links. + * + * This route previously rendered the *detail* page: it called + * `useParams<{ id }>()` on `/links`, a route with no `[id]` segment, so `id` + * was always undefined and the page requested `/payment-links/undefined`. The + * detail view now lives at `/links/[id]`, where its param exists. + */ + +const FILTERS = [ + { value: "all", label: "All" }, + { value: "active", label: "Active" }, + { value: "expired", label: "Expired" }, + { value: "deactivated", label: "Deactivated" }, +] as const; + +type FilterValue = (typeof FILTERS)[number]["value"]; + +export default function LinksPage() { const { toast } = useToast(); - const [link, setLink] = useState(null); - const [stats, setStats] = useState(null); - const [payments, setPayments] = useState([]); - const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(12); + const [status, setStatus] = useState("all"); + const [search, setSearch] = useState(""); + + const [createOpen, setCreateOpen] = useState(false); + const [qrLink, setQrLink] = useState(null); + const [createdLink, setCreatedLink] = useState(null); + + const { data, isLoading, isError, error } = usePaymentLinks({ + page, + limit, + status, + }); + const createLink = useCreatePaymentLink(); + const deactivateLink = useDeactivatePaymentLink(); + + const links = useMemo(() => data?.data ?? [], [data]); + const meta = data?.meta; + + // Search filters the current page only. The API has no search parameter for + // links yet, so filtering server-side would silently return nothing; this at + // least does what it appears to do. Worth moving server-side once the + // endpoint supports it. + const visibleLinks = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return links; + return links.filter( + (link) => + link.description?.toLowerCase().includes(q) || + link.id.toLowerCase().includes(q), + ); + }, [links, search]); - async function fetchData() { - try { - setLoading(true); + const hasFilters = status !== "all" || search.trim() !== ""; + const showEmptyState = !isLoading && links.length === 0 && !hasFilters; - // allSettled, not all: a missing link is "not found" (no toast), while a - // failing stats/payments call should still leave the link itself usable. - const [linkResult, statsResult, paymentsResult] = await Promise.allSettled( - [ - api.get(`/payment-links/${id}`), - api.get(`/payment-links/${id}/stats`), - // `/payments` is paginated: the envelope is { items, meta }, and the - // api client has already unwrapped the outer { data }. Reading - // `.data` here would always be undefined and the table would render - // empty with no error to explain why. - api.get<{ items?: LinkPayment[] }>(`/payments`, { - params: { linkId: id }, - }), - ] - ); + function changeStatus(next: FilterValue) { + setStatus(next); + // Page 3 of "all" is rarely page 3 of "expired"; staying put would show an + // empty page for a filter that has results. + setPage(1); + } - if (linkResult.status !== "fulfilled") { - setLink(null); - return; + async function handleCreate(input: CreatePaymentLinkInput) { + try { + const link = await createLink.mutateAsync(input); + setCreateOpen(false); + + // Best-effort: clipboard access can be denied, and the URL is shown in + // the modal regardless, so a failure here is not worth an error toast. + try { + await navigator.clipboard.writeText(link.url); + toast("Link created and copied to clipboard", "success"); + } catch { + toast("Link created", "success"); } - setLink(linkResult.value); - setStats(statsResult.status === "fulfilled" ? statsResult.value : null); - setPayments( - paymentsResult.status === "fulfilled" - ? (paymentsResult.value?.items ?? []) - : [] - ); - } catch { - toast(`Failed to load link`, "error"); - } finally { - setLoading(false); + setCreatedLink(link); + } catch (err) { + // Surfaces the API envelope's error.message — "insufficient liquidity" + // reads very differently from a generic failure. + toast(err instanceof Error ? err.message : "Failed to create link", "error"); } } - useEffect(() => { - fetchData(); - }, [id]); - - async function copyUrl() { - if (!link) return; - await navigator.clipboard.writeText(link.url); - toast("Copied link", "success"); - } - - function downloadQr() { - if (!link?.qrCodeUrl) return; - - const a = document.createElement("a"); - a.href = link.qrCodeUrl; - a.download = `${link.id}.png`; - a.click(); - } - - async function deactivate() { - if (!link) return; - - const ok = window.confirm( - "Are you sure you want to deactivate this link?" - ); - - if (!ok) return; - + async function handleDeactivate(link: PaymentLink) { try { - await api.delete(`/payment-links/${link.id}`); - } catch (error) { + await deactivateLink.mutateAsync(link.id); + toast("Link deactivated", "success"); + } catch (err) { toast( - error instanceof Error ? error.message : "Failed to deactivate link", - "error" + err instanceof Error ? err.message : "Failed to deactivate link", + "error", ); - return; } - - toast("Link deactivated", "success"); - - fetchData(); - } - - if (loading) { - return ( -
- - - -
- ); - } - - if (!link) { - return ( -
-

Link not found

- - - -
- ); } return ( -
- {/* BACK */} - - ← Back to links - - - {/* HERO */} -
-
- {link.id} - +
+ + Get paid with{" "} + + a URL. + + + } + description={ + meta && meta.total > 0 + ? `${meta.total.toLocaleString()} link${meta.total === 1 ? "" : "s"} created. Share one anywhere a customer can click.` + : "Create a link, share it anywhere, and take payment without writing any code." + } + actions={ + + } + /> + + {!showEmptyState && ( +
+ +
+ {FILTERS.map((filter) => ( + + ))} +
+ )} -
- {link.amount - ? formatCurrency(link.amount, link.currency) - : "Open amount"} + {isError ? ( +
+ {error instanceof Error + ? error.message + : "Could not load your payment links."}
- -

- {link.description} -

- - {/* ACTIONS */} -
- - - + ) : isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))}
-
- - {/* STATS */} -
- - - setCreateOpen(true), + }} /> - + No links match these filters. +
+ ) : ( +
+ {visibleLinks.map((link) => ( + + ))} +
+ )} + + {meta && meta.totalPages > 1 && ( + { + setLimit(size); + setPage(1); + }} /> -
- - {/* PAYMENTS */} -
-

Recent Payments

- - {payments.length === 0 ? ( -

- No payments yet -

- ) : ( -
- {payments.slice(0, 10).map((p) => ( - - {p.payer ?? "—"} - - {formatCurrency(p.amount, p.currency)} - - {p.status} - - ))} -
- )} - - - View all payments → - -
- - {/* SIDEBAR */} -
-

- Created:{" "} - {new Date(link.createdAt).toLocaleString()} -

- -

- Updated:{" "} - {new Date(link.updatedAt).toLocaleString()} -

- -

- Type: {link.type} -

- -

- Expiry:{" "} - {link.expiresAt - ? new Date(link.expiresAt).toLocaleDateString() - : "Never"} -

- - {link.status === "active" && ( - - )} -
+ )} + + + + {createdLink && ( + !open && setCreatedLink(null)} + linkUrl={createdLink.url} + linkName={createdLink.description ?? "Payment link"} + /> + )} + + {qrLink && ( + !open && setQrLink(null)} + url={qrLink.url} + linkName={qrLink.description ?? "Payment link"} + /> + )}
); } - -/* ---------- helper ---------- */ -function Stat({ - label, - value, -}: { - label: string; - value: string | number; -}) { - return ( -
-
- {label} -
-
{value}
-
- ); -} \ No newline at end of file diff --git a/apps/dashboard/src/components/links/LinkCard.tsx b/apps/dashboard/src/components/links/LinkCard.tsx index e4ac654..7d63d14 100644 --- a/apps/dashboard/src/components/links/LinkCard.tsx +++ b/apps/dashboard/src/components/links/LinkCard.tsx @@ -10,6 +10,7 @@ import { } from "@useroutr/ui"; import { QrCode, Trash } from "@phosphor-icons/react"; import { motion } from "framer-motion"; +import Link from "next/link"; import { formatCurrency } from "@/lib/utils"; import { LinkStatusBadge } from "./LinkStatusBadge"; import { CopyButton } from "./CopyButton"; @@ -52,12 +53,19 @@ export function LinkCard({ link, onQRCode, onDeactivate }: LinkCardProps) { > {/* Header — mono ID + status */}
- {link.id} - +
diff --git a/apps/dashboard/src/hooks/usePaymentLinks.ts b/apps/dashboard/src/hooks/usePaymentLinks.ts index 00848f9..54dd11b 100644 --- a/apps/dashboard/src/hooks/usePaymentLinks.ts +++ b/apps/dashboard/src/hooks/usePaymentLinks.ts @@ -10,14 +10,23 @@ import type { interface PaymentLinksParams { page?: number; limit?: number; + /** "all" is normalised away server-side, so it is safe to pass through. */ status?: string; - search?: string; } export function usePaymentLinks(params: PaymentLinksParams = {}) { return useQuery({ queryKey: ["payment-links", params], - queryFn: () => api.get("/payment-links", { params: params as Record }), + // `/payment-links` answers with `{ data, meta }`. The api client only + // unwraps the envelope when there is no `meta`, so both halves arrive here + // intact — which is what pagination needs. + queryFn: () => + api.get("/payment-links", { + params: params as Record, + }), + // Keeps the previous page on screen while the next one loads, instead of + // collapsing the list to a skeleton on every page change. + placeholderData: (previous) => previous, }); } diff --git a/packages/types/src/payment.types.ts b/packages/types/src/payment.types.ts index 1ee3275..1621559 100644 --- a/packages/types/src/payment.types.ts +++ b/packages/types/src/payment.types.ts @@ -54,11 +54,31 @@ export interface CreatePaymentLinkInput { expires_at?: string; } -export interface PaymentLinksResponse { - data: PaymentLink[]; - total: number; +/** + * Pagination envelope shared by the paginated list endpoints. + * + * The API's TransformInterceptor treats a `{ data, meta }` payload as already + * wrapped and passes it through untouched, so this is exactly what a client + * receives — `meta` is a sibling of `data`, not flattened alongside it. + */ +export interface PaginationMeta { page: number; limit: number; + total: number; + totalPages: number; +} + +/** + * Response of `GET /v1/payment-links`. + * + * This previously declared `total`, `page` and `limit` as siblings of `data`, + * which is not a shape the API has ever sent. Nothing caught it because the one + * consumer read `.data` off it and never touched the counts — so pagination had + * nothing to render from. + */ +export interface PaymentLinksResponse { + data: PaymentLink[]; + meta: PaginationMeta; } export default {};