diff --git a/app/frontend/src/app/bulk/page.tsx b/app/frontend/src/app/bulk/page.tsx
new file mode 100644
index 0000000..532da7c
--- /dev/null
+++ b/app/frontend/src/app/bulk/page.tsx
@@ -0,0 +1,158 @@
+"use client";
+
+import Link from "next/link";
+import { NetworkBadge } from "@/components/NetworkBadge";
+import { useBulkInvoice } from "@/hooks/useBulkInvoice";
+import { CSVDropZone } from "@/components/bulk/CSVDropZone";
+import { ReviewTable } from "@/components/bulk/ReviewTable";
+import { BatchProgress } from "@/components/bulk/BatchProgress";
+import { BatchSuccess } from "@/components/bulk/BatchSuccess";
+
+export default function BulkInvoicing() {
+ const {
+ step,
+ rows,
+ errors,
+ hasErrors,
+ generatedLinks,
+ progress,
+ parseFile,
+ removeRow,
+ updateRow,
+ getRowErrors,
+ generateBatch,
+ downloadCSV,
+ reset,
+ } = useBulkInvoice();
+
+ return (
+
+
+
+ {/* Background glows */}
+
+
+
+ {/* Sidebar */}
+
+
+ {/* Main content */}
+
+ {/* Header */}
+
+
+
+
+ Bulk Invoice
+
+
+ Generator.
+
+
+
+
+ Upload a CSV with payment details, review & edit, then generate all your payment links in one batch.
+
+
+
+ {/* Step indicator */}
+
+ {(["upload", "review", "processing", "success"] as const).map((s, i) => {
+ const labels = ["Upload", "Review", "Generate", "Done"];
+ const icons = ["๐ค", "๐", "โก", "โ
"];
+ const isActive = s === step;
+ const isPast =
+ ["upload", "review", "processing", "success"].indexOf(step) >
+ ["upload", "review", "processing", "success"].indexOf(s);
+
+ return (
+
+
+ {isPast ? "โ" : icons[i]}
+
+
+ {labels[i]}
+
+ {i < 3 && (
+
+ )}
+
+ );
+ })}
+
+
+ {/* Dynamic content */}
+
+ {step === "upload" && }
+
+ {step === "review" && (
+
+ )}
+
+ {step === "processing" && (
+
+ )}
+
+ {step === "success" && (
+
+ )}
+
+
+
+ );
+}
diff --git a/app/frontend/src/app/globals.css b/app/frontend/src/app/globals.css
index a2dc41e..8523a65 100644
--- a/app/frontend/src/app/globals.css
+++ b/app/frontend/src/app/globals.css
@@ -24,3 +24,8 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+
+@keyframes shimmer {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(100%); }
+}
diff --git a/app/frontend/src/components/bulk/BatchProgress.tsx b/app/frontend/src/components/bulk/BatchProgress.tsx
new file mode 100644
index 0000000..f0204cc
--- /dev/null
+++ b/app/frontend/src/components/bulk/BatchProgress.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import type { GeneratedLink } from "@/hooks/useBulkInvoice";
+
+type BatchProgressProps = {
+ progress: number;
+ total: number;
+ generatedLinks: GeneratedLink[];
+};
+
+export function BatchProgress({ progress, total, generatedLinks }: BatchProgressProps) {
+ const completed = generatedLinks.length;
+
+ return (
+
+ {/* Animated icon */}
+
+
+ {/* Title */}
+
+
Generating Links
+
+ Processing {completed} of {total} invoices...
+
+
+
+ {/* Progress bar */}
+
+
+
+ {completed} completed
+ {progress}%
+
+
+
+ {/* Live counter */}
+
+
+
{completed}
+
Generated
+
+
+
{total - completed}
+
Remaining
+
+
+
+ );
+}
diff --git a/app/frontend/src/components/bulk/BatchSuccess.tsx b/app/frontend/src/components/bulk/BatchSuccess.tsx
new file mode 100644
index 0000000..bf7f2d7
--- /dev/null
+++ b/app/frontend/src/components/bulk/BatchSuccess.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import type { GeneratedLink } from "@/hooks/useBulkInvoice";
+
+type BatchSuccessProps = {
+ generatedLinks: GeneratedLink[];
+ onDownload: () => void;
+ onReset: () => void;
+};
+
+export function BatchSuccess({ generatedLinks, onDownload, onReset }: BatchSuccessProps) {
+ const totalValue = generatedLinks.reduce((sum, l) => sum + Number(l.amount), 0);
+
+ return (
+
+ {/* Success banner */}
+
+
+
Batch Complete!
+
+ All {generatedLinks.length} payment links have been generated successfully.
+
+
+
+ {/* Stats */}
+
+
+
{generatedLinks.length}
+
Links Generated
+
+
+
+ {totalValue.toLocaleString(undefined, { minimumFractionDigits: 2 })}
+
+
Total Value
+
+
+
+
+ {/* Action buttons */}
+
+
+
+
+
+ {/* Links preview table */}
+
+
+
Generated Links
+
Click any link to copy
+
+
+
+
+
+ | # |
+ Destination |
+ Amount |
+ Link |
+
+
+
+ {generatedLinks.map((link, i) => (
+
+ |
+
+ {i + 1}
+
+ |
+
+ {link.destination}
+ |
+
+ {link.amount} {link.asset}
+ |
+
+
+ |
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/app/frontend/src/components/bulk/CSVDropZone.tsx b/app/frontend/src/components/bulk/CSVDropZone.tsx
new file mode 100644
index 0000000..3be66f4
--- /dev/null
+++ b/app/frontend/src/components/bulk/CSVDropZone.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useRef, useState, useCallback } from "react";
+
+type CSVDropZoneProps = {
+ onFileSelected: (file: File) => void;
+};
+
+export function CSVDropZone({ onFileSelected }: CSVDropZoneProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [error, setError] = useState(null);
+ const inputRef = useRef(null);
+
+ const handleFile = useCallback(
+ (file: File) => {
+ setError(null);
+ if (!file.name.endsWith(".csv")) {
+ setError("Please upload a .csv file.");
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ setError("File is too large. Max 5 MB.");
+ return;
+ }
+ setSelectedFile(file.name);
+ onFileSelected(file);
+ },
+ [onFileSelected]
+ );
+
+ const onDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(true);
+ };
+
+ const onDragLeave = () => setIsDragging(false);
+
+ const onDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ const file = e.dataTransfer.files[0];
+ if (file) handleFile(file);
+ };
+
+ const onClick = () => inputRef.current?.click();
+
+ const onChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) handleFile(file);
+ };
+
+ return (
+
+
e.key === "Enter" && onClick()}
+ className={`
+ relative group cursor-pointer rounded-3xl p-12 sm:p-16
+ border-2 border-dashed transition-all duration-300 ease-out
+ flex flex-col items-center justify-center text-center
+ ${
+ isDragging
+ ? "border-indigo-400 bg-indigo-500/10 scale-[1.02]"
+ : "border-white/10 bg-neutral-900/30 hover:border-indigo-500/30 hover:bg-neutral-900/50"
+ }
+ `}
+ >
+ {/* Glow */}
+
+
+
+ {/* Icon */}
+
+
+ {selectedFile ? (
+ <>
+
+ ๐
+ {selectedFile}
+ โ
+
+
Parsing complete. Proceeding to review...
+ >
+ ) : (
+ <>
+
+
+ {isDragging ? "Drop your CSV here" : "Drag & drop your CSV"}
+
+
+ or click to browse
+
+
+
+
+ destination
+ amount
+ asset
+ memo
+
+ >
+ )}
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Template hint */}
+
+ Need a template?{" "}
+
+
+
+ );
+}
diff --git a/app/frontend/src/components/bulk/ReviewTable.tsx b/app/frontend/src/components/bulk/ReviewTable.tsx
new file mode 100644
index 0000000..7c15f58
--- /dev/null
+++ b/app/frontend/src/components/bulk/ReviewTable.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import { useState } from "react";
+import type { InvoiceRow, RowError } from "@/utils/csvParser";
+
+type ReviewTableProps = {
+ rows: InvoiceRow[];
+ errors: RowError[];
+ onUpdateRow: (index: number, field: keyof InvoiceRow, value: string) => void;
+ onRemoveRow: (index: number) => void;
+ onGenerate: () => void;
+ onBack: () => void;
+ getRowErrors: (index: number) => RowError[];
+ hasErrors: boolean;
+};
+
+export function ReviewTable({
+ rows,
+ errors,
+ onUpdateRow,
+ onRemoveRow,
+ onGenerate,
+ onBack,
+ getRowErrors,
+ hasErrors,
+}: ReviewTableProps) {
+ const [editingCell, setEditingCell] = useState<{ row: number; field: keyof InvoiceRow } | null>(null);
+
+ const errorCount = errors.length;
+ const totalValue = rows.reduce((sum, r) => {
+ const n = Number(r.amount);
+ return sum + (isNaN(n) ? 0 : n);
+ }, 0);
+
+ return (
+
+ {/* Header bar */}
+
+
+
Review Invoices
+
+ {rows.length} row{rows.length !== 1 ? "s" : ""} parsed ยท Total value:{" "}
+ {totalValue.toLocaleString(undefined, { minimumFractionDigits: 2 })}
+
+
+
+
+
+
+
+
+ {/* Error banner */}
+ {errorCount > 0 && (
+
+
โ
+
+
+ {errorCount} validation error{errorCount !== 1 ? "s" : ""} found
+
+
Fix or remove the highlighted rows to continue.
+
+
+ )}
+
+ {/* Table */}
+
+
+
+
+
+ | # |
+ Destination |
+ Amount |
+ Asset |
+ Memo |
+ |
+
+
+
+ {rows.map((row, i) => {
+ const rowErrors = getRowErrors(i);
+ const hasRowError = rowErrors.length > 0;
+ const errorFields = new Set(rowErrors.map((e) => e.field));
+
+ return (
+
+ |
+
+ {i + 1}
+
+ |
+ {(["destination", "amount", "asset", "memo"] as const).map((field) => {
+ const isEditing = editingCell?.row === i && editingCell.field === field;
+ const fieldHasError = errorFields.has(field);
+ const errorMsg = rowErrors.find((e) => e.field === field)?.message;
+
+ return (
+
+ {isEditing ? (
+ field === "asset" ? (
+
+ ) : (
+ onUpdateRow(i, field, e.target.value)}
+ onBlur={() => setEditingCell(null)}
+ onKeyDown={(e) => e.key === "Enter" && setEditingCell(null)}
+ className="w-full bg-neutral-800 border border-indigo-500/30 rounded-lg px-3 py-2 text-sm font-bold focus:outline-none focus:ring-1 focus:ring-indigo-500"
+ />
+ )
+ ) : (
+ setEditingCell({ row: i, field })}
+ className={`
+ cursor-pointer px-3 py-2 rounded-lg text-sm font-medium transition
+ ${fieldHasError ? "bg-red-500/10 border border-red-500/20 text-red-400" : "hover:bg-white/5 text-neutral-300"}
+ ${field === "destination" ? "font-mono text-xs" : ""}
+ `}
+ title={errorMsg || "Click to edit"}
+ >
+ {row[field] || empty}
+ {fieldHasError && (
+ {errorMsg}
+ )}
+
+ )}
+ |
+ );
+ })}
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ {rows.length === 0 && (
+
+
No rows to display.
+
+
+ )}
+
+
+ );
+}
diff --git a/app/frontend/src/hooks/useBulkInvoice.ts b/app/frontend/src/hooks/useBulkInvoice.ts
new file mode 100644
index 0000000..973d0aa
--- /dev/null
+++ b/app/frontend/src/hooks/useBulkInvoice.ts
@@ -0,0 +1,158 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { parseCSV, type InvoiceRow, type RowError } from "@/utils/csvParser";
+
+export type BulkStep = "upload" | "review" | "processing" | "success";
+
+export type GeneratedLink = {
+ index: number;
+ destination: string;
+ amount: string;
+ asset: string;
+ memo: string;
+ link: string;
+};
+
+export function useBulkInvoice() {
+ const [step, setStep] = useState("upload");
+ const [rows, setRows] = useState([]);
+ const [errors, setErrors] = useState([]);
+ const [generatedLinks, setGeneratedLinks] = useState([]);
+ const [progress, setProgress] = useState(0);
+ const [fileName, setFileName] = useState("");
+
+ const parseFile = useCallback(async (file: File) => {
+ setFileName(file.name);
+ const text = await file.text();
+ const result = parseCSV(text);
+ setRows(result.validRows);
+ setErrors(result.errors);
+ if (result.validRows.length > 0) {
+ setStep("review");
+ }
+ }, []);
+
+ const removeRow = useCallback((index: number) => {
+ setRows((prev) => prev.filter((_, i) => i !== index));
+ setErrors((prev) =>
+ prev
+ .filter((e) => e.row !== index + 1)
+ .map((e) => (e.row > index + 1 ? { ...e, row: e.row - 1 } : e))
+ );
+ }, []);
+
+ const updateRow = useCallback((index: number, field: keyof InvoiceRow, value: string) => {
+ setRows((prev) => {
+ const updated = [...prev];
+ updated[index] = { ...updated[index], [field]: value };
+ return updated;
+ });
+ // Clear errors for the updated field
+ setErrors((prev) => prev.filter((e) => !(e.row === index + 1 && e.field === field)));
+ }, []);
+
+ const getRowErrors = useCallback(
+ (rowIndex: number) => errors.filter((e) => e.row === rowIndex + 1),
+ [errors]
+ );
+
+ const hasErrors = errors.length > 0;
+
+ const revalidate = useCallback(() => {
+ const newErrors: RowError[] = [];
+ rows.forEach((row, i) => {
+ if (!row.destination) {
+ newErrors.push({ row: i + 1, field: "destination", message: "Destination is required." });
+ }
+ if (!row.amount) {
+ newErrors.push({ row: i + 1, field: "amount", message: "Amount is required." });
+ } else {
+ const num = Number(row.amount);
+ if (isNaN(num)) newErrors.push({ row: i + 1, field: "amount", message: "Invalid number." });
+ else if (num <= 0) newErrors.push({ row: i + 1, field: "amount", message: "Must be > 0." });
+ }
+ if (!["USDC", "XLM"].includes(row.asset.toUpperCase())) {
+ newErrors.push({ row: i + 1, field: "asset", message: "Use USDC or XLM." });
+ }
+ });
+ setErrors(newErrors);
+ return newErrors.length === 0;
+ }, [rows]);
+
+ const generateBatch = useCallback(async () => {
+ if (!revalidate()) return;
+
+ setStep("processing");
+ setProgress(0);
+ setGeneratedLinks([]);
+
+ const links: GeneratedLink[] = [];
+
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ // Simulate link generation with a delay
+ await new Promise((r) => setTimeout(r, 150 + Math.random() * 200));
+
+ const id = Math.random().toString(36).substring(2, 10);
+ links.push({
+ index: i,
+ destination: row.destination,
+ amount: row.amount,
+ asset: row.asset,
+ memo: row.memo,
+ link: `https://quickex.to/pay/${id}`,
+ });
+
+ setGeneratedLinks([...links]);
+ setProgress(Math.round(((i + 1) / rows.length) * 100));
+ }
+
+ setStep("success");
+ }, [rows, revalidate]);
+
+ const downloadCSV = useCallback(() => {
+ const header = "destination,amount,asset,memo,link";
+ const body = generatedLinks
+ .map(
+ (l) =>
+ `"${l.destination}","${l.amount}","${l.asset}","${l.memo}","${l.link}"`
+ )
+ .join("\n");
+ const csv = `${header}\n${body}`;
+ const blob = new Blob([csv], { type: "text/csv" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `quickex-batch-${Date.now()}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }, [generatedLinks]);
+
+ const reset = useCallback(() => {
+ setStep("upload");
+ setRows([]);
+ setErrors([]);
+ setGeneratedLinks([]);
+ setProgress(0);
+ setFileName("");
+ }, []);
+
+ return {
+ step,
+ rows,
+ errors,
+ hasErrors,
+ generatedLinks,
+ progress,
+ fileName,
+ parseFile,
+ removeRow,
+ updateRow,
+ getRowErrors,
+ revalidate,
+ generateBatch,
+ downloadCSV,
+ reset,
+ };
+}
diff --git a/app/frontend/src/utils/csvParser.ts b/app/frontend/src/utils/csvParser.ts
new file mode 100644
index 0000000..49f67a6
--- /dev/null
+++ b/app/frontend/src/utils/csvParser.ts
@@ -0,0 +1,155 @@
+export type InvoiceRow = {
+ destination: string;
+ amount: string;
+ asset: string;
+ memo: string;
+};
+
+export type RowError = {
+ row: number;
+ field: string;
+ message: string;
+};
+
+export type ParseResult = {
+ validRows: InvoiceRow[];
+ errors: RowError[];
+};
+
+const REQUIRED_COLUMNS = ["destination", "amount"];
+const VALID_ASSETS = ["USDC", "XLM"];
+
+function normalizeHeader(header: string): string {
+ return header.trim().toLowerCase().replace(/[^a-z]/g, "");
+}
+
+const HEADER_MAP: Record = {
+ destination: "destination",
+ address: "destination",
+ recipient: "destination",
+ wallet: "destination",
+ amount: "amount",
+ value: "amount",
+ asset: "asset",
+ currency: "asset",
+ token: "asset",
+ memo: "memo",
+ note: "memo",
+ description: "memo",
+ message: "memo",
+};
+
+function parseLine(line: string): string[] {
+ const result: string[] = [];
+ let current = "";
+ let inQuotes = false;
+
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i];
+ if (ch === '"') {
+ if (inQuotes && line[i + 1] === '"') {
+ current += '"';
+ i++;
+ } else {
+ inQuotes = !inQuotes;
+ }
+ } else if (ch === "," && !inQuotes) {
+ result.push(current.trim());
+ current = "";
+ } else {
+ current += ch;
+ }
+ }
+ result.push(current.trim());
+ return result;
+}
+
+export function parseCSV(text: string): ParseResult {
+ const lines = text
+ .split(/\r?\n/)
+ .map((l) => l.trim())
+ .filter((l) => l.length > 0);
+
+ if (lines.length === 0) {
+ return { validRows: [], errors: [{ row: 0, field: "file", message: "CSV file is empty." }] };
+ }
+
+ // Map headers
+ const rawHeaders = parseLine(lines[0]);
+ const columnMap: Record = {};
+
+ rawHeaders.forEach((h, i) => {
+ const normalized = normalizeHeader(h);
+ if (HEADER_MAP[normalized]) {
+ columnMap[i] = HEADER_MAP[normalized];
+ }
+ });
+
+ // Check required columns exist
+ const mappedFields = new Set(Object.values(columnMap));
+ for (const req of REQUIRED_COLUMNS) {
+ if (!mappedFields.has(req as keyof InvoiceRow)) {
+ return {
+ validRows: [],
+ errors: [{ row: 0, field: req, message: `Missing required column: "${req}". Expected columns: destination, amount, asset (optional), memo (optional).` }],
+ };
+ }
+ }
+
+ const validRows: InvoiceRow[] = [];
+ const errors: RowError[] = [];
+
+ for (let i = 1; i < lines.length; i++) {
+ const cells = parseLine(lines[i]);
+ const row: InvoiceRow = { destination: "", amount: "", asset: "USDC", memo: "" };
+
+ // Fill from column map
+ for (const [colIdx, field] of Object.entries(columnMap)) {
+ const value = cells[Number(colIdx)] ?? "";
+ row[field] = value;
+ }
+
+ // Default asset
+ if (!row.asset || row.asset.trim() === "") {
+ row.asset = "USDC";
+ } else {
+ row.asset = row.asset.toUpperCase().trim();
+ }
+
+ // Validate
+ let hasError = false;
+
+ if (!row.destination) {
+ errors.push({ row: i, field: "destination", message: "Destination address is required." });
+ hasError = true;
+ }
+
+ if (!row.amount) {
+ errors.push({ row: i, field: "amount", message: "Amount is required." });
+ hasError = true;
+ } else {
+ const num = Number(row.amount);
+ if (isNaN(num)) {
+ errors.push({ row: i, field: "amount", message: `"${row.amount}" is not a valid number.` });
+ hasError = true;
+ } else if (num <= 0) {
+ errors.push({ row: i, field: "amount", message: "Amount must be greater than zero." });
+ hasError = true;
+ }
+ }
+
+ if (!VALID_ASSETS.includes(row.asset)) {
+ errors.push({ row: i, field: "asset", message: `Unsupported asset "${row.asset}". Use USDC or XLM.` });
+ hasError = true;
+ }
+
+ if (hasError) {
+ // Still add so user can fix in review table
+ validRows.push(row);
+ } else {
+ validRows.push(row);
+ }
+ }
+
+ return { validRows, errors };
+}
diff --git a/test-invoices.csv b/test-invoices.csv
new file mode 100644
index 0000000..648862c
--- /dev/null
+++ b/test-invoices.csv
@@ -0,0 +1,6 @@
+destination,amount,asset,memo
+GD2PQ5YH2WTQRGPU6HH735AOV3PFC7HMYG4YDNOQUSRQKRWD5H2W,50.00,USDC,Invoice #001
+GD1R3K9LXM8PQTGWXN6YKNG4PCTZLNV7RM9VTH4TGQSKXZML3K9L,125.00,XLM,Consulting Fee
+GC8T9Q0M2PYXVFM6HK3TG8LNRQW7ZXHJ5YDNQKRWD2PQ5YH9Q0M,20.00,USDC,Subscription
+GD4XFHZ7QKPLNRJT2YGWXV9SBMQPCH8AE6NKVWRD3LT5YH2WTQR,,XLM,Missing Amount
+INVALID_ADDR,abc,BTC,Bad Row