"use client"; import { useModuleClient } from "@86d-app/core/client"; import { useCallback, useEffect, useRef, useState } from "react"; import CustomerListTemplate from "./customer-list.mdx"; // ─── Types ──────────────────────────────────────────────────────────────────── interface Customer { id: string; email: string; firstName?: string | null; lastName?: string | null; phone?: string | null; tags?: string[] | null; createdAt: string; } interface ListResult { customers: Customer[]; total: number; pages: number; } interface ExportResult { customers: Customer[]; total: number; } interface ImportError { row: number; field: string; message: string; } interface ImportResult { created: number; updated: number; errors: ImportError[]; } interface TagEntry { tag: string; count: number; } // ─── Helpers ────────────────────────────────────────────────────────────────── function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); return `${days}d ago`; } function escapeCsvField(value: string): string { if (value.includes(",") || value.includes('"') || value.includes("\n")) { return `"${value.replace(/"/g, '""')}"`; } return value; } function downloadCsv(filename: string, rows: string[][]): void { const csv = rows.map((row) => row.map(escapeCsvField).join(",")).join("\n"); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } function parseCsv(text: string): string[][] { const rows: string[][] = []; let current = ""; let inQuotes = false; let row: string[] = []; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (inQuotes) { if (ch === '"') { if (i + 1 < text.length && text[i + 1] === '"') { current += '"'; i++; } else { inQuotes = false; } } else { current += ch; } } else if (ch === '"') { inQuotes = true; } else if (ch === ",") { row.push(current.trim()); current = ""; } else if (ch === "\n" || ch === "\r") { if (ch === "\r" && i + 1 < text.length && text[i + 1] === "\n") { i++; } row.push(current.trim()); current = ""; if (row.some((cell) => cell !== "")) { rows.push(row); } row = []; } else { current += ch; } } row.push(current.trim()); if (row.some((cell) => cell !== "")) { rows.push(row); } return rows; } const CSV_HEADERS = ["Email", "First Name", "Last Name", "Phone", "Tags"]; const HEADER_MAP: Record = { email: "email", "e-mail": "email", "email address": "email", "first name": "firstName", firstname: "firstName", first_name: "firstName", "last name": "lastName", lastname: "lastName", last_name: "lastName", phone: "phone", "phone number": "phone", telephone: "phone", tags: "tags", tag: "tags", }; type ImportCustomerRow = { email: string; firstName?: string; lastName?: string; phone?: string; tags?: string[]; }; function rowToCustomer( headers: string[], row: string[], ): ImportCustomerRow | null { const normalizedHeaders = headers.map((h) => h.toLowerCase().trim()); const obj: Record = {}; for (let i = 0; i < normalizedHeaders.length; i++) { const field = HEADER_MAP[normalizedHeaders[i]]; if (field && i < row.length && row[i] !== "") { if (field === "tags") { obj[field] = row[i] .split(";") .map((t) => t.trim()) .filter(Boolean); } else { obj[field] = row[i]; } } } if (!obj.email) return null; return obj as ImportCustomerRow; } // ─── Module Client ─────────────────────────────────────────────────────────── function useCustomersAdminApi() { const client = useModuleClient(); return { listCustomers: client.module("customers").admin["/admin/customers"], listTags: client.module("customers").admin["/admin/customers/tags"], exportCustomers: client.module("customers").admin["/admin/customers/export"], importCustomers: client.module("customers").admin["/admin/customers/import"], }; } // ─── Import Dialog ─────────────────────────────────────────────────────────── function ImportDialog({ onClose, onImport, }: { onClose: () => void; onImport: (customers: ImportCustomerRow[]) => Promise; }) { useEffect(() => { function handler(e: KeyboardEvent) { if (e.key === "Escape") onClose(); } document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [onClose]); const fileRef = useRef(null); const [preview, setPreview] = useState<{ headers: string[]; rows: ImportCustomerRow[]; } | null>(null); const [importing, setImporting] = useState(false); const [result, setResult] = useState(null); const [parseError, setParseError] = useState(null); const handleFile = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setParseError(null); setResult(null); const reader = new FileReader(); reader.onload = (ev) => { const text = ev.target?.result; if (typeof text !== "string") return; const parsed = parseCsv(text); if (parsed.length < 2) { setParseError("CSV must have at least a header row and one data row."); return; } const headers = parsed[0]; const normalizedHeaders = headers.map((h) => h.toLowerCase().trim()); const hasEmail = normalizedHeaders.some((h) => HEADER_MAP[h] === "email"); if (!hasEmail) { setParseError('CSV must include an "Email" column.'); return; } const dataRows = parsed.slice(1); const customers = dataRows .map((row) => rowToCustomer(headers, row)) .filter((c): c is ImportCustomerRow => c !== null); if (customers.length === 0) { setParseError("No valid customer rows found in CSV."); return; } setPreview({ headers, rows: customers }); }; reader.readAsText(file); }, []); const handleImport = useCallback(async () => { if (!preview) return; setImporting(true); try { const importResult = await onImport(preview.rows); setResult(importResult); } finally { setImporting(false); } }, [preview, onImport]); const handleDownloadTemplate = useCallback(() => { const sampleRow = [ "alice@example.com", "Alice", "Smith", "+1-555-0100", "vip;wholesale", ]; downloadCsv("customers-template.csv", [CSV_HEADERS, sampleRow]); }, []); return (

