From 599ccbe9f4b0c537d05ae9e42c77be86d6e3041c Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sun, 9 Aug 2026 20:47:27 +0100 Subject: [PATCH] fix(dashboard): route five screens through the api client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These five called `fetch` directly, and each was broken in its own way: - payouts, recipients and CreatePayoutDialog hit `/api/v1/...` — a path the dashboard does not serve and the API does not expose. Always 404. - links hit `/v1/payment-links/...` relative to the dashboard origin, so it never reached the API either. - AnalyticsDashboard built a base from `NEXT_PUBLIC_API_BASE_URL`, a variable that is not defined anywhere in this repo. It resolved to `""`, making every analytics call relative to the dashboard too. None of them sent an Authorization header, so even with a correct URL they would have 401'd. Going through `api` fixes the origin, the `/v1` prefix and the bearer token at once, and picks up refresh-on-401 for free. Two behaviour fixes that came with it: - The link detail page read `.data` from the payments response. That endpoint is paginated as `{ items, meta }`, and the client already unwraps the outer `{ data }`, so the value was always undefined — the payments table rendered empty and silently. It reads `.items` now. The other endpoints here really are `{ ..., data }` shaped and were checked against the running API individually. - CreatePayoutDialog was fire-and-forget: `.then()` closed the dialog whether or not the payout was created, with no catch. A failure now surfaces a toast and leaves the dialog open. The link page also moves to `Promise.allSettled`, so a failing stats or payments call no longer takes down the whole page when the link itself loaded. Recovered from an abandoned worktree that never opened a PR. Dashboard: typecheck clean, 0 lint errors, 38/38 tests. Co-Authored-By: Claude Opus 5 --- .../src/app/(dashboard)/links/page.tsx | 51 ++++++++++++------- .../src/app/(dashboard)/payouts/page.tsx | 8 ++- .../analytics/AnalyticsDashboard.tsx | 24 ++------- .../components/payouts/CreatePayoutDialog.tsx | 32 +++++++----- .../components/recipients/RecipientSelect.tsx | 8 ++- 5 files changed, 63 insertions(+), 60 deletions(-) diff --git a/apps/dashboard/src/app/(dashboard)/links/page.tsx b/apps/dashboard/src/app/(dashboard)/links/page.tsx index 03f3845..a3c097d 100644 --- a/apps/dashboard/src/app/(dashboard)/links/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/links/page.tsx @@ -6,6 +6,7 @@ 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"; @@ -40,24 +41,34 @@ export default function LinkDetailPage() { try { setLoading(true); - const [linkRes, statsRes, paymentsRes] = await Promise.all([ - fetch(`/v1/payment-links/${id}`), - fetch(`/v1/payment-links/${id}/stats`), - fetch(`/v1/payments?linkId=${id}`), - ]); - - if (!linkRes.ok) { + // 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; } - const linkData = await linkRes.json(); - const statsData = await statsRes.json(); - const paymentsData = await paymentsRes.json(); - - setLink(linkData); - setStats(statsData); - setPayments(paymentsData?.data ?? paymentsData ?? []); + 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 { @@ -93,9 +104,15 @@ export default function LinkDetailPage() { if (!ok) return; - await fetch(`/v1/payment-links/${link.id}`, { - method: "DELETE", - }); + 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"); diff --git a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx index ccb41dc..e93b9a7 100644 --- a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx @@ -3,18 +3,16 @@ import { useQuery } from '@tanstack/react-query'; import { Button } from '@useroutr/ui'; import { Plus } from 'lucide-react'; +import type { PayoutListResponse } from '@useroutr/types'; import { RecipientsTable } from '@/components/recipients/RecipientsTable'; import { CreatePayoutDialog } from '@/components/payouts/CreatePayoutDialog'; import { PayoutsTable } from '@/components/payouts/PayoutsTable'; +import { api } from '@/lib/api'; export default function PayoutsPage() { const payoutsQuery = useQuery({ queryKey: ['payouts'], - queryFn: async () => { - const res = await fetch('/api/v1/payouts'); - if (!res.ok) throw new Error('Failed to fetch payouts'); - return res.json(); - }, + queryFn: () => api.get('/payouts'), }); return ( diff --git a/apps/dashboard/src/components/analytics/AnalyticsDashboard.tsx b/apps/dashboard/src/components/analytics/AnalyticsDashboard.tsx index 40726fd..57852a1 100644 --- a/apps/dashboard/src/components/analytics/AnalyticsDashboard.tsx +++ b/apps/dashboard/src/components/analytics/AnalyticsDashboard.tsx @@ -9,6 +9,7 @@ import { DownloadSimple, Info, } from "@phosphor-icons/react"; +import { api } from "@/lib/api"; import { cn, formatCurrency } from "@/lib/utils"; type Period = "7d" | "30d" | "90d" | "1y"; @@ -118,10 +119,6 @@ const PAYMENT_COLORS: Record = { const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; -function getApiBaseUrl() { - return (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); -} - function formatMoney(value: number, currency = "USD") { return formatCurrency(value, currency); } @@ -414,17 +411,6 @@ function normalizeLiveSnapshot( }; } -async function fetchJson(path: string): Promise { - const base = getApiBaseUrl(); - const response = await fetch(`${base}${path}`, { cache: "no-store" }); - - if (!response.ok) { - throw new Error(`${path} failed with ${response.status}`); - } - - return response.json() as Promise; -} - async function loadSnapshot(period: Period, range: DateRange) { const hasCustomRange = Boolean(range.start && range.end); const { bucket } = getBucketDates(period, range); @@ -441,10 +427,10 @@ async function loadSnapshot(period: Period, range: DateRange) { const baseQuery = query.toString(); const [revenueResult, paymentsResult, failuresResult, currenciesResult] = await Promise.allSettled([ - fetchJson(`/v1/analytics/revenue?${baseQuery}`), - fetchJson(`/v1/analytics/payments?${baseQuery}`), - fetchJson(`/v1/analytics/failures?${baseQuery}`), - fetchJson(`/v1/analytics/currencies?${baseQuery}`), + api.get(`/analytics/revenue?${baseQuery}`), + api.get(`/analytics/payments?${baseQuery}`), + api.get(`/analytics/failures?${baseQuery}`), + api.get(`/analytics/currencies?${baseQuery}`), ] as const); if ( diff --git a/apps/dashboard/src/components/payouts/CreatePayoutDialog.tsx b/apps/dashboard/src/components/payouts/CreatePayoutDialog.tsx index 5277d2c..e95b726 100644 --- a/apps/dashboard/src/components/payouts/CreatePayoutDialog.tsx +++ b/apps/dashboard/src/components/payouts/CreatePayoutDialog.tsx @@ -16,29 +16,33 @@ import { ShadSelectItem as SelectItem, ShadSelectTrigger as SelectTrigger, ShadSelectValue as SelectValue, + useToast, } from '@useroutr/ui'; import { RecipientSelect } from '@/components/recipients/RecipientSelect'; +import { api } from '@/lib/api'; export function CreatePayoutDialog() { const [open, setOpen] = useState(false); const [recipientId, setRecipientId] = useState(''); const [amount, setAmount] = useState(''); const [currency, setCurrency] = useState('USD'); + const { toast } = useToast(); - const handleSubmit = () => { - fetch('/api/v1/payouts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - recipientId, - amount, - currency, - }), - }).then(() => { - setOpen(false); - // Refetch payouts - window.dispatchEvent(new CustomEvent('payouts:refetch')); - }); + const handleSubmit = async () => { + try { + await api.post('/payouts', { recipientId, amount, currency }); + } catch (error) { + // Keep the dialog open so the entered values survive a failed submit. + toast( + error instanceof Error ? error.message : 'Failed to create payout', + 'error', + ); + return; + } + + setOpen(false); + // Refetch payouts + window.dispatchEvent(new CustomEvent('payouts:refetch')); }; return ( diff --git a/apps/dashboard/src/components/recipients/RecipientSelect.tsx b/apps/dashboard/src/components/recipients/RecipientSelect.tsx index ece9dfb..56b6769 100644 --- a/apps/dashboard/src/components/recipients/RecipientSelect.tsx +++ b/apps/dashboard/src/components/recipients/RecipientSelect.tsx @@ -8,6 +8,7 @@ import { } from '@useroutr/ui'; import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'; import { Recipient } from '@useroutr/types'; +import { api } from '@/lib/api'; import { cn } from '@/lib/utils'; import { useState } from 'react'; @@ -24,11 +25,8 @@ export function RecipientSelect({ const recipientsQuery = useQuery({ queryKey: ['recipients'], - queryFn: async () => { - const res = await fetch('/api/v1/recipients?limit=20'); - if (!res.ok) throw new Error('Failed to fetch recipients'); - return (await res.json()) as { data: Recipient[] }; - }, + queryFn: () => + api.get<{ data: Recipient[] }>('/recipients', { params: { limit: 20 } }), }); const selectedRecipient = recipientsQuery.data?.data.find(r => r.id === value);