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) => ( ))}
Recipient Description Base Fee Total Status Created Actions
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)} />