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/ui.tsx
// Small shared pieces: dialog, toast, copy and share.
import { useEffect, useRef, useState, type ReactNode } from "react";

const FOCUSABLE = "a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex='-1'])";

/** A dialog (a bottom sheet on phones). Escape, the backdrop and the close button animate it out, then call onClose. */
export function Modal({ title, onClose, children, footer }: {
  title: string;
  onClose: () => void;
  children: ReactNode;
  footer?: (close: () => void) => ReactNode;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const [leaving, setLeaving] = useState(false);
  const close = () => setLeaving(true);
  useEffect(() => {
    const prev = document.activeElement as HTMLElement | null;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") setLeaving(true);
      if (e.key !== "Tab" || !ref.current) return;
      // Keep Tab inside the dialog.
      const items = [...ref.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
      if (!items.length) return;
      const first = items[0];
      const last = items[items.length - 1];
      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
    };
    document.addEventListener("keydown", onKey);
    document.body.style.overflow = "hidden";
    ref.current?.focus();
    return () => {
      document.removeEventListener("keydown", onKey);
      document.body.style.overflow = "";
      prev?.focus();
    };
  }, []);
  return (
    <div
      className={`dialog-backdrop ${leaving ? "leaving" : ""}`}
      onMouseDown={(e) => { if (e.target === e.currentTarget) close(); }}
      onAnimationEnd={(e) => { if (leaving && e.target === e.currentTarget) onClose(); }}
    >
      <div className="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" tabIndex={-1} ref={ref}>
        <div className="dialog-head">
          <h2 className="dialog-title" id="dialog-title">{title}</h2>
          <button className="btn btn-quiet btn-icon btn-sm" type="button" onClick={close} aria-label="Close">
            <svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5 5 15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>
          </button>
        </div>
        <div className="dialog-body">{children}</div>
        {footer && <div className="dialog-foot">{footer(close)}</div>}
      </div>
    </div>
  );
}

/** A "More" menu under a button. It closes on a pick, a click outside, or Escape. */
export function Menu({ label, title, children }: { label: ReactNode; title: string; children: ReactNode }) {
  const ref = useRef<HTMLDetailsElement>(null);
  useEffect(() => {
    const onDown = (e: PointerEvent) => { if (ref.current?.open && !ref.current.contains(e.target as Node)) ref.current.open = false; };
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Escape" || !ref.current?.open) return;
      ref.current.open = false;
      ref.current.querySelector("summary")?.focus();
    };
    document.addEventListener("pointerdown", onDown);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("pointerdown", onDown); document.removeEventListener("keydown", onKey); };
  }, []);
  return (
    <details className="dropdown" ref={ref}>
      <summary className="btn btn-line btn-icon" aria-label={title} title={title}>{label}</summary>
      <div className="dropdown-menu dropdown-menu-end" onClick={() => { if (ref.current) ref.current.open = false; }}>{children}</div>
    </details>
  );
}

let pushToast: ((text: string) => void) | null = null;
export function toast(text: string) { pushToast?.(text); }

export function Toaster() {
  const [toastState, setToast] = useState<{ text: string; leaving: boolean; n: number } | null>(null);
  useEffect(() => {
    let timer: number | undefined;
    let n = 0;
    pushToast = (text) => {
      setToast({ text, leaving: false, n: ++n });
      window.clearTimeout(timer);
      timer = window.setTimeout(() => setToast((t) => (t ? { ...t, leaving: true } : t)), 2600);
    };
    return () => { pushToast = null; window.clearTimeout(timer); };
  }, []);
  if (!toastState) return null;
  return (
    <div key={toastState.n} className={`toast ${toastState.leaving ? "leaving" : ""}`} role="status"
      onAnimationEnd={() => { if (toastState.leaving) setToast(null); }}>
      {toastState.text}
    </div>
  );
}

export async function copy(text: string, done = "Copied") {
  try {
    await navigator.clipboard.writeText(text);
    toast(done);
  } catch {
    window.prompt("Copy this:", text);
  }
}

/** Phones open the share sheet; elsewhere the link is copied. */
export const sharesNatively = () => typeof navigator.share === "function" && matchMedia("(pointer: coarse)").matches;

export async function share(url: string, title: string) {
  if (sharesNatively()) {
    try { await navigator.share({ title, url }); return; } catch { /* cancelled */ }
  }
  await copy(url, "Link copied. Send it to your client.");
}

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