invoice-free.openapps.xyz

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.
Owned app · rev 333971bf96d2
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.
← All files
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
333971bf96 · 5 hours ago
Bounded source sync
088b53bddb · 1 day ago
Bounded source sync
3f3e9ff8f9 · 1 day ago
Bounded source sync
209bec4267 · 1 day ago
Bounded source sync
11f3d98cda · 1 day ago
Bounded source sync
722948ebc3 · 1 day ago
Bounded source sync
3e9d04b2c0 · 1 day ago