From 30fb72d68ba3902c7ead45b88db2181887e054ae Mon Sep 17 00:00:00 2001 From: superstarflayce Date: Sat, 30 May 2026 10:49:07 +0100 Subject: [PATCH] feat: add payment link detail page --- .../src/app/(dashboard)/links/loading.tsx | 18 + .../src/app/(dashboard)/links/page.tsx | 520 ++++++++---------- 2 files changed, 241 insertions(+), 297 deletions(-) create mode 100644 apps/dashboard/src/app/(dashboard)/links/loading.tsx diff --git a/apps/dashboard/src/app/(dashboard)/links/loading.tsx b/apps/dashboard/src/app/(dashboard)/links/loading.tsx new file mode 100644 index 0000000..bf59252 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/links/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/page.tsx b/apps/dashboard/src/app/(dashboard)/links/page.tsx index ee86758..99e75dd 100644 --- a/apps/dashboard/src/app/(dashboard)/links/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/links/page.tsx @@ -1,327 +1,253 @@ "use client"; -import { useState, useEffect } from "react"; -import { - Button, - Input, - Select, - EmptyState, - Skeleton, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@useroutr/ui"; -import { Plus, MagnifyingGlass, Link as LinkIcon } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Button, Skeleton } from "@useroutr/ui"; import { useToast } from "@useroutr/ui"; -import { PageHeader } from "@/components/brand/PageHeader"; -import { EmptyState as BrandEmptyState } from "@/components/brand/EmptyState"; -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 { - usePaymentLinks, - useCreatePaymentLink, - useDeactivatePaymentLink, -} from "@/hooks/usePaymentLinks"; -import { useDashboardSocket } from "@/hooks/useDashboardSocket"; -import type { PaymentLink, CreatePaymentLinkInput } from "@useroutr/types"; - -// Simple debounce hook -function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); +import Link from "next/link"; +import { LinkStatusBadge } from "@/components/links/LinkStatusBadge"; +import { formatCurrency } from "@/lib/utils"; - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value); - }, delay); +import type { PaymentLink } from "@useroutr/types"; - return () => { - clearTimeout(handler); - }; - }, [value, delay]); +export default function LinkDetailPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { toast } = useToast(); - return debouncedValue; -} + 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); + + 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) { + 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 ?? []); + } catch (err: any) { + toast(`Failed to load link`, "error"); + } finally { + setLoading(false); + } + } -function LinkCardSkeleton() { - return ( -
-
- - -
- - -
- - -
-
- - - -
-
- ); -} + useEffect(() => { + fetchData(); + }, [id]); -export default function PaymentLinksPage() { - const { toast } = useToast(); - const [search, setSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); - const [createdLink, setCreatedLink] = useState(null); - const [isQRModalOpen, setIsQRModalOpen] = useState(false); - const [selectedLinkForQR, setSelectedLinkForQR] = useState(null); - const [linkToDeactivate, setLinkToDeactivate] = useState(null); + async function copyUrl() { + if (!link) return; + await navigator.clipboard.writeText(link.url); + toast("Copied link", "success"); + } - // Debounce search input (300ms) - const debouncedSearch = useDebounce(search, 300); + function downloadQr() { + if (!link?.qrCodeUrl) return; - const { data, isLoading, refetch } = usePaymentLinks({ - status: statusFilter !== "all" ? statusFilter : undefined, - search: debouncedSearch || undefined, - }); + const a = document.createElement("a"); + a.href = link.qrCodeUrl; + a.download = `${link.id}.png`; + a.click(); + } - const createMutation = useCreatePaymentLink(); - const deactivateMutation = useDeactivatePaymentLink(); + async function deactivate() { + if (!link) return; - // WebSocket for real-time payment notifications - const { subscribe } = useDashboardSocket(); + const ok = window.confirm( + "Are you sure you want to deactivate this link?" + ); - useEffect(() => { - // Subscribe to payment link payment events - const unsubscribe = subscribe("payment-link.payment", (...args: unknown[]) => { - const payload = args[0] as { linkId: string; amount: number }; - toast(`Payment received: $${payload.amount}`, "success"); - refetch(); - }); + if (!ok) return; - return () => unsubscribe(); - }, [subscribe, toast, refetch]); - - const handleCreate = (data: CreatePaymentLinkInput) => { - createMutation.mutate(data, { - onSuccess: (newLink) => { - setCreatedLink(newLink); - setIsCreateModalOpen(false); - toast("Payment link created successfully!", "success"); - }, - onError: (error) => { - toast(`Failed to create link: ${error.message}`, "error"); - }, - }); - }; - - const handleDeactivate = (link: PaymentLink) => { - setLinkToDeactivate(link); - }; - - const confirmDeactivate = () => { - if (!linkToDeactivate) return; - - deactivateMutation.mutate(linkToDeactivate.id, { - onSuccess: () => { - toast("Link deactivated successfully", "success"); - setLinkToDeactivate(null); - }, - onError: (error) => { - toast(`Failed to deactivate: ${error.message}`, "error"); - }, + await fetch(`/v1/payment-links/${link.id}`, { + method: "DELETE", }); - }; - const handleQRCode = (link: PaymentLink) => { - setSelectedLinkForQR(link); - setIsQRModalOpen(true); - }; + toast("Link deactivated", "success"); - const links = data?.data ?? []; - const hasLinks = links.length > 0; + fetchData(); + } - // Determine if filters are active - const hasActiveFilters = debouncedSearch.length > 0 || statusFilter !== "all"; - - // Show empty state for "no links" vs "no results" - const showNoResults = hasActiveFilters && !hasLinks; - const showNoLinks = !hasActiveFilters && !hasLinks; + if (loading) { + return ( +
+ + + +
+ ); + } + + if (!link) { + return ( +
+

Link not found

+ + + +
+ ); + } return ( -
- - Shareable URLs that{" "} - - actually pay. - - - } - description="Fixed amount or open, single-use or reusable. Generate a link and share it anywhere — checkout opens on click." - actions={ - - } - /> - - {/* Filters */} -
-
- - setSearch(e.target.value)} - className="pl-10" - /> +
+ {/* BACK */} + + ← Back to links + + + {/* HERO */} +
+
+ {link.id} +
-