diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3c7ef49 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash", + "Read", + "Edit", + "Write", + "WebFetch", + "Grep", + "Glob", + "LS", + "MultiEdit", + "NotebookRead", + "NotebookEdit", + "TodoRead", + "TodoWrite", + "WebSearch" + ] + } +} diff --git a/src/app/api/groups/[id]/invites/bulk/route.ts b/src/app/api/groups/[id]/invites/bulk/route.ts new file mode 100644 index 0000000..9e04004 --- /dev/null +++ b/src/app/api/groups/[id]/invites/bulk/route.ts @@ -0,0 +1,126 @@ +/** + * POST /api/groups/[id]/invites/bulk + * Bulk invite contacts to a group. + * + * Request body: + * { + * invitees: Array<{ + * address: string; + * label: string; + * email?: string; + * }> + * } + * + * Response: + * { + * successes: Array<{ address: string; label: string; isExistingUser: boolean }>; + * failures: Array<{ address: string; label: string; error: string }>; + * } + */ + +import { NextRequest, NextResponse } from "next/server"; + +interface InviteeData { + address: string; + label: string; + email?: string; +} + +interface InviteResult { + address: string; + label: string; + isExistingUser: boolean; +} + +interface FailedInvite { + address: string; + label: string; + error: string; +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const groupId = params.id; + const body = await request.json(); + const invitees: InviteeData[] = body.invitees || []; + + if (!Array.isArray(invitees) || invitees.length === 0) { + return NextResponse.json( + { error: "No invitees provided" }, + { status: 400 } + ); + } + + if (invitees.length > 50) { + return NextResponse.json( + { error: "Maximum 50 invites per action" }, + { status: 400 } + ); + } + + const successes: InviteResult[] = []; + const failures: FailedInvite[] = []; + + // Process each invitee + for (const invitee of invitees) { + try { + // Validate Stellar address format + if (!invitee.address || !/^G[A-Z2-7]{55}$/.test(invitee.address)) { + failures.push({ + address: invitee.address, + label: invitee.label, + error: "Invalid Stellar address format", + }); + continue; + } + + // Check if user exists (mock implementation - in production, query user database) + // For now, assume any address that passes validation could be an existing user + const isExistingUser = Math.random() > 0.3; // Mock: 70% chance of existing user + + if (isExistingUser) { + // In production, create group membership record + successes.push({ + address: invitee.address, + label: invitee.label, + isExistingUser: true, + }); + } else { + // In production, store pending invite and send email + // This would involve: + // 1. Creating a pending_invite record with groupId, address, and email + // 2. Sending an email notification using the receipt email infrastructure + + // For now, just track as successful (pending invite created) + successes.push({ + address: invitee.address, + label: invitee.label, + isExistingUser: false, + }); + } + } catch (error) { + failures.push({ + address: invitee.address, + label: invitee.label, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + return NextResponse.json({ + successes, + failures, + }); + } catch (error) { + console.error("Bulk invite error:", error); + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/invoices/qr-batch/route.ts b/src/app/api/invoices/qr-batch/route.ts new file mode 100644 index 0000000..20779aa --- /dev/null +++ b/src/app/api/invoices/qr-batch/route.ts @@ -0,0 +1,83 @@ +/** + * POST /api/invoices/qr-batch + * Generate QR codes for multiple invoices in batch. + * + * Request body: + * { + * invoiceIds: string[]; + * } + * + * Response: + * { + * qrCodes: Array<{ + * invoiceId: string; + * dataUrl: string; + * url: string; + * }>; + * } + */ + +import { NextRequest, NextResponse } from "next/server"; + +interface QRCodeResult { + invoiceId: string; + dataUrl: string; + url: string; +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const invoiceIds: string[] = body.invoiceIds || []; + + if (!Array.isArray(invoiceIds) || invoiceIds.length === 0) { + return NextResponse.json( + { error: "No invoice IDs provided" }, + { status: 400 } + ); + } + + if (invoiceIds.length > 12) { + return NextResponse.json( + { error: "Maximum 12 invoices per batch" }, + { status: 400 } + ); + } + + const qrCodes: QRCodeResult[] = []; + + // Generate QR codes for each invoice + for (const invoiceId of invoiceIds) { + try { + // In production, use a QR library like 'qrcode' to generate the QR code + // For now, we'll provide a mock data URL + const invoiceUrl = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/invoice/${invoiceId}`; + + // Mock QR data URL (in production, use qrcode library) + // Example: const qrCode = await QRCode.toDataURL(invoiceUrl); + const mockDataUrl = `data:image/svg+xml,${invoiceId.slice(0, 8)}`; + + qrCodes.push({ + invoiceId, + dataUrl: mockDataUrl, + url: invoiceUrl, + }); + } catch (error) { + console.error(`Failed to generate QR for invoice ${invoiceId}:`, error); + // Continue with next invoice on error + } + } + + return NextResponse.json({ + qrCodes, + }); + } catch (error) { + console.error("QR batch generation error:", error); + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Internal server error", + }, + { status: 500 } + ); + } +} diff --git a/src/components/groups/BulkInviteModal.tsx b/src/components/groups/BulkInviteModal.tsx new file mode 100644 index 0000000..6b2ea30 --- /dev/null +++ b/src/components/groups/BulkInviteModal.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { getAddressBook, AddressEntry } from "@/lib/addressBook"; +import RecipientReputationBadge from "@/components/ReputationBadge"; + +interface SelectedContact extends AddressEntry { + email?: string; +} + +interface BulkInviteModalProps { + isOpen: boolean; + groupId: string; + groupName: string; + onSubmit: (invitees: SelectedContact[]) => Promise; + onClose: () => void; +} + +const MAX_INVITES = 50; + +export default function BulkInviteModal({ + isOpen, + groupId, + groupName, + onSubmit, + onClose, +}: BulkInviteModalProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedContacts, setSelectedContacts] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const addressBook = getAddressBook(); + + const filteredContacts = useMemo(() => { + if (!searchQuery.trim()) return addressBook; + + const query = searchQuery.toLowerCase(); + return addressBook.filter( + (contact) => + contact.nickname.toLowerCase().includes(query) || + contact.address.toLowerCase().startsWith(query) + ); + }, [searchQuery, addressBook]); + + const isMaxSelected = selectedContacts.length >= MAX_INVITES; + const canAddMore = selectedContacts.length < MAX_INVITES; + + const toggleContact = (contact: AddressEntry) => { + setSelectedContacts((prev) => { + const isSelected = prev.some((c) => c.address === contact.address); + if (isSelected) { + return prev.filter((c) => c.address !== contact.address); + } else if (canAddMore) { + return [...prev, contact]; + } + return prev; + }); + }; + + const removeContact = (address: string) => { + setSelectedContacts((prev) => + prev.filter((c) => c.address !== address) + ); + }; + + const handleSubmit = async () => { + if (selectedContacts.length === 0) { + setError("Please select at least one contact to invite"); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + await onSubmit(selectedContacts); + setSuccessMessage( + `Successfully invited ${selectedContacts.length} contact${selectedContacts.length !== 1 ? "s" : ""} to ${groupName}` + ); + setTimeout(() => { + setSuccessMessage(null); + setSelectedContacts([]); + setSearchQuery(""); + onClose(); + }, 2000); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to send invitations" + ); + } finally { + setIsSubmitting(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+

Bulk Invite to {groupName}

+ + {/* Search Box */} +
+ setSearchQuery(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Selected Contacts List */} + {selectedContacts.length > 0 && ( +
+

+ Selected ({selectedContacts.length}/{MAX_INVITES}) +

+
+ {selectedContacts.map((contact) => ( +
+ {contact.nickname} + +
+ ))} +
+
+ )} + + {/* Available Contacts */} +
+ {filteredContacts.length > 0 ? ( + filteredContacts.map((contact) => { + const isSelected = selectedContacts.some( + (c) => c.address === contact.address + ); + return ( +
toggleContact(contact)} + className={`flex items-center gap-3 p-2 rounded cursor-pointer ${ + isSelected + ? "bg-blue-100 border border-blue-300" + : "bg-gray-50 hover:bg-gray-100" + } ${!canAddMore && !isSelected ? "opacity-50 cursor-not-allowed" : ""}`} + > + { + e.stopPropagation(); + toggleContact(contact); + }} + disabled={!canAddMore && !isSelected} + className="w-4 h-4" + /> +
+

+ {contact.nickname} +

+

+ {contact.address} +

+
+ +
+ ); + }) + ) : ( +

+ No contacts found +

+ )} +
+ + {isMaxSelected && ( +
+

+ Maximum {MAX_INVITES} invites per action +

+
+ )} + + {error && ( +
+

{error}

+
+ )} + + {successMessage && ( +
+

{successMessage}

+
+ )} + + {/* Action Buttons */} +
+ + +
+
+
+ ); +} diff --git a/src/components/invoice/CSVRowImporter.tsx b/src/components/invoice/CSVRowImporter.tsx new file mode 100644 index 0000000..efbbea3 --- /dev/null +++ b/src/components/invoice/CSVRowImporter.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState, useRef } from "react"; +import { parseRecipients, parseCSVRow, saveColumnMapping, loadColumnMapping, InvoiceFormFields, ColumnMapping, EXPECTED_COLUMNS } from "@/lib/csvInvoiceSchema"; +import ColumnMappingModal from "./ColumnMappingModal"; + +interface CSVRowImporterProps { + onImportComplete: (data: InvoiceFormFields & { recipients?: any[] }) => void; +} + +export default function CSVRowImporter({ onImportComplete }: CSVRowImporterProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [csvInput, setCsvInput] = useState(""); + const [error, setError] = useState(null); + const [showMappingModal, setShowMappingModal] = useState(false); + const [pendingCsvData, setPendingCsvData] = useState<{ [key: string]: any } | null>(null); + const [csvColumns, setCsvColumns] = useState([]); + const [currentMapping, setCurrentMapping] = useState(null); + const [spreadsheetName, setSpreadsheetName] = useState(""); + const fileInputRef = useRef(null); + + const handleParseCSVRow = (text: string): { columns: string[]; data: { [key: string]: string } } | null => { + const parsed = parseCSVRow(text); + if (!parsed) { + setError("CSV must contain headers and at least one data row with matching column counts"); + return null; + } + return parsed; + }; + + const handlePasteCSV = () => { + if (!csvInput.trim()) { + setError("Please paste CSV content"); + return; + } + + setError(null); + const parsed = handleParseCSVRow(csvInput); + if (!parsed) return; + + const { columns, data } = parsed; + setCsvColumns(columns); + setPendingCsvData(data); + + const detectedMapping = detectMapping(columns); + if (needsMapping(columns)) { + setShowMappingModal(true); + } else { + processImport(data, detectedMapping); + } + }; + + const handleFileUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setError(null); + setSpreadsheetName(file.name.replace(/\.[^/.]+$/, "")); // Remove extension for localStorage key + + try { + const text = await file.text(); + setCsvInput(text); + + const parsed = handleParseCSVRow(text); + if (!parsed) return; + + const { columns, data } = parsed; + setCsvColumns(columns); + setPendingCsvData(data); + + const savedMapping = loadColumnMapping(file.name); + if (savedMapping) { + processImport(data, savedMapping); + } else if (needsMapping(columns)) { + setShowMappingModal(true); + } else { + processImport(data, detectMapping(columns)); + } + } catch (err) { + setError(`Failed to read file: ${err instanceof Error ? err.message : "Unknown error"}`); + } + }; + + const detectMapping = (columns: string[]): ColumnMapping => { + const mapping: ColumnMapping = {}; + columns.forEach((col) => { + const normalized = col.toLowerCase().trim(); + if (EXPECTED_COLUMNS.includes(normalized)) { + mapping[col] = normalized as any; + } + }); + return mapping; + }; + + const needsMapping = (columns: string[]): boolean => { + return !columns.every( + (col) => + EXPECTED_COLUMNS.includes(col.toLowerCase().trim()) + ); + }; + + const processImport = (data: { [key: string]: string }, mapping: ColumnMapping) => { + const mapped: any = {}; + let hasData = false; + + Object.entries(mapping).forEach(([csvCol, formField]) => { + const value = data[csvCol]; + if (value && csvCol in data) { + if (formField === "recipients" && typeof value === "string") { + mapped.recipients = parseRecipients(value); + } else { + mapped[formField] = value; + } + hasData = true; + } + }); + + if (!hasData) { + setError("No data found in CSV row"); + return; + } + + if (spreadsheetName && currentMapping) { + saveColumnMapping(spreadsheetName, currentMapping); + } + + setCsvInput(""); + setIsExpanded(false); + onImportComplete(mapped); + }; + + const handleMappingConfirm = (mapping: ColumnMapping) => { + setCurrentMapping(mapping); + setShowMappingModal(false); + if (pendingCsvData) { + processImport(pendingCsvData, mapping); + } + }; + + return ( +
+ + + {isExpanded && ( +
+
+ +