Import Customers

{result ? (

Import complete

{result.created} created, {result.updated} updated {result.errors.length > 0 && `, ${result.errors.length} errors`}

{result.errors.length > 0 && (
{result.errors.map((err) => (

Row {err.row}: {err.field} — {err.message}

))}
)}
) : ( <>

Upload a CSV file with customer data. Required column:{" "} Email. Optional: First Name, Last Name, Phone, Tags (semicolon-separated).

Existing customers are matched by email and updated. New emails create new customers.

{parseError && (

{parseError}

)} {preview && (
{preview.rows.slice(0, 5).map((c) => ( ))}
Email First Name Last Name Tags
{c.email} {c.firstName ?? "—"} {c.lastName ?? "—"} {c.tags?.join(", ") ?? "—"}

{preview.rows.length}{" "} {preview.rows.length === 1 ? "customer" : "customers"} to import {preview.rows.length > 5 && ` (showing first 5 of ${preview.rows.length})`}

)} )}
); } // ─── Main Component ────────────────────────────────────────────────────────── const PAGE_SIZE = 20; export function CustomerList() { const api = useCustomersAdminApi(); const [page, setPage] = useState(1); const [search, setSearch] = useState(""); const [tagFilter, setTagFilter] = useState(""); const [exporting, setExporting] = useState(false); const [showImport, setShowImport] = useState(false); const queryInput: Record = { page: String(page), limit: String(PAGE_SIZE), }; if (search) queryInput.search = search; if (tagFilter) queryInput.tag = tagFilter; const { data: listData, isLoading: loading, isError: customersError, refetch: refetchCustomers, } = api.listCustomers.useQuery(queryInput) as { data: ListResult | undefined; isLoading: boolean; isError: boolean; refetch: () => void; }; const { data: tagsData } = api.listTags.useQuery({}) as { data: { tags: TagEntry[] } | undefined; }; const allTags = tagsData?.tags ?? []; const customers = listData?.customers ?? []; const total = listData?.total ?? 0; const totalPages = listData?.pages ?? 1; const handleExport = useCallback(async () => { setExporting(true); try { const exportQuery: Record = {}; if (search) exportQuery.search = search; if (tagFilter) exportQuery.tag = tagFilter; const result = (await api.exportCustomers.fetch(exportQuery)) as | ExportResult | undefined; const exportCustomers = result?.customers ?? []; if (exportCustomers.length === 0) return; const header = [ "Email", "First Name", "Last Name", "Phone", "Tags", "Joined", ]; const dataRows = exportCustomers.map((c) => [ c.email, c.firstName ?? "", c.lastName ?? "", c.phone ?? "", (c.tags ?? []).join(";"), new Date(c.createdAt).toISOString(), ]); const dateStr = new Date().toISOString().slice(0, 10); downloadCsv(`customers-${dateStr}.csv`, [header, ...dataRows]); } finally { setExporting(false); } }, [api.exportCustomers, search, tagFilter]); const handleImport = useCallback( async (rows: ImportCustomerRow[]) => { const result = (await api.importCustomers.fetch({ customers: rows, })) as ImportResult; void api.listCustomers.invalidate(); void api.listTags.invalidate(); return result; }, [api.importCustomers, api.listCustomers, api.listTags], ); if (customersError) { return (

Failed to load customers

Check your connection and try again.

); } const subtitle = `${total} ${total === 1 ? "customer" : "customers"}`; const tableBody = loading ? ( Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, j) => (
))} )) ) : customers.length === 0 ? (

No customers yet

Customers will appear here after they create an account

) : ( customers.map((customer) => ( { window.location.href = `/admin/customers/${customer.id}`; }} > e.stopPropagation()} > {customer.firstName || customer.lastName ? `${customer.firstName ?? ""} ${customer.lastName ?? ""}`.trim() : "—"} {customer.email}
{(customer.tags ?? []).length > 0 ? ( (customer.tags ?? []).map((tag) => ( {tag} )) ) : ( )}
{timeAgo(customer.createdAt)} )) ); return ( <> void handleExport()} exportDisabled={exporting || total === 0} exportLabel={exporting ? "Exporting…" : "Export CSV"} onImport={() => setShowImport(true)} search={search} onSearchChange={(v: string) => { setSearch(v); setPage(1); }} tagFilter={tagFilter} allTags={allTags} onTagFilterChange={(v: string) => { setTagFilter(v); setPage(1); }} tableBody={tableBody} showPagination={totalPages > 1} page={page} totalPages={totalPages} onPrevPage={() => setPage((p) => Math.max(1, p - 1))} onNextPage={() => setPage((p) => Math.min(totalPages, p + 1))} /> {showImport && ( setShowImport(false)} onImport={handleImport} /> )} ); }