diff --git a/src/App.tsx b/src/App.tsx
index 8658243..cb06e9c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 = () => (
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/pages/RVBoatLotsPage.tsx b/src/pages/RVBoatLotsPage.tsx
index 7ccffd1..ae5e14a 100644
--- a/src/pages/RVBoatLotsPage.tsx
+++ b/src/pages/RVBoatLotsPage.tsx
@@ -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" && Vacating}
{r.renter_email}
+ {r.insurance_carrier ? (
+
+ Insured: {r.insurance_carrier}
+ {r.insurance_expiration_date ? ` · exp ${new Date(r.insurance_expiration_date + "T00:00:00").toLocaleDateString()}` : ""}
+ {r.insurance_document_url ? <> ·
COI> : null}
+
+ ) : (
+ No insurance on file
+ )}
{r.vehicle_description || "-"}
{r.start_date}
@@ -651,6 +683,7 @@ export default function RVBoatLotsPage() {
+
diff --git a/src/pages/RvRenterInsuranceSubmitPage.tsx b/src/pages/RvRenterInsuranceSubmitPage.tsx
new file mode 100644
index 0000000..3265339
--- /dev/null
+++ b/src/pages/RvRenterInsuranceSubmitPage.tsx
@@ -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(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(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 (
+
+
+
+ );
+ }
+
+ if (submitted) {
+ return (
+
+
+
+
+ Thank you!
+
+ Your insurance information has been received and your rental record has been updated.
+
+
+
+
+ );
+ }
+
+ if (error && !rental) {
+ return (
+
+
+
+
+ Unable to submit
+ {error}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Submit Insurance Information
+
+ {rental?.renter_name ? `For: ${rental.renter_name}` : "RV / Boat lot rental insurance"}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/supabase/functions/send-rv-renter-insurance-request/index.ts b/supabase/functions/send-rv-renter-insurance-request/index.ts
new file mode 100644
index 0000000..c3f0d7f
--- /dev/null
+++ b/supabase/functions/send-rv-renter-insurance-request/index.ts
@@ -0,0 +1,134 @@
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
+
+const corsHeaders = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+}
+
+const PUBLIC_BASE_URL = 'https://avria.cloud'
+
+Deno.serve(async (req) => {
+ if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
+
+ try {
+ const { rental_id } = await req.json()
+ if (!rental_id) {
+ return new Response(JSON.stringify({ error: 'rental_id required' }), {
+ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ })
+ }
+
+ const supabaseUrl = Deno.env.get('SUPABASE_URL')!
+ const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+ const anonKey = Deno.env.get('SUPABASE_ANON_KEY')!
+ const authHeader = req.headers.get('Authorization') || ''
+
+ const authClient = createClient(supabaseUrl, anonKey, {
+ global: { headers: { Authorization: authHeader } },
+ })
+ const { data: userRes } = await authClient.auth.getUser()
+ if (!userRes?.user) {
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
+ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ })
+ }
+ const userId = userRes.user.id
+
+ const admin = createClient(supabaseUrl, serviceKey)
+
+ const { data: roleRows } = await admin
+ .from('user_roles').select('role').eq('user_id', userId)
+ const roles = (roleRows || []).map((r: any) => r.role)
+ if (!roles.includes('admin') && !roles.includes('manager') && !roles.includes('association_management')) {
+ return new Response(JSON.stringify({ error: 'Forbidden' }), {
+ status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ })
+ }
+
+ const { data: rental, error: rErr } = await admin
+ .from('rv_boat_lot_rentals')
+ .select('id, renter_name, renter_email')
+ .eq('id', rental_id)
+ .single()
+ if (rErr || !rental) {
+ return new Response(JSON.stringify({ error: 'Rental not found' }), {
+ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ })
+ }
+ if (!rental.renter_email) {
+ return new Response(JSON.stringify({ error: 'Renter has no email on file' }), {
+ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ })
+ }
+
+ const { data: profile } = await admin
+ .from('profiles').select('full_name').eq('user_id', userId).maybeSingle()
+
+ const { data: reqRow, error: reqErr } = await admin
+ .from('rv_renter_insurance_requests')
+ .insert({ rental_id, sent_to_email: rental.renter_email, created_by: userId })
+ .select('id, token, expires_at')
+ .single()
+ if (reqErr || !reqRow) {
+ return new Response(JSON.stringify({ error: 'Failed to create request', detail: reqErr?.message }), {
+ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ })
+ }
+
+ const link = `${PUBLIC_BASE_URL}/rv-insurance/${reqRow.token}`
+ const expiresAt = new Date(reqRow.expires_at).toLocaleDateString('en-US', {
+ month: 'long', day: 'numeric', year: 'numeric',
+ })
+
+ let emailErr: any = null
+ try {
+ const emailRes = await fetch(`${supabaseUrl}/functions/v1/send-transactional-email`, {
+ method: 'POST',
+ headers: {
+ 'apikey': anonKey,
+ ...(authHeader ? { Authorization: authHeader } : {}),
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ templateName: 'vendor-insurance-request',
+ recipientEmail: rental.renter_email,
+ idempotencyKey: `rv-renter-insurance-${reqRow.id}`,
+ templateData: {
+ vendorName: rental.renter_name,
+ requesterName: profile?.full_name || 'Avria Community Management',
+ link,
+ expiresAt,
+ },
+ }),
+ })
+ if (!emailRes.ok) {
+ const text = await emailRes.text()
+ emailErr = { message: `status ${emailRes.status}: ${text}` }
+ }
+ } catch (e) {
+ emailErr = e
+ }
+
+ if (emailErr) {
+ console.error('Email send failed:', emailErr)
+ return new Response(JSON.stringify({
+ ok: false,
+ request_id: reqRow.id,
+ link,
+ error: 'Email failed to send. Share the link manually.',
+ }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
+ }
+
+ return new Response(JSON.stringify({
+ ok: true,
+ request_id: reqRow.id,
+ link,
+ sent_to: rental.renter_email,
+ }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
+ } catch (e) {
+ console.error('send-rv-renter-insurance-request error:', e)
+ return new Response(JSON.stringify({ error: String(e) }), {
+ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
+ })
+ }
+})
diff --git a/supabase/migrations/20260607230000_rv_renter_insurance_requests.sql b/supabase/migrations/20260607230000_rv_renter_insurance_requests.sql
new file mode 100644
index 0000000..b2e210b
--- /dev/null
+++ b/supabase/migrations/20260607230000_rv_renter_insurance_requests.sql
@@ -0,0 +1,81 @@
+-- RV/Boat renter insurance: mirror of the vendor insurance request flow.
+-- Adds insurance fields to rentals, a token-based request table, and the
+-- public lookup/submit RPCs used by the renter-facing submission page.
+
+alter table public.rv_boat_lot_rentals
+ add column if not exists insurance_carrier text,
+ add column if not exists insurance_policy_number text,
+ add column if not exists insurance_expiration_date date,
+ add column if not exists insurance_document_url text;
+
+create table if not exists public.rv_renter_insurance_requests (
+ id uuid primary key default gen_random_uuid(),
+ rental_id uuid not null references public.rv_boat_lot_rentals(id) on delete cascade,
+ token text not null default encode(gen_random_bytes(24), 'hex'),
+ sent_to_email text,
+ sent_at timestamptz default now(),
+ submitted_at timestamptz,
+ expires_at timestamptz default (now() + interval '30 days'),
+ created_by uuid,
+ created_at timestamptz default now()
+);
+
+create index if not exists idx_rv_renter_ins_req_token on public.rv_renter_insurance_requests(token);
+create index if not exists idx_rv_renter_ins_req_rental on public.rv_renter_insurance_requests(rental_id);
+
+alter table public.rv_renter_insurance_requests enable row level security;
+
+drop policy if exists "Staff manage rv renter insurance requests" on public.rv_renter_insurance_requests;
+create policy "Staff manage rv renter insurance requests"
+ on public.rv_renter_insurance_requests for all
+ to authenticated
+ using (has_role(auth.uid(), 'admin'::app_role) or has_role(auth.uid(), 'manager'::app_role) or has_role(auth.uid(), 'association_management'::app_role))
+ with check (has_role(auth.uid(), 'admin'::app_role) or has_role(auth.uid(), 'manager'::app_role) or has_role(auth.uid(), 'association_management'::app_role));
+
+-- Public, token-scoped lookup (no direct table access needed by the renter).
+create or replace function public.lookup_rv_renter_insurance_request(p_token text)
+returns table(request_id uuid, rental_id uuid, renter_name text, expires_at timestamptz, submitted_at timestamptz)
+language sql stable security definer set search_path to 'public'
+as $$
+ select r.id, rn.id, rn.renter_name, r.expires_at, r.submitted_at
+ from public.rv_renter_insurance_requests r
+ join public.rv_boat_lot_rentals rn on rn.id = r.rental_id
+ where r.token = p_token
+ limit 1;
+$$;
+
+create or replace function public.submit_rv_renter_insurance(
+ p_token text,
+ p_carrier text,
+ p_policy_number text,
+ p_expiration_date date,
+ p_document_url text default null
+) returns boolean
+language plpgsql security definer set search_path to 'public'
+as $$
+declare
+ v_request record;
+begin
+ select * into v_request from public.rv_renter_insurance_requests
+ where token = p_token and expires_at > now() and submitted_at is null
+ limit 1;
+
+ if v_request is null then
+ return false;
+ end if;
+
+ update public.rv_boat_lot_rentals
+ set insurance_carrier = p_carrier,
+ insurance_policy_number = p_policy_number,
+ insurance_expiration_date = p_expiration_date,
+ insurance_document_url = coalesce(p_document_url, insurance_document_url),
+ updated_at = now()
+ where id = v_request.rental_id;
+
+ update public.rv_renter_insurance_requests set submitted_at = now() where id = v_request.id;
+ return true;
+end;
+$$;
+
+grant execute on function public.lookup_rv_renter_insurance_request(text) to anon, authenticated;
+grant execute on function public.submit_rv_renter_insurance(text, text, text, date, text) to anon, authenticated;