/* ============================================================================
MOWDEW — Billing kit (shared)
Recurring billing data + the reusable components behind the billing flows:
FreqSelector · BillDayStepper · NextInvoiceNote · TimeToBillCard ·
DueRow / HeldRow · VisitTag (verified vs logged) · PdfInvoice (US Letter).
Used by the owner desktop app, the owner mobile app, the design system page
and the standalone PDF / email artifacts. Exposed on window.
Load AFTER mowdew-kit.jsx (and mowdew-owner-data.jsx where CUSTOMERS is
needed at render time — nothing here touches CUSTOMERS at load time).
========================================================================= */
/* ─── Business profile — what prints on the PDF ─────────────────────── */
const BIZPROFILE = {
name: 'Marta Lawn & Pool',
owner: 'Diego Marta',
addr: '2210 Middlefield Rd',
city: 'Palo Alto, CA 94301',
phone: '(650) 555-0144',
email: 'diego@martalawn.co',
zelle: 'diego@martalawn.co',
venmo: '@diego-marta',
paypal: 'paypal.me/martalawn',
};
/* ─── Frequencies ───────────────────────────────────────────────────── */
const FREQS = ['Monthly', 'Every 3 months', 'Every 6 months', 'Yearly', 'Manual'];
const FREQ_SHORT = { 'Monthly': 'Monthly', 'Every 3 months': 'Every 3 mo', 'Every 6 months': 'Every 6 mo', 'Yearly': 'Yearly', 'Manual': 'Manual' };
/* Next-due preview for the billing card (prototype world: today = May 12, 2026) */
function nextDueLabel(freq, day) {
if (freq === 'Manual') return null;
if (freq === 'Monthly') return (day > 12 ? 'May' : 'Jun') + ` ${day}, 2026`;
if (freq === 'Every 3 months') return `Aug ${day}, 2026`;
if (freq === 'Every 6 months') return `Nov ${day}, 2026`;
return `May ${day}, 2027`;
}
/* ─── The May 12 billing run ────────────────────────────────────────────
Owner-confirmed, no cron: these are the invoices Mowdew has READY, built
from recorded visits. Nothing sends until the owner taps the button. */
const BILLRUN = {
dateLabel: 'Tuesday, May 12',
issued: 'May 12, 2026',
dueDate: 'May 26, 2026',
rows: [
{ cid: 'c2', inv: 'INV-0007', period: 'Apr 12 – May 12, 2026',
visits: [
{ d: 'Apr 21', verified: true }, { d: 'Apr 28', verified: true },
{ d: 'May 5', verified: true }, { d: 'May 12', verified: true },
] },
{ cid: 'c4', inv: 'INV-0008', period: 'Feb 12 – May 12, 2026',
visits: [
{ d: 'Feb 24', verified: true },
{ d: 'Mar 18', verified: false, by: 'Diego' },
{ d: 'Apr 12', verified: true },
] },
{ cid: 'c7', inv: 'INV-0009', period: 'Apr 12 – May 12, 2026',
visits: [
{ d: 'Apr 14', verified: true }, { d: 'Apr 21', verified: true },
{ d: 'Apr 28', verified: true }, { d: 'May 5', verified: true },
] },
],
held: ['c3', 'c8', 'c5', 'c6', 'c1'],
};
function runAmount(row, c) { return row.visits.length * c.price; }
/* one-shot run state for the prototype: once sent, the action item hides */
function runPending() { return window.__mowdewRunSent ? [] : BILLRUN.rows; }
function markRunSent() { window.__mowdewRunSent = true; }
function runTotal(customers) {
return runPending().reduce((s, r) => { const c = customers.find(x => x.id === r.cid); return s + (c ? runAmount(r, c) : 0); }, 0);
}
function heldWhy(c) {
return c.freq === 'Manual' ? 'Manual — you bill when ready' : `${c.freq} — next due ${c.nextDue}`;
}
/* ─── FreqSelector — "How often do you bill {Name}?" ─────────────────────
compact=false: chip/segment grid (desktop). compact=true: tappable field
that expands an inline list (mobile). Aqua = the billing accent. */
function FreqSelector({ value, onChange, compact = false }) {
const [open, setOpen] = React.useState(false);
if (!compact) {
return (
{FREQS.map(f => {
const on = value === f;
return (
onChange(f)}
style={{ padding: '11px 6px', borderRadius: 11, cursor: 'pointer', fontFamily: 'var(--font-body)', fontSize: 12.5, fontWeight: 700, letterSpacing: '-0.01em',
border: on ? '2px solid var(--aqua-500)' : '1.5px solid var(--ink-200)',
background: on ? 'var(--aqua-50)' : '#fff', color: on ? 'var(--aqua-700)' : 'var(--ink-700)' }}>
{FREQ_SHORT[f]}
);
})}
);
}
return (
setOpen(o => !o)}
style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 10, padding: '13px 14px', borderRadius: 'var(--radius-md)', border: open ? '2px solid var(--aqua-500)' : '1.5px solid var(--ink-200)', background: '#fff', cursor: 'pointer', textAlign: 'left' }}>
{value}
{open && (
{FREQS.map((f, i) => {
const on = value === f;
return (
{ onChange(f); setOpen(false); }}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderTop: i ? '1px solid var(--ink-100)' : 'none', cursor: 'pointer', background: on ? 'var(--aqua-50)' : '#fff' }}>
{f}
{on && }
);
})}
)}
);
}
/* ─── BillDayStepper — "Bill on day N of the month" (1–28) ──────────── */
function BillDayStepper({ day, onChange }) {
const Step = ({ dir, label }) => (
onChange(Math.min(28, Math.max(1, day + dir)))} aria-label={label}
style={{ width: 38, height: 38, borderRadius: 10, border: '1.5px solid var(--ink-200)', background: '#fff', color: 'var(--ink-700)', display: 'grid', placeItems: 'center', cursor: 'pointer', fontFamily: 'var(--font-display)', fontWeight: 800, fontSize: 18, lineHeight: 1, flexShrink: 0 }}>
{dir < 0 ? '−' : '+'}
);
return (
Bill on day
{day}
of the month
);
}
/* ─── NextInvoiceNote — the calm aqua line ──────────────────────────── */
function NextInvoiceNote({ date }) {
return (
Next invoice {date}
);
}
/* ─── SaveButton — primary with a saved ✓ state ─────────────────────── */
function SaveButton({ label, savedLabel = 'Saved', onSave, block = false, style = {} }) {
const [state, setState] = React.useState('idle'); // idle | saving | saved
const click = () => {
if (state !== 'idle') return;
setState('saving');
setTimeout(() => { setState('saved'); onSave && onSave(); setTimeout(() => setState('idle'), 2200); }, 550);
};
const saved = state === 'saved';
return (
{state === 'saving'
?
: }
{state === 'saving' ? 'Saving…' : saved ? savedLabel : label}
);
}
/* ─── TimeToBillCard — the dashboard / money action item ─────────────────
Aqua-tinted, calendar glyph. Renders nothing when count is 0 (zero state
= hidden). compact = mobile width. */
function TimeToBillCard({ count, total, onOpen, compact = false, style = {} }) {
if (!count) return null;
return (
Time to bill — {count} customer{count === 1 ? '' : 's'} due
Review who's being billed, then send them all.
{total != null && }
);
}
/* ─── Checkbox (run rows) ───────────────────────────────────────────── */
function RunCheck({ on }) {
return (
{on && }
);
}
/* ─── DueRow — selectable billing-run row ───────────────────────────── */
function DueRow({ c, row, checked, onToggle, first = false, compact = false }) {
const amt = runAmount(row, c);
return (
{c.name}
{c.freq} · due May 12
{row.visits.length} visits
);
}
/* ─── HeldRow — clearly shows WHY someone is skipped ────────────────── */
function HeldRow({ c, first = false, compact = false }) {
return (
);
}
/* ─── VisitTag — the verified / logged wording decision ─────────────────
Verified (geofence check-in) -> sky-blue shield "Verified on-site".
Manually logged (bad GPS etc) -> neutral ink notes "Logged by {Pro}".
Honest both ways: never implies GPS proof it doesn't have, never makes
real work look second-class. */
function VisitTag({ verified, by = 'Diego', size = 11.5 }) {
if (verified) {
return (
Verified on-site
);
}
return (
Logged by {by}
);
}
/* ─── Droplet mark — logo-less fallback for the PDF header ──────────── */
function DropletMark({ size = 54 }) {
return (
);
}
/* ─── PdfInvoice — the printable artifact (US Letter, 816×1056 @96dpi) ───
inv: { inv, period, issued, due, customer:{name,addr,city}, lines:[{d,
label, verified, by, cents}], total }. logoless toggles the fallback. */
function PdfInvoice({ inv, biz = BIZPROFILE, logoless = false }) {
const mono = { fontFamily: 'var(--font-mono)', fontSize: 9.5, fontWeight: 600, letterSpacing: '.12em', color: 'var(--ink-500)', textTransform: 'uppercase' };
const hasLogged = inv.lines.some(l => !l.verified);
return (
{/* header */}
{logoless ?
:
}
{biz.name}
{biz.addr} · {biz.city} {biz.phone} · {biz.email}
INVOICE
{inv.inv}
Issued {inv.issued}Due {inv.due}
{/* bill-to + period */}
Bill to
{inv.customer.name}
{inv.customer.addr} · {inv.customer.city}
Service period
{inv.period}
{inv.lines.length} visits, every one on the record
{/* line items */}
Date Service Record Amount
{inv.lines.map((l, i) => (
{l.d}
{l.label}
{l.verified
? Verified on-site
: Logged by {l.by || 'your pro'} }
{fmtMoney(l.cents)}
))}
{/* total */}
Total due
{fmtMoney(inv.total)}
{/* how to pay */}
How to pay
Any of these reaches {biz.owner.split(' ')[0]} directly — please pay by {inv.due}.
{[['zelle', biz.zelle], ['venmo', biz.venmo], ['paypal', biz.paypal]].map(([rail, handle]) => (
{RAIL[rail].label}
{handle}
))}
Payments go straight to {biz.name} — Mowdew never touches the money.
{/* verification footnote */}
Verified on-site — confirmed by an automatic location check-in at your property.
{hasLogged && <> · Logged — recorded by your pro when GPS couldn't confirm. Still real work, still on the record.>}
{/* footer */}
{logoless ? : }
Every visit, on the record.
SENT WITH MOWDEW · MOWDEW.COM
);
}
/* Build a PdfInvoice model from a billing-run row + a CUSTOMERS record */
function invModelFromRun(row, c) {
return {
inv: row.inv, period: row.period, issued: BILLRUN.issued, due: BILLRUN.dueDate,
customer: { name: c.name, addr: c.addr, city: c.city },
lines: row.visits.map(v => ({ d: v.d, label: c.svc, verified: v.verified, by: v.by, cents: c.price })),
total: runAmount(row, c),
};
}
Object.assign(window, {
BIZPROFILE, FREQS, FREQ_SHORT, nextDueLabel, BILLRUN, runAmount, heldWhy,
runPending, markRunSent, runTotal,
FreqSelector, BillDayStepper, NextInvoiceNote, SaveButton, TimeToBillCard,
RunCheck, DueRow, HeldRow, VisitTag, DropletMark, PdfInvoice, invModelFromRun,
});