Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone b3b80918bc912dda8c53a48d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/NewInvoice.tsx
// The editor in the landing hero: work on an invoice or estimate, download the PDF, or share it by link.
// The draft, your business details, clients and the next numbers stay on this device.
import { useEffect, useRef, useState } from "react";
import { computeTotals, validateInvoice, nextNumber, randomId, docWord, type InvoiceData } from "../functions/lib/invoice";
import { client, me, explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { Modal, copy, share, sharesNatively } from "./ui";
import { Editor } from "./Editor";
import { cleanData, clientEmail } from "./format";
import { loadDraft, newDraft, saveDraft, saveDefaults, rememberMine, rememberWork, draftStarted } from "./store";
/** Remembers a shared invoice in "Your invoices" on this device. */
export function remember(id: string, key: string, data: InvoiceData) {
rememberMine({
id, key, number: data.number, client: data.to.name, total: computeTotals(data).total, currency: data.currency,
issueDate: data.issueDate, dueDate: data.dueDate, at: Date.now(), ...(data.kind === "estimate" ? { kind: data.kind } : {}),
});
window.dispatchEvent(new Event("invoice:mine"));
}
/** The number after this one becomes the default for the next invoice (or estimate). */
function advanceNumber(data: InvoiceData) {
saveDefaults(data.kind === "estimate" ? { nextEstimate: nextNumber(data.number) } : { next: nextNumber(data.number) });
}
export function NewInvoice() {
const [draft, setDraft] = useState<InvoiceData>(loadDraft);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<"pdf" | "share" | null>(null);
const [shared, setShared] = useState<{ id: string; key: string; data: InvoiceData } | null>(null);
const done = useRef(false);
const pdfLoaded = useRef(false);
const refocus = useRef(false);
// A new invoice remounts the form, so its open sections and tax mode start fresh too.
const [round, setRound] = useState(0);
const startNext = () => {
const next = newDraft(draft.kind);
// Saved now: opening the invoice page right after unmounts this editor before an effect would run.
saveDraft(next);
setDraft(next);
setRound((r) => r + 1);
setError(null);
done.current = false;
};
useEffect(() => {
saveDraft(draft);
saveDefaults({ from: draft.from, currency: draft.currency, taxRate: draft.taxRate, paymentInstructions: draft.paymentInstructions });
}, [draft]);
const change = (d: InvoiceData) => {
setDraft(d);
setError(null);
done.current = false;
// Fetch the PDF maker while the person types, so Download is instant and works if the connection drops.
if (!pdfLoaded.current) {
pdfLoaded.current = true;
import("./pdf").catch(() => { pdfLoaded.current = false; });
}
};
const ready = (): InvoiceData | null => {
const data = cleanData(draft);
const err = validateInvoice(data);
setError(err);
return err ? null : data;
};
const download = async () => {
const data = ready();
if (!data) return;
setBusy("pdf");
try {
const { downloadPdf } = await import("./pdf");
await downloadPdf(data);
rememberWork(data);
advanceNumber(data);
done.current = true;
} catch (err) {
setError(`The PDF could not be made: ${explain(err)}`);
} finally {
setBusy(null);
}
};
/** Saves the invoice for its link. From then on it is edited from its own page, and this editor starts the next one. */
const shareLink = async () => {
const data = ready();
if (!data) return;
setBusy("share");
try {
const [uid, { setMany, serverTimestamp }] = await Promise.all([me(), client()]);
const id = randomId(20);
const key = randomId(24);
await setMany([
{ path: `invoices/${id}`, document: { data: JSON.stringify(data), total: computeTotals(data).total, currency: data.currency, status: "unpaid", createdBy: uid, createdAt: serverTimestamp() } },
{ path: `invoices/${id}/joins/${uid}`, document: { at: serverTimestamp() } },
{ path: `invoices/${id}/owners/${uid}`, document: { at: serverTimestamp() } },
{ path: `invoicekeys/${id}`, document: { key } },
]);
remember(id, key, data);
rememberWork(data);
advanceNumber(data);
setShared({ id, key, data });
} catch (err) {
setError(explain(err));
} finally {
setBusy(null);
}
};
const closeShared = () => {
setShared(null);
startNext();
refocus.current = true;
};
// The form remounts for the next invoice, so focus moves to it instead of being lost.
useEffect(() => {
if (!refocus.current) return;
refocus.current = false;
document.querySelector<HTMLElement>("#editor .editor")?.focus({ preventScroll: true });
}, [round]);
const fresh = () => {
if (draftStarted(draft) && !done.current && !window.confirm(`Start a new ${docWord(draft).toLowerCase()}? This one will be cleared.`)) return;
startNext();
};
const actions = (
<>
<button className="btn" type="button" onClick={download} disabled={busy === "share"} aria-busy={busy === "pdf" || undefined}>
<svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3v10m0 0 4-4m-4 4L6 9M4 16h12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
<span>Download PDF</span>
</button>
<button className="btn btn-line" type="button" onClick={shareLink} disabled={busy === "pdf"} aria-busy={busy === "share" || undefined}>
<span>Share link</span>
</button>
<button className="btn btn-quiet new-btn" type="button" onClick={fresh} disabled={busy !== null}>New {docWord(draft).toLowerCase()}</button>
</>
);
const origin = location.origin;
const word = shared ? docWord(shared.data).toLowerCase() : "";
const clientUrl = shared ? `${origin}/i/${shared.id}` : "";
const privateUrl = shared ? `${origin}/i/${shared.id}/admin#k=${shared.key}` : "";
return (
<>
<Editor key={round} data={draft} onChange={change} actions={actions} error={error} collapseBusiness
aside={<button className="btn btn-quiet btn-sm" type="button" onClick={fresh} disabled={busy !== null}>New</button>} />
{shared && (
<Modal title={`Send ${word} ${shared.data.number}`} onClose={closeShared} footer={(close) => (
<>
<button className="btn btn-line" type="button" onClick={() => { closeShared(); navigate(`/i/${shared.id}/admin#k=${shared.key}`); }}>Open {word}</button>
<button className="btn" type="button" onClick={close}>Done</button>
</>
)}>
<div className="field">
<span className="label">Link for your client</span>
<div className="link-row">
<code className="link-code">{clientUrl}</code>
<button className="btn btn-sm" type="button" onClick={() => share(clientUrl, `${docWord(shared.data)} ${shared.data.number}`)}>{sharesNatively() ? "Share" : "Copy"}</button>
</div>
<small>They can view the {word} and download the PDF. No account needed.</small>
</div>
<div className="row send-row">
<a className="btn btn-line btn-sm" href={clientEmail(shared.data, clientUrl)}>
<svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true"><rect x="2.5" y="4.5" width="15" height="11" rx="2" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="m3 5.5 7 5.5 7-5.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" /></svg>
Email it
</a>
<button className="btn btn-line btn-sm" type="button" onClick={() => import("./pdf").then((m) => m.downloadPdf(shared.data))}>Download PDF</button>
</div>
<div className="field">
<span className="label">Your private link</span>
<div className="link-row">
<code className="link-code">{privateUrl}</code>
<button className="btn btn-sm btn-line" type="button" onClick={() => copy(privateUrl, "Private link copied. Keep it to yourself.")}>Copy</button>
</div>
<small>Opens this {word} on any device so you can edit it{shared.data.kind === "estimate" ? "" : " or mark it paid"}. Keep it to yourself.</small>
</div>
<p className="hint">Saved under Your invoices on this device. Done starts your next {word}.</p>
</Modal>
)}
</>
);
}
Change history
Quality pass: phone-readable invoice, estimates, email client, saved clients, CSV export, openapi.json
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync