RV/Boat Lots: request renter insurance (vendor-style flow)

Phase 4. Mirror the vendor insurance request flow for RV/boat renters:
- Migration: insurance fields on rv_boat_lot_rentals + rv_renter_insurance_requests
  table + token-scoped lookup/submit SECURITY DEFINER RPCs (granted to anon).
- Edge fn send-rv-renter-insurance-request emails the renter a secure link
  (reuses the vendor-insurance-request email template).
- Public page /rv-insurance/:token to submit carrier/policy/expiration + COI upload.
- "Request Insurance" button on each active rental + insurance status display.

DB RPCs verified end-to-end (rolled-back txn): submit matches token, updates the
rental, marks the request submitted. Edge function deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:16:31 -04:00
parent d8465f2297
commit 308af20aa1
5 changed files with 421 additions and 1 deletions
+34 -1
View File
@@ -24,7 +24,9 @@ type Rental = {
id: string; association_id: string; lot_id: string; renter_name: string;
renter_email: string | null; renter_phone: string | null; vehicle_description: string | null;
start_date: string; end_date: string | null; monthly_rate: number | null; status: string; notes: string | null;
is_owner?: boolean | null; user_id?: string | null;
is_owner?: boolean | null; user_id?: string | null; owner_id?: string | null; unit_id?: string | null;
insurance_carrier?: string | null; insurance_policy_number?: string | null;
insurance_expiration_date?: string | null; insurance_document_url?: string | null;
};
type WaitlistEntry = {
id: string; association_id: string; position: number;
@@ -251,6 +253,27 @@ export default function RVBoatLotsPage() {
}
};
const requestInsurance = async (r: Rental) => {
if (!r.renter_email) { toast.error("Add a renter email first, then request insurance."); return; }
try {
const { data, error } = await supabase.functions.invoke("send-rv-renter-insurance-request", {
body: { rental_id: r.id },
});
if (error) throw error;
const res = data as any;
if (res?.error && !res?.link) throw new Error(res.error);
if (res?.ok) {
toast.success(`Insurance request emailed to ${res.sent_to}`);
} else if (res?.link) {
await navigator.clipboard.writeText(res.link).catch(() => {});
toast.message("Email didn't send — link copied", { description: "Share the submission link with the renter manually." });
}
load();
} catch (e: any) {
toast.error(e.message || "Failed to request insurance");
}
};
const toggleIsOwner = async (r: Rental) => {
const next = !r.is_owner;
const { error } = await supabase.from("rv_boat_lot_rentals")
@@ -641,6 +664,15 @@ export default function RVBoatLotsPage() {
{r.status === "vacating" && <Badge variant="outline" className="text-[10px] border-amber-300 text-amber-700">Vacating</Badge>}
</div>
<div className="text-xs text-muted-foreground">{r.renter_email}</div>
{r.insurance_carrier ? (
<div className="text-xs text-emerald-700 mt-0.5">
Insured: {r.insurance_carrier}
{r.insurance_expiration_date ? ` · exp ${new Date(r.insurance_expiration_date + "T00:00:00").toLocaleDateString()}` : ""}
{r.insurance_document_url ? <> · <a href={r.insurance_document_url} target="_blank" rel="noreferrer" className="underline">COI</a></> : null}
</div>
) : (
<div className="text-xs text-amber-600 mt-0.5">No insurance on file</div>
)}
</TableCell>
<TableCell className="text-sm">{r.vehicle_description || "-"}</TableCell>
<TableCell className="text-sm">{r.start_date}</TableCell>
@@ -651,6 +683,7 @@ export default function RVBoatLotsPage() {
</Button>
<Button size="sm" variant="secondary" onClick={() => invitePortal(r, false)}>Invite Renter</Button>
<Button size="sm" variant="secondary" onClick={() => invitePortal(r, true)}>Invite Owner</Button>
<Button size="sm" variant="secondary" onClick={() => requestInsurance(r)}>Request Insurance</Button>
<Button size="sm" variant="outline" onClick={() => endRental(r)}>End rental</Button>
</TableCell>
</TableRow>