diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 5802b2be..20519c79 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -32,6 +32,17 @@ import { isLastColumn, type SearchColumn, } from '@/lib/searchColumns'; +import { + searchInvoices, + getStoredSearches, + saveSearch, + removeRecentSearch, + clearSearchHistory, + getSavedSearches, + saveNamedSearch, + removeSavedSearch, + type SavedSearch, +} from '@/lib/invoiceSearch'; // ── Constants ────────────────────────────────────────────────────────────────── @@ -77,7 +88,199 @@ const STATUS_STYLES: Record = { Refunded: 'bg-gray-500/20 text-gray-300', }; -// ── ColumnPicker ────────────────────────────────────────────────────────────── +// ── FullTextSearchBar ───────────────────────────────────────────────────────── + +interface FullTextSearchBarProps { + value: string; + onChange: (v: string) => void; + onSubmit: (v: string) => void; + onFocus: () => void; + inputRef: React.RefObject; +} + +function FullTextSearchBar({ + value, + onChange, + onSubmit, + onFocus, + inputRef, +}: FullTextSearchBarProps) { + return ( +
+ + } + type="search" + value={value} + placeholder="Full-text search: id, creator, recipient, token, status…" + className="w-full pl-9 pr-4 py-2.5 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + aria-label="Full-text invoice search" + autoComplete="off" + onChange={(e) => onChange(e.target.value)} + onFocus={onFocus} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onSubmit(value); + } + }} + data-testid="fulltext-search-input" + /> + {value && ( + + )} +
+ ); +} + +// ── SearchHistoryPanel ──────────────────────────────────────────────────────── + +interface SearchHistoryPanelProps { + recentSearches: string[]; + savedSearches: SavedSearch[]; + onSelect: (query: string) => void; + onRemoveRecent: (query: string) => void; + onRemoveSaved: (id: string) => void; + onClearHistory: () => void; + onSaveCurrent: () => void; + currentQuery: string; +} + +function SearchHistoryPanel({ + recentSearches, + savedSearches, + onSelect, + onRemoveRecent, + onRemoveSaved, + onClearHistory, + onSaveCurrent, + currentQuery, +}: SearchHistoryPanelProps) { + const hasAnything = recentSearches.length > 0 || savedSearches.length > 0; + if (!hasAnything && !currentQuery.trim()) return null; + + return ( +
+ {/* Save current search */} + {currentQuery.trim() && ( +
+ + Current: “{currentQuery}” + + +
+ )} + + {/* Saved searches */} + {savedSearches.length > 0 && ( +
+
+

Saved Searches

+
+
    + {savedSearches.map((s) => ( +
  • + + +
  • + ))} +
+
+ )} + + {/* Recent searches */} + {recentSearches.length > 0 && ( +
+
+

Recent

+ +
+
    + {recentSearches.map((q) => ( +
  • + + +
  • + ))} +
+
+ )} +
+ ); +} function ColumnPicker({ visibleColumns, @@ -326,6 +529,13 @@ function SearchPageInner() { const [draftCreator, setDraftCreator] = useState(criteria.creator ?? ''); const [draftRecipient, setDraftRecipient] = useState(criteria.recipient ?? ''); + // Full-text search state + const [fullTextQuery, setFullTextQuery] = useState(''); + const [showHistory, setShowHistory] = useState(false); + const [recentSearches, setRecentSearches] = useState([]); + const [savedSearches, setSavedSearches] = useState([]); + const fullTextInputRef = useRef(null); + // Share status feedback const [shareStatus, setShareStatus] = useState(null); @@ -356,6 +566,49 @@ function SearchPageInner() { const [fetchError, setFetchError] = useState(null); const [knownTokens, setKnownTokens] = useState(DEFAULT_TOKENS); + // ── Load search history from localStorage ──────────────────────────────── + + useEffect(() => { + setRecentSearches(getStoredSearches()); + setSavedSearches(getSavedSearches()); + }, []); + + // ── Collapse history panel when clicking outside ─────────────────────────── + + useEffect(() => { + const handler = (e: MouseEvent) => { + if ( + fullTextInputRef.current && + !fullTextInputRef.current.contains(e.target as Node) && + !(e.target as Element)?.closest('[data-testid="search-history-panel"]') + ) { + setShowHistory(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + // ── Full-text search submit ──────────────────────────────────────────────── + + const handleFullTextSubmit = useCallback((query: string) => { + const trimmed = query.trim(); + if (trimmed) { + saveSearch(trimmed); + setRecentSearches(getStoredSearches()); + } + setShowHistory(false); + }, []); + + // ── Save current search as named ────────────────────────────────────────── + + const handleSaveCurrentSearch = useCallback(() => { + const trimmed = fullTextQuery.trim(); + if (!trimmed) return; + saveNamedSearch(trimmed, trimmed); + setSavedSearches(getSavedSearches()); + }, [fullTextQuery]); + // ── Sync URL back to state on changes ────────────────────────────────────── const searchParamsString = searchParams.toString(); @@ -417,6 +670,7 @@ function SearchPageInner() { setDraftFundedMax(''); setDraftCreator(''); setDraftRecipient(''); + setFullTextQuery(''); router.replace('/search', { scroll: false }); }, [router]); @@ -518,10 +772,15 @@ function SearchPageInner() { const results = useMemo(() => { const compiled = compileFilter(criteria); - return FilterIndex.queryIndex(allInvoices, compiled); - }, [allInvoices, criteria]); + const filtered = FilterIndex.queryIndex(allInvoices, compiled); + // Apply full-text search on top of structured filters + if (fullTextQuery.trim()) { + return searchInvoices(filtered, fullTextQuery); + } + return filtered; + }, [allInvoices, criteria, fullTextQuery]); - const filtersActive = hasActiveFilters(criteria); + const filtersActive = hasActiveFilters(criteria) || !!fullTextQuery.trim(); // ── Input handlers ───────────────────────────────────────────────────────── @@ -587,6 +846,43 @@ function SearchPageInner() {

{fetchError}

)} + {/* Full-text search bar */} +
+ setShowHistory(true)} + inputRef={fullTextInputRef} + /> + {showHistory && ( +
+ { + setFullTextQuery(q); + setShowHistory(false); + }} + onRemoveRecent={(q) => { + removeRecentSearch(q); + setRecentSearches(getStoredSearches()); + }} + onRemoveSaved={(id) => { + removeSavedSearch(id); + setSavedSearches(getSavedSearches()); + }} + onClearHistory={() => { + clearSearchHistory(); + setRecentSearches([]); + }} + onSaveCurrent={handleSaveCurrentSearch} + /> +
+ )} +
+ {/* Filter panel */}
>> 0; + } + return COLOR_PALETTE[hash % COLOR_PALETTE.length]; +} + +function contrastTextColor(hex: string): string { + // Convert hex to RGB and choose white or black text + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? "#111" : "#fff"; +} + +// ── PresenceDot ────────────────────────────────────────────────────────────── + +function PresenceDot({ address, color, field }: { address: string; color: string; field?: string }) { + const [hover, setHover] = useState(false); + const short = truncateAddress(address); + const textColor = contrastTextColor(color); + + return ( +
+
setHover(true)} + onMouseLeave={() => setHover(false)} + aria-label={`Collaborator: ${short}${field ? ` editing ${field}` : ""}`} + > + {short.charAt(0).toUpperCase()} +
+
+

