---
format: typebulb/v1
name: Invoice Generator
---

**code.tsx**

```tsx
import React, { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { jsPDF } from "jspdf";

interface LineItem {
  description: string;
  qty: string;
  rate: string;
}

interface Invoice {
  number: string;
  date: string;
  dueDate: string;
  currency: string;
  fromName: string;
  fromAddress: string;
  fromEmail: string;
  fromTaxId: string;
  toName: string;
  toAddress: string;
  toEmail: string;
  items: LineItem[];
  taxPercent: string;
  taxNote: string;
  paymentInfo: string;
  notes: string;
}

const today = new Date().toISOString().split("T")[0];

const STORAGE_KEY = "typebulb_invoice";

const blank: Invoice = {
  number: "1",
  date: today,
  dueDate: "Due on Receipt",
  currency: "USD",
  fromName: "",
  fromAddress: "",
  fromEmail: "",
  fromTaxId: "",
  toName: "",
  toAddress: "",
  toEmail: "",
  items: [{ description: "", qty: "", rate: "" }],
  taxPercent: "0",
  taxNote: "",
  paymentInfo: "",
  notes: "",
};

const fmtMoney = (n: number) =>
  n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });

const fmtDate = (s: string) => {
  const d = new Date(s + "T00:00:00");
  if (isNaN(d.getTime())) return s;
  return d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "2-digit" });
};

const calcSubtotal = (items: LineItem[]) =>
  items.reduce((sum, it) => sum + (parseFloat(it.qty) || 0) * (parseFloat(it.rate) || 0), 0);

function buildPDF(inv: Invoice) {
  const doc = new jsPDF();
  const pw = doc.internal.pageSize.getWidth();
  const m = 20;
  const cw = pw - 2 * m;
  let y = 25;

  const gray = () => doc.setTextColor(100);
  const black = () => doc.setTextColor(30);
  const bold = () => doc.setFont("helvetica", "bold");
  const norm = () => doc.setFont("helvetica", "normal");
  const fs = (s: number) => doc.setFontSize(s);

  // Title
  bold(); fs(22); black();
  doc.text("INVOICE", pw / 2, y, { align: "center" });
  y += 16;

  // Invoice details
  fs(9.5);
  bold(); gray();
  doc.text("Invoice Number:", m, y);
  norm(); black();
  doc.text(inv.number, m + 33, y);

  bold(); gray();
  doc.text("Invoice Date:", pw / 2 + 5, y);
  norm(); black();
  doc.text(fmtDate(inv.date), pw / 2 + 5 + 28, y);
  y += 6;

  bold(); gray();
  doc.text("Due Date:", pw / 2 + 5, y);
  norm(); black();
  doc.text(inv.dueDate, pw / 2 + 5 + 28, y);
  y += 12;

  // Divider
  doc.setDrawColor(220);
  doc.setLineWidth(0.3);
  doc.line(m, y, m + cw, y);
  y += 10;

  // From / Bill To headers
  bold(); fs(8); gray();
  doc.text("From:", m, y);
  doc.text("Bill To:", pw / 2 + 5, y);
  y += 6;

  // Names
  fs(10); bold(); black();
  doc.text(inv.fromName, m, y);
  doc.text(inv.toName, pw / 2 + 5, y);
  y += 5.5;

  // Addresses
  norm(); fs(9.5);
  const fAddr = inv.fromAddress.split("\n").filter(Boolean);
  const tAddr = inv.toAddress.split("\n").filter(Boolean);
  for (let i = 0; i < Math.max(fAddr.length, tAddr.length); i++) {
    if (fAddr[i]) doc.text(fAddr[i], m, y);
    if (tAddr[i]) doc.text(tAddr[i], pw / 2 + 5, y);
    y += 5;
  }

  if (inv.fromEmail || inv.toEmail) {
    gray();
    if (inv.fromEmail) doc.text("Email: " + inv.fromEmail, m, y);
    if (inv.toEmail) doc.text("Email: " + inv.toEmail, pw / 2 + 5, y);
    y += 5;
  }

  if (inv.fromTaxId) {
    gray();
    doc.text("Tax ID: " + inv.fromTaxId, m, y);
    y += 5;
  }

  black();
  y += 10;

  // Table
  const col = [m, m + 90, m + 118, m + 145];

  doc.setFillColor(245, 245, 245);
  doc.rect(m, y - 1, cw, 9, "F");

  bold(); fs(8.5); gray();
  doc.text("Description", col[0] + 3, y + 5.5);
  doc.text("Qty", col[1] + 3, y + 5.5);
  doc.text("Rate (" + inv.currency + ")", col[2] + 3, y + 5.5);
  doc.text("Amount (" + inv.currency + ")", col[3] + 3, y + 5.5);

  y += 9;
  doc.setDrawColor(200);
  doc.line(m, y, m + cw, y);
  y += 2;

  norm(); fs(9.5); black();
  inv.items.forEach((it) => {
    const q = parseFloat(it.qty) || 0;
    const r = parseFloat(it.rate) || 0;
    y += 6;
    doc.text(it.description, col[0] + 3, y);
    doc.text(it.qty || "0", col[1] + 3, y);
    doc.text("$" + fmtMoney(r), col[2] + 3, y);
    doc.text("$" + fmtMoney(q * r), col[3] + 3, y);
  });

  y += 4;
  doc.setDrawColor(220);
  doc.line(m, y, m + cw, y);
  y += 10;

  // Totals
  const sub = calcSubtotal(inv.items);
  const taxPct = parseFloat(inv.taxPercent) || 0;
  const tax = sub * taxPct / 100;
  const total = sub + tax;
  const tl = col[2] + 3;
  const tv = m + cw - 3;

  fs(9.5);
  bold(); gray();
  doc.text("Subtotal:", tl, y);
  norm(); black();
  doc.text(inv.currency + " $" + fmtMoney(sub), tv, y, { align: "right" });
  y += 6;

  bold(); gray();
  doc.text("Tax:", tl, y);
  norm(); black();
  doc.text(inv.currency + " $" + fmtMoney(tax), tv, y, { align: "right" });
  if (inv.taxNote) {
    y += 4;
    gray(); fs(8);
    doc.text("(" + inv.taxNote + ")", tv, y, { align: "right" });
    fs(9.5); black();
  }
  y += 7;

  doc.setDrawColor(180);
  doc.line(tl - 3, y - 2, m + cw, y - 2);

  bold(); fs(11); gray();
  doc.text("Total Due:", tl, y + 2);
  black();
  doc.text(inv.currency + " $" + fmtMoney(total), tv, y + 2, { align: "right" });
  y += 16;

  // Payment Info
  if (inv.paymentInfo.trim()) {
    fs(9); bold(); gray();
    doc.text("Payment Information:", m, y);
    y += 6;
    norm(); black(); fs(9.5);
    const lines = doc.splitTextToSize(inv.paymentInfo, cw);
    doc.text(lines, m, y);
    y += lines.length * 4.5 + 8;
  }

  // Notes
  if (inv.notes.trim()) {
    fs(9); bold(); gray();
    doc.text("Notes:", m, y);
    y += 6;
    norm(); black(); fs(9.5);
    const lines = doc.splitTextToSize(inv.notes, cw);
    doc.text(lines, m, y);
  }

  const blob = doc.output("blob");
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "invoice_" + inv.number + ".pdf";
  a.click();
  URL.revokeObjectURL(url);
}

function loadSaved(): Invoice {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return blank;
    return { ...blank, ...JSON.parse(raw) };
  } catch {
    return blank;
  }
}

const App = () => {
  const [inv, setInv] = useState<Invoice>(loadSaved);

  useEffect(() => {
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(inv)); } catch {}
  }, [inv]);

  const newInvoice = () => {
    const nextNum = String((parseInt(inv.number) || 0) + 1);
    setInv({ ...inv, number: nextNum, date: today, dueDate: "Due on Receipt", toName: "", toAddress: "", toEmail: "", items: [{ description: "", qty: "", rate: "" }], notes: "" });
  };

  const set = <K extends keyof Invoice>(k: K, v: Invoice[K]) =>
    setInv((p) => ({ ...p, [k]: v }));

  const setItem = (i: number, field: keyof LineItem, val: string) => {
    const next = [...inv.items];
    next[i] = { ...next[i], [field]: val };
    set("items", next);
  };

  const addItem = () => set("items", [...inv.items, { description: "", qty: "", rate: "" }]);

  const removeItem = (i: number) => {
    if (inv.items.length <= 1) return;
    set("items", inv.items.filter((_, idx) => idx !== i));
  };

  const sub = calcSubtotal(inv.items);
  const taxPct = parseFloat(inv.taxPercent) || 0;
  const tax = sub * taxPct / 100;
  const total = sub + tax;

  const ua = navigator.userAgent;
  const isWebView = (/iPhone|iPad|iPod/i.test(ua) && !ua.includes("Safari")) || /; wv\)/.test(ua);

  if (isWebView) return (
    <div className="container">
      <div className="in-app-banner">
        Your in-app browser can't download files. Please open this page directly in your browser.
        <button className="copy-link-btn" onClick={async () => tb.copy(await tb.url())}>
          Copy Link
        </button>
      </div>
    </div>
  );

  return (
    <div className="container">
      <header className="main-header">
        <h1>Invoice Generator</h1>
        <p className="subtitle">Create simple, professional invoices. Runs entirely in your browser.</p>
      </header>

      <div className="form-card">
        <div className="row-4">
          <div className="field">
            <label>Invoice #</label>
            <input value={inv.number} onChange={(e) => set("number", e.target.value)} />
          </div>
          <div className="field">
            <label>Date</label>
            <input type="date" value={inv.date} onChange={(e) => set("date", e.target.value)} />
          </div>
          <div className="field">
            <label>Due Date</label>
            <input value={inv.dueDate} onChange={(e) => set("dueDate", e.target.value)} />
          </div>
          <div className="field">
            <label>Currency</label>
            <input value={inv.currency} onChange={(e) => set("currency", e.target.value)} />
          </div>
        </div>
      </div>

      <div className="form-row-2">
        <div className="form-card">
          <h3>From</h3>
          <div className="field">
            <label>Name</label>
            <input value={inv.fromName} onChange={(e) => set("fromName", e.target.value)} placeholder="Your name" />
          </div>
          <div className="field">
            <label>Address</label>
            <textarea rows={3} value={inv.fromAddress} onChange={(e) => set("fromAddress", e.target.value)} placeholder="Street address&#10;City, State, ZIP" />
          </div>
          <div className="field">
            <label>Email</label>
            <input type="email" value={inv.fromEmail} onChange={(e) => set("fromEmail", e.target.value)} placeholder="email@example.com" />
          </div>
          <div className="field">
            <label>Tax ID</label>
            <input value={inv.fromTaxId} onChange={(e) => set("fromTaxId", e.target.value)} placeholder="Optional" />
          </div>
        </div>

        <div className="form-card">
          <h3>Bill To</h3>
          <div className="field">
            <label>Name</label>
            <input value={inv.toName} onChange={(e) => set("toName", e.target.value)} placeholder="Client name" />
          </div>
          <div className="field">
            <label>Address</label>
            <textarea rows={3} value={inv.toAddress} onChange={(e) => set("toAddress", e.target.value)} placeholder="Street address&#10;City, State, ZIP" />
          </div>
          <div className="field">
            <label>Email</label>
            <input type="email" value={inv.toEmail} onChange={(e) => set("toEmail", e.target.value)} placeholder="client@example.com" />
          </div>
        </div>
      </div>

      <div className="form-card">
        <h3>Line Items</h3>
        <table className="items-table">
          <thead>
            <tr>
              <th className="col-desc">Description</th>
              <th className="col-qty">Qty</th>
              <th className="col-rate">Rate ({inv.currency})</th>
              <th className="col-amount">Amount</th>
              <th className="col-action"></th>
            </tr>
          </thead>
          <tbody>
            {inv.items.map((item, i) => {
              const amt = (parseFloat(item.qty) || 0) * (parseFloat(item.rate) || 0);
              return (
                <tr key={i}>
                  <td><input value={item.description} onChange={(e) => setItem(i, "description", e.target.value)} placeholder="Service description" /></td>
                  <td><input type="number" value={item.qty} onChange={(e) => setItem(i, "qty", e.target.value)} placeholder="0" /></td>
                  <td><input type="number" value={item.rate} onChange={(e) => setItem(i, "rate", e.target.value)} placeholder="0" step="any" /></td>
                  <td className="amount-cell">${fmtMoney(amt)}</td>
                  <td><button className="remove-btn" onClick={() => removeItem(i)} disabled={inv.items.length <= 1}>×</button></td>
                </tr>
              );
            })}
          </tbody>
        </table>
        <button className="add-btn" onClick={addItem}>+ Add Line Item</button>
      </div>

      <div className="form-card">
        <div className="row-tax">
          <div className="field">
            <label>Tax %</label>
            <input type="number" value={inv.taxPercent} onChange={(e) => set("taxPercent", e.target.value)} step="any" />
          </div>
          <div className="field tax-note-field">
            <label>Tax Note</label>
            <input value={inv.taxNote} onChange={(e) => set("taxNote", e.target.value)} placeholder="e.g. not applicable — non-resident supplier" />
          </div>
        </div>
      </div>

      <div className="form-row-2">
        <div className="form-card">
          <h3>Payment Information</h3>
          <textarea rows={4} value={inv.paymentInfo} onChange={(e) => set("paymentInfo", e.target.value)} placeholder="Bank name, BSB, account number, etc." />
        </div>
        <div className="form-card">
          <h3>Notes</h3>
          <textarea rows={4} value={inv.notes} onChange={(e) => set("notes", e.target.value)} placeholder="Additional notes..." />
        </div>
      </div>

      <div className="totals-card">
        <div className="total-row">
          <span>Subtotal</span>
          <span>{inv.currency} ${fmtMoney(sub)}</span>
        </div>
        <div className="total-row">
          <span>Tax ({inv.taxPercent}%)</span>
          <span>{inv.currency} ${fmtMoney(tax)}</span>
        </div>
        <div className="total-row total-due">
          <span>Total Due</span>
          <span>{inv.currency} ${fmtMoney(total)}</span>
        </div>
      </div>

      <div className="action-row">
        <button className="action-btn" onClick={newInvoice}>New Invoice</button>
        <button className="action-btn" onClick={() => buildPDF(inv)}>Download PDF</button>
      </div>
    </div>
  );
};

const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);
```
**styles.css**

