import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Alert, Box, Button, Checkbox, Group, Image, Loader, Modal, Paper, PinInput, Stack, Text, TextInput, } from "@mantine/core"; import { ArrowLeft, Download, FileSignature, Printer, RotateCw, ShieldCheck, } from "lucide-react"; import toast from "react-hot-toast"; import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; import useAuth from "@/hooks/useAuth"; import { extractApiError } from "@/utils/result"; const CONSENT_TEXT = "I have read the entire contract and agree to its terms."; /** * Customer contract preview + sign. Customers must scroll through the full * contract and accept the terms before signing. */ export default function ContractViewPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); const { user } = useAuth(); const iframeRef = useRef(null); const [signOpen, setSignOpen] = useState(false); const [otpOpen, setOtpOpen] = useState(false); const [otpCode, setOtpCode] = useState(""); const [otpError, setOtpError] = useState(null); const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); const [drawNew, setDrawNew] = useState(false); const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); // The signed-in customer's registered phone — where the sudo-mode OTP is sent. const customerPhone = user?.phoneNumber ?? ""; const maskedPhone = customerPhone.length > 4 ? `${customerPhone.slice(0, 4)}${"*".repeat( Math.max(customerPhone.length - 6, 0), )}${customerPhone.slice(-2)}` : customerPhone; const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["contract-view", id], queryFn: () => contractsService.getContractView(id!), enabled: Boolean(id), }); const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null; const usingSaved = Boolean(savedSignatureImage) && !drawNew; const canProceedToSign = hasScrolledToBottom && agreedToTerms; const checkScrollBottom = useCallback(() => { try { const win = iframeRef.current?.contentWindow; if (!win?.document?.documentElement) return; const el = win.document.documentElement; const threshold = 48; if (el.scrollHeight <= el.clientHeight + threshold) { setHasScrolledToBottom(true); return; } if (el.scrollTop + el.clientHeight >= el.scrollHeight - threshold) { setHasScrolledToBottom(true); } } catch { /* srcDoc is same-origin; ignore edge cases */ } }, []); const handleIframeLoad = () => { checkScrollBottom(); try { const win = iframeRef.current?.contentWindow; win?.addEventListener("scroll", checkScrollBottom); } catch { /* ignore */ } }; useEffect(() => { return () => { try { iframeRef.current?.contentWindow?.removeEventListener( "scroll", checkScrollBottom, ); } catch { /* ignore */ } }; }, [checkScrollBottom]); // Send (or resend) the fresh OTP challenge to the customer's phone. On success // we swap the signature modal for the OTP entry modal. const sendOtpMutation = useMutation({ mutationFn: () => api.auth.sendOTP.call({ phone: customerPhone }), onSuccess: () => { setSignOpen(false); setOtpError(null); setOtpOpen(true); }, onError: () => toast.error("Failed to send verification code"), }); const signMutation = useMutation({ mutationFn: () => contractsService.signContract(id!, { role: "CUSTOMER", signatureImageBase64: usingSaved ? (savedSignatureImage as string) : (signatureData as string), signerDisplayName: signerName.trim(), consentText: CONSENT_TEXT, otp: otpCode.trim(), otpPhone: customerPhone, }), onSuccess: () => { setOtpOpen(false); setOtpCode(""); setSuccessOpen(true); void refetch(); void qc.invalidateQueries({ queryKey: api.contracts.get.queryKey({ id: id! }), }); }, onError: (err) => setOtpError( extractApiError(err).message ?? "Failed to verify code and sign", ), }); const openSign = () => { if (!canProceedToSign) return; setSignerName(data?.savedSignature?.signerDisplayName ?? ""); setSignatureData(null); setDrawNew(false); setSignOpen(true); }; const confirmSign = () => { if (!signerName.trim()) return; const image = usingSaved ? savedSignatureImage : signatureData; if (!image) return; if (!customerPhone) { toast.error("No phone number on file to verify your signature."); return; } setOtpCode(""); sendOtpMutation.mutate(); }; const confirmOtp = () => { if (otpCode.trim().length !== 6) return; setOtpError(null); signMutation.mutate(); }; const handlePrint = () => iframeRef.current?.contentWindow?.print(); const downloadPdf = useCallback(async () => { if (!id) return; try { const blob = await contractsService.downloadContractDocument(id); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `contract-${data?.reference ?? id}.pdf`; a.click(); URL.revokeObjectURL(url); } catch { toast.error("Could not download contract PDF."); } }, [id, data?.reference]); if (isLoading) { return ( ); } if (isError || !data) { return ( Could not load contract. ); } return ( {data.canSignCustomer && !hasScrolledToBottom && ( Please scroll through the entire contract before signing. )}