{short}

+ {field && ( +

+ editing {field} +

+ )} +
+ + {/* Tooltip on hover */} + {hover && ( +
+ {address} + {field && currently editing: {field}} + +
+ )} +
+ ); +} + +// ── FieldHighlight ──────────────────────────────────────────────────────────── + +/** + * Wraps an input/textarea with a colored outline when a remote collaborator + * has that field focused. Usage: + * + * + * + * + */ +export function FieldHighlight({ + fieldName, + remoteCursors, + children, + className, +}: { + fieldName: string; + remoteCursors: { address: string; field: string; color: string }[]; + children: React.ReactNode; + className?: string; +}) { + const activeEditors = remoteCursors.filter((c) => c.field === fieldName); + const firstColor = activeEditors[0]?.color; + + return ( +
0 + ? `Field being edited by ${activeEditors.map((e) => truncateAddress(e.address)).join(", ")}` + : undefined + } + > + {children} + + {/* Glowing border overlay */} + {firstColor && ( + + ); +} + +// ── ConnectionIndicator ─────────────────────────────────────────────────────── + +function ConnectionIndicator({ + isConnected, + error, +}: { + isConnected: boolean; + error: string | null; +}) { + if (error) { + return ( +
+
+ ); + } + if (!isConnected) { + return ( +
+
+ ); + } + return ( +
+
+ ); +} + +// ── CoEditorPanel ───────────────────────────────────────────────────────────── + +interface CoEditorPanelProps { + invoiceId: string; + currentAddress: string | null; + hasWritePermission?: boolean; +} + +/** + * Sidebar panel showing real-time collaborators, their field focus, and + * connection status. Renders an empty/collapsed state when offline. + */ +export default function CoEditorPanel({ + invoiceId, + currentAddress, + hasWritePermission = true, +}: CoEditorPanelProps) { + const [collapsed, setCollapsed] = useState(false); + const [permDeniedMessage, setPermDeniedMessage] = useState(null); + const permDeniedTimerRef = useRef | null>(null); + + const handlePermissionDenied = useCallback(() => { + setPermDeniedMessage("You have read-only access to this invoice."); + if (permDeniedTimerRef.current) clearTimeout(permDeniedTimerRef.current); + permDeniedTimerRef.current = setTimeout(() => setPermDeniedMessage(null), 5000); + }, []); + + const { + remoteCursors, + remotePresence, + isConnected, + connectionError, + } = useInvoiceCollaboration({ + invoiceId, + currentAddress, + hasWritePermission, + onPermissionDenied: handlePermissionDenied, + }); + + useEffect(() => { + return () => { + if (permDeniedTimerRef.current) clearTimeout(permDeniedTimerRef.current); + }; + }, []); + + const activeCount = remotePresence.filter((p) => p.online).length; + const totalEditing = remoteCursors.length; + + return ( +
+ {/* Header row */} +
+
+

