'use client'; /** * MonthlyPaymentEstimate — drop-in "estimated monthly payment" widget for the * public Monthly Payment API (https://payment-estimate-api.pages.dev/). * * - Questions (labels, placeholders, help text, values) come from GET /options. * - Payments, APRs, Apply links and disclosures come from POST /estimate. * - Switching terms never calls the API again (every term is in the response). * - Styles: monthly-payment-estimate.css (all classes prefixed .pme-). */ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { ApiResult, Brand, DisclosureBlock, EstimateField, EstimateRequest, EstimateResponse, LoanLenderKey, LoanOffer, LoanTerm, OptionsResponse, Question, RentToOwn, RentToOwnTerm, Simulate, } from './payment-estimate-api'; import { getEstimate, getOptions } from './payment-estimate-api'; export type DisplayStyle = 'auto' | 'dropdown' | 'button'; export interface MonthlyPaymentEstimateProps { /** Brand slug. Default "stoltzfus". */ brand?: Brand; /** Total price in dollars from your price calculator. The widget re-estimates when it changes. */ price: number; /** Delivery ZIP. Omit to show a ZIP input inside the widget. */ zip?: string; /** 'auto' (tiles, no button) · 'dropdown' (one big payment + term select) · 'button' (button, then tiles). Default 'auto'. */ displayStyle?: DisplayStyle; /** Override the API base URL. */ apiBase?: string; className?: string; /** Called after every successful estimate. */ onEstimate?: (data: EstimateResponse) => void; /** Called when an Apply button is clicked (navigation still happens). */ onApplyClick?: (lender: LoanLenderKey | 'rto', term: LoanTerm | RentToOwnTerm, offer?: LoanOffer) => void; /** Pre-select a credit score value (one of the /options values). */ initialCreditScore?: string; /** Pre-select a property answer value (one of the /options values). */ initialOwnProperty?: string; /** Render disclosures.forThisWidget under the widget. Default true. Keep true unless you render them yourself. */ showDisclosures?: boolean; /** Remember the two answers in localStorage ("pmt_est_answers"). Default true. */ persistAnswers?: boolean; /** Testing only: ask the API to simulate an outage / slow / invalid response. */ simulate?: Simulate; } const STORAGE_KEY = 'pmt_est_answers'; const DEBOUNCE_MS = 400; const UNAVAILABLE_NOTE = 'Payment estimates are temporarily unavailable.'; const ZIP_MESSAGE = 'Enter a 5-digit US ZIP code'; const SELECT_MESSAGE = 'Please choose an answer.'; /* ------------------------------------------------------------------ */ /* Tiny whitelist sanitizer (server already allows only these tags) */ /* ------------------------------------------------------------------ */ const ALLOWED_TAGS = new Set(['strong', 'b', 'em', 'sup']); const DROP_WITH_CONTENT = new Set([ 'script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'textarea', 'title', 'svg', 'math', 'select', ]); function escapeText(s: string): string { return s .replace(/&(?!(?:#\d{1,7}|#x[0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});)/g, '&') .replace(//g, '>'); } /** Keeps only , , , (without attributes); strips every other tag. */ export function sanitizeDisclosureHtml(input: string | null | undefined): string { if (!input) return ''; const re = /|<(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g; let out = ''; let last = 0; let skipUntil: string | null = null; let m: RegExpExecArray | null; while ((m = re.exec(input)) !== null) { if (!skipUntil) out += escapeText(input.slice(last, m.index)); last = re.lastIndex; if (m[2] === undefined) continue; // comment const closing = m[1] === '/'; const tag = m[2].toLowerCase(); if (skipUntil) { if (closing && tag === skipUntil) skipUntil = null; continue; } if (DROP_WITH_CONTENT.has(tag)) { if (!closing && !/\/\s*>$/.test(m[0])) skipUntil = tag; continue; } if (ALLOWED_TAGS.has(tag)) out += closing ? `` : `<${tag}>`; } if (!skipUntil) out += escapeText(input.slice(last)); return out; } function Html({ as: Tag = 'span', html, className, id }: { as?: 'span' | 'p' | 'div' | 'h3' | 'h4'; html: string; className?: string; id?: string }) { return ; } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ type Answers = { creditScore: string; ownProperty: string }; function readStoredAnswers(): Partial { try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return {}; const v = JSON.parse(raw) as unknown; if (!v || typeof v !== 'object') return {}; const o = v as Record; return { creditScore: typeof o.creditScore === 'string' ? o.creditScore : undefined, ownProperty: typeof o.ownProperty === 'string' ? o.ownProperty : undefined, }; } catch { return {}; } } function writeStoredAnswers(a: Answers): void { try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(a)); } catch { /* storage blocked (private mode, cookies off) — the widget works without it */ } } function hasOption(q: Question | undefined, value: string): boolean { return !!q && q.options.some((o) => o.value === value); } function cx(...parts: Array): string { return parts.filter(Boolean).join(' '); } type ResultState = | { kind: 'idle' } | { kind: 'ok'; data: EstimateResponse } | { kind: 'unavailable' } | { kind: 'invalid'; fields: Partial>; message?: string }; /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ export default function MonthlyPaymentEstimate({ brand = 'stoltzfus', price, zip: zipProp, displayStyle = 'auto', apiBase, className, onEstimate, onApplyClick, initialCreditScore, initialOwnProperty, showDisclosures = true, persistAnswers = true, simulate, }: MonthlyPaymentEstimateProps) { const uid = useId().replace(/:/g, ''); const ids = { zip: `pme-${uid}-zip`, cs: `pme-${uid}-cs`, op: `pme-${uid}-op`, loanSel: `pme-${uid}-loan-sel`, rtoSel: `pme-${uid}-rto-sel`, loanTitle: `pme-${uid}-loans`, rtoTitle: `pme-${uid}-rto`, }; const [options, setOptions] = useState(null); const [optionsFailed, setOptionsFailed] = useState(false); const [creditScore, setCreditScore] = useState(initialCreditScore ?? ''); const [ownProperty, setOwnProperty] = useState(initialOwnProperty ?? ''); const [zipInput, setZipInput] = useState(''); const [result, setResult] = useState({ kind: 'idle' }); const [loading, setLoading] = useState(false); const [liveMsg, setLiveMsg] = useState(''); const [buttonPressed, setButtonPressed] = useState(false); const [showMissing, setShowMissing] = useState(false); const [loanMonths, setLoanMonths] = useState(null); const [rtoMonths, setRtoMonths] = useState(null); const abortRef = useRef(null); const seqRef = useRef(0); const lastKeyRef = useRef(null); const loanPickedRef = useRef(false); const rtoPickedRef = useRef(false); const mountedRef = useRef(true); const onEstimateRef = useRef(onEstimate); onEstimateRef.current = onEstimate; const loanMonthsRef = useRef(loanMonths); loanMonthsRef.current = loanMonths; const rtoMonthsRef = useRef(rtoMonths); rtoMonthsRef.current = rtoMonths; const zip = (zipProp ?? zipInput).trim(); const zipValid = /^\d{5}$/.test(zip); const priceValid = typeof price === 'number' && Number.isFinite(price) && price > 0; const q = options?.questions; const answersValid = !!q && hasOption(q.creditScore, creditScore) && hasOption(q.ownProperty, ownProperty); const ready = zipValid && priceValid && answersValid; const autoMode = displayStyle !== 'button' || buttonPressed; /* mount / unmount */ useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; abortRef.current?.abort(); }; }, []); /* restore remembered answers (client only, so SSR markup stays stable) */ useEffect(() => { if (!persistAnswers) return; const saved = readStoredAnswers(); if (!initialCreditScore && saved.creditScore) setCreditScore(saved.creditScore); if (!initialOwnProperty && saved.ownProperty) setOwnProperty(saved.ownProperty); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /* load the questions */ useEffect(() => { const ctrl = new AbortController(); setOptionsFailed(false); getOptions(brand, apiBase, ctrl.signal).then((res: ApiResult) => { if (ctrl.signal.aborted) return; if (res.ok) setOptions(res.data); else if (res.error !== 'aborted') setOptionsFailed(true); }); return () => ctrl.abort(); }, [brand, apiBase]); /* drop remembered answers the API no longer offers */ useEffect(() => { if (!q) return; if (creditScore && !hasOption(q.creditScore, creditScore)) setCreditScore(''); if (ownProperty && !hasOption(q.ownProperty, ownProperty)) setOwnProperty(''); }, [q, creditScore, ownProperty]); /* remember answers */ useEffect(() => { if (persistAnswers && (creditScore || ownProperty)) writeStoredAnswers({ creditScore, ownProperty }); }, [persistAnswers, creditScore, ownProperty]); const request: EstimateRequest | null = useMemo( () => (ready ? { zip, price, creditScore, ownProperty, ...(simulate ? { simulate } : {}) } : null), [ready, zip, price, creditScore, ownProperty, simulate], ); const run = useCallback( async (req: EstimateRequest) => { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; const seq = ++seqRef.current; lastKeyRef.current = JSON.stringify([brand, apiBase, req]); setLoading(true); setLiveMsg('Updating payment estimates…'); const res = await getEstimate(brand, req, { signal: ctrl.signal, base: apiBase }); // Ignore stale responses (a newer request was sent) and late responses after unmount. if (seq !== seqRef.current || !mountedRef.current) return; setLoading(false); if (res.ok) { const data = res.data; const loans = data.loans.terms; const rto = data.rentToOwn.terms; const curLoan = loanMonthsRef.current; const keepLoan = loanPickedRef.current && curLoan !== null && loans.some((t) => t.months === curLoan); const defLoan = loans.find((t) => t.years === data.loans.defaultTermYears) ?? loans[loans.length - 1]; const lt = keepLoan ? loans.find((t) => t.months === curLoan) : defLoan; const curRto = rtoMonthsRef.current; const keepRto = rtoPickedRef.current && curRto !== null && rto.some((t) => t.months === curRto); const defRto = rto.find((t) => t.months === data.rentToOwn.defaultTermMonths) ?? rto[rto.length - 1]; const rt = keepRto ? rto.find((t) => t.months === curRto) : defRto; setLoanMonths(lt ? lt.months : null); setRtoMonths(rt ? rt.months : null); setResult({ kind: 'ok', data }); const parts: string[] = ['Payment estimates updated.']; if (lt) parts.push(`Loan: ${lt.monthlyPaymentDisplay} per month, ${lt.aprDisplay}, ${lt.label} term.`); if (lt && data.loans.notice) parts.push(data.loans.notice); else if (data.loans.message) parts.push(data.loans.message); if (rt) parts.push(`Rent-to-own: ${rt.monthlyPaymentDisplay} per month for ${rt.label}.`); setLiveMsg(parts.join(' ')); onEstimateRef.current?.(data); return; } if (res.error === 'aborted') return; lastKeyRef.current = null; // allow a retry with the same inputs if (res.status === 400 && res.fields && Object.keys(res.fields).length) { setResult({ kind: 'invalid', fields: res.fields, message: res.message }); setLiveMsg(Object.values(res.fields).join(' ')); return; } setResult({ kind: 'unavailable' }); setLiveMsg(UNAVAILABLE_NOTE); }, [brand, apiBase], ); /* automatic estimate: debounced, deduped, stale-safe */ useEffect(() => { if (!autoMode) return; if (!request) { abortRef.current?.abort(); seqRef.current++; lastKeyRef.current = null; setLoading(false); setResult((r) => (r.kind === 'idle' ? r : { kind: 'idle' })); return; } if (JSON.stringify([brand, apiBase, request]) === lastKeyRef.current) return; const t = setTimeout(() => void run(request), DEBOUNCE_MS); return () => clearTimeout(t); }, [autoMode, request, brand, apiBase, run]); const onSeePayment = () => { setShowMissing(true); if (!request) { setLiveMsg('Please answer the questions above to see your monthly payment.'); return; } setButtonPressed(true); void run(request); }; /* ---------------- render pieces ---------------- */ const data = result.kind === 'ok' ? result.data : null; const fieldErr = (f: EstimateField): string | undefined => (result.kind === 'invalid' ? result.fields[f] : undefined); const zipErr = fieldErr('zip') ?? (zipProp === undefined && showMissing && !zipValid ? ZIP_MESSAGE : undefined); const csErr = fieldErr('creditScore') ?? (showMissing && q && !hasOption(q.creditScore, creditScore) ? SELECT_MESSAGE : undefined); const opErr = fieldErr('ownProperty') ?? (showMissing && q && !hasOption(q.ownProperty, ownProperty) ? SELECT_MESSAGE : undefined); // Field errors that have no input inside the widget (price, or a ZIP passed as a prop). const generalErrors = result.kind === 'invalid' ? (Object.entries(result.fields) as Array<[EstimateField, string]>) .filter(([f]) => f === 'price' || (f === 'zip' && zipProp !== undefined)) .map(([, msg]) => msg) : []; const disclosureBlocks: DisclosureBlock[] = (data ?? options)?.disclosures.forThisWidget ?? []; const renderSelect = (id: string, question: Question, value: string, set: (v: string) => void, err?: string) => (

{question.helpText}

{err && (

{err}

)}
); const tilesMode = displayStyle !== 'dropdown'; const renderLoans = (d: EstimateResponse) => { const L = d.loans; const term = L.terms.find((t) => t.months === loanMonths) ?? null; return (

Finance with a loan

{L.available && L.terms.length > 0 && L.notice &&

{L.notice}

} {!L.available || L.terms.length === 0 ? (

{L.message ?? 'No loan options match these answers.'}

) : tilesMode ? (
Choose a loan term
{L.terms.map((t) => { const id = `pme-${uid}-loan-${t.months}`; return (
{ loanPickedRef.current = true; setLoanMonths(t.months); }} />
); })}
) : ( term && (
{term.monthlyPaymentDisplay} /mo {term.footnote && {term.footnote}}
{term.aprDisplay} {term.label} term
) )} {term && L.available && ( <> {term.paymentNote && (

{term.footnote && {term.footnote}} {term.paymentNote}

)}

Apply for the {term.label} term

)}
); }; const renderRto = (R: RentToOwn) => { const term = R.terms.find((t) => t.months === rtoMonths) ?? null; const applyBlock = (t: RentToOwnTerm | null) => R.applyLink ? ( ) : null; return (

Rent-to-own {R.creditCheck === false && No credit check}

{!R.available || R.terms.length === 0 ? ( <> {R.reasonText &&

{R.reasonText}

} {R.eligible === true && applyBlock(null)} ) : ( <> {tilesMode ? (
Choose a rent-to-own length
{R.terms.map((t) => { const id = `pme-${uid}-rto-${t.months}`; return (
{ rtoPickedRef.current = true; setRtoMonths(t.months); }} />
); })}
) : ( term && (
{term.monthlyPaymentDisplay} /mo
{term.label} {term.upfrontPaymentDisplay} due upfront
) )} {R.overMaxText &&

{R.overMaxText}

} {R.notes.length > 0 && (
    {R.notes.map((n, i) => (
  • {n}
  • ))}
)} {applyBlock(term)} )}
); }; return (
{zipProp === undefined && (
setZipInput(e.target.value.replace(/\D/g, '').slice(0, 5))} aria-invalid={zipErr ? true : undefined} aria-describedby={zipErr ? `${ids.zip}-err` : undefined} /> {zipErr && (

{zipErr}

)}
)} {q && renderSelect(ids.cs, q.creditScore, creditScore, setCreditScore, csErr)} {q && renderSelect(ids.op, q.ownProperty, ownProperty, setOwnProperty, opErr)} {!q && !optionsFailed &&

Loading…

}
{displayStyle === 'button' && q && ( )}
{liveMsg}
{loading && !data &&

Calculating your monthly payment…

} {(optionsFailed || result.kind === 'unavailable') &&

{UNAVAILABLE_NOTE}

} {generalErrors.map((msg, i) => (

{msg}

))} {data && ( <> {renderLoans(data)} {renderRto(data.rentToOwn)} )}
{showDisclosures && disclosureBlocks.length > 0 && (
{disclosureBlocks.map((b, i) => { if (b.type === 'heading') { return (

{b.text}

); } const titleHtml = b.titleHtml ?? null; return (
{titleHtml && } {(b.paragraphs ?? []).map((p, j) => ( ))}
); })}
)}
); }