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]} +
+ + {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

+
+
+

100%

+

Success Rate

+
+
+ + {/* Action buttons */} +
+ + +
+ + {/* Links preview table */} +
+
+

Generated Links

+

Click any link to copy

+
+
+ + + + + + + + + + + {generatedLinks.map((link, i) => ( + + + + + + + ))} + +
#DestinationAmountLink
+ + {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 */} +
+
+ + + + + + + + + + + + + {rows.map((row, i) => { + const rowErrors = getRowErrors(i); + const hasRowError = rowErrors.length > 0; + const errorFields = new Set(rowErrors.map((e) => e.field)); + + return ( + + + {(["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 ( + + ); + })} + + + ); + })} + +
#DestinationAmountAssetMemo
+ + {i + 1} + + + {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