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
+2
View File
@@ -12,6 +12,7 @@ import ResetPasswordPage from "./pages/ResetPasswordPage";
import NotFound from "./pages/NotFound";
import PublicFormSubmitPage from "./pages/PublicFormSubmitPage";
import VendorInsuranceSubmitPage from "./pages/VendorInsuranceSubmitPage";
import RvRenterInsuranceSubmitPage from "./pages/RvRenterInsuranceSubmitPage";
import VendorProfileSubmitPage from "./pages/VendorProfileSubmitPage";
import TenantInfoSubmitPage from "./pages/TenantInfoSubmitPage";
import UnsubscribePage from "./pages/UnsubscribePage";
@@ -287,6 +288,7 @@ const App = () => (
<Route path="/bill-approve/:billId" element={<BillApprovePublicPage />} />
<Route path="/sign/:token" element={<PublicSignPage />} />
<Route path="/vendor-insurance/:token" element={<VendorInsuranceSubmitPage />} />
<Route path="/rv-insurance/:token" element={<RvRenterInsuranceSubmitPage />} />
<Route path="/vendor-profile/:token" element={<VendorProfileSubmitPage />} />
<Route path="/tenant-info/:token" element={<TenantInfoSubmitPage />} />
<Route path="/rv-boat-waitlist/:associationId" element={<PublicRVBoatWaitlistPage />} />
+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>
+170
View File
@@ -0,0 +1,170 @@
import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { supabase } from "@/integrations/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { CheckCircle2, Loader2, ShieldCheck, AlertTriangle } from "lucide-react";
export default function RvRenterInsuranceSubmitPage() {
const { token } = useParams<{ token: string }>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [rental, setRental] = useState<{ rental_id: string; renter_name: string } | null>(null);
const [carrier, setCarrier] = useState("");
const [policyNumber, setPolicyNumber] = useState("");
const [expirationDate, setExpirationDate] = useState("");
const [docFile, setDocFile] = useState<File | null>(null);
useEffect(() => {
if (!token) return;
(async () => {
setLoading(true);
const { data, error } = await supabase.rpc("lookup_rv_renter_insurance_request", { p_token: token });
if (error || !data || data.length === 0) {
setError("This link is invalid or could not be found.");
setLoading(false);
return;
}
const row = data[0];
if (row.submitted_at) {
setError("This insurance information has already been submitted. If you need to update it, please contact us.");
} else if (new Date(row.expires_at) < new Date()) {
setError("This link has expired. Please contact us to request a new submission link.");
} else {
setRental({ rental_id: row.rental_id, renter_name: row.renter_name });
}
setLoading(false);
})();
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token || !rental) return;
if (!carrier.trim() || !policyNumber.trim() || !expirationDate) {
setError("Please complete all required fields.");
return;
}
setSubmitting(true);
setError(null);
let documentUrl: string | null = null;
if (docFile) {
const ext = docFile.name.split(".").pop() || "pdf";
const path = `rv-renter-insurance/${rental.rental_id}/${Date.now()}.${ext}`;
const { error: upErr } = await supabase.storage
.from("public-form-attachments")
.upload(path, docFile, { upsert: false });
if (upErr) {
setError("Could not upload document: " + upErr.message);
setSubmitting(false);
return;
}
const { data: pub } = supabase.storage.from("public-form-attachments").getPublicUrl(path);
documentUrl = pub.publicUrl;
}
const { data, error: rpcErr } = await supabase.rpc("submit_rv_renter_insurance", {
p_token: token,
p_carrier: carrier.trim(),
p_policy_number: policyNumber.trim(),
p_expiration_date: expirationDate,
p_document_url: documentUrl,
});
setSubmitting(false);
if (rpcErr || data === false) {
setError("Submission failed. The link may have expired. Please contact us.");
return;
}
setSubmitted(true);
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/20 p-4">
<Card className="max-w-md w-full">
<CardContent className="p-8 text-center space-y-4">
<CheckCircle2 className="h-16 w-16 text-emerald-600 mx-auto" />
<h2 className="text-2xl font-bold">Thank you!</h2>
<p className="text-muted-foreground">
Your insurance information has been received and your rental record has been updated.
</p>
</CardContent>
</Card>
</div>
);
}
if (error && !rental) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/20 p-4">
<Card className="max-w-md w-full">
<CardContent className="p-8 text-center space-y-4">
<AlertTriangle className="h-12 w-12 text-destructive mx-auto" />
<h2 className="text-xl font-bold">Unable to submit</h2>
<p className="text-muted-foreground">{error}</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-muted/20 py-10 px-4">
<div className="max-w-xl mx-auto">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<ShieldCheck className="h-8 w-8 text-primary" />
<div>
<CardTitle>Submit Insurance Information</CardTitle>
<CardDescription>
{rental?.renter_name ? `For: ${rental.renter_name}` : "RV / Boat lot rental insurance"}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="carrier">Insurance Carrier *</Label>
<Input id="carrier" value={carrier} onChange={(e) => setCarrier(e.target.value)} placeholder="e.g. State Farm, Progressive" required />
</div>
<div>
<Label htmlFor="policy">Policy Number *</Label>
<Input id="policy" value={policyNumber} onChange={(e) => setPolicyNumber(e.target.value)} required />
</div>
<div>
<Label htmlFor="exp">Expiration Date *</Label>
<Input id="exp" type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} required />
</div>
<div>
<Label htmlFor="doc">Certificate of Insurance (optional)</Label>
<Input id="doc" type="file" accept=".pdf,.png,.jpg,.jpeg" onChange={(e) => setDocFile(e.target.files?.[0] || null)} />
<p className="text-xs text-muted-foreground mt-1">PDF or image, up to 10MB.</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={submitting}>
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Submit Insurance Information
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}