Co-editors

+ {activeCount > 0 && ( + + {activeCount} online + + )} +
+
+ + +
+
+ + {/* Permission denied message */} + {permDeniedMessage && ( +

+ {permDeniedMessage} +

+ )} + + {/* Collapsed state: show avatar row only */} + {collapsed ? ( +
+ {remotePresence.slice(0, 5).map((p) => { + const color = colorForAddress(p.address); + const short = truncateAddress(p.address); + const textColor = contrastTextColor(color); + return ( +
+ {short.charAt(0).toUpperCase()} +
+ ); + })} + {remotePresence.length > 5 && ( + +{remotePresence.length - 5} + )} +
+ ) : ( + <> + {/* No collaborators */} + {remotePresence.length === 0 && remoteCursors.length === 0 && ( +

+ {isConnected ? "No other editors online." : "Waiting for connection…"} +

+ )} + + {/* Online presence list */} + {remotePresence.length > 0 && ( +
+

Online

+
    + {remotePresence + .filter((p) => p.online) + .map((p) => { + const cursor = remoteCursors.find((c) => c.address === p.address); + return ( +
  • + +
  • + ); + })} +
+
+ )} + + {/* Active field edits */} + {totalEditing > 0 && ( +
+

+ Currently editing +

+
    + {remoteCursors.map((c) => ( +
  • +
  • + ))} +
+
+ )} + + {/* Current user */} + {currentAddress && ( +
+
+ )} + + )} +
+ ); +} diff --git a/src/components/InvoiceExportButton.tsx b/src/components/InvoiceExportButton.tsx index 1342239c..30999d9f 100644 --- a/src/components/InvoiceExportButton.tsx +++ b/src/components/InvoiceExportButton.tsx @@ -38,6 +38,487 @@ async function fetchLogoDataUrl(url: string): Promise { } } +/** + * Generates a QR code data URL for the given invoice verification URL. + */ +async function generateQrCodeDataUrl(invoiceId: string): Promise { + try { + const QRCode = (await import('qrcode')).default; + const verifyUrl = + typeof window !== 'undefined' + ? `${window.location.origin}/verify/${invoiceId}` + : `https://stellarsplit-dapp.vercel.app/verify/${invoiceId}`; + return await QRCode.toDataURL(verifyUrl, { margin: 1, width: 120 }); + } catch { + return null; + } +} + +/** + * Downloads a receipt-style PDF for the invoice (compact, single-page). + * Useful after payment is confirmed to share a "paid receipt" with the payer. + */ +async function downloadReceiptPdf(invoice: Invoice, total: bigint, brand: BrandSettings | null): Promise { + const { pdf, Document, Page, Text, View, StyleSheet, Image } = await import('@react-pdf/renderer'); + + const accent = brand?.accentColor ?? DEFAULT_ACCENT_COLOR; + const logoDataUrl = brand?.logoUrl ? await fetchLogoDataUrl(brand.logoUrl) : null; + const qrCodeDataUrl = await generateQrCodeDataUrl(invoice.id); + const exportedAt = new Date().toLocaleString(); + const verifyUrl = + typeof window !== 'undefined' + ? `${window.location.origin}/verify/${invoice.id}` + : `https://stellarsplit-dapp.vercel.app/verify/${invoice.id}`; + + const styles = StyleSheet.create({ + page: { padding: 36, fontSize: 10, fontFamily: 'Helvetica', color: '#111', backgroundColor: '#fff' }, + banner: { backgroundColor: accent, padding: '10 16', marginBottom: 18, borderRadius: 4 }, + bannerTitle: { fontSize: 16, fontWeight: 'bold', color: '#fff' }, + bannerSubtitle: { fontSize: 9, color: 'rgba(255,255,255,0.8)', marginTop: 2 }, + logo: { height: 28, width: 'auto', marginBottom: 6, objectFit: 'contain' }, + row: { flexDirection: 'row', borderBottomWidth: 1, borderColor: '#eee', paddingVertical: 5 }, + label: { width: 130, color: '#555', fontWeight: 'bold' }, + value: { flex: 1 }, + totalRow: { flexDirection: 'row', paddingVertical: 6, marginTop: 2 }, + totalLabel: { width: 130, fontWeight: 'bold', fontSize: 12 }, + totalValue: { flex: 1, fontWeight: 'bold', fontSize: 12, color: accent }, + divider: { borderBottomWidth: 2, borderColor: '#111', marginVertical: 4 }, + qrRow: { flexDirection: 'row', alignItems: 'center', marginTop: 14, padding: 8, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 4 }, + qrImg: { width: 56, height: 56, marginRight: 10 }, + qrText: { flex: 1 }, + qrTitle: { fontSize: 9, fontWeight: 'bold' }, + qrUrl: { fontSize: 7, color: '#666', marginTop: 2 }, + footer: { position: 'absolute', bottom: 24, left: 36, right: 36, fontSize: 8, color: '#aaa', textAlign: 'center' }, + statusBadge: { fontSize: 9, color: '#fff', backgroundColor: invoice.status === 'Released' ? '#16a34a' : invoice.status === 'Refunded' ? '#6b7280' : '#d97706', padding: '2 6', borderRadius: 10, alignSelf: 'flex-start' }, + }); + + const doc = ( + + + {/* Banner */} + + {logoDataUrl && } + ✦ StellarSplit Receipt + + {brand?.tagline ?? 'On-chain Invoice & Payment Splitting'} + + + + {/* Invoice details */} + Invoice #{invoice.id} + Status{invoice.status} + Creator{invoice.creator} + Total Amount{formatAmount(total)} {invoice.token || 'USDC'} + Funded{formatAmount(invoice.funded)} {invoice.token || 'USDC'} + {invoice.deadline > 0 && ( + Deadline{new Date(invoice.deadline * 1000).toLocaleString()} + )} + + + + Funded + {formatAmount(invoice.funded)} {invoice.token || 'USDC'} + + + {/* QR Code */} + {qrCodeDataUrl && ( + + {/* eslint-disable-next-line jsx-a11y/alt-text */} + + + Verify On-Chain + {verifyUrl} + + + )} + + Generated {exportedAt} · StellarSplit + + + ); + + const blob = await pdf(doc).toBlob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const dateStr = new Date().toISOString().split('T')[0]; + a.download = `receipt-${invoice.id}-${dateStr}.pdf`; + a.click(); + URL.revokeObjectURL(url); +} + +/** + * Sends a receipt email for the invoice by calling the /api/send-confirmation route. + */ +async function sendReceiptEmail(invoice: Invoice, recipientEmail: string): Promise<{ ok: boolean; message: string }> { + try { + const res = await fetch('/api/send-confirmation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + invoiceId: invoice.id, + email: recipientEmail, + status: invoice.status, + creatorAddress: invoice.creator, + }), + }); + if (res.ok) return { ok: true, message: 'Receipt email sent!' }; + const data = await res.json().catch(() => ({})); + return { ok: false, message: (data as { error?: string }).error ?? 'Failed to send email.' }; + } catch { + return { ok: false, message: 'Network error. Please try again.' }; + } +} + +export default function InvoiceExportButton({ invoice, total, branding }: Props) { + const [loading, setLoading] = useState(false); + const [receiptLoading, setReceiptLoading] = useState(false); + const [emailLoading, setEmailLoading] = useState(false); + const [showEmailInput, setShowEmailInput] = useState(false); + const [emailValue, setEmailValue] = useState(''); + const [emailStatus, setEmailStatus] = useState<{ ok: boolean; message: string } | null>(null); + + const resolveBranding = useCallback(async (): Promise => { + if (branding) return branding; + try { + const res = await fetch(`/api/settings/branding?address=${encodeURIComponent(invoice.creator)}`); + if (res.ok) return (await res.json()) as BrandSettings; + } catch {} + return null; + }, [branding, invoice.creator]); + + const handleExport = useCallback(async () => { + setLoading(true); + try { + // Lazy-load @react-pdf/renderer so it doesn't bloat the initial bundle + const { pdf, Document, Page, Text, View, StyleSheet, Image } = await import('@react-pdf/renderer'); + + const brand = await resolveBranding(); + const accent = brand?.accentColor ?? DEFAULT_ACCENT_COLOR; + const logoDataUrl = brand?.logoUrl ? await fetchLogoDataUrl(brand.logoUrl) : null; + const qrCodeDataUrl = await generateQrCodeDataUrl(invoice.id); + const exportedAt = new Date().toLocaleString(); + + const styles = StyleSheet.create({ + page: { padding: 40, fontSize: 11, fontFamily: 'Helvetica', color: '#111' }, + header: { marginBottom: 24 }, + logo: { height: 40, width: 'auto', marginBottom: 8, objectFit: 'contain' }, + title: { fontSize: 22, fontWeight: 'bold', marginBottom: 4, color: accent }, + subtitle: { fontSize: 11, color: '#666' }, + tagline: { fontSize: 11, color: '#444', marginTop: 2, fontStyle: 'italic' }, + section: { marginBottom: 16 }, + sectionTitle: { fontSize: 13, fontWeight: 'bold', marginBottom: 8, color: '#1a1a1a' }, + table: { width: '100%', borderWidth: 1, borderColor: '#ddd' }, + tableHeader: { + flexDirection: 'row', + backgroundColor: accent, + color: '#fff', + padding: '6 8', + fontWeight: 'bold', + }, + tableRow: { flexDirection: 'row', borderTopWidth: 1, borderColor: '#ddd', padding: '5 8' }, + col1: { flex: 3 }, + col2: { flex: 1, textAlign: 'right' }, + meta: { flexDirection: 'row', marginBottom: 4 }, + metaLabel: { width: 120, color: '#555', fontWeight: 'bold' }, + metaValue: { flex: 1 }, + qrSection: { flexDirection: 'row', alignItems: 'center', marginTop: 14, padding: 10, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 6 }, + qrImage: { width: 64, height: 64, marginRight: 12 }, + qrTextContainer: { flex: 1 }, + qrTitle: { fontSize: 10, fontWeight: 'bold', color: '#1a1a1a' }, + qrSubtitle: { fontSize: 8, color: '#666', marginTop: 3 }, + footer: { position: 'absolute', bottom: 30, left: 40, right: 40, fontSize: 9, color: '#aaa', textAlign: 'center' }, + }); + + const doc = ( + + + {/* Header */} + + {/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer Image node, not an HTML img */} + {logoDataUrl && } + ✦ StellarSplit + Invoice Export + {brand?.tagline && {brand.tagline}} + + + {/* Invoice meta */} + + Invoice Details + + Invoice ID + #{invoice.id} + + + Status + {invoice.status} + + + Creator + {invoice.creator} + + + Total + {formatAmount(total)} {invoice.token || 'USDC'} + + + Funded + {formatAmount(invoice.funded)} {invoice.token || 'USDC'} + + {invoice.deadline > 0 && ( + + Deadline + {new Date(invoice.deadline * 1000).toLocaleString()} + + )} + + + {/* Recipients */} + + Recipients + + + Address + Amount + + {invoice.recipients.map((r, i) => ( + + {r.address} + {formatAmount(r.amount)} + + ))} + + + + {/* Payment history */} + {invoice.payments.length > 0 && ( + + Payment History + + + Payer + Amount + + {invoice.payments.map((p, i) => ( + + {p.payer} + {formatAmount(p.amount)} + + ))} + + + )} + + {/* QR Code Verification Section */} + {qrCodeDataUrl && ( + + {/* eslint-disable-next-line jsx-a11y/alt-text */} + + + Public On-Chain Verification + + Scan this QR code or visit /verify/{invoice.id} to verify this invoice on-chain with payment proof. + + + + )} + + {/* Footer */} + + Exported {exportedAt} · Generated by StellarSplit + + + + ); + + const blob = await pdf(doc).toBlob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const formattedDate = new Date().toISOString().split('T')[0]; + a.download = `invoice-${invoice.id}-${formattedDate}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } finally { + setLoading(false); + } + }, [invoice, total, resolveBranding]); + + const handleReceiptDownload = useCallback(async () => { + setReceiptLoading(true); + try { + const brand = await resolveBranding(); + await downloadReceiptPdf(invoice, total, brand); + } finally { + setReceiptLoading(false); + } + }, [invoice, total, resolveBranding]); + + const handleSendEmail = useCallback(async () => { + const trimmed = emailValue.trim(); + if (!trimmed) return; + setEmailLoading(true); + setEmailStatus(null); + try { + const result = await sendReceiptEmail(invoice, trimmed); + setEmailStatus(result); + if (result.ok) { + setShowEmailInput(false); + setEmailValue(''); + } + } finally { + setEmailLoading(false); + } + }, [invoice, emailValue]); + + return ( +
+
+ {/* Primary: full invoice export */} + + + {/* Receipt PDF download (compact A5) */} + + + {/* Email receipt */} + + + {/* Print */} + +
+ + {/* Email receipt input panel */} + {showEmailInput && ( +
+ setEmailValue(e.target.value)} + placeholder="recipient@example.com" + className="flex-1 px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-gray-200 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + aria-label="Email address for receipt" + onKeyDown={(e) => { if (e.key === 'Enter') handleSendEmail(); }} + /> + +
+ )} + + {/* Email status feedback */} + {emailStatus && ( +

+ {emailStatus.message} +

+ )} +
+ ); +} + +/** + * Fetches an image URL and returns it as a data URL that @react-pdf/renderer + * can embed, or null when the image cannot be inlined (network failure or a + * format react-pdf cannot decode, e.g. WebP — branding colors/tagline still + * apply; the export never fails because of the logo). + */ +async function fetchLogoDataUrl(url: string): Promise { + try { + const res = await fetch(url); + if (!res.ok) return null; + const blob = await res.blob(); + if (!PDF_SAFE_IMAGE_TYPES.has(blob.type)) return null; + return await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : null); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } +} + export default function InvoiceExportButton({ invoice, total, branding }: Props) { const [loading, setLoading] = useState(false); diff --git a/src/components/OnboardingFlow.tsx b/src/components/OnboardingFlow.tsx index 627c2eb8..cb6ecde8 100644 --- a/src/components/OnboardingFlow.tsx +++ b/src/components/OnboardingFlow.tsx @@ -1,48 +1,229 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import WalletConnect from "@/components/WalletConnect"; const ONBOARDING_KEY = "split-onboarded"; const ONBOARDING_STEP_KEY = "stellarsplit_onboarding_step"; -const TOTAL_STEPS = 3; +const CHECKLIST_KEY = "stellarsplit_checklist"; +const TOTAL_STEPS = 5; + +// ── Checklist items ────────────────────────────────────────────────────────── + +const CHECKLIST_ITEMS = [ + { id: "wallet", label: "Connect your Freighter wallet" }, + { id: "invoice", label: "Create your first invoice" }, + { id: "share", label: "Share an invoice link" }, + { id: "payment", label: "Receive your first payment" }, + { id: "explore", label: "Explore the dashboard" }, +] as const; + +type ChecklistId = (typeof CHECKLIST_ITEMS)[number]["id"]; + +// ── Step definitions ───────────────────────────────────────────────────────── const STEPS = [ { + id: "welcome", + icon: "🌟", + title: "Welcome to StellarSplit", + description: + "StellarSplit lets you create on-chain invoices, split payments between multiple recipients, and get paid in USDC on the Stellar network.", + tooltip: "You can always revisit this guide from the Help menu.", + hasVideo: false, + videoPoster: "", + videoSrc: "", + }, + { + id: "wallet", icon: "🔗", title: "Connect your Freighter wallet", description: - "Freighter is a browser extension wallet for Stellar. Connect it to create and pay invoices on-chain.", + "Freighter is a browser extension wallet for Stellar. Connect it below to sign transactions and create invoices on-chain.", + tooltip: "Freighter is free and takes less than 2 minutes to set up.", + hasVideo: false, + videoPoster: "", + videoSrc: "", }, { + id: "invoice", icon: "📄", title: "Create your first invoice", description: - "Fill in recipients, amounts, and a deadline. We'll pre-fill an example to get you started fast.", + "Fill in recipients, amounts, and a deadline. Each recipient gets a unique Stellar address. The total is split automatically when payers send USDC.", + tooltip: "Tip: you can add up to 20 recipients per invoice.", + hasVideo: false, + videoPoster: "", + videoSrc: "", }, { + id: "share", icon: "🔗", title: "Share and get paid", description: - "Every invoice gets a unique link. Share it with payers — when the invoice is fully funded, USDC routes automatically to all recipients.", + "Every invoice gets a unique shareable link and QR code. Share it with payers — when fully funded, USDC routes automatically to all recipients.", + tooltip: "Payers don't need a wallet to view the invoice status.", + hasVideo: false, + videoPoster: "", + videoSrc: "", }, -]; + { + id: "checklist", + icon: "✅", + title: "Your getting-started checklist", + description: + "Track your progress as you explore StellarSplit. Complete each item to become a power user.", + tooltip: "Your checklist progress is saved locally.", + hasVideo: false, + videoPoster: "", + videoSrc: "", + }, +] as const; + +// ── Tooltip bubble ──────────────────────────────────────────────────────────── + +function Tooltip({ text }: { text: string }) { + const [visible, setVisible] = useState(false); + + return ( + + + {visible && ( + + {text} + + + )} + + ); +} + +// ── Checklist component ─────────────────────────────────────────────────────── + +function OnboardingChecklist({ + completed, + onToggle, +}: { + completed: Set; + onToggle: (id: ChecklistId) => void; +}) { + const doneCount = completed.size; + const totalCount = CHECKLIST_ITEMS.length; + const pct = Math.round((doneCount / totalCount) * 100); + + return ( +
+ {/* Progress bar */} +
+
+ {doneCount} of {totalCount} completed + {pct}% +
+
+
+
+
+ + {/* Items */} +
    + {CHECKLIST_ITEMS.map((item) => { + const done = completed.has(item.id); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── export default function OnboardingFlow() { const [show, setShow] = useState(false); const [step, setStep] = useState(1); + const [checklistDone, setChecklistDone] = useState>(new Set()); + // Load state from localStorage useEffect(() => { try { const onboarded = localStorage.getItem(ONBOARDING_KEY); if (onboarded === "true") return; const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY); + const savedChecklist = localStorage.getItem(CHECKLIST_KEY); + setShow(true); if (savedStep) setStep(Math.min(parseInt(savedStep, 10), TOTAL_STEPS)); + if (savedChecklist) { + const parsed = JSON.parse(savedChecklist) as ChecklistId[]; + setChecklistDone(new Set(parsed)); + } } catch {} }, []); + const persistChecklist = useCallback((items: Set) => { + try { + localStorage.setItem(CHECKLIST_KEY, JSON.stringify([...items])); + } catch {} + }, []); + + const handleToggleChecklist = useCallback( + (id: ChecklistId) => { + setChecklistDone((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + persistChecklist(next); + return next; + }); + }, + [persistChecklist] + ); + const handleSkip = () => { try { localStorage.setItem(ONBOARDING_KEY, "true"); @@ -63,6 +244,15 @@ export default function OnboardingFlow() { } catch {} }; + const handleBack = () => { + if (step <= 1) return; + const prev = step - 1; + setStep(prev); + try { + localStorage.setItem(ONBOARDING_STEP_KEY, String(prev)); + } catch {} + }; + if (!show) return null; const current = STEPS[step - 1]; @@ -92,10 +282,17 @@ export default function OnboardingFlow() { {/* Progress bar */}
{Array.from({ length: TOTAL_STEPS }).map((_, i) => ( -
{ + const s = i + 1; + setStep(s); + try { localStorage.setItem(ONBOARDING_STEP_KEY, String(s)); } catch {} + }} + className={`h-1 flex-1 rounded-full transition-all duration-300 focus:outline-none ${ + i < step ? "bg-brand-500" : "bg-white/[0.08] hover:bg-white/[0.15]" }`} /> ))} @@ -105,19 +302,44 @@ export default function OnboardingFlow() {
-

{current.title}

+

+ {current.title} + +

{current.description}

- {/* Step 1: wallet connect inline */} + {/* Step-specific interactive content */} {step === 1 && ( +
+

What you can do

+
    +
  • ✦ Create multi-recipient invoices on Stellar
  • +
  • ✦ Accept USDC payments with automatic splitting
  • +
  • ✦ Track payments in real time with on-chain proof
  • +
  • ✦ Export receipts and share QR codes
  • +
+
+ )} + + {step === 2 && (
+

+ Don't have Freighter?{" "} + + Install it free → + +

)} - {/* Step 2: CTA to create invoice with example values */} - {step === 2 && ( + {step === 3 && ( )} - {/* Step 3: share explanation, no extra widget needed */} + {step === 4 && ( +
+
+

How sharing works:

+
    +
  1. Copy your invoice link from the invoice detail page
  2. +
  3. Share it via email, chat, or the built-in QR code
  4. +
  5. Payers visit the link and pay with their Stellar wallet
  6. +
  7. Funds route to recipients automatically on-chain
  8. +
+
+
+ )} + + {step === 5 && ( +
+ +
+ )}
{/* Footer actions */}
- + {step > 1 && ( + + )}