diff --git a/frontend/package.json b/frontend/package.json index fe80757a..f95bde0c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,7 @@ "date-fns": "^3.6.0", "eciesjs": "0.5.0", "ethers": "^6.13.5", + "fflate": "^0.8.3", "framer-motion": "^12.23.12", "html2canvas": "^1.4.1", "idb": "^8.0.3", @@ -41,6 +42,7 @@ "jspdf": "^3.0.0", "lucide-react": "^0.471.1", "papaparse": "^5.5.2", + "qrcode": "^1.5.4", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5c202295..58f7bd06 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -46,6 +46,7 @@ export const config = getDefaultConfig({ const queryClient = new QueryClient(); import { Toaster } from "react-hot-toast"; const GenerateLink = lazy(() => import("./page/GenerateLink")); +const ImportInvoice = lazy(() => import("./page/ImportInvoice")); const CreateInvoicesBatch = lazy(() => import("./page/CreateInvoicesBatch")); const NotFound = lazy(() => import("./page/NotFound")); const Settings = lazy(() => import("./page/Settings")); @@ -103,6 +104,7 @@ function App() { } /> } /> } /> + } /> } diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 74ac36ed..52623f43 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -10,6 +10,8 @@ import { motion, AnimatePresence } from "framer-motion"; import CloseIcon from "@mui/icons-material/Close"; import MenuIcon from "@mui/icons-material/Menu"; import InfoIcon from "@mui/icons-material/Info"; +import { cn } from "@/lib/utils"; +import { SHELL } from "@/utils/layout"; function Navbar() { const { address, isConnected } = useAccount(); @@ -138,7 +140,9 @@ function Navbar() { : "bg-[#161920]" }`} > -
+ {/* Shares the page shell's gutter so the logo and wallet button line up + with the content edges. */} +
{ + const [record, setRecord] = useState(null); + const [loadState, setLoadState] = useState("idle"); + const [qrDataUrl, setQrDataUrl] = useState(""); + const [copied, setCopied] = useState(false); + + const id = invoiceId === undefined || invoiceId === null ? null : String(invoiceId); + + // Load the stored payload whenever the dialog opens for a different invoice. + useEffect(() => { + if (!open || id === null || !chainId) return; + + let cancelled = false; + setLoadState("loading"); + setRecord(null); + setQrDataUrl(""); + + (async () => { + try { + const stored = await getInvoiceById(chainId, id); + if (cancelled) return; + setRecord(stored ?? null); + setLoadState("ready"); + } catch (err) { + if (cancelled) return; + console.error("[ShareInvoiceDialog] Failed to read invoice:", err); + setLoadState("error"); + } + })(); + + return () => { + cancelled = true; + }; + }, [open, id, chainId]); + + /** + * Why the invoice cannot be shared, or null when it can. + * + * A payload that no longer matches its own recorded hash would produce a + * link the recipient's chain check rejects, so it is refused here rather + * than handed out to fail confusingly later. + */ + const blockedReason = useMemo(() => { + if (loadState !== "ready") return null; + if (!record?.data) { + return "This invoice's details are not on this device — only the on-chain summary is available, and there is nothing to put in a link. Details are unrecoverable once local storage is cleared."; + } + if ( + record.invoiceDataHash && + computeInvoiceHash(record.data) !== record.invoiceDataHash + ) { + return "The details stored for this invoice no longer match what was recorded on-chain, so a share link could not be verified by the recipient."; + } + return null; + }, [loadState, record]); + + const shareUrl = useMemo(() => { + if (loadState !== "ready" || blockedReason || !record?.data) return ""; + try { + return buildInvoiceShareUrl({ + invoiceId: id, + chainId, + invoiceData: record.data, + }); + } catch (err) { + console.error("[ShareInvoiceDialog] Failed to build share link:", err); + return ""; + } + }, [loadState, blockedReason, record, id, chainId]); + + const size = useMemo(() => describeShareSize(shareUrl), [shareUrl]); + + // Render the QR only when the link is short enough to scan reliably. + useEffect(() => { + if (!shareUrl || !size.fitsQr) { + setQrDataUrl(""); + return; + } + let cancelled = false; + QRCode.toDataURL(shareUrl, { margin: 1, width: 320 }) + .then((url) => { + if (!cancelled) setQrDataUrl(url); + }) + .catch((err) => { + console.warn("[ShareInvoiceDialog] QR generation failed:", err); + if (!cancelled) setQrDataUrl(""); + }); + return () => { + cancelled = true; + }; + }, [shareUrl, size.fitsQr]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("[ShareInvoiceDialog] Copy failed:", err); + toast.error("Could not copy the link. Select it and copy manually."); + } + }, [shareUrl]); + + /** + * Hand the link to the OS share sheet. + * + * This is the reason link length stops mattering on mobile: one tap goes + * straight into WhatsApp or Telegram and the user never sees the string. + */ + const handleNativeShare = useCallback(async () => { + try { + await navigator.share({ + title: `Chainvoice invoice #${id}`, + text: "Open this link to view and pay the invoice.", + url: shareUrl, + }); + } catch (err) { + // Dismissing the share sheet is a choice, not a failure. + if (err?.name !== "AbortError") { + console.warn("[ShareInvoiceDialog] Native share failed:", err); + } + } + }, [shareUrl, id]); + + const handleDownloadQr = useCallback(() => { + if (!qrDataUrl) return; + const link = document.createElement("a"); + link.href = qrDataUrl; + link.download = `chainvoice-invoice-${id}-qr.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, [qrDataUrl, id]); + + const handleDownloadFile = useCallback(() => { + try { + const filename = downloadInvoiceShareFile({ + invoiceId: id, + chainId, + invoiceData: record.data, + }); + toast.success(`Saved ${filename}`); + } catch (err) { + console.error("[ShareInvoiceDialog] File export failed:", err); + toast.error("Could not export the invoice file."); + } + }, [id, chainId, record]); + + const canNativeShare = + typeof navigator !== "undefined" && typeof navigator.share === "function"; + + return ( + onClose?.()} + maxWidth="sm" + fullWidth + PaperProps={{ sx: { borderRadius: 2 } }} + > + +
+
+

+ + Share invoice #{id} +

+

+ Send the invoice details directly, without waiting for your + client to register an encryption key. +

+
+ onClose?.()} aria-label="Close"> + + +
+
+ + + {loadState === "loading" && ( +
+ + Loading invoice details… +
+ )} + + {loadState === "error" && ( +

+ Could not read this invoice from local storage. +

+ )} + + {loadState === "ready" && blockedReason && ( +
+ +

{blockedReason}

+
+ )} + + {loadState === "ready" && !blockedReason && ( +
+ {/* The one thing every recipient of this link needs to know. */} +
+ +

+ Anyone with this link can view + the invoice{" "} + — including your and your client's names, addresses and line + items. Share it only with the client. It cannot be revoked. +

+
+ + {size.fitsUrl ? ( + <> +
+

+ {shortenUrl(shareUrl)} +

+

+ {size.chars} characters +

+
+ +
+ + {canNativeShare && ( + + )} +
+ + ) : ( +
+ +

+ This invoice has too many line items to fit in a link + ({size.chars} characters, limit {SHARE_URL_MAX_CHARS}). Send + it as a file instead — your client can import the file on the + same page. +

+
+ )} + + {qrDataUrl ? ( +
+
+ + Scan to import +
+
+ {`QR + +
+
+ ) : ( + size.fitsUrl && ( +

+ This invoice is too detailed for a reliable QR code + ({size.chars} characters, limit {SHARE_QR_MAX_CHARS}). The + link and the file both still work. +

+ ) + )} + +
+ +

+ For email or any channel that mangles long links. No size + limit. +

+
+
+ )} +
+
+ ); +}; + +export default ShareInvoiceDialog; diff --git a/frontend/src/components/UserProfileSettings.jsx b/frontend/src/components/UserProfileSettings.jsx index 02ce812a..9a5145fa 100644 --- a/frontend/src/components/UserProfileSettings.jsx +++ b/frontend/src/components/UserProfileSettings.jsx @@ -32,11 +32,16 @@ export default function UserProfileSettings() { onSubmit={handleSubmit} className="bg-white p-4 sm:p-6 rounded-xl border border-gray-100 shadow-sm" > -
-

- - Your Information -

+
+
+

+ + Your Information +

+

+ Applied as the sender on every invoice. Saved on this device only. +

+
{!loading && isComplete && ( diff --git a/frontend/src/page/Applayout.jsx b/frontend/src/page/Applayout.jsx index 83feae16..7c2d0d42 100644 --- a/frontend/src/page/Applayout.jsx +++ b/frontend/src/page/Applayout.jsx @@ -8,7 +8,9 @@ function Applayout() { return (
-
+ {/* Navbar is fixed at h-24, so the offset has to match it exactly — + pt-20 left content tucked 16px underneath. */} +
diff --git a/frontend/src/page/CreateInvoice.jsx b/frontend/src/page/CreateInvoice.jsx index e9193482..f49947af 100644 --- a/frontend/src/page/CreateInvoice.jsx +++ b/frontend/src/page/CreateInvoice.jsx @@ -55,6 +55,7 @@ import { toInvoiceUserDetails } from "@/utils/userProfile"; import { useUserProfile } from "@/hooks/useUserProfile"; import OnboardingProfileDialog from "@/components/OnboardingProfileDialog"; import SenderSummary from "@/components/SenderSummary"; +import { CARD, PAGE_CONTAINER } from "@/utils/layout"; import toast from "react-hot-toast"; import { storeInvoice } from "../services/invoiceStorage/invoiceDB.js"; import { computeInvoiceHash } from "../services/relay/invoiceHashUtils.js"; @@ -798,7 +799,7 @@ function CreateInvoice() {
{showUnsupportedNetwork && ( -
+

Unsupported network @@ -812,16 +813,11 @@ function CreateInvoice() {

)} -
+
{(searchParams.get("clientAddress") || searchParams.get("amount") || searchParams.get("description")) && ( -
+
@@ -837,42 +833,46 @@ function CreateInvoice() {
)} -
+

Create New Invoice

-
+ {/* Full width so its right edge lands on the same line as the Payment + Currency card and the items table below. Every block on the page + shares one left and one right edge. */} +
-
-
-