From 29b1ef6e5f73dc23122dad9407017a4f9f21af7c Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:51:54 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/app-shell.tsx | 2 + src/routeTree.gen.ts | 51 ++++ src/routes/pay.$id.tsx | 128 ++++++++++ src/routes/payments.index.tsx | 436 ++++++++++++++++++++++++++++++++++ 4 files changed, 617 insertions(+) create mode 100644 src/routes/pay.$id.tsx create mode 100644 src/routes/payments.index.tsx diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 9044bf2..0c72c6f 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -22,6 +22,7 @@ import { UserPlus, User as UserIcon, Inbox as InboxIcon, + CreditCard, } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; @@ -50,6 +51,7 @@ const NAV: NavItem[] = [ { to: "/inbox", label: "Inbox", icon: InboxIcon }, { to: "/invoices", label: "Invoices", icon: Receipt }, { to: "/messages", label: "Messages", icon: MessageSquare }, + { to: "/payments", label: "Payments", icon: CreditCard }, { to: "/documents", label: "Pleadings", icon: FolderOpen }, { to: "/settings", label: "Settings", icon: SettingsIcon }, { to: "/status", label: "Status Updates", icon: Activity }, diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a8b76f6..7539be3 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as TasksIndexRouteImport } from './routes/tasks.index' import { Route as StatusIndexRouteImport } from './routes/status.index' import { Route as SettingsIndexRouteImport } from './routes/settings.index' +import { Route as PaymentsIndexRouteImport } from './routes/payments.index' import { Route as MessagesIndexRouteImport } from './routes/messages.index' import { Route as InvoicesIndexRouteImport } from './routes/invoices.index' import { Route as InboxIndexRouteImport } from './routes/inbox.index' @@ -37,6 +38,7 @@ import { Route as SettingsFormTemplatesRouteImport } from './routes/settings.for import { Route as SettingsFeesRouteImport } from './routes/settings.fees' import { Route as SettingsCustomFieldsRouteImport } from './routes/settings.custom-fields' import { Route as SettingsClientFieldsRouteImport } from './routes/settings.client-fields' +import { Route as PayIdRouteImport } from './routes/pay.$id' import { Route as InvoicesInvoiceIdRouteImport } from './routes/invoices.$invoiceId' import { Route as HooksPollImapRouteImport } from './routes/hooks/poll-imap' import { Route as ContactsContactIdRouteImport } from './routes/contacts.$contactId' @@ -86,6 +88,11 @@ const SettingsIndexRoute = SettingsIndexRouteImport.update({ path: '/', getParentRoute: () => SettingsRoute, } as any) +const PaymentsIndexRoute = PaymentsIndexRouteImport.update({ + id: '/payments/', + path: '/payments/', + getParentRoute: () => rootRouteImport, +} as any) const MessagesIndexRoute = MessagesIndexRouteImport.update({ id: '/messages/', path: '/messages/', @@ -191,6 +198,11 @@ const SettingsClientFieldsRoute = SettingsClientFieldsRouteImport.update({ path: '/client-fields', getParentRoute: () => SettingsRoute, } as any) +const PayIdRoute = PayIdRouteImport.update({ + id: '/pay/$id', + path: '/pay/$id', + getParentRoute: () => rootRouteImport, +} as any) const InvoicesInvoiceIdRoute = InvoicesInvoiceIdRouteImport.update({ id: '/invoices/$invoiceId', path: '/invoices/$invoiceId', @@ -272,6 +284,7 @@ export interface FileRoutesByFullPath { '/contacts/$contactId': typeof ContactsContactIdRoute '/hooks/poll-imap': typeof HooksPollImapRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute + '/pay/$id': typeof PayIdRoute '/settings/client-fields': typeof SettingsClientFieldsRoute '/settings/custom-fields': typeof SettingsCustomFieldsRoute '/settings/fees': typeof SettingsFeesRoute @@ -293,6 +306,7 @@ export interface FileRoutesByFullPath { '/inbox/': typeof InboxIndexRoute '/invoices/': typeof InvoicesIndexRoute '/messages/': typeof MessagesIndexRoute + '/payments/': typeof PaymentsIndexRoute '/settings/': typeof SettingsIndexRoute '/status/': typeof StatusIndexRoute '/tasks/': typeof TasksIndexRoute @@ -314,6 +328,7 @@ export interface FileRoutesByTo { '/contacts/$contactId': typeof ContactsContactIdRoute '/hooks/poll-imap': typeof HooksPollImapRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute + '/pay/$id': typeof PayIdRoute '/settings/client-fields': typeof SettingsClientFieldsRoute '/settings/custom-fields': typeof SettingsCustomFieldsRoute '/settings/fees': typeof SettingsFeesRoute @@ -335,6 +350,7 @@ export interface FileRoutesByTo { '/inbox': typeof InboxIndexRoute '/invoices': typeof InvoicesIndexRoute '/messages': typeof MessagesIndexRoute + '/payments': typeof PaymentsIndexRoute '/settings': typeof SettingsIndexRoute '/status': typeof StatusIndexRoute '/tasks': typeof TasksIndexRoute @@ -358,6 +374,7 @@ export interface FileRoutesById { '/contacts/$contactId': typeof ContactsContactIdRoute '/hooks/poll-imap': typeof HooksPollImapRoute '/invoices/$invoiceId': typeof InvoicesInvoiceIdRoute + '/pay/$id': typeof PayIdRoute '/settings/client-fields': typeof SettingsClientFieldsRoute '/settings/custom-fields': typeof SettingsCustomFieldsRoute '/settings/fees': typeof SettingsFeesRoute @@ -379,6 +396,7 @@ export interface FileRoutesById { '/inbox/': typeof InboxIndexRoute '/invoices/': typeof InvoicesIndexRoute '/messages/': typeof MessagesIndexRoute + '/payments/': typeof PaymentsIndexRoute '/settings/': typeof SettingsIndexRoute '/status/': typeof StatusIndexRoute '/tasks/': typeof TasksIndexRoute @@ -403,6 +421,7 @@ export interface FileRouteTypes { | '/contacts/$contactId' | '/hooks/poll-imap' | '/invoices/$invoiceId' + | '/pay/$id' | '/settings/client-fields' | '/settings/custom-fields' | '/settings/fees' @@ -424,6 +443,7 @@ export interface FileRouteTypes { | '/inbox/' | '/invoices/' | '/messages/' + | '/payments/' | '/settings/' | '/status/' | '/tasks/' @@ -445,6 +465,7 @@ export interface FileRouteTypes { | '/contacts/$contactId' | '/hooks/poll-imap' | '/invoices/$invoiceId' + | '/pay/$id' | '/settings/client-fields' | '/settings/custom-fields' | '/settings/fees' @@ -466,6 +487,7 @@ export interface FileRouteTypes { | '/inbox' | '/invoices' | '/messages' + | '/payments' | '/settings' | '/status' | '/tasks' @@ -488,6 +510,7 @@ export interface FileRouteTypes { | '/contacts/$contactId' | '/hooks/poll-imap' | '/invoices/$invoiceId' + | '/pay/$id' | '/settings/client-fields' | '/settings/custom-fields' | '/settings/fees' @@ -509,6 +532,7 @@ export interface FileRouteTypes { | '/inbox/' | '/invoices/' | '/messages/' + | '/payments/' | '/settings/' | '/status/' | '/tasks/' @@ -532,6 +556,7 @@ export interface RootRouteChildren { ContactsContactIdRoute: typeof ContactsContactIdRoute HooksPollImapRoute: typeof HooksPollImapRoute InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute + PayIdRoute: typeof PayIdRoute CalendarIndexRoute: typeof CalendarIndexRoute CasesIndexRoute: typeof CasesIndexRoute ClientsIndexRoute: typeof ClientsIndexRoute @@ -543,6 +568,7 @@ export interface RootRouteChildren { InboxIndexRoute: typeof InboxIndexRoute InvoicesIndexRoute: typeof InvoicesIndexRoute MessagesIndexRoute: typeof MessagesIndexRoute + PaymentsIndexRoute: typeof PaymentsIndexRoute StatusIndexRoute: typeof StatusIndexRoute TasksIndexRoute: typeof TasksIndexRoute DocumentsPleadingNewRoute: typeof DocumentsPleadingNewRoute @@ -602,6 +628,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsIndexRouteImport parentRoute: typeof SettingsRoute } + '/payments/': { + id: '/payments/' + path: '/payments' + fullPath: '/payments/' + preLoaderRoute: typeof PaymentsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/messages/': { id: '/messages/' path: '/messages' @@ -749,6 +782,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsClientFieldsRouteImport parentRoute: typeof SettingsRoute } + '/pay/$id': { + id: '/pay/$id' + path: '/pay/$id' + fullPath: '/pay/$id' + preLoaderRoute: typeof PayIdRouteImport + parentRoute: typeof rootRouteImport + } '/invoices/$invoiceId': { id: '/invoices/$invoiceId' path: '/invoices/$invoiceId' @@ -889,6 +929,7 @@ const rootRouteChildren: RootRouteChildren = { ContactsContactIdRoute: ContactsContactIdRoute, HooksPollImapRoute: HooksPollImapRoute, InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute, + PayIdRoute: PayIdRoute, CalendarIndexRoute: CalendarIndexRoute, CasesIndexRoute: CasesIndexRoute, ClientsIndexRoute: ClientsIndexRoute, @@ -900,6 +941,7 @@ const rootRouteChildren: RootRouteChildren = { InboxIndexRoute: InboxIndexRoute, InvoicesIndexRoute: InvoicesIndexRoute, MessagesIndexRoute: MessagesIndexRoute, + PaymentsIndexRoute: PaymentsIndexRoute, StatusIndexRoute: StatusIndexRoute, TasksIndexRoute: TasksIndexRoute, DocumentsPleadingNewRoute: DocumentsPleadingNewRoute, @@ -910,3 +952,12 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/src/routes/pay.$id.tsx b/src/routes/pay.$id.tsx new file mode 100644 index 0000000..03a3068 --- /dev/null +++ b/src/routes/pay.$id.tsx @@ -0,0 +1,128 @@ +import { createFileRoute, useSearch } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { CheckCircle2, CreditCard, XCircle } from "lucide-react"; +import { formatCurrency } from "@/lib/format"; +import { getPublicPaymentRequest } from "@/lib/payments.functions"; +import { Scale } from "lucide-react"; + +export const Route = createFileRoute("/pay/$id")({ + validateSearch: (s: Record) => ({ + status: typeof s.status === "string" ? (s.status as string) : undefined, + }), + loader: async ({ params }) => { + return getPublicPaymentRequest({ data: { id: params.id } }); + }, + component: PayPage, + errorComponent: () => ( + +

Payment request not found.

+
+ ), +}); + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ + Stage Law Firm, PLLC +
+ + {children} + +
+
+ ); +} + +function PayPage() { + const data = Route.useLoaderData(); + const search = useSearch({ from: "/pay/$id" }); + const [row, setRow] = useState(data); + + useEffect(() => { + setRow(data); + }, [data]); + + if (search.status === "success" || row.status === "paid") { + return ( + +
+ +

Payment received

+

+ Thank you, {row.recipient_name}. Your payment of{" "} + {formatCurrency(row.total_amount_cents / 100)} has been received. +

+
+
+ ); + } + + if (row.status === "canceled") { + return ( + +
+ +

Payment canceled

+

+ This payment request is no longer active. Please contact our office. +

+
+
+ ); + } + + return ( + +
+ +

Payment request

+

For {row.recipient_name}

+
+ + {row.description && ( +

{row.description}

+ )} + +
+
+ Amount + {formatCurrency(row.base_amount_cents / 100)} +
+
+ Processing fee + {formatCurrency(row.fee_amount_cents / 100)} +
+
+ Total + {formatCurrency(row.total_amount_cents / 100)} +
+
+ + {row.stripe_checkout_url ? ( + + ) : ( + Checkout link unavailable + )} + + {search.status === "cancel" && ( +

+ Payment was not completed. You can try again above. +

+ )} +
+ ); +} diff --git a/src/routes/payments.index.tsx b/src/routes/payments.index.tsx new file mode 100644 index 0000000..5d217f9 --- /dev/null +++ b/src/routes/payments.index.tsx @@ -0,0 +1,436 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; +import { ProtectedLayout } from "@/components/protected-layout"; +import { PageContainer, PageHeader } from "@/components/app-shell"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { supabase } from "@/integrations/supabase/client"; +import { CreditCard, Plus, Copy, Mail, RefreshCw, X } from "lucide-react"; +import { formatCurrency, formatDateTime, statusBadgeClass } from "@/lib/format"; +import { toast } from "sonner"; +import { + createPaymentRequest, + refreshPaymentStatus, + cancelPaymentRequest, + markPaymentEmailSent, +} from "@/lib/payments.functions"; +import { useServerFn } from "@tanstack/react-start"; + +export const Route = createFileRoute("/payments/")({ + component: () => ( + + + + ), +}); + +interface Homeowner { + id: string; + first_name: string; + last_name: string; + email: string | null; + client_id: string; +} + +function PaymentsIndex() { + const [rows, setRows] = useState([]); + const [q, setQ] = useState(""); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(true); + + const create = useServerFn(createPaymentRequest); + const refresh = useServerFn(refreshPaymentStatus); + const cancel = useServerFn(cancelPaymentRequest); + const markSent = useServerFn(markPaymentEmailSent); + + const load = async () => { + const { data } = await supabase + .from("payment_requests") + .select("*, homeowner:homeowners(id, first_name, last_name)") + .order("created_at", { ascending: false }); + setRows(data ?? []); + setLoading(false); + }; + + useEffect(() => { + load(); + }, []); + + const filtered = useMemo(() => { + if (!q) return rows; + const s = q.toLowerCase(); + return rows.filter( + (r) => + r.recipient_name?.toLowerCase().includes(s) || + r.recipient_email?.toLowerCase().includes(s) || + r.description?.toLowerCase().includes(s), + ); + }, [rows, q]); + + const copyLink = async (r: any) => { + const url = `${window.location.origin}/pay/${r.id}`; + await navigator.clipboard.writeText(url); + toast.success("Payment link copied"); + }; + + const sendEmail = async (r: any) => { + if (!r.recipient_email) { + toast.error("No recipient email on file"); + return; + } + const url = `${window.location.origin}/pay/${r.id}`; + const html = ` +

Hello ${r.recipient_name},

+

You have a payment request for ${formatCurrency(r.total_amount_cents / 100)}${ + r.description ? ` (${r.description})` : "" + }.

+

Pay now

+

Or open this link: ${url}

+ `; + const { error } = await supabase.functions.invoke("send-smtp-email", { + body: { + to: r.recipient_email, + subject: `Payment request: ${formatCurrency(r.total_amount_cents / 100)}`, + html, + context: "payment_request", + }, + }); + if (error) { + toast.error(error.message); + return; + } + await markSent({ data: { id: r.id } }); + toast.success("Email sent"); + load(); + }; + + const onRefresh = async (id: string) => { + await refresh({ data: { id } }); + load(); + }; + + const onCancel = async (id: string) => { + if (!confirm("Cancel this payment request?")) return; + await cancel({ data: { id } }); + load(); + }; + + return ( + + setOpen(true)}> + New payment request + + } + /> + + + + setQ(e.target.value)} + /> + + + + + + + + + + + + + + + + + + + + {loading && ( + + + + )} + {!loading && filtered.length === 0 && ( + + + + )} + {filtered.map((r) => ( + + + + + + + + + + + ))} + +
RecipientDescriptionBaseFeeTotalStatusCreatedActions
+ Loading… +
+ + No payment requests yet. +
+
{r.recipient_name}
+ {r.recipient_email && ( +
{r.recipient_email}
+ )} +
{r.description ?? "—"} + {formatCurrency(r.base_amount_cents / 100)} + + {formatCurrency(r.fee_amount_cents / 100)} + + {formatCurrency(r.total_amount_cents / 100)} + + + {r.status} + + + {formatDateTime(r.created_at)} + +
+ + + {r.status === "pending" && ( + <> + + + + )} +
+
+
+
+ + { + setOpen(false); + load(); + }} + createFn={create} + /> +
+ ); +} + +function NewPaymentDialog({ + open, + onOpenChange, + onCreated, + createFn, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + onCreated: () => void; + createFn: ReturnType>; +}) { + const [homeowners, setHomeowners] = useState([]); + const [homeownerId, setHomeownerId] = useState(""); + const [recipientName, setRecipientName] = useState(""); + const [recipientEmail, setRecipientEmail] = useState(""); + const [description, setDescription] = useState(""); + const [amount, setAmount] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) return; + (async () => { + const { data } = await supabase + .from("homeowners") + .select("id, first_name, last_name, email, client_id") + .is("archived_at", null) + .order("last_name"); + setHomeowners((data ?? []) as Homeowner[]); + })(); + }, [open]); + + useEffect(() => { + const ho = homeowners.find((h) => h.id === homeownerId); + if (ho) { + setRecipientName(`${ho.first_name} ${ho.last_name}`.trim()); + setRecipientEmail(ho.email ?? ""); + } + }, [homeownerId, homeowners]); + + const baseCents = Math.round((parseFloat(amount) || 0) * 100); + const preview = baseCents > 0 ? (baseCents + 30) / (1 - 0.029) : 0; + const totalPreview = Math.ceil(preview) / 100; + const feePreview = totalPreview - baseCents / 100; + + const submit = async () => { + if (!recipientName.trim() || baseCents < 100) { + toast.error("Recipient and amount (≥ $1.00) required"); + return; + } + setSubmitting(true); + try { + const ho = homeowners.find((h) => h.id === homeownerId); + await createFn({ + data: { + recipient_name: recipientName, + recipient_email: recipientEmail || undefined, + description: description || undefined, + base_amount_cents: baseCents, + homeowner_id: homeownerId || undefined, + client_id: ho?.client_id || undefined, + }, + }); + toast.success("Payment request created"); + setHomeownerId(""); + setRecipientName(""); + setRecipientEmail(""); + setDescription(""); + setAmount(""); + onCreated(); + } catch (e: any) { + toast.error(e?.message ?? "Failed to create"); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + New payment request + + A Stripe checkout link will be generated. The homeowner pays the base amount plus + processing fees (2.9% + $0.30). + + + +
+
+ + +
+ +
+
+ + setRecipientName(e.target.value)} /> +
+
+ + setRecipientEmail(e.target.value)} + /> +
+
+ +
+ +