```css
:root {
  --bg: #fafafa;
  --text: #1d1d1f;
  --text-muted: #6e6e73;
  --border: rgba(0, 0, 0, 0.1);
  --card-bg: #ffffff;
  --input-bg: #f5f5f7;
  --button-bg: #1d1d1f;
  --button-text: #ffffff;
  --radius: 12px;
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
  --accent: #1d1d1f;
}

html[data-theme="dark"] {
  --bg: #1c1c1c;
  --text: #f5f5f7;
  --text-muted: #a1a1a6;
  --border: rgba(255, 255, 255, 0.1);
  --card-bg: #232323;
  --input-bg: #2a2a2a;
  --button-bg: #f5f5f7;
  --button-text: #1d1d1f;
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
  --accent: #f5f5f7;
}

body {
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "SF Pro Display", sans-serif;
  margin: 0;
  padding: 0;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  letter-spacing: -0.01em;
  line-height: 1.5;
}

.container {
  max-width: 820px;
  margin: 0 auto;
  padding: 3rem 2rem;
}

.main-header {
  text-align: center;
  margin-bottom: 2.5rem;
}

h1 {
  font-size: 2.5rem;
  font-weight: 700;
  margin: 0 0 0.5rem;
  letter-spacing: -0.03em;
  line-height: 1.1;
}

.subtitle {
  color: var(--text-muted);
  font-size: 1rem;
  font-weight: 400;
  margin: 0;
  line-height: 1.6;
}

.form-card {
  background: var(--card-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.25rem;
  margin-bottom: 1rem;
  box-shadow: var(--shadow-sm);
}

.form-card h3 {
  margin: 0 0 0.875rem;
  font-size: 0.8125rem;
  font-weight: 600;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.form-row-2 {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  margin-bottom: 1rem;
}

.row-4 {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr 1fr;
  gap: 0.75rem;
}

.row-tax {
  display: grid;
  grid-template-columns: 100px 1fr;
  gap: 0.75rem;
}

.field {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin-bottom: 0.5rem;
}

.field:last-child {
  margin-bottom: 0;
}

.field label {
  font-size: 0.6875rem;
  font-weight: 600;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

input, textarea {
  background: var(--input-bg);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.625rem 0.75rem;
  font-size: 0.875rem;
  color: var(--text);
  font-family: inherit;
  transition: border-color 0.2s;
  width: 100%;
  box-sizing: border-box;
}

input:focus, textarea:focus {
  outline: none;
  border-color: var(--accent);
}

textarea {
  resize: vertical;
}

input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

input[type="number"] {
  -moz-appearance: textfield;
}

/* Items table */
.items-table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 0.75rem;
}

.items-table th {
  text-align: left;
  font-size: 0.6875rem;
  font-weight: 600;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 0 0.25rem 0.5rem;
}

.items-table td {
  padding: 0.25rem;
}

.items-table input {
  width: 100%;
}

.col-desc { width: 42%; }
.col-qty { width: 12%; }
.col-rate { width: 18%; }
.col-amount { width: 20%; }
.col-action { width: 8%; }

.amount-cell {
  font-size: 0.875rem;
  font-weight: 600;
  padding: 0.625rem 0.75rem !important;
  color: var(--text);
  white-space: nowrap;
}

.remove-btn {
  background: none;
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text-muted);
  font-size: 1.25rem;
  cursor: pointer;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s;
}

.remove-btn:hover:not(:disabled) {
  color: #e55;
  border-color: #e55;
}

.remove-btn:disabled {
  opacity: 0.3;
  cursor: not-allowed;
}

.add-btn {
  background: none;
  border: 1px dashed var(--border);
  border-radius: 8px;
  color: var(--text-muted);
  font-size: 0.8125rem;
  font-weight: 600;
  cursor: pointer;
  padding: 0.5rem;
  width: 100%;
  transition: all 0.2s;
}

.add-btn:hover {
  border-color: var(--accent);
  color: var(--text);
}

/* Totals */
.totals-card {
  background: var(--card-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.25rem;
  margin-bottom: 1.5rem;
  box-shadow: var(--shadow-sm);
  max-width: 100%;
}

.total-row {
  display: flex;
  justify-content: space-between;
  padding: 0.375rem 0;
  font-size: 0.9375rem;
}

.total-row span:first-child {
  color: var(--text-muted);
  font-weight: 500;
}

.total-row span:last-child {
  font-weight: 600;
}

.total-due {
  border-top: 1px solid var(--border);
  margin-top: 0.375rem;
  padding-top: 0.75rem;
  font-size: 1.125rem;
}

.total-due span:last-child {
  font-weight: 700;
}

/* Actions */
.action-row {
  display: flex;
  gap: 0.75rem;
}

.action-btn {
  background: var(--card-bg);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 0.75rem 1.5rem;
  font-size: 0.875rem;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s;
  box-shadow: var(--shadow-sm);
}

.action-btn:hover {
  box-shadow: var(--shadow-md);
}

.action-btn {
  flex: 1;
}

/* In-app banner */
.in-app-banner {
  background: var(--card-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1rem 1.25rem;
  font-size: 0.875rem;
  text-align: center;
  line-height: 1.5;
}

.copy-link-btn {
  display: block;
  margin: 0.75rem auto 0;
  padding: 0.5rem 1rem;
  font-size: 0.8rem;
  font-weight: 600;
  cursor: pointer;
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  transition: all 0.2s;
}

.copy-link-btn:hover {
  box-shadow: var(--shadow-sm);
}

@media (max-width: 640px) {
  .container {
    padding: 2rem 1.25rem;
  }

  h1 {
    font-size: 2rem;
  }

  .form-row-2 {
    grid-template-columns: 1fr;
  }

  .row-4 {
    grid-template-columns: 1fr 1fr;
    gap: 0.5rem;
  }

  .row-tax {
    grid-template-columns: 80px 1fr;
  }

  .items-table th {
    font-size: 0.625rem;
  }

}
```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "dependencies": {
    "react": "^19.2.3",
    "react-dom": "^19.2.3",
    "jspdf": "^2.5.2"
  },
  "description": "Invoice Generator - Create simple, professional invoices as PDF."
}
```