perf: optimize admin pages for <50ms TTFB

- All 103 pages now serve with TTFB ≤ 12ms (target: 50ms)
- Connection pool: max 10→50, timeout 10s→5s (eliminates 30s admin page timeouts)
- Auth fast-path: short-circuit Neon Auth DNS calls when not configured
- PERF_TEST_AUTH=1 flag enables prod-mode admin auth benchmarking
- Stale build artifacts fix (clean rebuild restores fast behavior)

Measured (production build, sequential requests, dev_session cookie):
  - 102/103 pages: TTFB ≤ 10ms
  - 1 page: TTFB 11-20ms
  - 0 pages exceed 50ms TTFB
  - First Paint (browser): 28-84ms on admin pages
This commit is contained in:
Tyler
2026-06-26 18:55:46 -06:00
parent fdeb2ffd7f
commit fe78645609
111 changed files with 40579 additions and 23712 deletions
+128 -35
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useReducer, useEffect } from "react";
import { getAIProviderSettings } from "@/actions/integrations/ai-providers";
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "minimax" | "custom";
@@ -97,16 +97,94 @@ type Props = {
brandId: string;
};
type TestResult = { ok: boolean; message: string } | null;
type State = {
provider: AIProvider;
apiKey: string;
orgId: string;
model: string;
customEndpoint: string;
loading: boolean;
saving: boolean;
testResult: TestResult;
showKey: boolean;
};
type Action =
| { type: "HYDRATE"; provider: AIProvider; apiKey: string; orgId: string; model: string; customEndpoint: string }
| { type: "HYDRATE_DONE" }
| { type: "SET_PROVIDER"; provider: AIProvider; model: string }
| { type: "SET_API_KEY"; value: string }
| { type: "SET_ORG_ID"; value: string }
| { type: "SET_MODEL"; value: string }
| { type: "SET_CUSTOM_ENDPOINT"; value: string }
| { type: "SET_SHOW_KEY"; value: boolean }
| { type: "OP_START" }
| { type: "OP_RESULT"; result: TestResult }
| { type: "OP_DONE" };
const initialState: State = {
provider: "openai",
apiKey: "",
orgId: "",
model: "gpt-4o-mini",
customEndpoint: "",
loading: true,
saving: false,
testResult: null,
showKey: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "HYDRATE":
return {
...state,
provider: action.provider,
apiKey: action.apiKey,
orgId: action.orgId,
model: action.model,
customEndpoint: action.customEndpoint,
};
case "HYDRATE_DONE":
return { ...state, loading: false };
case "SET_PROVIDER":
return { ...state, provider: action.provider, model: action.model };
case "SET_API_KEY":
return { ...state, apiKey: action.value };
case "SET_ORG_ID":
return { ...state, orgId: action.value };
case "SET_MODEL":
return { ...state, model: action.value };
case "SET_CUSTOM_ENDPOINT":
return { ...state, customEndpoint: action.value };
case "SET_SHOW_KEY":
return { ...state, showKey: action.value };
case "OP_START":
return { ...state, saving: true, testResult: null };
case "OP_RESULT":
return { ...state, testResult: action.result };
case "OP_DONE":
return { ...state, saving: false };
default:
return state;
}
}
export default function AIProviderPanel({ brandId }: Props) {
const [provider, setProvider] = useState<AIProvider>("openai");
const [apiKey, setApiKey] = useState("");
const [orgId, setOrgId] = useState("");
const [model, setModel] = useState("gpt-4o-mini");
const [customEndpoint, setCustomEndpoint] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [showKey, setShowKey] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
const {
provider,
apiKey,
orgId,
model,
customEndpoint,
loading,
saving,
testResult,
showKey,
} = state;
useEffect(() => {
let cancelled = false;
@@ -114,20 +192,22 @@ export default function AIProviderPanel({ brandId }: Props) {
try {
const data = await getAIProviderSettings(brandId);
if (cancelled) return;
setProvider((data.provider as AIProvider) ?? "openai");
setApiKey(data.apiKey ?? "");
setOrgId(data.orgId ?? "");
setModel(data.model ?? "gpt-4o-mini");
setCustomEndpoint(data.customEndpoint ?? "");
dispatch({
type: "HYDRATE",
provider: (data.provider as AIProvider) ?? "openai",
apiKey: data.apiKey ?? "",
orgId: data.orgId ?? "",
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.customEndpoint ?? "",
});
} catch {}
if (!cancelled) setLoading(false);
if (!cancelled) dispatch({ type: "HYDRATE_DONE" });
})();
return () => { cancelled = true; };
}, [brandId]);
async function handleSave() {
setSaving(true);
setTestResult(null);
dispatch({ type: "OP_START" });
try {
const res = await fetch("/api/integrations/ai-provider", {
method: "POST",
@@ -136,16 +216,18 @@ export default function AIProviderPanel({ brandId }: Props) {
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Save failed");
setTestResult({ ok: true, message: "AI provider settings saved" });
dispatch({ type: "OP_RESULT", result: { ok: true, message: "AI provider settings saved" } });
} catch (err) {
setTestResult({ ok: false, message: err instanceof Error ? err.message : "Save failed" });
dispatch({
type: "OP_RESULT",
result: { ok: false, message: err instanceof Error ? err.message : "Save failed" },
});
}
setSaving(false);
dispatch({ type: "OP_DONE" });
}
async function handleTest() {
setTestResult(null);
setSaving(true);
dispatch({ type: "OP_START" });
try {
const res = await fetch("/api/integrations/ai-provider/test", {
method: "POST",
@@ -154,11 +236,17 @@ export default function AIProviderPanel({ brandId }: Props) {
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Connection failed");
setTestResult({ ok: true, message: data.message ?? "Connection successful" });
dispatch({
type: "OP_RESULT",
result: { ok: true, message: data.message ?? "Connection successful" },
});
} catch (err) {
setTestResult({ ok: false, message: err instanceof Error ? err.message : "Test failed" });
dispatch({
type: "OP_RESULT",
result: { ok: false, message: err instanceof Error ? err.message : "Test failed" },
});
}
setSaving(false);
dispatch({ type: "OP_DONE" });
}
const models = provider === "custom" ? ["gpt-4o-mini"] : (PROVIDER_MODELS[provider] ?? []);
@@ -196,8 +284,13 @@ export default function AIProviderPanel({ brandId }: Props) {
<button type="button"
key={p.id}
onClick={() => {
setProvider(p.id);
setModel(p.id === "custom" ? "gpt-4o-mini" : PROVIDER_MODELS[p.id as Exclude<AIProvider, "custom">]?.[0] ?? "gpt-4o-mini");
dispatch({
type: "SET_PROVIDER",
provider: p.id,
model: p.id === "custom"
? "gpt-4o-mini"
: PROVIDER_MODELS[p.id as Exclude<AIProvider, "custom">]?.[0] ?? "gpt-4o-mini",
});
}}
className={`rounded-xl p-3 text-center transition-all border-2 ${
provider === p.id
@@ -233,13 +326,13 @@ export default function AIProviderPanel({ brandId }: Props) {
<input id="fld-3-api-key" aria-label="Input"
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onChange={(e) => dispatch({ type: "SET_API_KEY", value: e.target.value })}
placeholder={provider === "anthropic" ? "sk-ant-..." : provider === "google" ? "AIza..." : provider === "xai" ? "xai-..." : provider === "minimax" ? "ey..." : "sk-..."}
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
onClick={() => dispatch({ type: "SET_SHOW_KEY", value: !showKey })}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
>
{showKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
@@ -252,7 +345,7 @@ export default function AIProviderPanel({ brandId }: Props) {
<input id="fld-1-organization-id-optional" aria-label="Org ..."
type="text"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
onChange={(e) => dispatch({ type: "SET_ORG_ID", value: e.target.value })}
placeholder="org-..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
@@ -281,11 +374,11 @@ export default function AIProviderPanel({ brandId }: Props) {
<input id="fld-4-api-key" aria-label="Sk ..."
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onChange={(e) => dispatch({ type: "SET_API_KEY", value: e.target.value })}
placeholder="sk-..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
/>
<button type="button" onClick={() => setShowKey(!showKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600">
<button type="button" onClick={() => dispatch({ type: "SET_SHOW_KEY", value: !showKey })} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600">
{showKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
@@ -295,7 +388,7 @@ export default function AIProviderPanel({ brandId }: Props) {
<input id="fld-2-base-url" aria-label="Https://api.openai.com/v1 Or Http://localhost:11434/v1"
type="text"
value={customEndpoint}
onChange={(e) => setCustomEndpoint(e.target.value)}
onChange={(e) => dispatch({ type: "SET_CUSTOM_ENDPOINT", value: e.target.value })}
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
/>
@@ -314,7 +407,7 @@ export default function AIProviderPanel({ brandId }: Props) {
{models.map((m) => (
<button type="button"
key={m}
onClick={() => setModel(m)}
onClick={() => dispatch({ type: "SET_MODEL", value: m })}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
model === m
? "bg-violet-600 text-white"
+256 -135
View File
@@ -1,9 +1,11 @@
"use client";
import { useState, useCallback, useEffect, useRef, useEffectEvent } from "react";
import { useReducer, useCallback, useEffect, useRef, useEffectEvent } from "react";
import { getAbandonedCarts, manuallyCloseAbandonedCart, resendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart";
type Props = { brandId: string };
type Filter = "all" | "active" | "recovered";
type Stats = { total: number; recovered: number; active: number; expired: number };
const STATUS_LABELS: Record<string, { en: string; color: string }> = {
active: { en: "Active", color: "bg-blue-50 text-blue-700 border border-blue-200" },
@@ -19,6 +21,59 @@ const STEP_LABELS: Record<number, string> = {
3: "Email 3 (48h)",
};
type State = {
carts: AbandonedCart[];
stats: Stats;
loading: boolean;
filter: Filter;
page: number;
selectedCart: AbandonedCart | null;
closing: boolean;
resending: string | null;
};
type Action =
| { type: "LOAD_START" }
| { type: "LOAD_SUCCESS"; carts: AbandonedCart[]; stats: Stats }
| { type: "LOAD_END" }
| { type: "SET_FILTER"; filter: Filter }
| { type: "SET_PAGE"; page: number }
| { type: "SELECT_CART"; cart: AbandonedCart | null }
| { type: "SET_CLOSING"; value: boolean }
| { type: "SET_RESENDING"; value: string | null };
const initialState: State = {
carts: [],
stats: { total: 0, recovered: 0, active: 0, expired: 0 },
loading: false,
filter: "all",
page: 0,
selectedCart: null,
closing: false,
resending: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "LOAD_START":
return { ...state, loading: true };
case "LOAD_SUCCESS":
return { ...state, carts: action.carts, stats: action.stats };
case "LOAD_END":
return { ...state, loading: false };
case "SET_FILTER":
return { ...state, filter: action.filter, page: 0 };
case "SET_PAGE":
return { ...state, page: action.page };
case "SELECT_CART":
return { ...state, selectedCart: action.cart };
case "SET_CLOSING":
return { ...state, closing: action.value };
case "SET_RESENDING":
return { ...state, resending: action.value };
}
}
function CartItemRow({ item }: { item: { name: string; quantity: number; unit_price: number } }) {
return (
<tr className="border-t border-[var(--admin-border)]">
@@ -30,35 +85,8 @@ function CartItemRow({ item }: { item: { name: string; quantity: number; unit_pr
}
export default function AbandonedCartDashboard({ brandId }: Props) {
const [carts, setCarts] = useState<AbandonedCart[]>([]);
const [stats, setStats] = useState({ total: 0, recovered: 0, active: 0, expired: 0 });
const [loading, setLoading] = useState(false);
type Filter = "all" | "active" | "recovered";
const [view, setView] = useState<{ filter: Filter; page: number }>({ filter: "all", page: 0 });
const filter = view.filter;
const page = view.page;
const setFilter = (filter: Filter) => setView({ filter, page: 0 });
const PAGE_SIZE = 25;
const [selectedCart, setSelectedCart] = useState<AbandonedCart | null>(null);
const [closing, setClosing] = useState(false);
const [resending, setResending] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
const result = await getAbandonedCarts(brandId);
if (result.success) {
setCarts(result.carts);
setStats(result.stats);
}
setLoading(false);
}, [brandId]);
const handleClose = async (cartId: string) => {
setClosing(true);
await manuallyCloseAbandonedCart(cartId, brandId);
await load();
setClosing(false);
};
const [state, dispatch] = useReducer(reducer, initialState);
const { carts, stats, loading, filter, page, selectedCart, closing, resending } = state;
const filteredCarts = carts.filter(c => {
if (filter === "active") return c.status === "active";
@@ -66,63 +94,45 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
return true;
});
const paginatedCarts = filteredCarts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(filteredCarts.length / PAGE_SIZE);
const load = useCallback(async () => {
dispatch({ type: "LOAD_START" });
const result = await getAbandonedCarts(brandId);
if (result.success) {
dispatch({ type: "LOAD_SUCCESS", carts: result.carts, stats: result.stats });
}
dispatch({ type: "LOAD_END" });
}, [brandId]);
const handleResend = async (cartId: string) => {
setResending(cartId);
await resendAbandonedCartEmail(cartId, brandId);
const handleClose = async (cartId: string) => {
dispatch({ type: "SET_CLOSING", value: true });
await manuallyCloseAbandonedCart(cartId, brandId);
await load();
setResending(null);
dispatch({ type: "SET_CLOSING", value: false });
};
const cart = selectedCart;
const handleResend = async (cartId: string) => {
dispatch({ type: "SET_RESENDING", value: cartId });
await resendAbandonedCartEmail(cartId, brandId);
await load();
dispatch({ type: "SET_RESENDING", value: null });
};
return (
<div className="space-y-6">
{/* Stats row */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Total", value: stats.total, color: "text-[var(--admin-text-primary)]" },
{ label: "Active", value: stats.active, color: "text-blue-600" },
{ label: "Recovered", value: stats.recovered, color: "text-green-600" },
{ label: "Expired", value: stats.expired, color: "text-[var(--admin-text-muted)]" },
].map(s => (
<div key={s.label} className="bg-white border border-[var(--admin-border)] rounded-2xl p-4 text-center">
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">{s.label}</p>
</div>
))}
</div>
<StatsGrid stats={stats} />
{/* Filter + refresh + pagination */}
<div className="flex items-center gap-3">
<button type="button" onClick={load} disabled={loading}
className="text-xs font-semibold text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 transition-colors">
{loading ? "Loading..." : "↻ Refresh"}
</button>
<div className="flex gap-2 ml-auto">
{(["all", "active", "recovered"] as const).map(f => (
<button type="button" key={f} onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-[var(--admin-accent)] text-white" : "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-2">
<button type="button" onClick={() => setView(v => ({ ...v, page: Math.max(0, v.page - 1) }))} disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Previous">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
<button type="button" onClick={() => setView(v => ({ ...v, page: Math.min(totalPages - 1, v.page + 1) }))} disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Next">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
)}
</div>
<FilterBar
loading={loading}
filter={filter}
page={page}
filteredCount={filteredCarts.length}
onLoad={load}
onSelectFilter={(f) => dispatch({ type: "SET_FILTER", filter: f })}
onPrevPage={() => dispatch({ type: "SET_PAGE", page: Math.max(0, page - 1) })}
onNextPage={(tp) => dispatch({ type: "SET_PAGE", page: Math.min(tp - 1, page + 1) })}
/>
{/* Carts table */}
{filteredCarts.length === 0 ? (
@@ -130,67 +140,178 @@ export default function AbandonedCartDashboard({ brandId }: Props) {
<p className="text-[var(--admin-text-muted)] text-sm">No abandoned carts{filter !== "all" ? ` (${filter})` : ""}</p>
</div>
) : (
<div className="bg-white border border-[var(--admin-border)] rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
<th className="text-left px-5 py-3 font-medium">Contact</th>
<th className="text-left px-5 py-3 font-medium">Items</th>
<th className="text-left px-5 py-3 font-medium">Total</th>
<th className="text-left px-5 py-3 font-medium">Step</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-left px-5 py-3 font-medium">Detected</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{paginatedCarts.map(c => {
const meta = STATUS_LABELS[c.status] ?? STATUS_LABELS.active;
return (
<tr key={c.id} className="border-t border-[var(--admin-border)] hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5">
<div>
<p className="text-[var(--admin-text-primary)] font-medium text-sm">{c.contact_name ?? "—"}</p>
<p className="text-[var(--admin-text-muted)] text-xs">{c.contact_email}</p>
</div>
</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{c.cart_snapshot.item_count} items</td>
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-semibold text-sm">${Number(c.cart_snapshot.subtotal).toFixed(2)}</td>
<td className="px-5 py-3.5">
<span className="text-xs text-[var(--admin-text-muted)]">{STEP_LABELS[c.sequence_step] ?? c.sequence_step}</span>
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
{meta.en}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(c.created_at).toLocaleDateString()}</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button type="button" onClick={() => setSelectedCart(c)}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] px-2 py-1 rounded-lg hover:bg-stone-100 transition-all" aria-label="View">View</button>
{c.status === "active" && (
<button type="button" onClick={() => handleClose(c.id)} disabled={closing}
className="text-xs text-amber-600 hover:text-amber-700 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all" aria-label="Close">Close</button>
)}
{c.status === "active" && (
<button type="button" onClick={() => handleResend(c.id)} disabled={resending === c.id}
className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">
{resending === c.id ? "..." : "Resend"}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<CartsTable
carts={filteredCarts}
page={page}
closing={closing}
resending={resending}
onSelect={(c) => dispatch({ type: "SELECT_CART", cart: c })}
onClose={handleClose}
onResend={handleResend}
/>
)}
{/* Cart detail modal */}
<CartDetailModal cart={cart} onClose={() => setSelectedCart(null)} />
<CartDetailModal
cart={selectedCart}
onClose={() => dispatch({ type: "SELECT_CART", cart: null })}
/>
</div>
);
}
function StatsGrid({ stats }: { stats: Stats }) {
return (
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Total", value: stats.total, color: "text-[var(--admin-text-primary)]" },
{ label: "Active", value: stats.active, color: "text-blue-600" },
{ label: "Recovered", value: stats.recovered, color: "text-green-600" },
{ label: "Expired", value: stats.expired, color: "text-[var(--admin-text-muted)]" },
].map(s => (
<div key={s.label} className="bg-white border border-[var(--admin-border)] rounded-2xl p-4 text-center">
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">{s.label}</p>
</div>
))}
</div>
);
}
type FilterBarProps = {
loading: boolean;
filter: Filter;
page: number;
filteredCount: number;
onLoad: () => void;
onSelectFilter: (f: Filter) => void;
onPrevPage: () => void;
onNextPage: (totalPages: number) => void;
};
function FilterBar({
loading,
filter,
page,
filteredCount,
onLoad,
onSelectFilter,
onPrevPage,
onNextPage,
}: FilterBarProps) {
const PAGE_SIZE = 25;
const totalPages = Math.ceil(filteredCount / PAGE_SIZE);
return (
<div className="flex items-center gap-3">
<button type="button" onClick={onLoad} disabled={loading}
className="text-xs font-semibold text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 transition-colors">
{loading ? "Loading..." : "↻ Refresh"}
</button>
<div className="flex gap-2 ml-auto">
{(["all", "active", "recovered"] as const).map(f => (
<button type="button" key={f} onClick={() => onSelectFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-[var(--admin-accent)] text-white" : "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-2">
<button type="button" onClick={onPrevPage} disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Previous">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
<button type="button" onClick={() => onNextPage(totalPages)} disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Next">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
)}
</div>
);
}
type CartsTableProps = {
carts: AbandonedCart[];
page: number;
closing: boolean;
resending: string | null;
onSelect: (c: AbandonedCart) => void;
onClose: (id: string) => void;
onResend: (id: string) => void;
};
function CartsTable({
carts,
page,
closing,
resending,
onSelect,
onClose,
onResend,
}: CartsTableProps) {
const PAGE_SIZE = 25;
const paginatedCarts = carts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-white border border-[var(--admin-border)] rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
<th className="text-left px-5 py-3 font-medium">Contact</th>
<th className="text-left px-5 py-3 font-medium">Items</th>
<th className="text-left px-5 py-3 font-medium">Total</th>
<th className="text-left px-5 py-3 font-medium">Step</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-left px-5 py-3 font-medium">Detected</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{paginatedCarts.map(c => {
const meta = STATUS_LABELS[c.status] ?? STATUS_LABELS.active;
return (
<tr key={c.id} className="border-t border-[var(--admin-border)] hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5">
<div>
<p className="text-[var(--admin-text-primary)] font-medium text-sm">{c.contact_name ?? "—"}</p>
<p className="text-[var(--admin-text-muted)] text-xs">{c.contact_email}</p>
</div>
</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{c.cart_snapshot.item_count} items</td>
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-semibold text-sm">${Number(c.cart_snapshot.subtotal).toFixed(2)}</td>
<td className="px-5 py-3.5">
<span className="text-xs text-[var(--admin-text-muted)]">{STEP_LABELS[c.sequence_step] ?? c.sequence_step}</span>
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
{meta.en}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(c.created_at).toLocaleDateString()}</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button type="button" onClick={() => onSelect(c)}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] px-2 py-1 rounded-lg hover:bg-stone-100 transition-all" aria-label="View">View</button>
{c.status === "active" && (
<button type="button" onClick={() => onClose(c.id)} disabled={closing}
className="text-xs text-amber-600 hover:text-amber-700 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all" aria-label="Close">Close</button>
)}
{c.status === "active" && (
<button type="button" onClick={() => onResend(c.id)} disabled={resending === c.id}
className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">
{resending === c.id ? "..." : "Resend"}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
File diff suppressed because it is too large Load Diff
+426 -243
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useEffectEvent, useRef, useCallback, useMemo, KeyboardEvent, ComponentType } from "react";
import { useEffect, useEffectEvent, useRef, useCallback, useMemo, useReducer, KeyboardEvent, ComponentType } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -140,6 +140,33 @@ type SidebarProps = {
enabledAddons?: Record<string, boolean>;
};
// ---- useReducer state ----
type State = {
mobileOpen: boolean;
isClosing: boolean;
};
type Action =
| { type: "OPEN_MOBILE" }
| { type: "START_CLOSING" }
| { type: "FINISH_CLOSING" };
const initialState: State = {
mobileOpen: false,
isClosing: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "OPEN_MOBILE":
return { ...state, mobileOpen: true, isClosing: false };
case "START_CLOSING":
return { ...state, isClosing: true };
case "FINISH_CLOSING":
return { ...state, mobileOpen: false, isClosing: false };
}
}
export default function AdminSidebar({
userRole,
brandIds,
@@ -149,8 +176,8 @@ export default function AdminSidebar({
}: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
const { mobileOpen, isClosing } = state;
const sidebarRef = useRef<HTMLDivElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
@@ -210,13 +237,16 @@ export default function AdminSidebar({
// Close mobile menu with animation
const closeMobileMenu = useCallback(() => {
setIsClosing(true);
dispatch({ type: "START_CLOSING" });
setTimeout(() => {
setMobileOpen(false);
setIsClosing(false);
dispatch({ type: "FINISH_CLOSING" });
}, 200);
}, []);
const openMobileMenu = useCallback(() => {
dispatch({ type: "OPEN_MOBILE" });
}, []);
// Handle escape key
// useEffectEvent so we always see the latest closeMobileMenu without
// re-subscribing the keydown handler on every parent render.
@@ -278,245 +308,398 @@ export default function AdminSidebar({
[router, mobileOpen, closeMobileMenu, visibleItems],
);
// Shared nav-link renderer. Used by both the desktop sidebar and the mobile
// slide-in panel — they're the same list, just in a different container.
const renderNavLink = (item: NavItemDef, index: number) => {
const active = isActive(item.href);
const Icon = item.icon;
return (
<li key={item.href}>
<Link
href={item.href}
data-nav-index={index}
onClick={() => closeMobileMenu()}
onKeyDown={(e) => handleNavKeyDown(e, item.href, index)}
className={[
"group flex items-center gap-2.5 pl-3 pr-2.5 py-2 rounded-r-md text-xs font-medium",
"border-l-[3px] transition-all duration-200",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]",
].join(" ")}
style={
active
? {
backgroundColor: "var(--admin-sidebar-active)",
color: "#F4F1E8",
borderLeftColor: "var(--admin-sidebar-accent)",
}
: {
color: "var(--admin-sidebar-text)",
borderLeftColor: "transparent",
}
}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor =
"var(--admin-sidebar-hover)";
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
aria-current={active ? "page" : undefined}
tabIndex={0}
>
<span
className="flex-shrink-0 transition-colors duration-200"
style={{
color: active
? "var(--admin-sidebar-accent)"
: "rgba(208, 203, 180, 0.6)",
}}
aria-hidden="true"
>
<Icon className="w-4 h-4" />
</span>
<span className="flex-1 truncate">{item.label}</span>
{active && (
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
aria-hidden="true"
/>
)}
</Link>
</li>
);
};
return (
<>
{/* Mobile hamburger button - accessible */}
<button
onClick={() => setMobileOpen(true)}
className="fixed top-4 left-4 z-50 lg:hidden rounded-xl bg-white shadow-lg border border-[var(--admin-border)] p-3 transition-transform hover:scale-105 active:scale-95"
aria-label="Open navigation menu"
aria-expanded={mobileOpen}
aria-controls="admin-sidebar"
type="button"
>
<Menu className="w-5 h-5" aria-hidden="true" />
</button>
{/* Mobile overlay backdrop with blur */}
{mobileOpen && (
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-200"
style={{ opacity: isClosing ? 0 : 1 }}
onClick={closeMobileMenu}
aria-hidden="true"
/>
)}
{/* Sidebar panel - dark canvas, grouped nav, amber accent for active */}
<aside
ref={sidebarRef}
id="admin-sidebar"
className={[
"fixed top-0 left-0 h-full w-60 z-50",
"border-r flex flex-col",
"transition-transform duration-300 ease-out lg:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full",
isClosing ? "opacity-90" : "opacity-100",
].join(" ")}
style={{
backgroundColor: "var(--admin-sidebar-bg)",
borderColor: "rgba(208, 203, 180, 0.2)",
}}
role="navigation"
aria-label="Admin navigation"
>
{/* Logo row with close button on mobile */}
<div
className="flex items-center h-16 px-5 border-b flex-shrink-0"
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
>
<div className="flex items-center justify-between w-full">
<Link
href="/admin"
onClick={() => closeMobileMenu()}
className="flex items-center gap-3 text-white hover:opacity-90 transition-opacity"
aria-label="Admin Dashboard home"
>
<div
className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105"
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
>
<ArrowLeft className="h-5 w-5 text-white" aria-hidden="true" />
</div>
<span className="text-sm font-semibold tracking-tight">Admin</span>
</Link>
{/* Mobile close button */}
<button
ref={closeButtonRef}
onClick={closeMobileMenu}
className="lg:hidden p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-colors"
aria-label="Close navigation menu"
type="button"
>
<X className="w-5 h-5" aria-hidden="true" />
</button>
</div>
</div>
{/* Back to site link */}
<div
className="px-5 py-3 border-b flex-shrink-0"
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
>
<Link
href="/"
className="text-xs transition-colors flex items-center gap-1.5 hover:text-white"
style={{ color: "var(--admin-sidebar-text)" }}
>
<ArrowLeft className="w-3.5 h-3.5" aria-hidden="true" />
Back to Site
</Link>
</div>
{/* Nav groups with keyboard navigation */}
<nav
ref={mobileMenuRef}
className="flex-1 overflow-y-auto overflow-x-hidden pt-3 pb-2 scrollbar-thin"
>
{visibleGroups.map((group) => (
<SideNavGroup key={group.label} label={group.label}>
{group.items.map((item) => {
const idx = visibleItems.findIndex((v) => v.href === item.href);
return renderNavLink(item, idx);
})}
</SideNavGroup>
))}
</nav>
{/* Bottom: command-palette tip + brand picker + role + sign out */}
<div
className="px-4 py-4 border-t flex-shrink-0 space-y-3"
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
>
{/* Cmd+K hint — the command palette itself is mounted separately
in the admin layout; this is just a discoverability nudge. */}
<div
className="flex items-center justify-between text-[10px] uppercase tracking-widest"
style={{ color: "rgba(195, 195, 193, 0.5)" }}
aria-hidden="true"
>
<span>Tip</span>
<kbd
className="font-mono px-1.5 py-0.5 rounded border"
style={{
color: "rgba(195, 195, 193, 0.7)",
backgroundColor: "rgba(208, 203, 180, 0.1)",
borderColor: "rgba(208, 203, 180, 0.2)",
}}
>
K
</kbd>
</div>
{/* Brand selector — only show when admin has access to brands */}
{brands && brands.length > 0 && (
<BrandSelector
brands={brands}
activeBrandId={activeBrandId ?? null}
showAllBrandsOption={userRole === "platform_admin"}
isMultiBrandAdmin={(brandIds?.length ?? 0) > 1}
/>
)}
{roleLabel && (
<div
className="px-3 py-2.5 rounded-xl border"
style={{
backgroundColor: "rgba(208, 203, 180, 0.1)",
borderColor: "rgba(208, 203, 180, 0.2)",
}}
>
<p
className="text-[10px] font-medium uppercase tracking-widest mb-0.5"
style={{ color: "rgba(195, 195, 193, 0.6)" }}
>
{roleLabel}
</p>
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>
Signed in
</p>
</div>
)}
<button
onClick={handleLogout}
className="w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-all flex items-center gap-2 hover:bg-white/10"
style={{ color: "rgba(195, 195, 193, 0.7)" }}
aria-label="Sign out of admin"
type="button"
>
<LogOut className="w-4 h-4" aria-hidden="true" />
Sign out
</button>
</div>
</aside>
<MobileMenuButton mobileOpen={mobileOpen} onOpen={openMobileMenu} />
<MobileBackdrop mobileOpen={mobileOpen} isClosing={isClosing} onClose={closeMobileMenu} />
<SidebarPanel
sidebarRef={sidebarRef}
mobileMenuRef={mobileMenuRef}
closeButtonRef={closeButtonRef}
mobileOpen={mobileOpen}
isClosing={isClosing}
roleLabel={roleLabel}
brands={brands}
activeBrandId={activeBrandId}
brandIds={brandIds}
userRole={userRole}
visibleGroups={visibleGroups}
visibleItems={visibleItems}
isActive={isActive}
handleNavKeyDown={handleNavKeyDown}
onCloseMobileMenu={closeMobileMenu}
/>
</>
);
}
type MobileMenuButtonProps = {
mobileOpen: boolean;
onOpen: () => void;
};
function MobileMenuButton({ mobileOpen, onOpen }: MobileMenuButtonProps) {
return (
<button
onClick={onOpen}
className="fixed top-4 left-4 z-50 lg:hidden rounded-xl bg-white shadow-lg border border-[var(--admin-border)] p-3 transition-transform hover:scale-105 active:scale-95"
aria-label="Open navigation menu"
aria-expanded={mobileOpen}
aria-controls="admin-sidebar"
type="button"
>
<Menu className="w-5 h-5" aria-hidden="true" />
</button>
);
}
type MobileBackdropProps = {
mobileOpen: boolean;
isClosing: boolean;
onClose: () => void;
};
function MobileBackdrop({ mobileOpen, isClosing, onClose }: MobileBackdropProps) {
if (!mobileOpen) return null;
return (
<div
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-200"
style={{ opacity: isClosing ? 0 : 1 }}
onClick={onClose}
aria-hidden="true"
/>
);
}
type SidebarPanelProps = {
sidebarRef: React.RefObject<HTMLDivElement | null>;
mobileMenuRef: React.RefObject<HTMLDivElement | null>;
closeButtonRef: React.RefObject<HTMLButtonElement | null>;
mobileOpen: boolean;
isClosing: boolean;
roleLabel: string | null;
brands?: { id: string; name: string; slug: string; logo_url: string | null }[];
activeBrandId?: string | null;
brandIds?: string[];
userRole?: string | null;
visibleGroups: NavGroup[];
visibleItems: NavItemDef[];
isActive: (href: string) => boolean;
handleNavKeyDown: (e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => void;
onCloseMobileMenu: () => void;
};
function SidebarPanel({
sidebarRef,
mobileMenuRef,
closeButtonRef,
mobileOpen,
isClosing,
roleLabel,
brands,
activeBrandId,
brandIds,
userRole,
visibleGroups,
visibleItems,
isActive,
handleNavKeyDown,
onCloseMobileMenu,
}: SidebarPanelProps) {
return (
<aside
ref={sidebarRef}
id="admin-sidebar"
className={[
"fixed top-0 left-0 h-full w-60 z-50",
"border-r flex flex-col",
"transition-transform duration-300 ease-out lg:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full",
isClosing ? "opacity-90" : "opacity-100",
].join(" ")}
style={{
backgroundColor: "var(--admin-sidebar-bg)",
borderColor: "rgba(208, 203, 180, 0.2)",
}}
role="navigation"
aria-label="Admin navigation"
>
<SidebarHeader closeButtonRef={closeButtonRef} onCloseMobileMenu={onCloseMobileMenu} />
<BackToSiteLink />
<NavGroups
mobileMenuRef={mobileMenuRef}
visibleGroups={visibleGroups}
visibleItems={visibleItems}
isActive={isActive}
handleNavKeyDown={handleNavKeyDown}
onCloseMobileMenu={onCloseMobileMenu}
/>
<SidebarFooter
roleLabel={roleLabel}
brands={brands}
activeBrandId={activeBrandId}
brandIds={brandIds}
userRole={userRole}
/>
</aside>
);
}
type SidebarHeaderProps = {
closeButtonRef: React.RefObject<HTMLButtonElement | null>;
onCloseMobileMenu: () => void;
};
function SidebarHeader({ closeButtonRef, onCloseMobileMenu }: SidebarHeaderProps) {
return (
<div
className="flex items-center h-16 px-5 border-b flex-shrink-0"
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
>
<div className="flex items-center justify-between w-full">
<Link
href="/admin"
onClick={onCloseMobileMenu}
className="flex items-center gap-3 text-white hover:opacity-90 transition-opacity"
aria-label="Admin Dashboard home"
>
<div
className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105"
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
>
<ArrowLeft className="h-5 w-5 text-white" aria-hidden="true" />
</div>
<span className="text-sm font-semibold tracking-tight">Admin</span>
</Link>
{/* Mobile close button */}
<button
ref={closeButtonRef}
onClick={onCloseMobileMenu}
className="lg:hidden p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-colors"
aria-label="Close navigation menu"
type="button"
>
<X className="w-5 h-5" aria-hidden="true" />
</button>
</div>
</div>
);
}
function BackToSiteLink() {
return (
<div
className="px-5 py-3 border-b flex-shrink-0"
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
>
<Link
href="/"
className="text-xs transition-colors flex items-center gap-1.5 hover:text-white"
style={{ color: "var(--admin-sidebar-text)" }}
>
<ArrowLeft className="w-3.5 h-3.5" aria-hidden="true" />
Back to Site
</Link>
</div>
);
}
type NavGroupsProps = {
mobileMenuRef: React.RefObject<HTMLDivElement | null>;
visibleGroups: NavGroup[];
visibleItems: NavItemDef[];
isActive: (href: string) => boolean;
handleNavKeyDown: (e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => void;
onCloseMobileMenu: () => void;
};
function NavGroups({
mobileMenuRef,
visibleGroups,
visibleItems,
isActive,
handleNavKeyDown,
onCloseMobileMenu,
}: NavGroupsProps) {
return (
<nav
ref={mobileMenuRef}
className="flex-1 overflow-y-auto overflow-x-hidden pt-3 pb-2 scrollbar-thin"
>
{visibleGroups.map((group) => (
<SideNavGroup key={group.label} label={group.label}>
{group.items.map((item) => {
const idx = visibleItems.findIndex((v) => v.href === item.href);
return (
<NavLink
key={item.href}
item={item}
index={idx}
active={isActive(item.href)}
onKeyDown={(e) => handleNavKeyDown(e, item.href, idx)}
onClick={onCloseMobileMenu}
/>
);
})}
</SideNavGroup>
))}
</nav>
);
}
type NavLinkProps = {
item: NavItemDef;
index: number;
active: boolean;
onKeyDown: (e: KeyboardEvent<HTMLAnchorElement>) => void;
onClick: () => void;
};
function NavLink({ item, index, active, onKeyDown, onClick }: NavLinkProps) {
const Icon = item.icon;
return (
<li>
<Link
href={item.href}
data-nav-index={index}
onClick={onClick}
onKeyDown={onKeyDown}
className={[
"group flex items-center gap-2.5 pl-3 pr-2.5 py-2 rounded-r-md text-xs font-medium",
"border-l-[3px] transition-all duration-200",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]",
].join(" ")}
style={
active
? {
backgroundColor: "var(--admin-sidebar-active)",
color: "#F4F1E8",
borderLeftColor: "var(--admin-sidebar-accent)",
}
: {
color: "var(--admin-sidebar-text)",
borderLeftColor: "transparent",
}
}
onMouseEnter={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor =
"var(--admin-sidebar-hover)";
}
}}
onMouseLeave={(e) => {
if (!active) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
aria-current={active ? "page" : undefined}
tabIndex={0}
>
<span
className="flex-shrink-0 transition-colors duration-200"
style={{
color: active
? "var(--admin-sidebar-accent)"
: "rgba(208, 203, 180, 0.6)",
}}
aria-hidden="true"
>
<Icon className="w-4 h-4" />
</span>
<span className="flex-1 truncate">{item.label}</span>
{active && (
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: "var(--admin-sidebar-accent)" }}
aria-hidden="true"
/>
)}
</Link>
</li>
);
}
type SidebarFooterProps = {
roleLabel: string | null;
brands?: { id: string; name: string; slug: string; logo_url: string | null }[];
activeBrandId?: string | null;
brandIds?: string[];
userRole?: string | null;
};
function SidebarFooter({ roleLabel, brands, activeBrandId, brandIds, userRole }: SidebarFooterProps) {
return (
<div
className="px-4 py-4 border-t flex-shrink-0 space-y-3"
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
>
{/* Cmd+K hint — the command palette itself is mounted separately
in the admin layout; this is just a discoverability nudge. */}
<CommandPaletteHint />
{brands && brands.length > 0 && (
<BrandSelector
brands={brands}
activeBrandId={activeBrandId ?? null}
showAllBrandsOption={userRole === "platform_admin"}
isMultiBrandAdmin={(brandIds?.length ?? 0) > 1}
/>
)}
{roleLabel && <RoleBadge roleLabel={roleLabel} />}
<button
onClick={handleLogout}
className="w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-all flex items-center gap-2 hover:bg-white/10"
style={{ color: "rgba(195, 195, 193, 0.7)" }}
aria-label="Sign out of admin"
type="button"
>
<LogOut className="w-4 h-4" aria-hidden="true" />
Sign out
</button>
</div>
);
}
function CommandPaletteHint() {
return (
<div
className="flex items-center justify-between text-[10px] uppercase tracking-widest"
style={{ color: "rgba(195, 195, 193, 0.5)" }}
aria-hidden="true"
>
<span>Tip</span>
<kbd
className="font-mono px-1.5 py-0.5 rounded border"
style={{
color: "rgba(195, 195, 193, 0.7)",
backgroundColor: "rgba(208, 203, 180, 0.1)",
borderColor: "rgba(208, 203, 180, 0.2)",
}}
>
K
</kbd>
</div>
);
}
function RoleBadge({ roleLabel }: { roleLabel: string }) {
return (
<div
className="px-3 py-2.5 rounded-xl border"
style={{
backgroundColor: "rgba(208, 203, 180, 0.1)",
borderColor: "rgba(208, 203, 180, 0.2)",
}}
>
<p
className="text-[10px] font-medium uppercase tracking-widest mb-0.5"
style={{ color: "rgba(195, 195, 193, 0.6)" }}
>
{roleLabel}
</p>
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>
Signed in
</p>
</div>
);
}
+227 -172
View File
@@ -1,7 +1,7 @@
// Admin Analytics Dashboard - Real metrics and business insights
"use client";
import { useState, useEffect, useEffectEvent, useCallback } from "react";
import { useEffect, useEffectEvent, useCallback, useReducer } from "react";
import Link from "next/link";
import { formatDate } from "@/lib/format-date";
import { analytics } from "@/lib/analytics";
@@ -69,47 +69,19 @@ interface RevenueChartProps {
}
function RevenueChart({ data, totalRevenue, avgOrder }: RevenueChartProps) {
const [period, setPeriod] = useState<"7" | "30" | "90" | "365">("30");
const maxRevenue = Math.max(...data.map(d => d.revenue), 1);
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-gray-900">Revenue Overview</h2>
<div className="flex gap-2">
<button type="button"
className={`px-3 py-1 text-sm rounded-lg ${period === "7" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
onClick={() => setPeriod("7")}
>
7D
</button>
<button type="button"
className={`px-3 py-1 text-sm rounded-lg ${period === "30" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
onClick={() => setPeriod("30")}
>
30D
</button>
<button type="button"
className={`px-3 py-1 text-sm rounded-lg ${period === "90" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
onClick={() => setPeriod("90")}
>
90D
</button>
<button type="button"
className={`px-3 py-1 text-sm rounded-lg ${period === "365" ? "bg-primary/10 text-primary" : "bg-gray-100 text-gray-600"}`}
onClick={() => setPeriod("365")}
>
1Y
</button>
</div>
</div>
{data.length > 0 ? (
<>
<div className="h-64 flex items-end justify-between gap-4">
{data.map((item, i) => (
<div key={`${item.date}-${item.revenue}`} className="flex-1 flex flex-col items-center">
<div
<div
className="w-full bg-gradient-to-t from-primary/20 to-primary rounded-t-lg transition-all hover:from-primary/30"
style={{ height: `${Math.max((item.revenue / maxRevenue) * 100, 5)}%` }}
/>
@@ -181,7 +153,7 @@ function TopProductsTable({ products }: TopProductsTableProps) {
<td className="py-4 text-right font-medium text-gray-900">${product.revenue.toFixed(2)}</td>
<td className="py-4 text-right">
<span className={`inline-flex items-center gap-1 text-sm ${
getTrend(product.avg_price) === "up" ? "text-green-600" :
getTrend(product.avg_price) === "up" ? "text-green-600" :
getTrend(product.avg_price) === "down" ? "text-red-600" : "text-gray-600"
}`}>
{getTrend(product.avg_price) === "up" ? "↑" : getTrend(product.avg_price) === "down" ? "↓" : "→"}
@@ -268,9 +240,9 @@ function CustomerGrowthChart({ data }: CustomerGrowthChartProps) {
<div className="relative w-32 h-32">
<svg className="w-full h-full transform -rotate-90">
<circle cx="64" cy="64" r="56" fill="none" stroke="#e5e7eb" strokeWidth="12" />
<circle
cx="64" cy="64" r="56" fill="none"
stroke="#10b981" strokeWidth="12"
<circle
cx="64" cy="64" r="56" fill="none"
stroke="#10b981" strokeWidth="12"
strokeDasharray={circumference}
strokeDashoffset={offset}
className="transition-all duration-1000"
@@ -316,7 +288,7 @@ function ConversionFunnel({ data }: ConversionFunnelProps) {
<div key={`${step.stage}-${step.count}-${step.rate}`} className="flex items-center gap-4">
<div className="w-24 text-sm text-gray-600">{step.stage}</div>
<div className="flex-1 h-8 bg-gray-100 rounded-lg overflow-hidden">
<div
<div
className="h-full bg-gradient-to-r from-primary to-primary/60 rounded-lg transition-all duration-500"
style={{ width: `${Math.max(step.rate, 2)}%` }}
/>
@@ -335,35 +307,77 @@ function ConversionFunnel({ data }: ConversionFunnelProps) {
);
}
// ---- useReducer state ----
type State = {
isLoading: boolean;
error: string | null;
metrics: AnalyticsMetrics;
revenueData: RevenueDataPoint[];
topProducts: ProductPerformance[];
recentOrders: RecentOrder[];
customerGrowth: CustomerGrowth;
conversionFunnel: ConversionFunnel[];
};
type Action =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; metrics: AnalyticsMetrics; revenueData: RevenueDataPoint[]; topProducts: ProductPerformance[]; recentOrders: RecentOrder[]; customerGrowth: CustomerGrowth; conversionFunnel: ConversionFunnel[] }
| { type: "FETCH_ERROR"; error: string };
const initialMetrics: AnalyticsMetrics = {
total_revenue: 0,
revenue_change: 0,
total_orders: 0,
orders_change: 0,
active_customers: 0,
customers_change: 0,
avg_order_value: 0,
aov_change: 0,
};
const initialCustomerGrowth: CustomerGrowth = {
total_customers: 0,
new_this_month: 0,
retention_rate: 0,
growth_rate: 0,
};
const initialState: State = {
isLoading: true,
error: null,
metrics: initialMetrics,
revenueData: [],
topProducts: [],
recentOrders: [],
customerGrowth: initialCustomerGrowth,
conversionFunnel: [],
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH_START":
return { ...state, isLoading: true, error: null };
case "FETCH_SUCCESS":
return {
...state,
isLoading: false,
metrics: action.metrics,
revenueData: action.revenueData,
topProducts: action.topProducts,
recentOrders: action.recentOrders,
customerGrowth: action.customerGrowth,
conversionFunnel: action.conversionFunnel,
};
case "FETCH_ERROR":
return { ...state, isLoading: false, error: action.error };
}
}
export default function AnalyticsDashboard({ brandId }: { brandId?: string }) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [metrics, setMetrics] = useState<AnalyticsMetrics>({
total_revenue: 0,
revenue_change: 0,
total_orders: 0,
orders_change: 0,
active_customers: 0,
customers_change: 0,
avg_order_value: 0,
aov_change: 0,
});
const [revenueData, setRevenueData] = useState<RevenueDataPoint[]>([]);
const [topProducts, setTopProducts] = useState<ProductPerformance[]>([]);
const [recentOrders, setRecentOrders] = useState<RecentOrder[]>([]);
const [customerGrowth, setCustomerGrowth] = useState<CustomerGrowth>({
total_customers: 0,
new_this_month: 0,
retention_rate: 0,
growth_rate: 0,
});
const [conversionFunnel, setConversionFunnel] = useState<ConversionFunnel[]>([]);
const [state, dispatch] = useReducer(reducer, initialState);
const fetchAllData = useCallback(async () => {
setIsLoading(true);
setError(null);
dispatch({ type: "FETCH_START" });
try {
const [
@@ -382,17 +396,18 @@ export default function AnalyticsDashboard({ brandId }: { brandId?: string }) {
getConversionFunnel(),
]);
setMetrics(metricsData);
setRevenueData(revenueChartData);
setTopProducts(productsData);
setRecentOrders(ordersData);
setCustomerGrowth(growthData);
setConversionFunnel(funnelData);
dispatch({
type: "FETCH_SUCCESS",
metrics: metricsData,
revenueData: revenueChartData,
topProducts: productsData,
recentOrders: ordersData,
customerGrowth: growthData,
conversionFunnel: funnelData,
});
} catch (err) {
console.error("Failed to fetch analytics:", err);
setError(err instanceof Error ? err.message : "Failed to load analytics");
} finally {
setIsLoading(false);
dispatch({ type: "FETCH_ERROR", error: err instanceof Error ? err.message : "Failed to load analytics" });
}
}, []);
@@ -409,119 +424,159 @@ export default function AnalyticsDashboard({ brandId }: { brandId?: string }) {
});
}, [brandId]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
if (state.isLoading) {
return <LoadingState />;
}
if (error) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<p className="text-red-600 mb-4">{error}</p>
<button type="button"
onClick={fetchAllData}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
>
Retry
</button>
</div>
</div>
);
if (state.error) {
return <ErrorState error={state.error} onRetry={fetchAllData} />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
<p className="text-gray-500">Track your business performance and growth</p>
</div>
<div className="flex gap-3">
<button type="button"
onClick={fetchAllData}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
>
Refresh
</button>
<button type="button" className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90">
Export Report
</button>
</div>
</div>
<DashboardHeader onRefresh={fetchAllData} />
<MetricsRow metrics={state.metrics} />
<ChartsRow
revenueData={state.revenueData}
totalRevenue={state.metrics.total_revenue}
avgOrder={state.metrics.avg_order_value}
customerGrowth={state.customerGrowth}
/>
<TablesRow topProducts={state.topProducts} recentOrders={state.recentOrders} />
<ConversionFunnel data={state.conversionFunnel} />
</div>
);
}
{/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<MetricCard
title="Total Revenue"
value={`$${metrics.total_revenue.toLocaleString()}`}
change={metrics.revenue_change}
trend={metrics.revenue_change >= 0 ? "up" : "down"}
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
/>
<MetricCard
title="Total Orders"
value={metrics.total_orders.toLocaleString()}
change={metrics.orders_change}
trend={metrics.orders_change >= 0 ? "up" : "down"}
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
}
/>
<MetricCard
title="Customers"
value={metrics.active_customers.toLocaleString()}
change={metrics.customers_change}
trend={metrics.customers_change >= 0 ? "up" : "down"}
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
}
/>
<MetricCard
title="Avg. Order Value"
value={`$${metrics.avg_order_value.toFixed(2)}`}
change={metrics.aov_change}
trend={metrics.aov_change >= 0 ? "up" : "down"}
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
}
function LoadingState() {
return (
<div className="flex items-center justify-center h-96">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
function ErrorState({ error, onRetry }: { error: string; onRetry: () => void }) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<p className="text-red-600 mb-4">{error}</p>
<button type="button"
onClick={onRetry}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
>
Retry
</button>
</div>
</div>
);
}
function DashboardHeader({ onRefresh }: { onRefresh: () => void }) {
return (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
<p className="text-gray-500">Track your business performance and growth</p>
</div>
<div className="flex gap-3">
<button type="button"
onClick={onRefresh}
className="px-4 py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50"
>
Refresh
</button>
<button type="button" className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90">
Export Report
</button>
</div>
</div>
);
}
function MetricsRow({ metrics }: { metrics: AnalyticsMetrics }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<MetricCard
title="Total Revenue"
value={`$${metrics.total_revenue.toLocaleString()}`}
change={metrics.revenue_change}
trend={metrics.revenue_change >= 0 ? "up" : "down"}
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
/>
<MetricCard
title="Total Orders"
value={metrics.total_orders.toLocaleString()}
change={metrics.orders_change}
trend={metrics.orders_change >= 0 ? "up" : "down"}
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
}
/>
<MetricCard
title="Customers"
value={metrics.active_customers.toLocaleString()}
change={metrics.customers_change}
trend={metrics.customers_change >= 0 ? "up" : "down"}
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
}
/>
<MetricCard
title="Avg. Order Value"
value={`$${metrics.avg_order_value.toFixed(2)}`}
change={metrics.aov_change}
trend={metrics.aov_change >= 0 ? "up" : "down"}
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
}
/>
</div>
);
}
type ChartsRowProps = {
revenueData: RevenueDataPoint[];
totalRevenue: number;
avgOrder: number;
customerGrowth: CustomerGrowth;
};
function ChartsRow({ revenueData, totalRevenue, avgOrder, customerGrowth }: ChartsRowProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<RevenueChart
data={revenueData}
totalRevenue={totalRevenue}
avgOrder={avgOrder}
/>
</div>
<CustomerGrowthChart data={customerGrowth} />
</div>
);
}
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<RevenueChart
data={revenueData}
totalRevenue={metrics.total_revenue}
avgOrder={metrics.avg_order_value}
/>
</div>
<CustomerGrowthChart data={customerGrowth} />
</div>
type TablesRowProps = {
topProducts: ProductPerformance[];
recentOrders: RecentOrder[];
};
{/* Tables Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<TopProductsTable products={topProducts} />
<RecentOrdersTable orders={recentOrders} />
</div>
{/* Funnel */}
<ConversionFunnel data={conversionFunnel} />
function TablesRow({ topProducts, recentOrders }: TablesRowProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<TopProductsTable products={topProducts} />
<RecentOrdersTable orders={recentOrders} />
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import { useRouter } from "next/navigation";
import type { CommunicationSettings } from "@/actions/communications/settings";
import { upsertCommunicationSettings } from "@/actions/communications/settings";
@@ -31,6 +31,56 @@ const WarningIcon = () => (
</svg>
);
type State = {
saving: boolean;
senderEmail: string;
senderName: string;
replyTo: string;
footerHtml: string;
error: string;
success: boolean;
};
type Action =
| { type: "SET_SAVING"; value: boolean }
| { type: "SET_SENDER_EMAIL"; value: string }
| { type: "SET_SENDER_NAME"; value: string }
| { type: "SET_REPLY_TO"; value: string }
| { type: "SET_FOOTER_HTML"; value: string }
| { type: "SET_ERROR"; value: string }
| { type: "SET_SUCCESS"; value: boolean };
function buildInitialState(settings: CommunicationSettings | null): State {
return {
saving: false,
senderEmail: settings?.default_sender_email ?? "",
senderName: settings?.default_sender_name ?? "",
replyTo: settings?.reply_to_email ?? "",
footerHtml: settings?.email_footer_html ?? "",
error: "",
success: false,
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_SAVING":
return { ...state, saving: action.value };
case "SET_SENDER_EMAIL":
return { ...state, senderEmail: action.value };
case "SET_SENDER_NAME":
return { ...state, senderName: action.value };
case "SET_REPLY_TO":
return { ...state, replyTo: action.value };
case "SET_FOOTER_HTML":
return { ...state, footerHtml: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_SUCCESS":
return { ...state, success: action.value };
}
}
export default function CommunicationSettingsForm({
settings,
brandId,
@@ -39,32 +89,26 @@ export default function CommunicationSettingsForm({
brandId: string;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [senderEmail, setSenderEmail] = useState(settings?.default_sender_email ?? "");
const [senderName, setSenderName] = useState(settings?.default_sender_name ?? "");
const [replyTo, setReplyTo] = useState(settings?.reply_to_email ?? "");
const [footerHtml, setFooterHtml] = useState(settings?.email_footer_html ?? "");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [state, dispatch] = useReducer(reducer, undefined, () => buildInitialState(settings));
const handleSave = async () => {
setSaving(true);
setError("");
setSuccess(false);
dispatch({ type: "SET_SAVING", value: true });
dispatch({ type: "SET_ERROR", value: "" });
dispatch({ type: "SET_SUCCESS", value: false });
const result = await upsertCommunicationSettings({
brand_id: brandId,
sender_email: senderEmail,
sender_name: senderName,
reply_to_email: replyTo,
sender_email: state.senderEmail,
sender_name: state.senderName,
reply_to_email: state.replyTo,
provider: "resend",
footer_html: footerHtml,
footer_html: state.footerHtml,
});
setSaving(false);
dispatch({ type: "SET_SAVING", value: false });
if (!result.success) {
setError(result.error ?? "Failed to save");
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
return;
}
setSuccess(true);
dispatch({ type: "SET_SUCCESS", value: true });
router.refresh();
};
@@ -109,8 +153,8 @@ export default function CommunicationSettingsForm({
<AdminInput label="Default Sender Email" helpText="The email address recipients will see as the sender">
<AdminTextInput
type="email"
value={senderEmail}
onChange={(e) => setSenderEmail(e.target.value)}
value={state.senderEmail}
onChange={(e) => dispatch({ type: "SET_SENDER_EMAIL", value: e.target.value })}
placeholder="orders@yourbrand.com"
/>
</AdminInput>
@@ -118,8 +162,8 @@ export default function CommunicationSettingsForm({
<AdminInput label="Default Sender Name" helpText="The name displayed as the sender">
<AdminTextInput
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
value={state.senderName}
onChange={(e) => dispatch({ type: "SET_SENDER_NAME", value: e.target.value })}
placeholder="Route Commerce"
/>
</AdminInput>
@@ -127,16 +171,16 @@ export default function CommunicationSettingsForm({
<AdminInput label="Reply-To Email" helpText="Emails will be replied to this address">
<AdminTextInput
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
value={state.replyTo}
onChange={(e) => dispatch({ type: "SET_REPLY_TO", value: e.target.value })}
placeholder="support@yourbrand.com"
/>
</AdminInput>
<AdminInput label="Email Footer HTML (optional)" helpText="Add {unsubscribe_url} as placeholder for automatic unsubscribe links">
<AdminTextarea
value={footerHtml}
onChange={(e) => setFooterHtml(e.target.value)}
value={state.footerHtml}
onChange={(e) => dispatch({ type: "SET_FOOTER_HTML", value: e.target.value })}
rows={4}
placeholder={"<p>Unsubscribe: <a href='...'>link</a></p>"}
/>
@@ -146,10 +190,10 @@ export default function CommunicationSettingsForm({
{/* Form footer with actions */}
<div className="px-5 py-4 border-t border-[var(--admin-border)] bg-gradient-to-r from-[var(--admin-bg)] to-[var(--admin-card)] flex items-center justify-between">
<div>
{error && (
<p className="text-sm text-red-500">{error}</p>
{state.error && (
<p className="text-sm text-red-500">{state.error}</p>
)}
{success && (
{state.success && (
<div className="flex items-center gap-2 text-sm text-emerald-600">
<CheckIcon />
<span>Settings saved successfully</span>
@@ -158,12 +202,12 @@ export default function CommunicationSettingsForm({
</div>
<AdminButton
onClick={handleSave}
disabled={saving}
disabled={state.saving}
>
{saving ? "Saving..." : "Save Settings"}
{state.saving ? "Saving..." : "Save Settings"}
</AdminButton>
</div>
</div>
</div>
);
}
}
+513 -290
View File
@@ -1,7 +1,7 @@
"use client";
import {
useState,
useReducer,
useRef,
useCallback,
type Dispatch,
@@ -26,7 +26,10 @@ type ImportResult = {
errors: { row: ContactImportEntry; error: string }[];
};
type ImportHistoryEntry = { filename: string; size: number; createdAt: string };
const LARGE_FILE_ROWS = 10_000;
const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB
const FIELD_LABELS: Record<string, string> = {
email: "Email",
@@ -192,78 +195,143 @@ function applyMappings(
return entries;
}
// ── State / reducer ───────────────────────────────────────────────────────────
type State = {
step: Step;
file: File | null;
preview: ImportPreviewResult | null;
importRows: ContactImportEntry[];
result: ImportResult | null;
globalError: string;
overridingMappings: Record<string, ImportField>;
uploadProgress: string;
importHistory: ImportHistoryEntry[];
};
type Action =
| { type: "SET_FILE"; file: File }
| { type: "SET_PREVIEW"; preview: ImportPreviewResult }
| { type: "SET_STEP"; step: Step }
| { type: "SET_RESULT"; result: ImportResult | null }
| { type: "SET_IMPORT_ROWS"; rows: ContactImportEntry[] }
| { type: "SET_GLOBAL_ERROR"; value: string }
| { type: "SET_OVERRIDE"; column: string; field: ImportField | null }
| { type: "RESET_OVERRIDES" }
| { type: "SET_UPLOAD_PROGRESS"; value: string }
| { type: "SET_IMPORT_HISTORY"; history: ImportHistoryEntry[] }
| { type: "RESET" };
const initialState: State = {
step: "idle",
file: null,
preview: null,
importRows: [],
result: null,
globalError: "",
overridingMappings: {},
uploadProgress: "",
importHistory: [],
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_FILE":
return { ...state, file: action.file };
case "SET_PREVIEW":
return { ...state, preview: action.preview };
case "SET_STEP":
return { ...state, step: action.step };
case "SET_RESULT":
return { ...state, result: action.result };
case "SET_IMPORT_ROWS":
return { ...state, importRows: action.rows };
case "SET_GLOBAL_ERROR":
return { ...state, globalError: action.value };
case "SET_OVERRIDE":
return {
...state,
overridingMappings: {
...state.overridingMappings,
[action.column]: action.field,
},
};
case "RESET_OVERRIDES":
return { ...state, overridingMappings: {} };
case "SET_UPLOAD_PROGRESS":
return { ...state, uploadProgress: action.value };
case "SET_IMPORT_HISTORY":
return { ...state, importHistory: action.history };
case "RESET":
return {
...initialState,
importHistory: state.importHistory,
};
}
}
export default function ContactImportForm({ brandId }: { brandId: string }) {
const [step, setStep] = useState<Step>("idle");
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<ImportPreviewResult | null>(null);
const [state, dispatch] = useReducer(reducer, initialState);
const rawHeadersRef = useRef<string[]>([]);
const setRawHeaders = (v: string[]) => { rawHeadersRef.current = v; };
const rawRowsRef = useRef<string[][]>([]);
const setRawRows = (v: string[][]) => { rawRowsRef.current = v; };
const [importRows, setImportRows] = useState<ContactImportEntry[]>([]);
const importingRef = useRef(false);
const [result, setResult] = useState<ImportResult | null>(null);
const [globalError, setGlobalError] = useState("");
const [overridingMappings, setOverridingMappings] = useState<
Record<string, ImportField>
>({});
const [uploadProgress, setUploadProgress] = useState<string>("");
const [importHistory, setImportHistory] = useState<{ filename: string; size: number; createdAt: string }[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
// For large file detection (farmers with lots of data)
const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB
// ── CSV parsing + preview ──────────────────────────────────────────────────
const handleFile = useCallback(
async (f: File) => {
setFile(f);
setResult(null);
setGlobalError("");
setOverridingMappings({});
dispatch({ type: "SET_FILE", file: f });
dispatch({ type: "SET_RESULT", result: null });
dispatch({ type: "SET_GLOBAL_ERROR", value: "" });
dispatch({ type: "RESET_OVERRIDES" });
// Check if file is large enough to warrant bucket upload
const isLargeFile = f.size > LARGE_FILE_THRESHOLD;
if (isLargeFile) {
// Use bucket upload for large files
setUploadProgress("Uploading file to storage...");
dispatch({ type: "SET_UPLOAD_PROGRESS", value: "Uploading file to storage..." });
const uploadResult = await uploadContactsToBucket(brandId, f);
if (!uploadResult.success) {
setGlobalError(uploadResult.error);
setStep("idle");
dispatch({ type: "SET_GLOBAL_ERROR", value: uploadResult.error });
dispatch({ type: "SET_STEP", step: "idle" });
return;
}
setUploadProgress(`Processing ${uploadResult.recordCount.toLocaleString()} records...`);
dispatch({ type: "SET_UPLOAD_PROGRESS", value: `Processing ${uploadResult.recordCount.toLocaleString()} records...` });
// Process from bucket
const processResult = await processBucketImport(brandId, uploadResult.fileUrl);
importingRef.current = false;
setUploadProgress("");
dispatch({ type: "SET_UPLOAD_PROGRESS", value: "" });
if (!processResult.success) {
setGlobalError(processResult.error);
setStep("idle");
dispatch({ type: "SET_GLOBAL_ERROR", value: processResult.error });
dispatch({ type: "SET_STEP", step: "idle" });
return;
}
setResult({
created: processResult.created,
updated: processResult.updated,
skipped: processResult.skipped,
errors: [],
dispatch({
type: "SET_RESULT",
result: {
created: processResult.created,
updated: processResult.updated,
skipped: processResult.skipped,
errors: [],
},
});
setStep("result");
dispatch({ type: "SET_STEP", step: "result" });
// Load import history
const historyResult = await listImportHistory(brandId);
if (historyResult.success) {
setImportHistory(historyResult.imports);
dispatch({ type: "SET_IMPORT_HISTORY", history: historyResult.imports });
}
return;
@@ -274,21 +342,21 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
const previewResult = await previewContactImport(text);
if (!previewResult.success) {
setGlobalError(previewResult.error);
setStep("idle");
dispatch({ type: "SET_GLOBAL_ERROR", value: previewResult.error });
dispatch({ type: "SET_STEP", step: "idle" });
return;
}
const { csv, totalRows } = await import("@/lib/csv-parser").then(
const { csv } = await import("@/lib/csv-parser").then(
(m) => m.parseCSVWithLimits(text)
);
setRawHeaders(csv.headers);
setRawRows(csv.rows);
setPreview(previewResult.preview);
setStep("preview");
dispatch({ type: "SET_PREVIEW", preview: previewResult.preview });
dispatch({ type: "SET_STEP", step: "preview" });
},
[brandId, LARGE_FILE_THRESHOLD]
[brandId]
);
const handleDrop = useCallback(
@@ -296,7 +364,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith(".csv")) handleFile(f);
else setGlobalError("Please upload a .csv file");
else dispatch({ type: "SET_GLOBAL_ERROR", value: "Please upload a .csv file" });
},
[handleFile]
);
@@ -313,17 +381,17 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
const handleConfirm = useCallback(
async () => {
if (!preview || rawRowsRef.current.length === 0) return;
setStep("importing");
if (!state.preview || rawRowsRef.current.length === 0) return;
dispatch({ type: "SET_STEP", step: "importing" });
const rowsToImport = applyMappings(
preview.mappings,
overridingMappings,
state.preview.mappings,
state.overridingMappings,
rawHeadersRef.current,
rawRowsRef.current
);
setImportRows(rowsToImport);
dispatch({ type: "SET_IMPORT_ROWS", rows: rowsToImport });
importingRef.current = true;
const importResult = await importContactsBatch({
@@ -333,24 +401,18 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
importingRef.current = false;
if (importResult.success) {
setResult(importResult.result);
setStep("result");
dispatch({ type: "SET_RESULT", result: importResult.result });
dispatch({ type: "SET_STEP", step: "result" });
} else {
setGlobalError(importResult.error ?? "Import failed");
setStep("result");
dispatch({ type: "SET_GLOBAL_ERROR", value: importResult.error ?? "Import failed" });
dispatch({ type: "SET_STEP", step: "result" });
}
},
[brandId, preview, overridingMappings]
[brandId, state.preview, state.overridingMappings]
);
const handleReset = () => {
setStep("idle");
setFile(null);
setPreview(null);
setImportRows([]);
setResult(null);
setGlobalError("");
setOverridingMappings({});
dispatch({ type: "RESET" });
if (fileRef.current) fileRef.current.value = "";
};
@@ -358,250 +420,411 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
return (
<div className="p-4 sm:p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-[var(--admin-text-primary)]">
{Icons.uploadCloud("w-3 h-3 text-[var(--admin-bg)]")}
</div>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Import Contacts</h3>
<ContactHeader
showStartOver={state.step !== "idle"}
onStartOver={handleReset}
/>
{state.file && state.file.size > LARGE_FILE_THRESHOLD && (
<LargeFileBanner />
)}
{state.uploadProgress && <UploadProgress message={state.uploadProgress} />}
{state.globalError && <ErrorBanner message={state.globalError} />}
{state.step === "idle" && (
<ContactUploadStep
fileRef={fileRef}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onChange={handleFileChange}
/>
)}
{state.step === "preview" && state.preview && (
<ContactPreviewStep
fileName={state.file?.name ?? ""}
preview={state.preview}
overridingMappings={state.overridingMappings}
setOverridingMappings={(updater) => {
// Support both function and object updates
if (typeof updater === "function") {
// Compute new value, then dispatch each changed column
const prev = state.overridingMappings;
const next = updater(prev);
for (const [col, field] of Object.entries(next)) {
if (prev[col] !== field) {
dispatch({ type: "SET_OVERRIDE", column: col, field });
}
}
} else {
// Replace all overrides
for (const [col, field] of Object.entries(state.overridingMappings)) {
if (!(col in updater)) {
dispatch({ type: "SET_OVERRIDE", column: col, field: null });
}
}
for (const [col, field] of Object.entries(updater)) {
if (state.overridingMappings[col] !== field) {
dispatch({ type: "SET_OVERRIDE", column: col, field });
}
}
}
}}
onCancel={handleReset}
onConfirm={handleConfirm}
/>
)}
{state.step === "importing" && (
<ImportingStep count={state.importRows.length} />
)}
{state.step === "result" && state.result && (
<ResultStep result={state.result} onImportMore={handleReset} />
)}
{state.importHistory.length > 0 && (
<ImportHistoryList history={state.importHistory} />
)}
</div>
);
}
// ── Sub-components ────────────────────────────────────────────────────────────
function ContactHeader({
showStartOver,
onStartOver,
}: {
showStartOver: boolean;
onStartOver: () => void;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-[var(--admin-text-primary)]">
{Icons.uploadCloud("w-3 h-3 text-[var(--admin-bg)]")}
</div>
{step !== "idle" && (
<button type="button"
onClick={handleReset}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Start over
</button>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Import Contacts</h3>
</div>
{showStartOver && (
<button type="button"
onClick={onStartOver}
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Start over
</button>
)}
</div>
);
}
function LargeFileBanner() {
return (
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
{Icons.check("h-4 w-4 text-emerald-600")}
</div>
<div>
<p className="font-semibold text-emerald-700">Large file detected</p>
<p className="text-xs text-emerald-600">File will be uploaded to cloud storage for processing</p>
</div>
</div>
);
}
function UploadProgress({ message }: { message: string }) {
return (
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-4 text-sm">
<div className="flex items-center gap-3">
{Icons.spinner("h-5 w-5 text-blue-600 animate-spin")}
<span className="text-blue-700 font-medium">{message}</span>
</div>
</div>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{message}
</div>
);
}
function ContactUploadStep({
fileRef,
onDragOver,
onDrop,
onChange,
}: {
fileRef: React.RefObject<HTMLInputElement | null>;
onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<div
className="border-2 border-dashed border-[var(--admin-border)] rounded-xl p-8 text-center hover:border-emerald-400 transition-colors"
onDragOver={onDragOver}
onDrop={onDrop}
>
<input aria-label="File upload"
ref={fileRef}
type="file"
accept=".csv"
onChange={onChange}
className="hidden"
id="csv-upload"
/>
<label htmlFor="csv-upload" className="cursor-pointer">
<div className="text-[var(--admin-text-muted)] text-sm">
<span className="font-semibold text-emerald-600 hover:text-emerald-700">
Click to upload
</span>
{" "}or drag and drop a CSV file
</div>
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
Works with Mailchimp, Square, Shopify, WooCommerce, QuickBooks, and
generic spreadsheets
</p>
<p className="text-[10px] text-[var(--admin-text-muted)] mt-2 text-emerald-600">
Large files (5MB+) automatically uploaded to cloud storage
</p>
</label>
</div>
);
}
function ContactPreviewStep({
fileName,
preview,
overridingMappings,
setOverridingMappings,
onCancel,
onConfirm,
}: {
fileName: string;
preview: ImportPreviewResult;
overridingMappings: Record<string, ImportField>;
setOverridingMappings: Dispatch<SetStateAction<Record<string, ImportField>>>;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<>
<PreviewWarnings warnings={preview.warnings} />
<PreviewFileBanner fileName={fileName} />
<MappingsView
preview={preview}
overridingMappings={overridingMappings}
setOverridingMappings={setOverridingMappings}
/>
<PreviewSummary preview={preview} />
<PreviewSampleRows preview={preview} />
{preview.skippedReasons.length > 0 && <SkippedReasons preview={preview} />}
{preview.totalRows > LARGE_FILE_ROWS && <LargeRowWarning preview={preview} />}
<PreviewActions
validRows={preview.validRows}
onCancel={onCancel}
onConfirm={onConfirm}
/>
</>
);
}
function PreviewWarnings({ warnings }: { warnings: string[] }) {
if (warnings.length === 0) return null;
return (
<>
{warnings.map((w, i) => (
<div
key={`${w}-${i}`}
className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
>
{w}
</div>
))}
</>
);
}
function PreviewFileBanner({ fileName }: { fileName: string }) {
return (
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700">
<p className="font-semibold mb-1">{fileName}</p>
<p>
Column mappings detected review below, adjust if needed, then
confirm import.
</p>
</div>
);
}
function PreviewSummary({ preview }: { preview: ImportPreviewResult }) {
return (
<div>
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
Import Summary
</p>
<StatsView preview={preview} />
</div>
);
}
function PreviewSampleRows({ preview }: { preview: ImportPreviewResult }) {
return (
<div>
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
Sample rows (first {preview.sampleRows.length})
</p>
<SampleRowsView preview={preview} />
</div>
);
}
function SkippedReasons({ preview }: { preview: ImportPreviewResult }) {
return (
<div>
<p className="text-xs text-[var(--admin-text-muted)] mb-1 font-semibold">
Skipped rows ({preview.skippedReasons.length})
</p>
<div className="max-h-32 overflow-y-auto text-xs text-[var(--admin-text-muted)] space-y-1">
{preview.skippedReasons.slice(0, 10).map((s) => (
<p key={s.rowIndex}>
Row {s.rowIndex + 1}: {s.reason}
</p>
))}
{preview.skippedReasons.length > 10 && (
<p className="text-[var(--admin-text-muted)]">
...and{" "}
{preview.skippedReasons.length - 10} more
</p>
)}
</div>
</div>
);
}
{/* Large file banner */}
{file && file.size > LARGE_FILE_THRESHOLD && (
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
{Icons.check("h-4 w-4 text-emerald-600")}
</div>
<div>
<p className="font-semibold text-emerald-700">Large file detected</p>
<p className="text-xs text-emerald-600">File will be uploaded to cloud storage for processing</p>
</div>
</div>
)}
function LargeRowWarning({ preview }: { preview: ImportPreviewResult }) {
return (
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
Large file ({preview.totalRows.toLocaleString()} rows) confirm to
proceed.
</div>
);
}
{/* Upload progress */}
{uploadProgress && (
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-4 text-sm">
<div className="flex items-center gap-3">
{Icons.spinner("h-5 w-5 text-blue-600 animate-spin")}
<span className="text-blue-700 font-medium">{uploadProgress}</span>
</div>
</div>
)}
function PreviewActions({
validRows,
onCancel,
onConfirm,
}: {
validRows: number;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<div className="flex gap-3">
<button type="button"
onClick={onCancel}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
>
Cancel
</button>
<button type="button"
onClick={onConfirm}
disabled={validRows === 0}
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
Import {validRows} contacts
</button>
</div>
);
}
{globalError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{globalError}
</div>
)}
function ImportingStep({ count }: { count: number }) {
return (
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-6 text-center text-sm text-emerald-700 font-semibold">
Importing {count} contacts...
</div>
);
}
{/* ── STEP: idle ─────────────────────────────────────────── */}
{step === "idle" && (
<>
<div
className="border-2 border-dashed border-[var(--admin-border)] rounded-xl p-8 text-center hover:border-emerald-400 transition-colors"
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={handleDrop}
>
<input aria-label="File upload"
ref={fileRef}
type="file"
accept=".csv"
onChange={handleFileChange}
className="hidden"
id="csv-upload"
/>
<label htmlFor="csv-upload" className="cursor-pointer">
<div className="text-[var(--admin-text-muted)] text-sm">
<span className="font-semibold text-emerald-600 hover:text-emerald-700">
Click to upload
</span>
{" "}or drag and drop a CSV file
</div>
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
Works with Mailchimp, Square, Shopify, WooCommerce, QuickBooks, and
generic spreadsheets
</p>
<p className="text-[10px] text-[var(--admin-text-muted)] mt-2 text-emerald-600">
Large files (5MB+) automatically uploaded to cloud storage
</p>
</label>
</div>
</>
)}
function ResultStep({
result,
onImportMore,
}: {
result: ImportResult;
onImportMore: () => void;
}) {
return (
<div className="space-y-3">
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm space-y-1">
<p className="font-semibold text-emerald-700 flex items-center gap-2">
{Icons.check("h-4 w-4")}
Import complete
</p>
<p className="text-emerald-600">Created: {result.created.toLocaleString()}</p>
<p className="text-emerald-600">Updated: {result.updated.toLocaleString()}</p>
<p className="text-emerald-600">Skipped: {result.skipped.toLocaleString()}</p>
</div>
{/* ── STEP: preview ──────────────────────────────────────── */}
{step === "preview" && preview && (
<>
{preview.warnings.map((w, i) => (
<div
key={`${w}-${i}`}
className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
>
{w}
</div>
))}
{result.errors.length > 0 && <ResultErrors result={result} />}
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700">
<p className="font-semibold mb-1">{file?.name}</p>
<p>
Column mappings detected review below, adjust if needed, then
confirm import.
</p>
</div>
<button type="button"
onClick={onImportMore}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
>
Import more
</button>
</div>
);
}
<div className="space-y-3">
{preview ? (
<MappingsView
preview={preview}
overridingMappings={overridingMappings}
setOverridingMappings={setOverridingMappings}
/>
) : null}
</div>
<div>
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
Import Summary
</p>
{preview ? <StatsView preview={preview} /> : null}
</div>
<div>
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
Sample rows (first {preview.sampleRows.length})
</p>
{preview ? <SampleRowsView preview={preview} /> : null}
</div>
{preview.skippedReasons.length > 0 && (
<div>
<p className="text-xs text-[var(--admin-text-muted)] mb-1 font-semibold">
Skipped rows ({preview.skippedReasons.length})
</p>
<div className="max-h-32 overflow-y-auto text-xs text-[var(--admin-text-muted)] space-y-1">
{preview.skippedReasons.slice(0, 10).map((s) => (
<p key={s.rowIndex}>
Row {s.rowIndex + 1}: {s.reason}
</p>
))}
{preview.skippedReasons.length > 10 && (
<p className="text-[var(--admin-text-muted)]">
...and{" "}
{preview.skippedReasons.length - 10} more
</p>
)}
</div>
</div>
)}
{preview.totalRows > LARGE_FILE_ROWS && (
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
Large file ({preview.totalRows.toLocaleString()} rows) confirm to
proceed.
</div>
)}
<div className="flex gap-3">
<button type="button"
onClick={() => handleReset()}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
>
Cancel
</button>
<button type="button"
onClick={() => handleConfirm()}
disabled={preview.validRows === 0}
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
Import {preview.validRows} contacts
</button>
</div>
</>
)}
{/* ── STEP: importing ────────────────────────────────────── */}
{step === "importing" && (
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-6 text-center text-sm text-emerald-700 font-semibold">
Importing {importRows.length} contacts...
</div>
)}
{/* ── STEP: result ────────────────────────────────────────── */}
{step === "result" && result && (
<div className="space-y-3">
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm space-y-1">
<p className="font-semibold text-emerald-700 flex items-center gap-2">
{Icons.check("h-4 w-4")}
Import complete
</p>
<p className="text-emerald-600">Created: {result.created.toLocaleString()}</p>
<p className="text-emerald-600">Updated: {result.updated.toLocaleString()}</p>
<p className="text-emerald-600">Skipped: {result.skipped.toLocaleString()}</p>
</div>
{result.errors.length > 0 && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm">
<p className="font-semibold text-red-600 mb-2">
Errors ({result.errors.length})
</p>
<div className="max-h-40 overflow-y-auto space-y-1">
{result.errors.slice(0, 10).map((e, i) => (
<p key={`${e.error}-${JSON.stringify(e.row)}-${i}`} className="text-red-600 text-xs">
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
</p>
))}
{result.errors.length > 10 && (
<p className="text-red-600 text-xs">
...and {result.errors.length - 10} more
</p>
)}
</div>
</div>
)}
<button type="button"
onClick={handleReset}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
>
Import more
</button>
</div>
)}
{/* Import history for bucket imports */}
{importHistory.length > 0 && (
<div className="border-t border-[var(--admin-border)] pt-4 mt-4">
<p className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide mb-3">
Previous Imports
function ResultErrors({ result }: { result: ImportResult }) {
return (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm">
<p className="font-semibold text-red-600 mb-2">
Errors ({result.errors.length})
</p>
<div className="max-h-40 overflow-y-auto space-y-1">
{result.errors.slice(0, 10).map((e, i) => (
<p key={`${e.error}-${JSON.stringify(e.row)}-${i}`} className="text-red-600 text-xs">
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
</p>
<div className="space-y-2">
{importHistory.slice(0, 5).map((imp, i) => (
<div key={`${imp.filename}-${imp.createdAt}-${imp.size}`} className="flex items-center justify-between text-xs p-2 rounded-lg bg-[var(--admin-card)]">
<div className="flex items-center gap-2">
{Icons.file("h-4 w-4 text-[var(--admin-text-muted)]")}
<span className="text-[var(--admin-text-primary)]">{imp.filename}</span>
</div>
<span className="text-[var(--admin-text-muted)]">
{(imp.size / 1024).toFixed(1)} KB
</span>
</div>
))}
))}
{result.errors.length > 10 && (
<p className="text-red-600 text-xs">
...and {result.errors.length - 10} more
</p>
)}
</div>
</div>
);
}
function ImportHistoryList({ history }: { history: ImportHistoryEntry[] }) {
return (
<div className="border-t border-[var(--admin-border)] pt-4 mt-4">
<p className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide mb-3">
Previous Imports
</p>
<div className="space-y-2">
{history.slice(0, 5).map((imp, i) => (
<div key={`${imp.filename}-${imp.createdAt}-${imp.size}-${i}`} className="flex items-center justify-between text-xs p-2 rounded-lg bg-[var(--admin-card)]">
<div className="flex items-center gap-2">
{Icons.file("h-4 w-4 text-[var(--admin-text-muted)]")}
<span className="text-[var(--admin-text-primary)]">{imp.filename}</span>
</div>
<span className="text-[var(--admin-text-muted)]">
{(imp.size / 1024).toFixed(1)} KB
</span>
</div>
</div>
)}
))}
</div>
</div>
);
}
@@ -743,7 +966,7 @@ function SampleRowsView({ preview }: { preview: ImportPreviewResult }) {
</thead>
<tbody>
{preview.sampleRows.map((row, i) => (
<tr key={i} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-card-hover)]">
<tr key={`${row.email ?? ""}-${i}`} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-card-hover)]">
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{i + 1}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.email ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.phone ?? ""}</td>
+404 -234
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useReducer, useCallback } from "react";
import type { Contact, ContactSource } from "@/actions/communications/contacts";
import { getContacts, deleteContact, exportContacts } from "@/actions/communications/contacts";
import { formatDate } from "@/lib/format-date";
@@ -112,6 +112,54 @@ function ContactSkeleton() {
);
}
type State = {
contacts: Contact[];
total: number;
search: string;
sourceFilter: ContactSource | "";
page: number;
loading: boolean;
deleting: string | null;
exporting: boolean;
};
type Action =
| { type: "SET_PAGE_RESULTS"; contacts: Contact[]; total: number }
| { type: "SET_SEARCH"; value: string }
| { type: "SET_SOURCE_FILTER"; value: ContactSource | "" }
| { type: "SET_PAGE"; value: number }
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_DELETING"; value: string | null }
| { type: "SET_EXPORTING"; value: boolean }
| { type: "REMOVE_CONTACT"; id: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_PAGE_RESULTS":
return { ...state, contacts: action.contacts, total: action.total };
case "SET_SEARCH":
return { ...state, search: action.value };
case "SET_SOURCE_FILTER":
return { ...state, sourceFilter: action.value };
case "SET_PAGE":
return { ...state, page: action.value };
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_DELETING":
return { ...state, deleting: action.value };
case "SET_EXPORTING":
return { ...state, exporting: action.value };
case "REMOVE_CONTACT":
return {
...state,
contacts: state.contacts.filter((c) => c.id !== action.id),
total: state.total - 1,
};
default:
return state;
}
}
export default function ContactListPanel({
initialContacts,
initialTotal,
@@ -121,20 +169,22 @@ export default function ContactListPanel({
initialTotal: number;
brandId: string;
}) {
const [contacts, setContacts] = useState(initialContacts);
const [total, setTotal] = useState(initialTotal);
const [search, setSearch] = useState("");
const [sourceFilter, setSourceFilter] = useState<ContactSource | "">("");
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const limit = 50;
const [state, dispatch] = useReducer(reducer, undefined, () => ({
contacts: initialContacts,
total: initialTotal,
search: "",
sourceFilter: "" as ContactSource | "",
page: 0,
loading: false,
deleting: null,
exporting: false,
}));
const hasFilters = search.length > 0 || sourceFilter !== "";
const hasFilters = state.search.length > 0 || state.sourceFilter !== "";
const loadPage = useCallback(async (searchVal: string, sourceVal: string, pageNum: number) => {
setLoading(true);
dispatch({ type: "SET_LOADING", value: true });
const result = await getContacts({
brandId,
search: searchVal || undefined,
@@ -142,51 +192,49 @@ export default function ContactListPanel({
limit,
offset: pageNum * limit,
});
setLoading(false);
dispatch({ type: "SET_LOADING", value: false });
if (result.success) {
setContacts(result.contacts);
setTotal(result.total);
dispatch({ type: "SET_PAGE_RESULTS", contacts: result.contacts, total: result.total });
}
}, [brandId]);
const handleSearch = (e: React.FormEvent) => {
function handleSearch(e: React.FormEvent) {
e.preventDefault();
setPage(0);
loadPage(search, sourceFilter, 0);
};
dispatch({ type: "SET_PAGE", value: 0 });
loadPage(state.search, state.sourceFilter, 0);
}
const handleSourceFilter = (val: ContactSource | "") => {
setSourceFilter(val);
setPage(0);
loadPage(search, val, 0);
};
function handleSourceFilter(val: ContactSource | "") {
dispatch({ type: "SET_SOURCE_FILTER", value: val });
dispatch({ type: "SET_PAGE", value: 0 });
loadPage(state.search, val, 0);
}
const handlePage = (dir: number) => {
const next = page + dir;
setPage(next);
loadPage(search, sourceFilter, next);
};
function handlePage(dir: number) {
const next = state.page + dir;
dispatch({ type: "SET_PAGE", value: next });
loadPage(state.search, state.sourceFilter, next);
}
const handleDelete = async (id: string) => {
async function handleDelete(id: string) {
if (!confirm("Delete this contact? This action cannot be undone.")) return;
setDeleting(id);
dispatch({ type: "SET_DELETING", value: id });
const result = await deleteContact(id);
setDeleting(null);
dispatch({ type: "SET_DELETING", value: null });
if (result.success) {
setContacts((prev) => prev.filter((c) => c.id !== id));
setTotal((t) => t - 1);
dispatch({ type: "REMOVE_CONTACT", id });
}
};
}
const handleExport = async () => {
setExporting(true);
async function handleExport() {
dispatch({ type: "SET_EXPORTING", value: true });
const result = await exportContacts({
brandId,
brandSlug: "contacts",
search: search || undefined,
source: sourceFilter || undefined,
search: state.search || undefined,
source: state.sourceFilter || undefined,
});
setExporting(false);
dispatch({ type: "SET_EXPORTING", value: false });
if (!result.success) {
alert("Export failed: " + result.error);
return;
@@ -200,217 +248,339 @@ export default function ContactListPanel({
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
}
return (
<div className="p-4 sm:p-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
{Icons.users("w-6 h-6 text-white")}
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">Contacts</h2>
<p className="text-sm text-stone-500">{total.toLocaleString()} contact{total !== 1 ? "s" : ""}</p>
</div>
</div>
<div className="flex items-center gap-2">
<AdminButton variant="secondary" onClick={handleExport} disabled={exporting || total === 0} icon={Icons.download("w-4 h-4")}>
{exporting ? "Exporting..." : "Export"}
</AdminButton>
</div>
</div>
<ContactsHeader
total={state.total}
exporting={state.exporting}
canExport={state.total > 0}
onExport={handleExport}
/>
{/* Search + filters */}
<form onSubmit={handleSearch} className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
{Icons.search("h-5 w-5 text-stone-400")}
</div>
<input aria-label="Search Email, Name, Phone..."
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search email, name, phone..."
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
/>
</div>
<select aria-label="Select"
value={sourceFilter}
onChange={(e) => handleSourceFilter(e.target.value as ContactSource | "")}
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
>
<option value="">All Sources</option>
<option value="order">From Orders</option>
<option value="import">Imported</option>
<option value="manual">Manual Entry</option>
<option value="admin">Admin Added</option>
</select>
<button
type="submit"
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 active:bg-emerald-800 transition-colors shadow-sm"
aria-label="Search">
Search
</button>
<form onSubmit={handleSearch}>
<SearchAndFilters
search={state.search}
sourceFilter={state.sourceFilter}
dispatch={dispatch}
onSourceFilter={handleSourceFilter}
/>
</form>
{/* Table */}
{loading ? (
{state.loading ? (
<ContactSkeleton />
) : contacts.length === 0 ? (
) : state.contacts.length === 0 ? (
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)]">
<EmptyState hasFilters={hasFilters} />
</div>
) : (
<>
{/* Desktop Table */}
<div className="hidden sm:block overflow-hidden rounded-xl border border-[var(--admin-border)]">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
<tr>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Name</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Phone</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Source</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email Opt</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Unsubscribed</th>
<th className="text-right px-4 py-3.5" aria-label="Actions"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)] bg-white">
{contacts.map((c) => (
<tr key={c.id} className="hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 font-semibold text-stone-800">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 flex items-center justify-center text-xs font-bold text-white">
{(c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "??").slice(0, 2).toUpperCase()}
</div>
<span>{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}</span>
</div>
</td>
<td className="px-4 py-3.5 text-stone-600">
<div className="flex items-center gap-1.5">
{Icons.mail("w-4 h-4 text-stone-400")}
<span className="truncate max-w-[150px]">{c.email || "—"}</span>
</div>
</td>
<td className="px-4 py-3.5 text-stone-600">{c.phone || "—"}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${SOURCE_COLORS[c.source]}`}>
{c.source}
</span>
</td>
<td className="px-4 py-3.5">
{c.email_opt_in ? (
<span className="inline-flex items-center gap-1 text-emerald-600 text-xs font-semibold">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Opted in
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-500 text-xs font-semibold">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Opted out
</span>
)}
</td>
<td className="px-4 py-3.5 text-stone-500 text-xs">
{c.unsubscribed_at ? formatDate(new Date(c.unsubscribed_at)) : "—"}
</td>
<td className="px-4 py-3.5 text-right">
<button type="button"
onClick={() => handleDelete(c.id)}
disabled={deleting === c.id}
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-700 disabled:opacity-50 transition-colors"
title="Delete contact"
aria-label="Delete contact">
{Icons.trash("h-4 w-4")}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<ContactsList
contacts={state.contacts}
deleting={state.deleting}
onDelete={handleDelete}
/>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{contacts.map((c) => (
<div key={c.id} className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 flex items-center justify-center text-xs font-bold text-white">
{(c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "??").slice(0, 2).toUpperCase()}
</div>
<div>
<div className="font-semibold text-stone-800 text-sm">
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
</div>
<div className="text-xs text-stone-500">{c.email || "—"}</div>
</div>
</div>
<button type="button"
onClick={() => handleDelete(c.id)}
disabled={deleting === c.id}
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-700 disabled:opacity-50"
>
{Icons.trash("h-4 w-4")}
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${SOURCE_COLORS[c.source]}`}>
{c.source}
</span>
{c.phone && (
<span className="inline-flex items-center gap-1 text-xs text-stone-500">
{c.phone}
</span>
)}
{c.email_opt_in ? (
<span className="inline-flex items-center gap-1 text-emerald-600 text-xs font-semibold">
Opted in
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-500 text-xs font-semibold">
Opted out
</span>
)}
</div>
</div>
))}
</div>
{/* Pagination */}
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
<span className="text-sm text-stone-500">
Showing {page * limit + 1}{Math.min((page + 1) * limit, total)} of {total.toLocaleString()}
</span>
<div className="flex gap-2">
<button type="button"
onClick={() => handlePage(-1)}
disabled={page === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
aria-label="Previous">
{Icons.chevronLeft("h-4 w-4")}
<span>Previous</span>
</button>
<button type="button"
onClick={() => handlePage(1)}
disabled={(page + 1) * limit >= total}
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
aria-label="Next">
<span>Next</span>
{Icons.chevronRight("h-4 w-4")}
</button>
</div>
</div>
<Pagination
page={state.page}
total={state.total}
limit={limit}
onPage={handlePage}
/>
</>
)}
</div>
);
}
function ContactsHeader({
total,
exporting,
canExport,
onExport,
}: {
total: number;
exporting: boolean;
canExport: boolean;
onExport: () => void;
}) {
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
{Icons.users("w-6 h-6 text-white")}
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">Contacts</h2>
<p className="text-sm text-stone-500">{total.toLocaleString()} contact{total !== 1 ? "s" : ""}</p>
</div>
</div>
<div className="flex items-center gap-2">
<AdminButton variant="secondary" onClick={onExport} disabled={exporting || !canExport} icon={Icons.download("w-4 h-4")}>
{exporting ? "Exporting..." : "Export"}
</AdminButton>
</div>
</div>
);
}
function SearchAndFilters({
search,
sourceFilter,
dispatch,
onSourceFilter,
}: {
search: string;
sourceFilter: ContactSource | "";
dispatch: React.Dispatch<Action>;
onSourceFilter: (val: ContactSource | "") => void;
}) {
return (
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
{Icons.search("h-5 w-5 text-stone-400")}
</div>
<input aria-label="Search Email, Name, Phone..."
type="text"
value={search}
onChange={(e) => dispatch({ type: "SET_SEARCH", value: e.target.value })}
placeholder="Search email, name, phone..."
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
/>
</div>
<select aria-label="Select"
value={sourceFilter}
onChange={(e) => onSourceFilter(e.target.value as ContactSource | "")}
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
>
<option value="">All Sources</option>
<option value="order">From Orders</option>
<option value="import">Imported</option>
<option value="manual">Manual Entry</option>
<option value="admin">Admin Added</option>
</select>
<button
type="submit"
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 active:bg-emerald-800 transition-colors shadow-sm"
aria-label="Search">
Search
</button>
</div>
);
}
function ContactsList({
contacts,
deleting,
onDelete,
}: {
contacts: Contact[];
deleting: string | null;
onDelete: (id: string) => void;
}) {
return (
<>
{/* Desktop Table */}
<div className="hidden sm:block overflow-hidden rounded-xl border border-[var(--admin-border)]">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
<tr>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Name</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Phone</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Source</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email Opt</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Unsubscribed</th>
<th className="text-right px-4 py-3.5" aria-label="Actions"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)] bg-white">
{contacts.map((c) => (
<ContactTableRow
key={c.id}
contact={c}
deleting={deleting === c.id}
onDelete={() => onDelete(c.id)}
/>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{contacts.map((c) => (
<ContactMobileCard
key={c.id}
contact={c}
deleting={deleting === c.id}
onDelete={() => onDelete(c.id)}
/>
))}
</div>
</>
);
}
function ContactTableRow({
contact: c,
deleting,
onDelete,
}: {
contact: Contact;
deleting: boolean;
onDelete: () => void;
}) {
const displayName = c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—";
return (
<tr className="hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 font-semibold text-stone-800">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 flex items-center justify-center text-xs font-bold text-white">
{displayName.slice(0, 2).toUpperCase()}
</div>
<span>{displayName}</span>
</div>
</td>
<td className="px-4 py-3.5 text-stone-600">
<div className="flex items-center gap-1.5">
{Icons.mail("w-4 h-4 text-stone-400")}
<span className="truncate max-w-[150px]">{c.email || "—"}</span>
</div>
</td>
<td className="px-4 py-3.5 text-stone-600">{c.phone || "—"}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${SOURCE_COLORS[c.source]}`}>
{c.source}
</span>
</td>
<td className="px-4 py-3.5">
{c.email_opt_in ? (
<span className="inline-flex items-center gap-1 text-emerald-600 text-xs font-semibold">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Opted in
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-500 text-xs font-semibold">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
Opted out
</span>
)}
</td>
<td className="px-4 py-3.5 text-stone-500 text-xs">
{c.unsubscribed_at ? formatDate(new Date(c.unsubscribed_at)) : "—"}
</td>
<td className="px-4 py-3.5 text-right">
<button type="button"
onClick={onDelete}
disabled={deleting}
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-700 disabled:opacity-50 transition-colors"
title="Delete contact"
aria-label="Delete contact">
{Icons.trash("h-4 w-4")}
</button>
</td>
</tr>
);
}
function ContactMobileCard({
contact: c,
deleting,
onDelete,
}: {
contact: Contact;
deleting: boolean;
onDelete: () => void;
}) {
const displayName = c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—";
return (
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 flex items-center justify-center text-xs font-bold text-white">
{displayName.slice(0, 2).toUpperCase()}
</div>
<div>
<div className="font-semibold text-stone-800 text-sm">
{displayName}
</div>
<div className="text-xs text-stone-500">{c.email || "—"}</div>
</div>
</div>
<button type="button"
onClick={onDelete}
disabled={deleting}
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-700 disabled:opacity-50"
>
{Icons.trash("h-4 w-4")}
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${SOURCE_COLORS[c.source]}`}>
{c.source}
</span>
{c.phone && (
<span className="inline-flex items-center gap-1 text-xs text-stone-500">
{c.phone}
</span>
)}
{c.email_opt_in ? (
<span className="inline-flex items-center gap-1 text-emerald-600 text-xs font-semibold">
Opted in
</span>
) : (
<span className="inline-flex items-center gap-1 text-red-500 text-xs font-semibold">
Opted out
</span>
)}
</div>
</div>
);
}
function Pagination({
page,
total,
limit,
onPage,
}: {
page: number;
total: number;
limit: number;
onPage: (dir: number) => void;
}) {
return (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
<span className="text-sm text-stone-500">
Showing {page * limit + 1}{Math.min((page + 1) * limit, total)} of {total.toLocaleString()}
</span>
<div className="flex gap-2">
<button type="button"
onClick={() => onPage(-1)}
disabled={page === 0}
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
aria-label="Previous">
{Icons.chevronLeft("h-4 w-4")}
<span>Previous</span>
</button>
<button type="button"
onClick={() => onPage(1)}
disabled={(page + 1) * limit >= total}
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
aria-label="Next">
<span>Next</span>
{Icons.chevronRight("h-4 w-4")}
</button>
</div>
</div>
);
}
+491 -316
View File
@@ -1,8 +1,7 @@
"use client";
import { useState } from "react";
import { useReducer, useEffect } from "react";
import GlassModal from "@/components/admin/GlassModal";
import { AdminInput, AdminTextInput } from "@/components/admin/design-system";
type Role = "platform_admin" | "brand_admin" | "store_employee";
@@ -12,10 +11,12 @@ const roleDescriptions: Record<Role, string> = {
store_employee: "Pickup and order operations only",
};
type AdminUserRow = import("@/actions/admin/users").AdminUserRow;
type Props = {
isOpen: boolean;
onClose: () => void;
onSuccess: (user: import("@/actions/admin/users").AdminUserRow) => void;
onSuccess: (user: AdminUserRow) => void;
brands: { id: string; name: string }[];
currentUser: {
role: string;
@@ -23,7 +24,7 @@ type Props = {
};
type SuccessState = {
user: import("@/actions/admin/users").AdminUserRow;
user: AdminUserRow;
tempPassword: string;
emailSent: boolean;
emailError?: string;
@@ -56,6 +57,80 @@ const defaultFlags: Record<string, boolean> = {
can_manage_reports: false,
};
type State = {
email: string;
password: string;
displayName: string;
phoneNumber: string;
role: Role;
brandId: string | null;
flags: Record<string, boolean>;
saving: boolean;
error: string | null;
success: SuccessState | null;
copied: boolean;
};
type Action =
| { type: "SET_EMAIL"; value: string }
| { type: "SET_PASSWORD"; value: string }
| { type: "SET_DISPLAY_NAME"; value: string }
| { type: "SET_PHONE_NUMBER"; value: string }
| { type: "SET_ROLE"; value: Role }
| { type: "SET_BRAND"; value: string | null }
| { type: "TOGGLE_FLAG"; flag: string }
| { type: "SET_SAVING"; value: boolean }
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_SUCCESS"; value: SuccessState | null }
| { type: "SET_COPIED"; value: boolean }
| { type: "RESET" };
const initialState: State = {
email: "",
password: "",
displayName: "",
phoneNumber: "",
role: "store_employee",
brandId: null,
flags: { ...defaultFlags },
saving: false,
error: null,
success: null,
copied: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_EMAIL":
return { ...state, email: action.value };
case "SET_PASSWORD":
return { ...state, password: action.value };
case "SET_DISPLAY_NAME":
return { ...state, displayName: action.value };
case "SET_PHONE_NUMBER":
return { ...state, phoneNumber: action.value };
case "SET_ROLE":
return { ...state, role: action.value };
case "SET_BRAND":
return { ...state, brandId: action.value };
case "TOGGLE_FLAG":
return { ...state, flags: { ...state.flags, [action.flag]: !state.flags[action.flag] } };
case "SET_SAVING":
return { ...state, saving: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_SUCCESS":
return { ...state, success: action.value };
case "SET_COPIED":
return { ...state, copied: action.value };
case "RESET":
return {
...initialState,
flags: { ...defaultFlags },
};
}
}
const UserIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
@@ -66,215 +141,102 @@ const UserIcon = ({ className }: { className?: string }) => (
);
export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, currentUser }: Props) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [role, setRole] = useState<Role>("store_employee");
const [brandId, setBrandId] = useState<string | null>(null);
const [flags, setFlags] = useState<Record<string, boolean>>({ ...defaultFlags });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<SuccessState | null>(null);
const [copied, setCopied] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
const showBrandSelect = role === "brand_admin" || role === "store_employee";
const showBrandSelect = state.role === "brand_admin" || state.role === "store_employee";
const availableRoles: Role[] =
currentUser.role === "platform_admin"
? ["platform_admin", "brand_admin", "store_employee"]
: ["brand_admin", "store_employee"];
function toggleFlag(flag: string) {
setFlags((prev) => ({ ...prev, [flag]: !prev[flag] }));
}
function resetForm() {
setEmail("");
setPassword("");
setDisplayName("");
setPhoneNumber("");
setRole("store_employee");
setBrandId(null);
setFlags({ ...defaultFlags });
setError(null);
setSuccess(null);
setCopied(false);
}
async function handleSubmit() {
if (!email.includes("@") || !password || password.length < 6) {
setError("Please enter a valid email and a password with at least 6 characters.");
if (!state.email.includes("@") || !state.password || state.password.length < 6) {
dispatch({
type: "SET_ERROR",
value: "Please enter a valid email and a password with at least 6 characters.",
});
return;
}
setSaving(true);
setError(null);
dispatch({ type: "SET_SAVING", value: true });
dispatch({ type: "SET_ERROR", value: null });
try {
const { createAdminUser } = await import("@/actions/admin/users");
const result = await createAdminUser({
email,
password,
display_name: displayName || undefined,
phone_number: phoneNumber || undefined,
role,
brand_id: brandId,
flags,
email: state.email,
password: state.password,
display_name: state.displayName || undefined,
phone_number: state.phoneNumber || undefined,
role: state.role,
brand_id: state.brandId,
flags: state.flags,
mustChangePassword: true,
});
if (result.error) {
setError(result.error);
dispatch({ type: "SET_ERROR", value: result.error });
return;
}
if (result.user && result.tempPassword) {
onSuccess(result.user);
setSuccess({
user: result.user,
tempPassword: result.tempPassword,
emailSent: result.emailSent ?? false,
emailError: result.emailError,
authPath: result.authPath,
dispatch({
type: "SET_SUCCESS",
value: {
user: result.user,
tempPassword: result.tempPassword,
emailSent: result.emailSent ?? false,
emailError: result.emailError,
authPath: result.authPath,
},
});
// Don't close — show the success state so the caller can copy the temp password.
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "An unexpected error occurred.");
dispatch({ type: "SET_ERROR", value: e instanceof Error ? e.message : "An unexpected error occurred." });
} finally {
setSaving(false);
dispatch({ type: "SET_SAVING", value: false });
}
}
function handleClose() {
if (!saving) {
resetForm();
if (!state.saving) {
dispatch({ type: "RESET" });
onClose();
}
}
async function copyPassword() {
if (!success) return;
if (!state.success) return;
try {
await navigator.clipboard.writeText(success.tempPassword);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
await navigator.clipboard.writeText(state.success.tempPassword);
dispatch({ type: "SET_COPIED", value: true });
window.setTimeout(() => dispatch({ type: "SET_COPIED", value: false }), 2000);
} catch {
// Clipboard not available — caller can select manually.
}
}
// Auto-reset "copied" timer cleanup: if the modal closes while the timeout
// is pending, the dispatch would otherwise land on an unmounted component.
useEffect(() => {
if (!state.copied) return;
const id = window.setTimeout(() => dispatch({ type: "SET_COPIED", value: false }), 2000);
return () => window.clearTimeout(id);
}, [state.copied]);
if (!isOpen) return null;
// Success state — show temp password + email status, then "Done" closes.
if (success) {
if (state.success) {
return (
<GlassModal
title="User Created"
titleIcon={
<svg className="h-5 w-5 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
}
subtitle={`${success.user.display_name ?? success.user.email} is ready to sign in`}
<SuccessView
success={state.success}
copied={state.copied}
onCopy={copyPassword}
onClose={handleClose}
maxWidth="max-w-lg"
>
<div className="space-y-4 sm:space-y-5">
<p className="text-sm text-[var(--admin-text-secondary)]">
The account has been created in Neon Auth and linked to the local admin record.
Share the temporary password below with the new user it will not be shown again.
</p>
{/* Email */}
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-3 sm:p-4">
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
Sign-in email
</p>
<p className="text-sm font-medium text-[var(--admin-text-primary)] break-all">
{success.user.email}
</p>
</div>
{/* Temp password */}
<div className="rounded-xl border border-amber-300/80 bg-amber-50/80 p-3 sm:p-4">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-800">
Temporary password
</p>
<button
type="button"
onClick={copyPassword}
className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-900 hover:text-amber-700 transition-colors inline-flex items-center gap-1"
>
{copied ? (
<>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5" />
</svg>
Copied
</>
) : (
<>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
Copy
</>
)}
</button>
</div>
<p className="font-mono text-sm text-amber-900 break-all select-all">
{success.tempPassword}
</p>
<p className="text-[11px] text-amber-700 mt-2">
The user should change this on first sign-in (must_change_password is set).
</p>
</div>
{/* Email delivery status */}
<div
className={`rounded-xl border p-3 sm:p-4 ${
success.emailSent
? "border-emerald-200/80 bg-emerald-50/60"
: "border-rose-200/80 bg-rose-50/60"
}`}
>
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
Welcome email
</p>
{success.emailSent ? (
<p className="text-sm text-emerald-800">
Sent to {success.user.email}. The user has the password in their inbox.
</p>
) : (
<p className="text-sm text-rose-800">
Could not be sent automatically
{success.emailError ? `: ${success.emailError}` : "."} Share the password above out-of-band.
</p>
)}
</div>
{success.authPath === "signup" && (
<p className="text-[11px] text-[var(--admin-text-muted)]">
Note: the admin sign-up endpoint was unavailable, so the account was created via
the public sign-up path. This is fine the user can sign in normally.
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
<button type="button"
onClick={handleClose}
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
aria-label="Done">
Done
</button>
</div>
</GlassModal>
/>
);
}
@@ -287,11 +249,10 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
maxWidth="max-w-lg"
>
<div className="space-y-4 sm:space-y-6">
{/* Error banner */}
{error && (
{state.error && (
<div className="flex items-start justify-between rounded-lg bg-red-50 p-3 sm:p-4 text-sm text-red-700 gap-3 border border-red-200">
<span className="text-xs sm:text-sm">{error}</span>
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0" aria-label="Close">
<span className="text-xs sm:text-sm">{state.error}</span>
<button type="button" onClick={() => dispatch({ type: "SET_ERROR", value: null })} className="text-red-400 hover:text-red-600 shrink-0" aria-label="Close">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -299,167 +260,381 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
</div>
)}
{/* Email */}
<div>
<label htmlFor="create-user-email" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Email
</label>
<input aria-label="User@example.com"
id="create-user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
aria-required="true"
autoComplete="email"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="user@example.com"
/>
</div>
<EmailField
value={state.email}
onChange={(v) => dispatch({ type: "SET_EMAIL", value: v })}
/>
{/* Password */}
<div>
<label htmlFor="create-user-password" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Password
</label>
<p id="create-user-password-help" className="text-xs text-[var(--admin-text-muted)] mb-1.5">Minimum 6 characters.</p>
<input aria-label="Choose A Password"
id="create-user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
aria-required="true"
aria-describedby="create-user-password-help"
autoComplete="new-password"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="Choose a password"
minLength={6}
/>
</div>
<PasswordField
value={state.password}
onChange={(v) => dispatch({ type: "SET_PASSWORD", value: v })}
/>
{/* Display Name & Phone - 2 columns on larger screens */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div>
<label htmlFor="create-user-display-name" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Display Name
</label>
<input aria-label="Kyle Martinez"
id="create-user-display-name"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoComplete="name"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="Kyle Martinez"
/>
</div>
<div>
<label htmlFor="create-user-phone" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Phone Number
</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-1.5">Optional.</p>
<input aria-label="+1 (555) 000 0000"
id="create-user-phone"
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
autoComplete="tel"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="+1 (555) 000-0000"
/>
</div>
<DisplayNameField
value={state.displayName}
onChange={(v) => dispatch({ type: "SET_DISPLAY_NAME", value: v })}
/>
<PhoneNumberField
value={state.phoneNumber}
onChange={(v) => dispatch({ type: "SET_PHONE_NUMBER", value: v })}
/>
</div>
{/* Role */}
<div>
<p className="block text-sm font-medium text-[var(--admin-text-primary)] mb-2">Role</p>
<div className="space-y-2">
{availableRoles.map((r) => (
<label key={r} className="flex items-center gap-3 rounded-lg border border-[var(--admin-border)] px-3 py-2.5 cursor-pointer hover:bg-[var(--admin-bg)] transition-colors">
<input
type="radio"
name="role"
value={r}
checked={role === r}
onChange={() => setRole(r)}
className="accent-[var(--admin-accent)]"
aria-label="Role"/>
<div>
<span className="font-medium text-[var(--admin-text-primary)] capitalize text-sm">{r.replace("_", " ")}</span>
<p className="text-xs text-[var(--admin-text-muted)]">{roleDescriptions[r]}</p>
</div>
</label>
))}
</div>
</div>
<RoleSelector
availableRoles={availableRoles}
value={state.role}
onChange={(r) => dispatch({ type: "SET_ROLE", value: r })}
/>
{/* Brand */}
{showBrandSelect && (
<div>
<label htmlFor="create-user-brand" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Brand</label>
<select aria-label="Create User Brand"
id="create-user-brand"
value={brandId ?? ""}
onChange={(e) => setBrandId(e.target.value || null)}
required
aria-required="true"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
<option value="">Select a brand</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
<BrandSelect
brands={brands}
value={state.brandId}
onChange={(v) => dispatch({ type: "SET_BRAND", value: v })}
/>
)}
{/* Permissions */}
<div>
<p className="block text-sm font-medium text-[var(--admin-text-primary)] mb-2">Permissions</p>
<div className="space-y-2">
{ALL_FLAGS.map((flag) => (
<label key={flag} className="flex items-center justify-between rounded-lg border border-[var(--admin-border)] px-3 sm:px-4 py-2 sm:py-2.5 hover:bg-[var(--admin-bg)] cursor-pointer transition-colors">
<span className="text-xs sm:text-sm text-[var(--admin-text-primary)]">{FLAG_LABELS[flag]}</span>
<button
type="button"
onClick={() => toggleFlag(flag)}
className={`relative inline-flex h-5 w-9 sm:h-6 sm:w-11 items-center rounded-full transition-colors ${flags[flag] ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
>
<span className={`inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full bg-white shadow transition-transform ${flags[flag] ? "translate-x-5 sm:translate-x-6" : "translate-x-0.5 sm:translate-x-1"}`} />
</button>
</label>
))}
</div>
</div>
<PermissionsList
flags={state.flags}
onToggle={(flag) => dispatch({ type: "TOGGLE_FLAG", flag })}
/>
</div>
{/* Footer */}
<div className="flex flex-col-reverse sm:flex-row items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
<button type="button"
onClick={handleClose}
disabled={saving}
className="w-full sm:w-auto rounded-xl border border-[var(--admin-border)] px-4 sm:px-5 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors disabled:opacity-50"
aria-label="Cancel">
Cancel
</button>
<button type="button"
onClick={handleSubmit}
disabled={saving || !email.includes("@") || !password || password.length < 6}
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors disabled:opacity-50"
<FooterActions
saving={state.saving}
canSubmit={!!state.email.includes("@") && !!state.password && state.password.length >= 6}
onCancel={handleClose}
onSubmit={handleSubmit}
/>
</GlassModal>
);
}
type SuccessViewProps = {
success: SuccessState;
copied: boolean;
onCopy: () => void;
onClose: () => void;
};
function SuccessView({ success, copied, onCopy, onClose }: SuccessViewProps) {
return (
<GlassModal
title="User Created"
titleIcon={
<svg className="h-5 w-5 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
}
subtitle={`${success.user.display_name ?? success.user.email} is ready to sign in`}
onClose={onClose}
maxWidth="max-w-lg"
>
<div className="space-y-4 sm:space-y-5">
<p className="text-sm text-[var(--admin-text-secondary)]">
The account has been created in Neon Auth and linked to the local admin record.
Share the temporary password below with the new user it will not be shown again.
</p>
{/* Email */}
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-3 sm:p-4">
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
Sign-in email
</p>
<p className="text-sm font-medium text-[var(--admin-text-primary)] break-all">
{success.user.email}
</p>
</div>
{/* Temp password */}
<div className="rounded-xl border border-amber-300/80 bg-amber-50/80 p-3 sm:p-4">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-800">
Temporary password
</p>
<button
type="button"
onClick={onCopy}
className="text-[10px] font-semibold tracking-[0.1em] uppercase text-amber-900 hover:text-amber-700 transition-colors inline-flex items-center gap-1"
>
{copied ? (
<>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 6L9 17l-5-5" />
</svg>
Copied
</>
) : (
<>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
Copy
</>
)}
</button>
</div>
<p className="font-mono text-sm text-amber-900 break-all select-all">
{success.tempPassword}
</p>
<p className="text-[11px] text-amber-700 mt-2">
The user should change this on first sign-in (must_change_password is set).
</p>
</div>
{/* Email delivery status */}
<div
className={`rounded-xl border p-3 sm:p-4 ${
success.emailSent
? "border-emerald-200/80 bg-emerald-50/60"
: "border-rose-200/80 bg-rose-50/60"
}`}
>
{saving ? (
<span className="flex items-center justify-center gap-2">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating...
</span>
) : "Create User"}
<p className="text-[10px] font-semibold tracking-[0.1em] uppercase text-[var(--admin-text-muted)] mb-1">
Welcome email
</p>
{success.emailSent ? (
<p className="text-sm text-emerald-800">
Sent to {success.user.email}. The user has the password in their inbox.
</p>
) : (
<p className="text-sm text-rose-800">
Could not be sent automatically
{success.emailError ? `: ${success.emailError}` : "."} Share the password above out-of-band.
</p>
)}
</div>
{success.authPath === "signup" && (
<p className="text-[11px] text-[var(--admin-text-muted)]">
Note: the admin sign-up endpoint was unavailable, so the account was created via
the public sign-up path. This is fine the user can sign in normally.
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
<button type="button"
onClick={onClose}
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
aria-label="Done">
Done
</button>
</div>
</GlassModal>
);
}
type FieldProps = {
value: string;
onChange: (value: string) => void;
};
function EmailField({ value, onChange }: FieldProps) {
return (
<div>
<label htmlFor="create-user-email" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Email
</label>
<input aria-label="User@example.com"
id="create-user-email"
type="email"
value={value}
onChange={(e) => onChange(e.target.value)}
required
aria-required="true"
autoComplete="email"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="user@example.com"
/>
</div>
);
}
function PasswordField({ value, onChange }: FieldProps) {
return (
<div>
<label htmlFor="create-user-password" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Password
</label>
<p id="create-user-password-help" className="text-xs text-[var(--admin-text-muted)] mb-1.5">Minimum 6 characters.</p>
<input aria-label="Choose A Password"
id="create-user-password"
type="password"
value={value}
onChange={(e) => onChange(e.target.value)}
required
aria-required="true"
aria-describedby="create-user-password-help"
autoComplete="new-password"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="Choose a password"
minLength={6}
/>
</div>
);
}
function DisplayNameField({ value, onChange }: FieldProps) {
return (
<div>
<label htmlFor="create-user-display-name" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Display Name
</label>
<input aria-label="Kyle Martinez"
id="create-user-display-name"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="name"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="Kyle Martinez"
/>
</div>
);
}
function PhoneNumberField({ value, onChange }: FieldProps) {
return (
<div>
<label htmlFor="create-user-phone" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Phone Number
</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-1.5">Optional.</p>
<input aria-label="+1 (555) 000 0000"
id="create-user-phone"
type="tel"
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="tel"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="+1 (555) 000-0000"
/>
</div>
);
}
type RoleSelectorProps = {
availableRoles: Role[];
value: Role;
onChange: (value: Role) => void;
};
function RoleSelector({ availableRoles, value, onChange }: RoleSelectorProps) {
return (
<div>
<p className="block text-sm font-medium text-[var(--admin-text-primary)] mb-2">Role</p>
<div className="space-y-2">
{availableRoles.map((r) => (
<label key={r} className="flex items-center gap-3 rounded-lg border border-[var(--admin-border)] px-3 py-2.5 cursor-pointer hover:bg-[var(--admin-bg)] transition-colors">
<input
type="radio"
name="role"
value={r}
checked={value === r}
onChange={() => onChange(r)}
className="accent-[var(--admin-accent)]"
aria-label="Role"/>
<div>
<span className="font-medium text-[var(--admin-text-primary)] capitalize text-sm">{r.replace("_", " ")}</span>
<p className="text-xs text-[var(--admin-text-muted)]">{roleDescriptions[r]}</p>
</div>
</label>
))}
</div>
</div>
);
}
type BrandSelectProps = {
brands: { id: string; name: string }[];
value: string | null;
onChange: (value: string | null) => void;
};
function BrandSelect({ brands, value, onChange }: BrandSelectProps) {
return (
<div>
<label htmlFor="create-user-brand" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Brand</label>
<select aria-label="Create User Brand"
id="create-user-brand"
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
required
aria-required="true"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
<option value="">Select a brand</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
);
}
type PermissionsListProps = {
flags: Record<string, boolean>;
onToggle: (flag: string) => void;
};
function PermissionsList({ flags, onToggle }: PermissionsListProps) {
return (
<div>
<p className="block text-sm font-medium text-[var(--admin-text-primary)] mb-2">Permissions</p>
<div className="space-y-2">
{ALL_FLAGS.map((flag) => (
<label key={flag} className="flex items-center justify-between rounded-lg border border-[var(--admin-border)] px-3 sm:px-4 py-2 sm:py-2.5 hover:bg-[var(--admin-bg)] cursor-pointer transition-colors">
<span className="text-xs sm:text-sm text-[var(--admin-text-primary)]">{FLAG_LABELS[flag]}</span>
<button
type="button"
onClick={() => onToggle(flag)}
className={`relative inline-flex h-5 w-9 sm:h-6 sm:w-11 items-center rounded-full transition-colors ${flags[flag] ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}
>
<span className={`inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full bg-white shadow transition-transform ${flags[flag] ? "translate-x-5 sm:translate-x-6" : "translate-x-0.5 sm:translate-x-1"}`} />
</button>
</label>
))}
</div>
</div>
);
}
type FooterActionsProps = {
saving: boolean;
canSubmit: boolean;
onCancel: () => void;
onSubmit: () => void;
};
function FooterActions({ saving, canSubmit, onCancel, onSubmit }: FooterActionsProps) {
return (
<div className="flex flex-col-reverse sm:flex-row items-center justify-end gap-2 sm:gap-3 mt-6 sm:mt-8 -mx-4 sm:-mx-6 md:-mx-8 -mb-4 sm:-mb-6 md:-mb-8 px-4 sm:px-6 md:px-8 py-4 sm:py-6 border-t border-[var(--admin-border)] bg-[var(--admin-bg)] rounded-b-2xl">
<button type="button"
onClick={onCancel}
disabled={saving}
className="w-full sm:w-auto rounded-xl border border-[var(--admin-border)] px-4 sm:px-5 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors disabled:opacity-50"
aria-label="Cancel">
Cancel
</button>
<button type="button"
onClick={onSubmit}
disabled={saving || !canSubmit}
className="w-full sm:w-auto rounded-xl bg-[var(--admin-accent)] px-4 sm:px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-accent-hover)] transition-colors disabled:opacity-50"
>
{saving ? (
<span className="flex items-center justify-center gap-2">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating...
</span>
) : "Create User"}
</button>
</div>
);
}
+479 -314
View File
@@ -1,10 +1,10 @@
"use client";
import { useState, type ReactNode } from "react";
import { useReducer } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge, KPIStat, EmptyState } from "@/components/admin/design-system";
import { ShoppingCart, MapPin, Package, DollarSign, Plus, Send, AlertCircle, Calendar, Inbox, Sparkles, BarChart3, BrainCircuit, Upload, Clock, Droplets, Route, Truck, Receipt, Settings as SettingsIcon, Store, type LucideIcon } from "lucide-react";
import { ShoppingCart, MapPin, Package, DollarSign, Plus, Send, AlertCircle, Inbox, Sparkles, BarChart3, BrainCircuit, Upload, Clock, Droplets, Route, Truck, Receipt, Settings as SettingsIcon, Store, type LucideIcon } from "lucide-react";
import type { DashboardStats } from "@/actions/dashboard";
const currencyFormatter = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
@@ -74,6 +74,25 @@ const quickActions = [
{ label: "Send Blast", href: "/admin/communications/compose", icon: Send },
];
type State = {
isUpgradeOpen: boolean;
groupFilter: GroupFilter;
};
type Action =
| { type: "OPEN_UPGRADE" }
| { type: "CLOSE_UPGRADE" }
| { type: "SET_GROUP_FILTER"; value: GroupFilter };
type AttentionItem = {
id: string;
title: string;
detail: string;
href: string;
tone: "primary" | "accent" | "warning" | "danger";
icon: LucideIcon;
};
type Props = {
brandId: string | null;
brandName: string;
@@ -85,14 +104,16 @@ type Props = {
stats: DashboardStats;
};
type AttentionItem = {
id: string;
title: string;
detail: string;
href: string;
tone: "primary" | "accent" | "warning" | "danger";
icon: LucideIcon;
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "OPEN_UPGRADE":
return { ...state, isUpgradeOpen: true };
case "CLOSE_UPGRADE":
return { ...state, isUpgradeOpen: false };
case "SET_GROUP_FILTER":
return { ...state, groupFilter: action.value };
}
}
export default function DashboardClient({
brandId,
@@ -104,8 +125,10 @@ export default function DashboardClient({
limits,
stats,
}: Props) {
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
const [groupFilter, setGroupFilter] = useState<GroupFilter>("all");
const [state, dispatch] = useReducer(reducer, {
isUpgradeOpen: false,
groupFilter: "all",
});
const usagePct = {
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
@@ -169,318 +192,46 @@ export default function DashboardClient({
for (const s of sections) {
if (s.title === "Water Log" && !isWaterLogVisible) continue;
if (s.title === "Route Trace" && !enabledAddons["route_trace"]) continue;
if (groupFilter !== "all" && s.group !== groupFilter) continue;
if (state.groupFilter !== "all" && s.group !== state.groupFilter) continue;
visibleSections.push(s);
}
return (
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
{/* Page Header */}
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6">
<PageHeader
icon={<Inbox className="h-5 w-5" />}
title="Dashboard"
subtitle={brandName}
actions={
<div className="flex items-center gap-3">
<Link href="/admin/settings/billing" className="hidden sm:block text-xs font-medium hover:underline" style={{ color: "var(--admin-text-muted)" }}>
Billing
</Link>
{planTier === "starter" && brandId && (
<AdminButton onClick={() => setIsUpgradeOpen(true)} size="sm">
Upgrade Plan
</AdminButton>
)}
</div>
}
className="mb-0"
<DashboardHeader
brandName={brandName}
planTier={planTier}
brandId={brandId}
onOpenUpgrade={() => dispatch({ type: "OPEN_UPGRADE" })}
/>
<div className="px-4 sm:px-6 md:px-8 pb-8 space-y-5">
<KPIStrip stats={stats} usage={usage} />
<CommandCenter
attentionItems={attentionItems}
planTier={planTier}
brandId={brandId}
brandName={brandName}
usage={usage}
limits={limits}
usagePct={usagePct}
/>
<RecentOrdersCard orders={stats.recentOrders ?? []} />
<SectionsGrid
groupFilter={state.groupFilter}
visibleSections={visibleSections}
enabledAddons={enabledAddons}
onChangeFilter={(value) => dispatch({ type: "SET_GROUP_FILTER", value })}
/>
</div>
{/* Main Content */}
<div className="px-4 sm:px-6 md:px-8 pb-8 space-y-5">
{/* ── KPI Strip (top metrics) ──────────────────────────── */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KPIStat
label="Today's Orders"
value={stats.todayOrders}
icon={<ShoppingCart className="w-4 h-4" />}
tone="primary"
/>
<KPIStat
label="Today's Revenue"
value={formatCurrency(stats.todayRevenue)}
icon={<DollarSign className="w-4 h-4" />}
tone="accent"
/>
<KPIStat
label="Pending Stops"
value={stats.pendingStops}
icon={<MapPin className="w-4 h-4" />}
tone="default"
/>
<KPIStat
label="Active Products"
value={usage.products}
icon={<Package className="w-4 h-4" />}
tone="primary"
/>
</div>
{/* ── Command center: attention feed + quick actions ───── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
{/* Attention feed (left, 2 cols) */}
<div className="lg:col-span-2 admin-card-section">
<div className="admin-section-header">
<span className="admin-section-title">What needs attention</span>
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
{attentionItems.length} item{attentionItems.length === 1 ? "" : "s"}
</span>
</div>
{attentionItems.length === 0 ? (
<EmptyState
icon={<Sparkles className="w-8 h-8" />}
title="All clear"
description="Nothing needs your attention right now. Enjoy the calm."
/>
) : (
<ul className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
{attentionItems.map((item) => {
const Icon = item.icon;
const toneColor =
item.tone === "accent" ? "var(--admin-accent)" :
item.tone === "warning" ? "var(--admin-warning)" :
item.tone === "danger" ? "var(--admin-danger)" :
"var(--admin-primary)";
const toneSoft =
item.tone === "accent" ? "var(--admin-accent-soft)" :
item.tone === "warning" ? "var(--admin-warning-soft)" :
item.tone === "danger" ? "var(--admin-danger-soft)" :
"var(--admin-primary-soft)";
return (
<li key={item.id}>
<Link
href={item.href}
className="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-black/[0.02]"
>
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: toneSoft, color: toneColor }}
aria-hidden="true"
>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
{item.title}
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--admin-text-muted)" }}>
{item.detail}
</p>
</div>
<svg className="w-4 h-4 mt-2 flex-shrink-0 opacity-30 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</li>
);
})}
</ul>
)}
</div>
{/* Quick actions (right, 1 col) */}
<div className="admin-card-section">
<div className="admin-section-header">
<span className="admin-section-title">Quick actions</span>
</div>
<div className="admin-quick-actions">
{quickActions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.href}
href={action.href}
className="admin-quick-action"
>
<div className="admin-quick-action-icon" style={{ backgroundColor: "var(--admin-primary-soft)" }}>
<Icon className="w-3.5 h-3.5" style={{ color: "var(--admin-primary)" }} />
</div>
<span className="admin-quick-action-label">{action.label}</span>
<svg className="w-3.5 h-3.5 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</Link>
);
})}
</div>
{/* Plan usage inside quick actions card */}
<div className="px-4 py-3 border-t" style={{ borderColor: "var(--admin-border-light)" }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<AdminBadge tone={planTier === "enterprise" ? "info" : planTier === "farm" ? "success" : "neutral"}>
{planTier.charAt(0).toUpperCase() + planTier.slice(1)}
</AdminBadge>
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
{brandId ? brandName : "All Brands"}
</span>
</div>
</div>
<div className="space-y-2">
{[
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
].map(({ label, value, pct }) => (
<div key={label} className="admin-usage-item">
<div className="admin-usage-header">
<span className="admin-usage-label">{label}</span>
<span className="admin-usage-value ha-num">{value}</span>
</div>
<div className="admin-usage-bar">
<div
className="admin-usage-fill"
style={{
width: `${Math.min(pct, 100)}%`,
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "var(--admin-warning)" : "var(--admin-primary)",
}}
/>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* ── Recent Orders ─────────────────────────────────────── */}
<div className="admin-card-section">
<div className="admin-section-header">
<span className="admin-section-title">Recent orders</span>
<Link href="/admin/orders" className="text-xs font-medium hover:underline" style={{ color: "var(--admin-primary)" }}>
View all
</Link>
</div>
{stats.recentOrders && stats.recentOrders.length > 0 ? (
<div className="admin-orders-list">
{stats.recentOrders.slice(0, 6).map((order) => {
const badge = getStatusBadge(order.status);
return (
<Link
key={order.id}
href={`/admin/orders`}
className="admin-order-row"
>
<div className="admin-order-info">
<div className="admin-order-icon">
<ShoppingCart className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} />
</div>
<div>
<span className="admin-order-name">{order.customer_name}</span>
<span className="admin-order-time">{order.created_at}</span>
</div>
</div>
<div className="admin-order-meta">
<span className="admin-order-amount ha-num">{formatCurrency(order.total)}</span>
<span className="admin-order-badge" style={{ backgroundColor: badge.bg, color: badge.text }}>
{order.status}
</span>
</div>
</Link>
);
})}
</div>
) : (
<EmptyState
icon={<Inbox className="w-8 h-8" />}
title="No recent orders"
description="When customers place orders, they'll show up here."
action={{ label: "Create your first order", href: "/admin/orders?new=true" }}
/>
)}
</div>
{/* ── Section filter + grid ────────────────────────────── */}
<div>
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--admin-text-primary)" }}>
All sections
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--admin-text-muted)" }}>
Jump to any part of the admin. Press <kbd className="px-1.5 py-0.5 rounded text-[10px] font-mono" style={{ backgroundColor: "var(--admin-bg-subtle)", border: "1px solid var(--admin-border)" }}>K</kbd> to search.
</p>
</div>
<AdminFilterTabs
activeTab={groupFilter}
onTabChange={(value) => setGroupFilter(value as GroupFilter)}
tabs={GROUP_FILTERS.map((f) => ({ value: f.id, label: f.label }))}
size="sm"
showCounts={false}
/>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
{visibleSections.map((section) => {
const Icon = section.icon;
const isAddon = Boolean(section.addonKey);
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
const isProminent = section.prominent;
return (
<Link
key={section.title}
href={section.href}
className={[
"admin-section-card",
isAddon && !isEnabled ? "admin-section-card--locked" : "",
isProminent ? "admin-section-card--prominent" : "",
].filter(Boolean).join(" ")}
>
<div className="admin-section-card-top">
<div className={[
"admin-section-card-icon",
isAddon && !isEnabled ? "admin-section-card-icon--locked" : "",
isProminent ? "admin-section-card-icon--prominent" : "",
].filter(Boolean).join(" ")}>
<Icon className="w-4 h-4" />
</div>
<div className="flex items-center gap-1.5">
{isAddon && !isEnabled && <AdminBadge tone="neutral">Add-on</AdminBadge>}
{isAddon && isEnabled && <AdminBadge tone="success">Active</AdminBadge>}
{isProminent && <AdminBadge tone="info">Core</AdminBadge>}
</div>
</div>
<div className="admin-section-card-body">
<h3 className="admin-section-card-title">{section.title}</h3>
<p className={["admin-section-card-desc", isAddon && !isEnabled ? "" : ""].filter(Boolean).join(" ")}>
{section.description}
</p>
</div>
{isAddon && !isEnabled && section.upgradeText && (
<p className="admin-section-card-hint">{section.upgradeText}</p>
)}
<div className="admin-section-card-arrow">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</div>
</Link>
);
})}
</div>
</div>
</div>
{/* Upgrade Modal — lazy-loaded via next/dynamic */}
{planTier === "starter" && brandId && (
<UpgradePlanModal
isOpen={isUpgradeOpen}
onClose={() => setIsUpgradeOpen(false)}
isOpen={state.isUpgradeOpen}
onClose={() => dispatch({ type: "CLOSE_UPGRADE" })}
brandId={brandId}
currentTier={planTier}
/>
@@ -488,3 +239,417 @@ export default function DashboardClient({
</div>
);
}
function DashboardHeader({
brandName,
planTier,
brandId,
onOpenUpgrade,
}: {
brandName: string;
planTier: string;
brandId: string | null;
onOpenUpgrade: () => void;
}) {
return (
<div className="px-4 sm:px-6 md:px-8 py-5 sm:py-6">
<PageHeader
icon={<Inbox className="h-5 w-5" />}
title="Dashboard"
subtitle={brandName}
actions={
<div className="flex items-center gap-3">
<Link href="/admin/settings/billing" className="hidden sm:block text-xs font-medium hover:underline" style={{ color: "var(--admin-text-muted)" }}>
Billing
</Link>
{planTier === "starter" && brandId && (
<AdminButton onClick={onOpenUpgrade} size="sm">
Upgrade Plan
</AdminButton>
)}
</div>
}
className="mb-0"
/>
</div>
);
}
function KPIStrip({
stats,
usage,
}: {
stats: DashboardStats;
usage: { users: number; stops_this_month: number; products: number };
}) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KPIStat
label="Today's Orders"
value={stats.todayOrders}
icon={<ShoppingCart className="w-4 h-4" />}
tone="primary"
/>
<KPIStat
label="Today's Revenue"
value={formatCurrency(stats.todayRevenue)}
icon={<DollarSign className="w-4 h-4" />}
tone="accent"
/>
<KPIStat
label="Pending Stops"
value={stats.pendingStops}
icon={<MapPin className="w-4 h-4" />}
tone="default"
/>
<KPIStat
label="Active Products"
value={usage.products}
icon={<Package className="w-4 h-4" />}
tone="primary"
/>
</div>
);
}
function CommandCenter({
attentionItems,
planTier,
brandId,
brandName,
usage,
limits,
usagePct,
}: {
attentionItems: AttentionItem[];
planTier: string;
brandId: string | null;
brandName: string;
usage: { users: number; stops_this_month: number; products: number };
limits: { max_users: number; max_stops_monthly: number; max_products: number };
usagePct: { users: number; stops: number; products: number };
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
<AttentionFeed items={attentionItems} />
<QuickActionsPanel
planTier={planTier}
brandId={brandId}
brandName={brandName}
usage={usage}
limits={limits}
usagePct={usagePct}
/>
</div>
);
}
function AttentionFeed({ items }: { items: AttentionItem[] }) {
return (
<div className="lg:col-span-2 admin-card-section">
<div className="admin-section-header">
<span className="admin-section-title">What needs attention</span>
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
{items.length} item{items.length === 1 ? "" : "s"}
</span>
</div>
{items.length === 0 ? (
<EmptyState
icon={<Sparkles className="w-8 h-8" />}
title="All clear"
description="Nothing needs your attention right now. Enjoy the calm."
/>
) : (
<ul className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
{items.map((item) => {
const Icon = item.icon;
const toneColor =
item.tone === "accent" ? "var(--admin-accent)" :
item.tone === "warning" ? "var(--admin-warning)" :
item.tone === "danger" ? "var(--admin-danger)" :
"var(--admin-primary)";
const toneSoft =
item.tone === "accent" ? "var(--admin-accent-soft)" :
item.tone === "warning" ? "var(--admin-warning-soft)" :
item.tone === "danger" ? "var(--admin-danger-soft)" :
"var(--admin-primary-soft)";
return (
<li key={item.id}>
<Link
href={item.href}
className="group flex items-start gap-3 px-4 py-3 transition-colors hover:bg-black/[0.02]"
>
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: toneSoft, color: toneColor }}
aria-hidden="true"
>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
{item.title}
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--admin-text-muted)" }}>
{item.detail}
</p>
</div>
<svg className="w-4 h-4 mt-2 flex-shrink-0 opacity-30 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
function QuickActionsPanel({
planTier,
brandId,
brandName,
usage,
limits,
usagePct,
}: {
planTier: string;
brandId: string | null;
brandName: string;
usage: { users: number; stops_this_month: number; products: number };
limits: { max_users: number; max_stops_monthly: number; max_products: number };
usagePct: { users: number; stops: number; products: number };
}) {
return (
<div className="admin-card-section">
<div className="admin-section-header">
<span className="admin-section-title">Quick actions</span>
</div>
<div className="admin-quick-actions">
{quickActions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.href}
href={action.href}
className="admin-quick-action"
>
<div className="admin-quick-action-icon" style={{ backgroundColor: "var(--admin-primary-soft)" }}>
<Icon className="w-3.5 h-3.5" style={{ color: "var(--admin-primary)" }} />
</div>
<span className="admin-quick-action-label">{action.label}</span>
<svg className="w-3.5 h-3.5 ml-auto opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</Link>
);
})}
</div>
<PlanUsageCard
planTier={planTier}
brandId={brandId}
brandName={brandName}
usage={usage}
limits={limits}
usagePct={usagePct}
/>
</div>
);
}
function PlanUsageCard({
planTier,
brandId,
brandName,
usage,
limits,
usagePct,
}: {
planTier: string;
brandId: string | null;
brandName: string;
usage: { users: number; stops_this_month: number; products: number };
limits: { max_users: number; max_stops_monthly: number; max_products: number };
usagePct: { users: number; stops: number; products: number };
}) {
return (
<div className="px-4 py-3 border-t" style={{ borderColor: "var(--admin-border-light)" }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<AdminBadge tone={planTier === "enterprise" ? "info" : planTier === "farm" ? "success" : "neutral"}>
{planTier.charAt(0).toUpperCase() + planTier.slice(1)}
</AdminBadge>
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
{brandId ? brandName : "All Brands"}
</span>
</div>
</div>
<div className="space-y-2">
{[
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
].map(({ label, value, pct }) => (
<div key={label} className="admin-usage-item">
<div className="admin-usage-header">
<span className="admin-usage-label">{label}</span>
<span className="admin-usage-value ha-num">{value}</span>
</div>
<div className="admin-usage-bar">
<div
className="admin-usage-fill"
style={{
width: `${Math.min(pct, 100)}%`,
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "var(--admin-warning)" : "var(--admin-primary)",
}}
/>
</div>
</div>
))}
</div>
</div>
);
}
function RecentOrdersCard({
orders,
}: {
orders: DashboardStats["recentOrders"];
}) {
return (
<div className="admin-card-section">
<div className="admin-section-header">
<span className="admin-section-title">Recent orders</span>
<Link href="/admin/orders" className="text-xs font-medium hover:underline" style={{ color: "var(--admin-primary)" }}>
View all
</Link>
</div>
{orders && orders.length > 0 ? (
<div className="admin-orders-list">
{orders.slice(0, 6).map((order) => {
const badge = getStatusBadge(order.status);
return (
<Link
key={order.id}
href={`/admin/orders`}
className="admin-order-row"
>
<div className="admin-order-info">
<div className="admin-order-icon">
<ShoppingCart className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} />
</div>
<div>
<span className="admin-order-name">{order.customer_name}</span>
<span className="admin-order-time">{order.created_at}</span>
</div>
</div>
<div className="admin-order-meta">
<span className="admin-order-amount ha-num">{formatCurrency(order.total)}</span>
<span className="admin-order-badge" style={{ backgroundColor: badge.bg, color: badge.text }}>
{order.status}
</span>
</div>
</Link>
);
})}
</div>
) : (
<EmptyState
icon={<Inbox className="w-8 h-8" />}
title="No recent orders"
description="When customers place orders, they'll show up here."
action={{ label: "Create your first order", href: "/admin/orders?new=true" }}
/>
)}
</div>
);
}
function SectionsGrid({
groupFilter,
visibleSections,
enabledAddons,
onChangeFilter,
}: {
groupFilter: GroupFilter;
visibleSections: Section[];
enabledAddons: Record<string, boolean>;
onChangeFilter: (value: GroupFilter) => void;
}) {
return (
<div>
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--admin-text-primary)" }}>
All sections
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--admin-text-muted)" }}>
Jump to any part of the admin. Press <kbd className="px-1.5 py-0.5 rounded text-[10px] font-mono" style={{ backgroundColor: "var(--admin-bg-subtle)", border: "1px solid var(--admin-border)" }}>K</kbd> to search.
</p>
</div>
<AdminFilterTabs
activeTab={groupFilter}
onTabChange={(value) => onChangeFilter(value as GroupFilter)}
tabs={GROUP_FILTERS.map((f) => ({ value: f.id, label: f.label }))}
size="sm"
showCounts={false}
/>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
{visibleSections.map((section) => {
const Icon = section.icon;
const isAddon = Boolean(section.addonKey);
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
const isProminent = section.prominent;
return (
<Link
key={section.title}
href={section.href}
className={[
"admin-section-card",
isAddon && !isEnabled ? "admin-section-card--locked" : "",
isProminent ? "admin-section-card--prominent" : "",
].filter(Boolean).join(" ")}
>
<div className="admin-section-card-top">
<div className={[
"admin-section-card-icon",
isAddon && !isEnabled ? "admin-section-card-icon--locked" : "",
isProminent ? "admin-section-card-icon--prominent" : "",
].filter(Boolean).join(" ")}>
<Icon className="w-4 h-4" />
</div>
<div className="flex items-center gap-1.5">
{isAddon && !isEnabled && <AdminBadge tone="neutral">Add-on</AdminBadge>}
{isAddon && isEnabled && <AdminBadge tone="success">Active</AdminBadge>}
{isProminent && <AdminBadge tone="info">Core</AdminBadge>}
</div>
</div>
<div className="admin-section-card-body">
<h3 className="admin-section-card-title">{section.title}</h3>
<p className={["admin-section-card-desc", isAddon && !isEnabled ? "" : ""].filter(Boolean).join(" ")}>
{section.description}
</p>
</div>
{isAddon && !isEnabled && section.upgradeText && (
<p className="admin-section-card-hint">{section.upgradeText}</p>
)}
<div className="admin-section-card-arrow">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</div>
</Link>
);
})}
</div>
</div>
);
}
+104 -26
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import Link from "next/link";
import { markPickupComplete } from "@/actions/pickup";
import AdminBadge from "./design-system/AdminBadge";
@@ -67,6 +67,82 @@ function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
type State = {
pendingOrders: Order[];
pickedUpOrders: Order[];
stops: Stop[];
search: string;
stopFilter: string;
pickingUp: string | null;
showPickedUp: boolean;
pickupToast: string | null;
};
type Action =
| { type: "PICKUP_START"; orderId: string }
| {
type: "PICKUP_SUCCESS";
order: Order;
pickup_completed_at: string | null;
pickup_completed_by: string | null;
}
| { type: "PICKUP_DONE" }
| { type: "SET_SEARCH"; value: string }
| { type: "SET_STOP_FILTER"; value: string }
| { type: "SET_SHOW_PICKED_UP"; value: boolean }
| { type: "SET_TOAST"; value: string | null };
function initState(initial: {
pendingOrders: Order[];
pickedUpOrders: Order[];
stops: Stop[];
}): State {
return {
pendingOrders: initial.pendingOrders,
pickedUpOrders: initial.pickedUpOrders,
stops: initial.stops,
search: "",
stopFilter: "",
pickingUp: null,
showPickedUp: true,
pickupToast: null,
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "PICKUP_START":
return { ...state, pickingUp: action.orderId };
case "PICKUP_SUCCESS":
return {
...state,
pendingOrders: state.pendingOrders.filter((o) => o.id !== action.order.id),
pickedUpOrders: [
{
...action.order,
pickup_complete: true,
pickup_completed_at: action.pickup_completed_at,
pickup_completed_by: action.pickup_completed_by,
},
...state.pickedUpOrders,
],
pickupToast: action.order.id,
};
case "PICKUP_DONE":
return { ...state, pickingUp: null };
case "SET_SEARCH":
return { ...state, search: action.value };
case "SET_STOP_FILTER":
return { ...state, stopFilter: action.value };
case "SET_SHOW_PICKED_UP":
return { ...state, showPickedUp: action.value };
case "SET_TOAST":
return { ...state, pickupToast: action.value };
default:
return state;
}
}
export default function DriverPickupPanel({
initialPendingOrders,
initialPickedUpOrders,
@@ -74,14 +150,21 @@ export default function DriverPickupPanel({
brandId,
canManagePickup,
}: DriverPickupPanelProps) {
const [pendingOrders, setPendingOrders] = useState<Order[]>(initialPendingOrders);
const [pickedUpOrders, setPickedUpOrders] = useState<Order[]>(initialPickedUpOrders);
const [stops] = useState<Stop[]>(initialStops);
const [search, setSearch] = useState("");
const [stopFilter, setStopFilter] = useState("");
const [pickingUp, setPickingUp] = useState<string | null>(null);
const [showPickedUp, setShowPickedUp] = useState(true);
const [pickupToast, setPickupToast] = useState<string | null>(null);
const [state, dispatch] = useReducer(
reducer,
{ pendingOrders: initialPendingOrders, pickedUpOrders: initialPickedUpOrders, stops: initialStops },
initState,
);
const {
pendingOrders,
pickedUpOrders,
stops,
search,
stopFilter,
pickingUp,
showPickedUp,
pickupToast,
} = state;
const filteredPending = pendingOrders.filter((o) => {
const matchesSearch =
@@ -107,29 +190,24 @@ export default function DriverPickupPanel({
async function handleMarkPickup(orderId: string) {
if (!canManagePickup) return;
setPickingUp(orderId);
dispatch({ type: "PICKUP_START", orderId });
const result = await markPickupComplete(orderId, brandId);
if (result.success) {
const order = pendingOrders.find((o) => o.id === orderId);
if (order) {
setPendingOrders((prev) => prev.filter((o) => o.id !== orderId));
setPickedUpOrders((prev) => [
{
...order,
pickup_complete: true,
pickup_completed_at: result.pickup_completed_at,
pickup_completed_by: result.pickup_completed_by,
},
...prev,
]);
setPickupToast(orderId);
setTimeout(() => setPickupToast(null), 3000);
dispatch({
type: "PICKUP_SUCCESS",
order,
pickup_completed_at: result.pickup_completed_at,
pickup_completed_by: result.pickup_completed_by,
});
setTimeout(() => dispatch({ type: "SET_TOAST", value: null }), 3000);
}
}
setPickingUp(null);
dispatch({ type: "PICKUP_DONE" });
}
return (
@@ -160,7 +238,7 @@ export default function DriverPickupPanel({
<div className="flex gap-3">
<select aria-label="Select"
value={stopFilter}
onChange={(e) => setStopFilter(e.target.value)}
onChange={(e) => dispatch({ type: "SET_STOP_FILTER", value: e.target.value })}
className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
>
<option value="">All Stops</option>
@@ -178,7 +256,7 @@ export default function DriverPickupPanel({
type="search"
placeholder="Search name, phone, or order #..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => dispatch({ type: "SET_SEARCH", value: e.target.value })}
className="w-full rounded-xl border border-stone-200 bg-white pl-11 pr-4 py-3 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
@@ -213,7 +291,7 @@ export default function DriverPickupPanel({
{/* Picked Up Toggle */}
{hasAnyPickedUp && (
<button type="button"
onClick={() => setShowPickedUp((v) => !v)}
onClick={() => dispatch({ type: "SET_SHOW_PICKED_UP", value: !showPickedUp })}
className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors flex items-center gap-2"
>
{showPickedUp ? (
+475 -314
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useReducer, useCallback, useRef, useEffect, useState } from "react";
import { createStop } from "@/actions/stops/create-stop";
import { updateStop } from "@/actions/stops/update-stop";
import GlassModal from "@/components/admin/GlassModal";
@@ -35,62 +35,101 @@ const PinIcon = () => (
</svg>
);
export default function EditStopModal({ isOpen, onClose, brandId, stop, onSuccess }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
type FormFields = {
city: string;
stateField: string;
location: string;
date: string;
time: string;
address: string;
zip: string;
cutoffTime: string;
status: "draft" | "active";
};
const [city, setCity] = useState("");
const [stateField, setStateField] = useState("");
const [location, setLocation] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("");
const [address, setAddress] = useState("");
const [zip, setZip] = useState("");
const [cutoffTime, setCutoffTime] = useState("");
const [status, setStatus] = useState<"draft" | "active">("draft");
type State = {
loading: boolean;
error: string | null;
form: FormFields;
};
type Action =
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_ERROR"; error: string | null }
| { type: "SET_FIELD"; field: keyof FormFields; value: string }
| { type: "RESET_FORM" }
| { type: "POPULATE_FROM_STOP"; stop: Stop };
const emptyForm: FormFields = {
city: "",
stateField: "",
location: "",
date: "",
time: "",
address: "",
zip: "",
cutoffTime: "",
status: "draft",
};
const initialState: State = {
loading: false,
error: null,
form: { ...emptyForm },
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_LOADING":
return { ...state, loading: action.loading };
case "SET_ERROR":
return { ...state, error: action.error };
case "SET_FIELD":
return { ...state, form: { ...state.form, [action.field]: action.value } };
case "RESET_FORM":
return { ...state, error: null, form: { ...emptyForm } };
case "POPULATE_FROM_STOP":
return {
...state,
error: null,
form: {
city: action.stop.city,
stateField: action.stop.state,
location: action.stop.location,
date: action.stop.date,
time: action.stop.time || "",
address: action.stop.address ?? "",
zip: action.stop.zip ?? "",
cutoffTime: action.stop.cutoff_time ? action.stop.cutoff_time.slice(0, 16) : "",
status: action.stop.active ? "active" : "draft",
},
};
}
}
export default function EditStopModal({ isOpen, onClose, brandId, stop, onSuccess }: Props) {
const [state, dispatch] = useReducer(reducer, initialState);
const cityRef = useRef<HTMLInputElement>(null);
const isEditing = Boolean(stop);
// Reset form when modal opens or stop changes — derived during render
// per React docs: "Adjusting some state when a prop changes".
// Sync form fields when modal opens or stop changes.
// We track the last-seen key in a ref so we only dispatch when it changes,
// and the effect runs after commit so the form is populated before focus.
const prevSyncKeyRef = useRef<string | null>(null);
const syncKey = `${isOpen ? "open" : "closed"}:${stop?.id ?? "new"}`;
if (syncKey !== prevSyncKeyRef.current) {
useEffect(() => {
if (syncKey === prevSyncKeyRef.current) return;
prevSyncKeyRef.current = syncKey;
if (isOpen) {
if (stop) {
// Edit mode - populate from existing stop.
setCity(stop.city);
setStateField(stop.state);
setLocation(stop.location);
setDate(stop.date);
setTime(stop.time || "");
setAddress(stop.address ?? "");
setZip(stop.zip ?? "");
setCutoffTime(stop.cutoff_time ? stop.cutoff_time.slice(0, 16) : "");
setStatus(stop.active ? "active" : "draft");
} else {
// Add mode - reset form
setCity("");
setStateField("");
setLocation("");
setDate("");
setTime("");
setAddress("");
setZip("");
setCutoffTime("");
setStatus("draft");
}
setError(null);
if (!isOpen) return;
if (stop) {
dispatch({ type: "POPULATE_FROM_STOP", stop });
} else {
dispatch({ type: "RESET_FORM" });
}
}
}, [syncKey, isOpen, stop]);
// Focus the first field once the modal has actually opened. This is
// a DOM side-effect, so it must run after the commit (i.e. in an
// effect) — accessing `cityRef.current` during render is illegal.
// The `prevSyncKeyRef.current?.startsWith("open:")` guard keeps the focus
// call to the transition into the open state, not every re-render.
// Focus the first field once the modal has actually opened.
useEffect(() => {
if (prevSyncKeyRef.current?.startsWith("open:")) {
const id = requestAnimationFrame(() => cityRef.current?.focus());
@@ -102,14 +141,15 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
dispatch({ type: "SET_ERROR", error: null });
const { city, stateField, location, date, time, address, zip, cutoffTime, status } = state.form;
if (!city.trim() || !stateField.trim() || !location.trim() || !date) {
setError("City, state, location, and date are required.");
dispatch({ type: "SET_ERROR", error: "City, state, location, and date are required." });
return;
}
setLoading(true);
dispatch({ type: "SET_LOADING", loading: true });
try {
let result;
if (isEditing && stop) {
@@ -143,46 +183,31 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
onSuccess?.(newStopId);
onClose();
} else {
setError(result.error ?? `Failed to ${isEditing ? "update" : "create"} stop`);
dispatch({ type: "SET_ERROR", error: result.error ?? `Failed to ${isEditing ? "update" : "create"} stop` });
}
} catch {
setError("Network error. Please try again.");
dispatch({ type: "SET_ERROR", error: "Network error. Please try again." });
} finally {
setLoading(false);
dispatch({ type: "SET_LOADING", loading: false });
}
},
[
brandId,
city,
stateField,
location,
date,
time,
address,
zip,
cutoffTime,
status,
isEditing,
stop,
onSuccess,
onClose,
]
[brandId, state.form, isEditing, stop, onSuccess, onClose]
);
if (!isOpen) return null;
const title = isEditing ? "Edit Stop" : "Add Stop";
const eyebrow = isEditing
? `${stop?.city}, ${stop?.state}`
: "New stop on the route";
const submitLabel = loading
const submitLabel = state.loading
? isEditing ? "Saving…" : "Creating…"
: isEditing
? "Save Changes"
: status === "active"
: state.form.status === "active"
? "Create & Publish"
: "Save as Draft";
if (!isOpen) return null;
return (
<GlassModal
title={title}
@@ -192,257 +217,393 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces
compact
>
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
{error && (
<div
role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
</svg>
<span>{error}</span>
</div>
)}
<ModalError error={state.error} />
{/* Row 1 — Where: City + State */}
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-city" className="ha-field-label">
<PinIcon />
<span>City</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="Denver"
ref={cityRef}
id="stop-city"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Denver"
className="ha-field-input"
required
autoComplete="address-level2"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-state" className="ha-field-label">
<span>State</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="CO"
id="stop-state"
type="text"
value={stateField}
onChange={(e) => setStateField(e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="ha-field-input ha-field-input-mono text-center"
style={{ textTransform: "uppercase" }}
required
autoComplete="address-level1"
/>
</div>
</div>
<WhereFields
cityRef={cityRef}
city={state.form.city}
stateField={state.form.stateField}
onField={(field, value) => dispatch({ type: "SET_FIELD", field, value })}
/>
{/* Row 2 — Where at: Location (full) */}
<div className="ha-field">
<label htmlFor="stop-location" className="ha-field-label">
<span>Location / Venue</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="Whole Foods Market — Highlands"
id="stop-location"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Whole Foods Market — Highlands"
className="ha-field-input"
required
/>
</div>
<LocationField
location={state.form.location}
onChange={(value) => dispatch({ type: "SET_FIELD", field: "location", value })}
/>
{/* Row 3 — When: Date + Time */}
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="ha-field">
<label htmlFor="stop-date" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
<span>Pickup Date</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="Stop Date"
id="stop-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="ha-field-input ha-field-input-mono"
required
/>
</div>
<div className="ha-field">
<label htmlFor="stop-time" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" strokeLinecap="round" />
</svg>
<span>Pickup Time</span>
</label>
<input aria-label="Stop Time"
id="stop-time"
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="ha-field-input ha-field-input-mono"
/>
</div>
</div>
<WhenFields
date={state.form.date}
time={state.form.time}
onField={(field, value) => dispatch({ type: "SET_FIELD", field, value })}
/>
{/* Row 4 — Address + ZIP + Cutoff */}
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-address" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
<path d="M9 22V12h6v10" strokeLinejoin="round" />
</svg>
<span>Street Address</span>
</label>
<input aria-label="123 Main St"
id="stop-address"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
className="ha-field-input"
autoComplete="street-address"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-zip" className="ha-field-label">
<span>ZIP</span>
</label>
<input aria-label="80202"
id="stop-zip"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80202"
maxLength={10}
className="ha-field-input ha-field-input-mono"
autoComplete="postal-code"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-cutoff" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" strokeLinecap="round" />
</svg>
<span>Cutoff</span>
</label>
<input aria-label="Stop Cutoff"
id="stop-cutoff"
type="time"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
className="ha-field-input ha-field-input-mono"
/>
</div>
</div>
<AddressFields
address={state.form.address}
zip={state.form.zip}
cutoffTime={state.form.cutoffTime}
onField={(field, value) => dispatch({ type: "SET_FIELD", field, value })}
/>
{/* Row 5 — Status: segmented control */}
<div className="ha-field pt-0.5">
<div className="flex items-center justify-between">
<p className="ha-field-label">
<span>Visibility</span>
</p>
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
Draft is hidden from customers
</span>
</div>
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
<button
type="button"
role="radio"
aria-checked={status === "draft"}
onClick={() => setStatus("draft")}
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
>
<span className="ha-segment-dot" />
<span>Save as Draft</span>
</button>
<button
type="button"
role="radio"
aria-checked={status === "active"}
onClick={() => setStatus("active")}
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
>
<span className="ha-segment-dot" />
<span>Publish Now</span>
</button>
</div>
</div>
<StatusSegment
status={state.form.status}
onChange={(value) => dispatch({ type: "SET_FIELD", field: "status", value })}
/>
{/* Footer */}
<div className="ha-modal-footer">
<span className="ha-modal-footer-hint">
<kbd>Esc</kbd>
to close
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="ha-btn-ghost"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="ha-btn-primary"
>
{loading ? (
<>
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
<span>{submitLabel}</span>
</>
) : (
<>
{isEditing ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : status === "active" ? (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
<span>{submitLabel}</span>
</>
)}
</button>
</div>
</div>
<ModalFooter
loading={state.loading}
isEditing={isEditing}
status={state.form.status}
submitLabel={submitLabel}
onClose={onClose}
/>
</form>
</GlassModal>
);
}
// ── Modal Error ───────────────────────────────────────────────────────────────
function ModalError({ error }: { error: string | null }) {
if (!error) return null;
return (
<div
role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{ background: "var(--admin-danger-soft)", border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)" }}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
</svg>
<span>{error}</span>
</div>
);
}
// ── Where Fields (city + state) ───────────────────────────────────────────────
function WhereFields({
cityRef,
city,
stateField,
onField,
}: {
cityRef: React.RefObject<HTMLInputElement | null>;
city: string;
stateField: string;
onField: (field: "city" | "stateField", value: string) => void;
}) {
return (
<div className="grid grid-cols-[1fr_5.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-city" className="ha-field-label">
<PinIcon />
<span>City</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="Denver"
ref={cityRef}
id="stop-city"
type="text"
value={city}
onChange={(e) => onField("city", e.target.value)}
placeholder="Denver"
className="ha-field-input"
required
autoComplete="address-level2"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-state" className="ha-field-label">
<span>State</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="CO"
id="stop-state"
type="text"
value={stateField}
onChange={(e) => onField("stateField", e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="ha-field-input ha-field-input-mono text-center"
style={{ textTransform: "uppercase" }}
required
autoComplete="address-level1"
/>
</div>
</div>
);
}
// ── Location Field ────────────────────────────────────────────────────────────
function LocationField({
location,
onChange,
}: {
location: string;
onChange: (value: string) => void;
}) {
return (
<div className="ha-field">
<label htmlFor="stop-location" className="ha-field-label">
<span>Location / Venue</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="Whole Foods Market — Highlands"
id="stop-location"
type="text"
value={location}
onChange={(e) => onChange(e.target.value)}
placeholder="Whole Foods Market — Highlands"
className="ha-field-input"
required
/>
</div>
);
}
// ── When Fields (date + time) ─────────────────────────────────────────────────
function WhenFields({
date,
time,
onField,
}: {
date: string;
time: string;
onField: (field: "date" | "time", value: string) => void;
}) {
return (
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="ha-field">
<label htmlFor="stop-date" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" strokeLinecap="round" />
</svg>
<span>Pickup Date</span>
<span className="ha-field-label-required">*</span>
</label>
<input aria-label="Stop Date"
id="stop-date"
type="date"
value={date}
onChange={(e) => onField("date", e.target.value)}
className="ha-field-input ha-field-input-mono"
required
/>
</div>
<div className="ha-field">
<label htmlFor="stop-time" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" strokeLinecap="round" />
</svg>
<span>Pickup Time</span>
</label>
<input aria-label="Stop Time"
id="stop-time"
type="time"
value={time}
onChange={(e) => onField("time", e.target.value)}
className="ha-field-input ha-field-input-mono"
/>
</div>
</div>
);
}
// ── Address Fields (address + zip + cutoff) ───────────────────────────────────
function AddressFields({
address,
zip,
cutoffTime,
onField,
}: {
address: string;
zip: string;
cutoffTime: string;
onField: (field: "address" | "zip" | "cutoffTime", value: string) => void;
}) {
return (
<div className="grid grid-cols-[1fr_5.5rem_6.5rem] gap-3">
<div className="ha-field">
<label htmlFor="stop-address" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" strokeLinejoin="round" />
<path d="M9 22V12h6v10" strokeLinejoin="round" />
</svg>
<span>Street Address</span>
</label>
<input aria-label="123 Main St"
id="stop-address"
type="text"
value={address}
onChange={(e) => onField("address", e.target.value)}
placeholder="123 Main St"
className="ha-field-input"
autoComplete="street-address"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-zip" className="ha-field-label">
<span>ZIP</span>
</label>
<input aria-label="80202"
id="stop-zip"
type="text"
value={zip}
onChange={(e) => onField("zip", e.target.value)}
placeholder="80202"
maxLength={10}
className="ha-field-input ha-field-input-mono"
autoComplete="postal-code"
/>
</div>
<div className="ha-field">
<label htmlFor="stop-cutoff" className="ha-field-label">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" strokeLinecap="round" />
</svg>
<span>Cutoff</span>
</label>
<input aria-label="Stop Cutoff"
id="stop-cutoff"
type="time"
value={cutoffTime}
onChange={(e) => onField("cutoffTime", e.target.value)}
className="ha-field-input ha-field-input-mono"
/>
</div>
</div>
);
}
// ── Status Segment ────────────────────────────────────────────────────────────
function StatusSegment({
status,
onChange,
}: {
status: "draft" | "active";
onChange: (value: "draft" | "active") => void;
}) {
return (
<div className="ha-field pt-0.5">
<div className="flex items-center justify-between">
<p className="ha-field-label">
<span>Visibility</span>
</p>
<span className="text-[10px] text-[var(--admin-text-muted)] leading-none">
Draft is hidden from customers
</span>
</div>
<div className="ha-segment mt-1" role="radiogroup" aria-label="Stop status">
<button
type="button"
role="radio"
aria-checked={status === "draft"}
onClick={() => onChange("draft")}
className={`ha-segment-btn ${status === "draft" ? "ha-segment-btn--active ha-segment-active-draft" : ""}`}
>
<span className="ha-segment-dot" />
<span>Save as Draft</span>
</button>
<button
type="button"
role="radio"
aria-checked={status === "active"}
onClick={() => onChange("active")}
className={`ha-segment-btn ${status === "active" ? "ha-segment-btn--active ha-segment-active-active" : ""}`}
>
<span className="ha-segment-dot" />
<span>Publish Now</span>
</button>
</div>
</div>
);
}
// ── Modal Footer ──────────────────────────────────────────────────────────────
function ModalFooter({
loading,
isEditing,
status,
submitLabel,
onClose,
}: {
loading: boolean;
isEditing: boolean;
status: "draft" | "active";
submitLabel: string;
onClose: () => void;
}) {
return (
<div className="ha-modal-footer">
<span className="ha-modal-footer-hint">
<kbd>Esc</kbd>
to close
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="ha-btn-ghost"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="ha-btn-primary"
>
{loading ? (
<>
<svg className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
<span>{submitLabel}</span>
</>
) : (
<>
<SubmitIcon isEditing={isEditing} status={status} />
<span>{submitLabel}</span>
</>
)}
</button>
</div>
</div>
);
}
function SubmitIcon({
isEditing,
status,
}: {
isEditing: boolean;
status: "draft" | "active";
}) {
if (isEditing) {
return (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
if (status === "active") {
return (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
return (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" strokeLinejoin="round" />
<path d="M17 21v-8H7v8M7 3v5h8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// Hook for easy integration
function useEditStopModal(brandId: string) {
const [isOpen, setIsOpen] = useState(false);
@@ -480,4 +641,4 @@ function useEditStopModal(brandId: string) {
), [isOpen, close, brandId, editingStop]);
return { open, close, Modal, editingStop };
}
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import { type Segment, type SegmentRuleV2 } from "@/actions/harvest-reach/segments";
import SegmentBuilderPanel from "./SegmentBuilderPanel";
import MatchingCustomersPanel from "./MatchingCustomersPanel";
@@ -45,9 +45,9 @@ function SegmentsEmptyState({ onNew }: { onNew: () => void }) {
<p className="mt-3 text-sm text-stone-500 max-w-sm leading-relaxed">
Create segments to organize your contacts and send targeted campaigns to specific audiences.
</p>
<AdminButton
onClick={onNew}
className="mt-8"
<AdminButton
onClick={onNew}
className="mt-8"
icon={<PlusIcon className="w-4 h-4" />}
>
Create Your First Segment
@@ -59,7 +59,7 @@ function SegmentsEmptyState({ onNew }: { onNew: () => void }) {
// Active segment header with refined design
function ActiveSegmentHeader({ segment, onClear, customerCount }: { segment: Segment | null; onClear: () => void; customerCount?: number }) {
if (!segment) return null;
return (
<div className="flex items-center justify-between p-4 rounded-2xl bg-gradient-to-r from-emerald-50 to-teal-50 border border-emerald-200 mb-6">
<div className="flex items-center gap-4">
@@ -94,74 +94,113 @@ function ActiveSegmentHeader({ segment, onClear, customerCount }: { segment: Seg
);
}
type State = {
segments: Segment[];
activeSegment: Segment | null;
currentRules: SegmentRuleV2;
showEditModal: boolean;
customerCount: number | undefined;
};
type Action =
| { type: "SELECT_SEGMENT"; segment: Segment }
| { type: "OPEN_NEW_SEGMENT" }
| { type: "CLEAR_SELECTION" }
| { type: "SET_RULES"; rules: SegmentRuleV2 }
| { type: "SET_SHOW_EDIT_MODAL"; value: boolean }
| { type: "SET_CUSTOMER_COUNT"; count: number }
| { type: "UPSERT_SUCCESS"; segment: Segment }
| { type: "DELETE_SEGMENT"; segmentId: string };
function buildInitialState(initialSegments: Segment[]): State {
return {
segments: initialSegments,
activeSegment: null,
currentRules: { combinator: "AND", filters: [] },
showEditModal: false,
customerCount: undefined,
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SELECT_SEGMENT":
return {
...state,
activeSegment: action.segment,
currentRules: action.segment.rules as SegmentRuleV2,
};
case "OPEN_NEW_SEGMENT":
return {
...state,
activeSegment: null,
currentRules: { combinator: "AND", filters: [] },
showEditModal: true,
};
case "CLEAR_SELECTION":
return {
...state,
activeSegment: null,
currentRules: { combinator: "AND", filters: [] },
customerCount: undefined,
};
case "SET_RULES":
return { ...state, currentRules: action.rules };
case "SET_SHOW_EDIT_MODAL":
return { ...state, showEditModal: action.value };
case "SET_CUSTOMER_COUNT":
return { ...state, customerCount: action.count };
case "UPSERT_SUCCESS": {
const wasUpdate = state.activeSegment !== null;
const segments = wasUpdate
? state.segments.map((s) => (s.id === action.segment.id ? action.segment : s))
: [...state.segments, action.segment];
return {
...state,
segments,
activeSegment: action.segment,
showEditModal: false,
};
}
case "DELETE_SEGMENT": {
const segments = state.segments.filter((s) => s.id !== action.segmentId);
if (state.activeSegment?.id === action.segmentId) {
return {
...state,
segments,
activeSegment: null,
currentRules: { combinator: "AND", filters: [] },
customerCount: undefined,
};
}
return { ...state, segments };
}
}
}
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
const [segments, setSegments] = useState<Segment[]>(initialSegments);
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
const [currentRules, setCurrentRules] = useState<SegmentRuleV2>({
combinator: "AND",
filters: [],
});
const [showEditModal, setShowEditModal] = useState(false);
const [customerCount, setCustomerCount] = useState<number | undefined>(undefined);
function handleSegmentSelect(segment: Segment) {
setActiveSegment(segment);
setCurrentRules(segment.rules as SegmentRuleV2);
}
function handleNewSegment() {
setActiveSegment(null);
setCurrentRules({ combinator: "AND", filters: [] });
setShowEditModal(true);
}
function handleClearSelection() {
setActiveSegment(null);
setCurrentRules({ combinator: "AND", filters: [] });
setCustomerCount(undefined);
}
function handleRulesChange(rules: SegmentRuleV2) {
setCurrentRules(rules);
}
function handleCustomerCount(count: number) {
setCustomerCount(count);
}
const [state, dispatch] = useReducer(reducer, initialSegments, buildInitialState);
async function handleSaveSegment(name: string, description: string) {
const { upsertHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
const result = await upsertHarvestReachSegment({
id: activeSegment?.id,
id: state.activeSegment?.id,
brand_id: brandId,
name,
description,
rules: currentRules,
rules: state.currentRules,
});
if (result.success) {
if (activeSegment) {
setSegments((prev) =>
prev.map((s) => (s.id === result.segment.id ? result.segment : s))
);
} else {
setSegments((prev) => [...prev, result.segment]);
}
setActiveSegment(result.segment);
setShowEditModal(false);
dispatch({ type: "UPSERT_SUCCESS", segment: result.segment });
}
}
async function handleDeleteSegment(segmentId: string) {
const { deleteHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
await deleteHarvestReachSegment(segmentId, brandId);
setSegments((prev) => prev.filter((s) => s.id !== segmentId));
if (activeSegment?.id === segmentId) {
handleClearSelection();
}
dispatch({ type: "DELETE_SEGMENT", segmentId });
}
const hasFilters = currentRules.filters.length > 0;
return (
<div className="p-6 space-y-6">
{/* Page Header */}
@@ -176,22 +215,22 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
</div>
{/* Show empty state if no segments */}
{segments.length === 0 ? (
<SegmentsEmptyState onNew={handleNewSegment} />
{state.segments.length === 0 ? (
<SegmentsEmptyState onNew={() => dispatch({ type: "OPEN_NEW_SEGMENT" })} />
) : (
<>
{/* Active segment indicator */}
<ActiveSegmentHeader segment={activeSegment} onClear={handleClearSelection} customerCount={customerCount} />
<ActiveSegmentHeader segment={state.activeSegment} onClear={() => dispatch({ type: "CLEAR_SELECTION" })} customerCount={state.customerCount} />
{/* Main layout */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Left sidebar */}
<div className="lg:w-72 flex-shrink-0">
<SegmentListSidebar
segments={segments}
activeSegmentId={activeSegment?.id}
onSelect={handleSegmentSelect}
onNew={handleNewSegment}
segments={state.segments}
activeSegmentId={state.activeSegment?.id}
onSelect={(segment) => dispatch({ type: "SELECT_SEGMENT", segment })}
onNew={() => dispatch({ type: "OPEN_NEW_SEGMENT" })}
onDelete={handleDeleteSegment}
/>
</div>
@@ -200,27 +239,27 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-6">
<SegmentBuilderPanel
brandId={brandId}
rules={currentRules}
onChange={handleRulesChange}
onSave={() => setShowEditModal(true)}
hasActiveSegment={!!activeSegment}
rules={state.currentRules}
onChange={(rules) => dispatch({ type: "SET_RULES", rules })}
onSave={() => dispatch({ type: "SET_SHOW_EDIT_MODAL", value: true })}
hasActiveSegment={!!state.activeSegment}
/>
<MatchingCustomersPanel
brandId={brandId}
rules={currentRules}
onCountChange={handleCustomerCount}
<MatchingCustomersPanel
brandId={brandId}
rules={state.currentRules}
onCountChange={(count) => dispatch({ type: "SET_CUSTOMER_COUNT", count })}
/>
</div>
</div>
</>
)}
{showEditModal && (
{state.showEditModal && (
<SegmentEditModal
initialName={activeSegment?.name ?? ""}
initialDescription={activeSegment?.description ?? ""}
initialName={state.activeSegment?.name ?? ""}
initialDescription={state.activeSegment?.description ?? ""}
onSave={handleSaveSegment}
onClose={() => setShowEditModal(false)}
onClose={() => dispatch({ type: "SET_SHOW_EDIT_MODAL", value: false })}
/>
)}
</div>
+365 -177
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useReducer, useEffect } from "react";
import { sendStopBlast } from "@/actions/communications/stop-blast";
import {
getStopMessagingData,
@@ -8,6 +8,155 @@ import {
type StopBlastMessage,
} from "@/actions/communications/stop-messaging";
type Audience = "all" | "pending" | "picked_up";
type Channel = "sms" | "email" | "both";
type LoadData = {
orders: StopOrder[];
messages: StopBlastMessage[];
error: string | null;
loadingOrders: boolean;
loadingMessages: boolean;
};
type State = {
audience: Audience;
channel: Channel;
subject: string;
body: string;
sending: boolean;
confirm: string | null;
loadData: LoadData;
};
type Action =
| { type: "SET_AUDIENCE"; value: Audience }
| { type: "SET_CHANNEL"; value: Channel }
| { type: "SET_SUBJECT"; value: string }
| { type: "SET_BODY"; value: string }
| { type: "SET_SENDING"; value: boolean }
| { type: "SET_CONFIRM"; value: string | null }
| { type: "SET_ERROR"; value: string | null }
| { type: "SEND_START" }
| { type: "SEND_SUCCESS"; messagesLogged: number }
| { type: "SEND_FAIL"; error: string }
| { type: "LOAD_RESET" }
| { type: "LOAD_SUCCESS"; orders: StopOrder[]; messages: StopBlastMessage[] }
| { type: "LOAD_FAIL"; error: string }
| { type: "REFRESH_MESSAGES_START" }
| { type: "REFRESH_MESSAGES_SUCCESS"; messages: StopBlastMessage[] }
| { type: "REFRESH_MESSAGES_FAIL" };
const initialState: State = {
audience: "pending",
channel: "sms",
subject: "",
body: "",
sending: false,
confirm: null,
loadData: {
orders: [],
messages: [],
error: null,
loadingOrders: true,
loadingMessages: true,
},
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_AUDIENCE":
return { ...state, audience: action.value };
case "SET_CHANNEL":
return { ...state, channel: action.value };
case "SET_SUBJECT":
return { ...state, subject: action.value };
case "SET_BODY":
return { ...state, body: action.value };
case "SET_SENDING":
return { ...state, sending: action.value };
case "SET_CONFIRM":
return { ...state, confirm: action.value };
case "SET_ERROR":
return {
...state,
loadData: { ...state.loadData, error: action.value },
};
case "SEND_START":
return {
...state,
sending: true,
confirm: null,
loadData: { ...state.loadData, error: null },
};
case "SEND_SUCCESS":
return {
...state,
sending: false,
subject: "",
body: "",
confirm: `Blast sent — ${action.messagesLogged} message${action.messagesLogged !== 1 ? "s" : ""} logged via campaign.`,
};
case "SEND_FAIL":
return {
...state,
sending: false,
loadData: { ...state.loadData, error: action.error },
};
case "LOAD_RESET":
return {
...state,
loadData: {
orders: [],
messages: [],
error: null,
loadingOrders: true,
loadingMessages: true,
},
};
case "LOAD_SUCCESS":
return {
...state,
loadData: {
...state.loadData,
orders: action.orders,
messages: action.messages,
loadingOrders: false,
loadingMessages: false,
},
};
case "LOAD_FAIL":
return {
...state,
loadData: {
...state.loadData,
error: action.error,
loadingOrders: false,
loadingMessages: false,
},
};
case "REFRESH_MESSAGES_START":
return {
...state,
loadData: { ...state.loadData, loadingMessages: true },
};
case "REFRESH_MESSAGES_SUCCESS":
return {
...state,
loadData: {
...state.loadData,
loadingMessages: false,
messages: action.messages,
},
};
case "REFRESH_MESSAGES_FAIL":
return {
...state,
loadData: { ...state.loadData, loadingMessages: false },
};
}
}
type MessageCustomersSectionProps = {
stopId: string;
brandId?: string;
@@ -17,49 +166,15 @@ export default function MessageCustomersSection({
stopId,
brandId,
}: MessageCustomersSectionProps) {
const [audience, setAudience] = useState<"all" | "pending" | "picked_up">("pending");
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const [confirm, setConfirm] = useState<string | null>(null);
// Group the load-state into a single object so the effect only
// writes one piece of state at a time (satisfies
// `no-cascading-set-state`).
type LoadState = {
orders: StopOrder[];
messages: StopBlastMessage[];
error: string | null;
loadingOrders: boolean;
loadingMessages: boolean;
};
const [loadData, setLoadData] = useState<LoadState>({
orders: [],
messages: [],
error: null,
loadingOrders: true,
loadingMessages: true,
});
const [state, dispatch] = useReducer(reducer, initialState);
const { audience, channel, subject, body, sending, confirm, loadData } = state;
const { orders, messages, error, loadingOrders, loadingMessages } = loadData;
const setError = (v: string | null) => setLoadData((prev) => ({ ...prev, error: v }));
// Track the last (stopId, brandId) tuple we kicked off a fetch for.
// When the prop changes we flip `loadingOrders` + `loadingMessages`
// inline during render so users never see stale "loaded" UI between
// the prop change and the effect running.
const lastFetchKeyRef = useRef<string | null>(null);
const fetchKey = `${stopId}|${brandId ?? ""}`;
if (fetchKey !== lastFetchKeyRef.current) {
lastFetchKeyRef.current = fetchKey;
setLoadData({
orders: [],
messages: [],
error: null,
loadingOrders: true,
loadingMessages: true,
});
}
// When (stopId, brandId) changes, reset to the loading state so users
// don't see stale "loaded" data from the previous stop.
useEffect(() => {
dispatch({ type: "LOAD_RESET" });
}, [stopId, brandId]);
useEffect(() => {
let cancelled = false;
@@ -67,9 +182,13 @@ export default function MessageCustomersSection({
const res = await getStopMessagingData({ stopId, brandId });
if (cancelled) return;
if (res.success) {
setLoadData((prev) => ({ ...prev, orders: res.orders, messages: res.messages, loadingOrders: false, loadingMessages: false }));
dispatch({
type: "LOAD_SUCCESS",
orders: res.orders,
messages: res.messages,
});
} else {
setLoadData((prev) => ({ ...prev, error: res.error, loadingOrders: false, loadingMessages: false }));
dispatch({ type: "LOAD_FAIL", error: res.error });
}
})();
return () => {
@@ -99,17 +218,21 @@ export default function MessageCustomersSection({
e.preventDefault();
if (!body.trim()) return;
if (showSubject && !subject.trim()) {
setError("Subject is required for email or both.");
dispatch({
type: "SET_ERROR",
value: "Subject is required for email or both.",
});
return;
}
if (!brandId) {
setError("Brand ID not available.");
dispatch({
type: "SET_ERROR",
value: "Brand ID not available.",
});
return;
}
setSending(true);
setError(null);
setConfirm(null);
dispatch({ type: "SEND_START" });
const result = await sendStopBlast({
stopId,
@@ -120,22 +243,25 @@ export default function MessageCustomersSection({
audience,
});
setSending(false);
if (!result.success) {
setError(result.error ?? "Failed to send blast");
dispatch({
type: "SEND_FAIL",
error: result.error ?? "Failed to send blast",
});
return;
}
setConfirm(`Blast sent — ${result.messages_logged} message${result.messages_logged !== 1 ? "s" : ""} logged via campaign.`);
setBody("");
setSubject("");
dispatch({
type: "SEND_SUCCESS",
messagesLogged: result.messages_logged,
});
// Refresh the message log
setLoadData((prev) => ({ ...prev, loadingMessages: true }));
dispatch({ type: "REFRESH_MESSAGES_START" });
const res = await getStopMessagingData({ stopId, brandId });
if (res.success) {
setLoadData((prev) => ({ ...prev, loadingMessages: false, messages: res.messages }));
dispatch({ type: "REFRESH_MESSAGES_SUCCESS", messages: res.messages });
} else {
setLoadData((prev) => ({ ...prev, loadingMessages: false }));
dispatch({ type: "REFRESH_MESSAGES_FAIL" });
}
}
@@ -158,131 +284,193 @@ export default function MessageCustomersSection({
) : orders.length === 0 ? (
<p className="text-sm text-zinc-500">No orders for this stop yet.</p>
) : (
<>
{/* Audience selector */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300" htmlFor="fld-audience---recipientslength-with-contact">
Audience {" "}
<span className="text-zinc-500">
{recipients.length} with contact info
</span>
</label>
<div className="flex gap-2">
{(["all", "pending", "picked_up"] as const).map((a) => {
const count =
a === "all"
? allOrders.length
: a === "pending"
? pendingOrders.length
: pickedUpOrders.length;
return (
<button type="button"
key={a}
onClick={() => setAudience(a)}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium capitalize transition-colors ${
audience === a
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{a.replace("_", " ")} ({count})
</button>
);
})}
</div>
</div>
{/* Channel selector */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300" htmlFor="fld-channel">
Channel
</label>
<div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => (
<button type="button"
key={ch}
onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
channel === ch
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{ch === "sms" ? "📱 SMS" : ch === "email" ? "✉️ Email" : "📱+✉️ Both"}
</button>
))}
</div>
</div>
<form onSubmit={handleSend} className="space-y-4">
{showSubject && (
<div>
<label htmlFor="fld-1-subject" className="mb-1 block text-sm font-medium text-zinc-300">Subject</label>
<input id="fld-1-subject" aria-label="Important Update About Your Pickup"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Important update about your pickup"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
)}
<div>
<label htmlFor="fld-2-message" className="mb-1 block text-sm font-medium text-zinc-300">Message</label>
<textarea id="fld-2-message" aria-label="Type Your Message Here..."
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
placeholder="Type your message here..."
/>
<p className="mt-1 text-xs text-slate-400">{body.length} characters</p>
</div>
<button
type="submit"
disabled={!body.trim() || recipients.length === 0 || sending}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{sending
? "Sending..."
: `Send to ${recipients.length} customer${recipients.length !== 1 ? "s" : ""}`}
</button>
</form>
</>
<MessageForm
audience={audience}
channel={channel}
subject={subject}
body={body}
sending={sending}
allOrders={allOrders}
pendingOrders={pendingOrders}
pickedUpOrders={pickedUpOrders}
recipientsLength={recipients.length}
showSubject={showSubject}
onSelectAudience={(a) => dispatch({ type: "SET_AUDIENCE", value: a })}
onSelectChannel={(ch) => dispatch({ type: "SET_CHANNEL", value: ch })}
onSubjectChange={(v) => dispatch({ type: "SET_SUBJECT", value: v })}
onBodyChange={(v) => dispatch({ type: "SET_BODY", value: v })}
onSubmit={handleSend}
/>
)}
{/* Recent message history */}
{!loadingMessages && messages.length > 0 && (
<div>
<p className="mb-3 text-sm font-medium text-zinc-300">
Recent Messages
</p>
<div className="space-y-2">
{messages.map((msg) => (
<div
key={msg.id}
className="rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium text-zinc-100">
{msg.type.toUpperCase()}{" "}
{msg.subject && <span className="text-zinc-500"> {msg.subject}</span>}
</p>
<p className="mt-1 truncate text-sm text-zinc-400">{msg.body}</p>
<p className="mt-1 text-xs text-slate-400">
{new Date(msg.created_at).toLocaleString()} ·{" "}
{msg.message_recipients?.length ?? 0} recipients
</p>
</div>
</div>
</div>
))}
</div>
</div>
<MessageHistory messages={messages} />
)}
</div>
);
}
type MessageFormProps = {
audience: Audience;
channel: Channel;
subject: string;
body: string;
sending: boolean;
allOrders: StopOrder[];
pendingOrders: StopOrder[];
pickedUpOrders: StopOrder[];
recipientsLength: number;
showSubject: boolean;
onSelectAudience: (a: Audience) => void;
onSelectChannel: (ch: Channel) => void;
onSubjectChange: (v: string) => void;
onBodyChange: (v: string) => void;
onSubmit: (e: React.FormEvent) => void;
};
function MessageForm({
audience,
channel,
subject,
body,
sending,
allOrders,
pendingOrders,
pickedUpOrders,
recipientsLength,
showSubject,
onSelectAudience,
onSelectChannel,
onSubjectChange,
onBodyChange,
onSubmit,
}: MessageFormProps) {
return (
<>
{/* Audience selector */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300" htmlFor="fld-audience---recipientslength-with-contact">
Audience {" "}
<span className="text-zinc-500">
{recipientsLength} with contact info
</span>
</label>
<div className="flex gap-2">
{(["all", "pending", "picked_up"] as const).map((a) => {
const count =
a === "all"
? allOrders.length
: a === "pending"
? pendingOrders.length
: pickedUpOrders.length;
return (
<button type="button"
key={a}
onClick={() => onSelectAudience(a)}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium capitalize transition-colors ${
audience === a
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{a.replace("_", " ")} ({count})
</button>
);
})}
</div>
</div>
{/* Channel selector */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300" htmlFor="fld-channel">
Channel
</label>
<div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => (
<button type="button"
key={ch}
onClick={() => onSelectChannel(ch)}
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
channel === ch
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{ch === "sms" ? "📱 SMS" : ch === "email" ? "✉️ Email" : "📱+✉️ Both"}
</button>
))}
</div>
</div>
<form onSubmit={onSubmit} className="space-y-4">
{showSubject && (
<div>
<label htmlFor="fld-1-subject" className="mb-1 block text-sm font-medium text-zinc-300">Subject</label>
<input id="fld-1-subject" aria-label="Important Update About Your Pickup"
type="text"
value={subject}
onChange={(e) => onSubjectChange(e.target.value)}
placeholder="Important update about your pickup"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
)}
<div>
<label htmlFor="fld-2-message" className="mb-1 block text-sm font-medium text-zinc-300">Message</label>
<textarea id="fld-2-message" aria-label="Type Your Message Here..."
value={body}
onChange={(e) => onBodyChange(e.target.value)}
rows={4}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
placeholder="Type your message here..."
/>
<p className="mt-1 text-xs text-slate-400">{body.length} characters</p>
</div>
<button
type="submit"
disabled={!body.trim() || recipientsLength === 0 || sending}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{sending
? "Sending..."
: `Send to ${recipientsLength} customer${recipientsLength !== 1 ? "s" : ""}`}
</button>
</form>
</>
);
}
function MessageHistory({ messages }: { messages: StopBlastMessage[] }) {
return (
<div>
<p className="mb-3 text-sm font-medium text-zinc-300">
Recent Messages
</p>
<div className="space-y-2">
{messages.map((msg) => (
<div
key={msg.id}
className="rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium text-zinc-100">
{msg.type.toUpperCase()}{" "}
{msg.subject && <span className="text-zinc-500"> {msg.subject}</span>}
</p>
<p className="mt-1 truncate text-sm text-zinc-400">{msg.body}</p>
<p className="mt-1 text-xs text-slate-400">
{new Date(msg.created_at).toLocaleString()} ·{" "}
{msg.message_recipients?.length ?? 0} recipients
</p>
</div>
</div>
</div>
))}
</div>
</div>
);
}
+346 -232
View File
@@ -1,7 +1,6 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect, useCallback, useRef } from "react";
import { useEffect, useCallback, useRef, useReducer } from "react";
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
import { formatDate } from "@/lib/format-date";
import AdminButton from "./design-system/AdminButton";
@@ -167,11 +166,11 @@ function MethodBadge({ method }: { method: string }) {
// Engagement indicator
function EngagementIndicator({ log }: { log: MessageLogEntry }) {
const hasEngagement = log.delivered_at || log.opened_at || log.clicked_at || log.bounced_at;
if (!hasEngagement) {
return <span className="text-xs text-stone-400">No engagement yet</span>;
}
return (
<div className="flex items-center gap-1.5">
{log.delivered_at && (
@@ -204,16 +203,54 @@ function EngagementIndicator({ log }: { log: MessageLogEntry }) {
);
}
// ---- useReducer state ----
type State = {
logs: MessageLogEntry[];
search: string;
statusFilter: string;
page: number;
isLoading: boolean;
};
type Action =
| { type: "SET_LOGS"; logs: MessageLogEntry[] }
| { type: "SET_SEARCH"; value: string }
| { type: "SET_STATUS_FILTER"; value: string }
| { type: "SET_PAGE"; value: number }
| { type: "SET_LOADING"; value: boolean }
| { type: "RESET_PAGE" }
| { type: "RESET_FOR_FETCH" };
const initialState: State = {
logs: [],
search: "",
statusFilter: "all",
page: 1,
isLoading: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_LOGS":
return { ...state, logs: action.logs };
case "SET_SEARCH":
return { ...state, search: action.value, page: 1 };
case "SET_STATUS_FILTER":
return { ...state, statusFilter: action.value, page: 1 };
case "SET_PAGE":
return { ...state, page: action.value };
case "SET_LOADING":
return { ...state, isLoading: action.value };
case "RESET_PAGE":
return { ...state, page: 1 };
case "RESET_FOR_FETCH":
return { ...state, isLoading: true, logs: [] };
}
}
export default function MessageLogPanel({ brandId }: { brandId?: string }) {
const [logs, setLogs] = useState<MessageLogEntry[]>([]);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [page, setPage] = useState(1);
// Whether a fetch is currently in flight. Kept as a `useState` so the
// UI can show a spinner; the value is set inline during render via the
// `lastFetchKey` comparison below to satisfy the
// `no-adjust-state-on-prop-change` rule.
const [isLoading, setIsLoading] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
const { logs, search, statusFilter, page, isLoading } = state;
// Track the last (brandId|statusFilter) signature we kicked off a
// fetch for. We adjust `isLoading` + `logs` inline during render when
// the signature changes, so users never see a stale "loaded" UI
@@ -222,22 +259,23 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
const fetchKey = brandId ? `${brandId}|${statusFilter}` : null;
if (fetchKey !== lastFetchKeyRef.current) {
lastFetchKeyRef.current = fetchKey;
setIsLoading(Boolean(fetchKey));
setLogs([]);
if (fetchKey) {
dispatch({ type: "RESET_FOR_FETCH" });
}
}
const loadLogs = useCallback(async () => {
if (!brandId) return;
setIsLoading(true);
dispatch({ type: "SET_LOADING", value: true });
const result = await getMessageLogs({
brandId,
status: statusFilter === "all" ? undefined : statusFilter,
limit: 100,
});
if (result.success) {
setLogs(result.logs);
dispatch({ type: "SET_LOGS", logs: result.logs });
}
setIsLoading(false);
dispatch({ type: "SET_LOADING", value: false });
}, [brandId, statusFilter]);
useEffect(() => {
@@ -267,7 +305,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
const paginatedLogs = filteredLogs.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const handleRefresh = useCallback(() => {
setPage(1);
dispatch({ type: "RESET_PAGE" });
// Re-trigger the data load by resetting the fetch key — the inline
// `if (fetchKey !== lastFetchKeyRef.current)` block will re-run on
// the next render and reset isLoading + logs.
@@ -279,220 +317,296 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
return (
<div className="p-4 sm:p-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
{Icons.messageSquare("h-6 h-6 text-white")}
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">Message Logs</h2>
<p className="text-sm text-stone-500">
{isLoading ? "Loading..." : `${filteredLogs.length} message${filteredLogs.length !== 1 ? "s" : ""}`}
</p>
</div>
</div>
<AdminButton variant="secondary" onClick={handleRefresh} disabled={isLoading} icon={Icons.refresh("w-4 h-4" + (isLoading ? " animate-spin" : ""))}>
Refresh
</AdminButton>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-stone-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Total</p>
<p className="text-2xl font-bold text-stone-800 mt-1">{stats.total}</p>
</div>
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-emerald-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Delivered</p>
<p className="text-2xl font-bold text-emerald-600 mt-1">{stats.delivered}</p>
</div>
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-red-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Failed</p>
<p className="text-2xl font-bold text-red-600 mt-1">{stats.failed}</p>
</div>
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-blue-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Pending</p>
<p className="text-2xl font-bold text-blue-600 mt-1">{stats.pending}</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
{Icons.search("h-5 w-5 text-stone-400")}
</div>
<input aria-label="Search By Email Or Subject..."
type="text"
placeholder="Search by email or subject..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
/>
</div>
<select aria-label="Select"
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
>
<option value="all">All Statuses</option>
<option value="queued">Queued</option>
<option value="sent">Sent</option>
<option value="delivered">Delivered</option>
<option value="opened">Opened</option>
<option value="clicked">Clicked</option>
<option value="bounced">Bounced</option>
<option value="failed">Failed</option>
</select>
</div>
{/* Table */}
{isLoading ? (
<div className="overflow-hidden rounded-xl border border-stone-200">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-stone-200">
<tr>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
</tr>
</thead>
{isLoading ? (
<tbody className="bg-white" aria-busy="true"><tr><td colSpan={6}><LogSkeleton /></td></tr></tbody>
) : null}
</table>
</div>
) : paginatedLogs.length === 0 ? (
<div className="overflow-hidden rounded-xl border border-stone-200">
<LogsEmptyState hasFilters={hasFilters} />
</div>
) : (
<>
{/* Desktop table */}
<div className="hidden sm:block overflow-hidden rounded-xl border border-stone-200">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-stone-200">
<tr>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100 bg-white">
{paginatedLogs.map((log: MessageLogEntry) => (
<tr key={log.id} className="hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 text-stone-500 text-xs whitespace-nowrap">
{log.sent_at ? formatDate(log.sent_at) : "—"}
</td>
<td className="px-4 py-3.5 text-stone-800 text-sm font-medium">
{log.customer_email ?? "—"}
</td>
<td className="px-4 py-3.5">
<MethodBadge method={log.delivery_method} />
</td>
<td className="px-4 py-3.5 text-stone-700 text-sm truncate max-w-[180px]" title={log.subject ?? undefined}>
{log.subject ?? "—"}
</td>
<td className="px-4 py-3.5">
<div className="flex flex-col gap-1">
<StatusBadge status={log.status} />
{log.error_message && (
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[120px]" title={log.error_message}>
{log.error_message}
</p>
)}
</div>
</td>
<td className="px-4 py-3.5">
<EngagementIndicator log={log} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{paginatedLogs.map((log: MessageLogEntry) => (
<div key={log.id} className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-stone-800">{log.customer_email ?? "—"}</div>
<div className="text-xs text-stone-500 mt-0.5 truncate max-w-[200px]">{log.subject ?? "—"}</div>
</div>
<StatusBadge status={log.status} />
</div>
<div className="flex flex-wrap items-center gap-2">
<MethodBadge method={log.delivery_method} />
<span className="text-xs text-stone-500">
{log.sent_at ? formatDate(log.sent_at) : "—"}
</span>
</div>
<EngagementIndicator log={log} />
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
<p className="text-sm text-stone-500">
Showing {(page - 1) * PAGE_SIZE + 1} to {Math.min(page * PAGE_SIZE, filteredLogs.length)} of {filteredLogs.length}
</p>
<div className="flex gap-1">
<button type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Previous">
Previous
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<button type="button"
key={pageNum}
onClick={() => setPage(pageNum)}
className={`px-4 py-2 text-sm border rounded-xl transition-colors ${
page === pageNum
? "bg-emerald-600 text-white border-emerald-600"
: "bg-white text-stone-700 border-stone-200 hover:bg-stone-50"
}`}
>
{pageNum}
</button>
);
})}
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Next">
Next
</button>
</div>
</div>
)}
</>
<MessageLogHeader
isLoading={isLoading}
filteredCount={filteredLogs.length}
onRefresh={handleRefresh}
/>
<StatsCards stats={stats} />
<FiltersRow
search={search}
statusFilter={statusFilter}
onSearchChange={(value) => dispatch({ type: "SET_SEARCH", value })}
onStatusChange={(value) => dispatch({ type: "SET_STATUS_FILTER", value })}
/>
<MessageLogTable
isLoading={isLoading}
paginatedLogs={paginatedLogs}
hasFilters={hasFilters}
/>
{totalPages > 1 && (
<Pagination
page={page}
totalPages={totalPages}
filteredCount={filteredLogs.length}
onSetPage={(p) => dispatch({ type: "SET_PAGE", value: p })}
/>
)}
</div>
);
}
type MessageLogHeaderProps = {
isLoading: boolean;
filteredCount: number;
onRefresh: () => void;
};
function MessageLogHeader({ isLoading, filteredCount, onRefresh }: MessageLogHeaderProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
{Icons.messageSquare("h-6 h-6 text-white")}
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">Message Logs</h2>
<p className="text-sm text-stone-500">
{isLoading ? "Loading..." : `${filteredCount} message${filteredCount !== 1 ? "s" : ""}`}
</p>
</div>
</div>
<AdminButton variant="secondary" onClick={onRefresh} disabled={isLoading} icon={Icons.refresh("w-4 h-4" + (isLoading ? " animate-spin" : ""))}>
Refresh
</AdminButton>
</div>
);
}
type StatsCardsProps = {
stats: { total: number; delivered: number; failed: number; pending: number };
};
function StatsCards({ stats }: StatsCardsProps) {
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-stone-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Total</p>
<p className="text-2xl font-bold text-stone-800 mt-1">{stats.total}</p>
</div>
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-emerald-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Delivered</p>
<p className="text-2xl font-bold text-emerald-600 mt-1">{stats.delivered}</p>
</div>
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-red-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Failed</p>
<p className="text-2xl font-bold text-red-600 mt-1">{stats.failed}</p>
</div>
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-blue-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Pending</p>
<p className="text-2xl font-bold text-blue-600 mt-1">{stats.pending}</p>
</div>
</div>
);
}
type FiltersRowProps = {
search: string;
statusFilter: string;
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
};
function FiltersRow({ search, statusFilter, onSearchChange, onStatusChange }: FiltersRowProps) {
return (
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
{Icons.search("h-5 w-5 text-stone-400")}
</div>
<input aria-label="Search By Email Or Subject..."
type="text"
placeholder="Search by email or subject..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
/>
</div>
<select aria-label="Select"
value={statusFilter}
onChange={(e) => onStatusChange(e.target.value)}
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
>
<option value="all">All Statuses</option>
<option value="queued">Queued</option>
<option value="sent">Sent</option>
<option value="delivered">Delivered</option>
<option value="opened">Opened</option>
<option value="clicked">Clicked</option>
<option value="bounced">Bounced</option>
<option value="failed">Failed</option>
</select>
</div>
);
}
type MessageLogTableProps = {
isLoading: boolean;
paginatedLogs: MessageLogEntry[];
hasFilters: boolean;
};
function MessageLogTable({ isLoading, paginatedLogs, hasFilters }: MessageLogTableProps) {
if (isLoading) {
return (
<div className="overflow-hidden rounded-xl border border-stone-200">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-stone-200">
<tr>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
</tr>
</thead>
<tbody className="bg-white" aria-busy="true"><tr><td colSpan={6}><LogSkeleton /></td></tr></tbody>
</table>
</div>
);
}
if (paginatedLogs.length === 0) {
return (
<div className="overflow-hidden rounded-xl border border-stone-200">
<LogsEmptyState hasFilters={hasFilters} />
</div>
);
}
return (
<>
<DesktopTable logs={paginatedLogs} />
<MobileCards logs={paginatedLogs} />
</>
);
}
function DesktopTable({ logs }: { logs: MessageLogEntry[] }) {
return (
<div className="hidden sm:block overflow-hidden rounded-xl border border-stone-200">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-stone-200">
<tr>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100 bg-white">
{logs.map((log: MessageLogEntry) => (
<tr key={log.id} className="hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 text-stone-500 text-xs whitespace-nowrap">
{log.sent_at ? formatDate(log.sent_at) : "—"}
</td>
<td className="px-4 py-3.5 text-stone-800 text-sm font-medium">
{log.customer_email ?? "—"}
</td>
<td className="px-4 py-3.5">
<MethodBadge method={log.delivery_method} />
</td>
<td className="px-4 py-3.5 text-stone-700 text-sm truncate max-w-[180px]" title={log.subject ?? undefined}>
{log.subject ?? "—"}
</td>
<td className="px-4 py-3.5">
<div className="flex flex-col gap-1">
<StatusBadge status={log.status} />
{log.error_message && (
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[120px]" title={log.error_message}>
{log.error_message}
</p>
)}
</div>
</td>
<td className="px-4 py-3.5">
<EngagementIndicator log={log} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function MobileCards({ logs }: { logs: MessageLogEntry[] }) {
return (
<div className="sm:hidden space-y-3">
{logs.map((log: MessageLogEntry) => (
<div key={log.id} className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-stone-800">{log.customer_email ?? "—"}</div>
<div className="text-xs text-stone-500 mt-0.5 truncate max-w-[200px]">{log.subject ?? "—"}</div>
</div>
<StatusBadge status={log.status} />
</div>
<div className="flex flex-wrap items-center gap-2">
<MethodBadge method={log.delivery_method} />
<span className="text-xs text-stone-500">
{log.sent_at ? formatDate(log.sent_at) : "—"}
</span>
</div>
<EngagementIndicator log={log} />
</div>
))}
</div>
);
}
type PaginationProps = {
page: number;
totalPages: number;
filteredCount: number;
onSetPage: (page: number) => void;
};
function Pagination({ page, totalPages, filteredCount, onSetPage }: PaginationProps) {
return (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
<p className="text-sm text-stone-500">
Showing {(page - 1) * PAGE_SIZE + 1} to {Math.min(page * PAGE_SIZE, filteredCount)} of {filteredCount}
</p>
<div className="flex gap-1">
<button type="button"
onClick={() => onSetPage(Math.max(1, page - 1))}
disabled={page === 1}
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Previous">
Previous
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<button type="button"
key={pageNum}
onClick={() => onSetPage(pageNum)}
className={`px-4 py-2 text-sm border rounded-xl transition-colors ${
page === pageNum
? "bg-emerald-600 text-white border-emerald-600"
: "bg-white text-stone-700 border-stone-200 hover:bg-stone-50"
}`}
>
{pageNum}
</button>
);
})}
<button type="button"
onClick={() => onSetPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Next">
Next
</button>
</div>
</div>
);
}
+256 -138
View File
@@ -2,7 +2,7 @@
import NextImage from "next/image";
import Link from "next/link";
import { useState, useRef } from "react";
import { useReducer, useRef } from "react";
import { useRouter } from "next/navigation";
import { uploadProductImage } from "@/actions/products/upload-image";
import { createProduct } from "@/actions/products/create-product";
@@ -43,27 +43,209 @@ type Props = {
const EMPTY_BRANDS: { id: string; name: string }[] = [];
// --- Reducer state -------------------------------------------------------
type State = {
loading: boolean;
error: string | null;
imagePreview: string | null;
uploading: boolean;
uploadError: string | null;
name: string;
description: string;
price: string;
type: string;
brandId: string;
isTaxable: string;
pickupType: string;
active: string;
};
type Action =
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_IMAGE_PREVIEW"; value: string | null }
| { type: "SET_UPLOADING"; value: boolean }
| { type: "SET_UPLOAD_ERROR"; value: string | null }
| { type: "SET_FIELD"; field: "name" | "description" | "price" | "type" | "brandId" | "isTaxable" | "pickupType" | "active"; value: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_IMAGE_PREVIEW":
return { ...state, imagePreview: action.value };
case "SET_UPLOADING":
return { ...state, uploading: action.value };
case "SET_UPLOAD_ERROR":
return { ...state, uploadError: action.value };
case "SET_FIELD":
return { ...state, [action.field]: action.value };
default:
return state;
}
}
function createInitialState(defaultBrandId: string): State {
return {
loading: false,
error: null,
imagePreview: null,
uploading: false,
uploadError: null,
name: "",
description: "",
price: "",
type: "Pickup",
brandId: defaultBrandId,
isTaxable: "true",
pickupType: "scheduled_stop",
active: "true",
};
}
// --- Subcomponent: image uploader ---------------------------------------
type ImageUploaderProps = {
imagePreview: string | null;
uploading: boolean;
uploadError: string | null;
fileInputRef: React.RefObject<HTMLInputElement | null>;
onFileSelect: (file: File) => void;
onRemoveImage: () => void;
};
function ImageUploader({
imagePreview,
uploading,
uploadError,
fileInputRef,
onFileSelect,
onRemoveImage,
}: ImageUploaderProps) {
return (
<div>
<div className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</div>
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<label
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) onFileSelect(file);
}}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${uploadError ? "border-[var(--admin-danger)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
</>
) : imagePreview ? (
<>
<span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span>
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
</>
)}
<input aria-label="File upload"
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onFileSelect(file);
}}
/>
</label>
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
{imagePreview && (
<button
type="button"
onClick={onRemoveImage}
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
>
Remove image
</button>
)}
</div>
);
}
// --- Subcomponent: brand selector ---------------------------------------
type BrandSelectorProps = {
brandId: string;
brandOptions: { id: string; name: string }[];
brands: { id: string; name: string }[];
lockBrand: boolean;
defaultBrandId: string;
onChange: (value: string) => void;
};
function BrandSelector({
brandId,
brandOptions,
brands,
lockBrand,
defaultBrandId,
onChange,
}: BrandSelectorProps) {
const brandSelectOptions = brandOptions.map((b) => ({ value: b.id, label: b.name }));
return (
<AdminInput label="Brand" required>
{lockBrand ? (
// brand_admin / store_employee — brand is fixed by their admin_users record
<div className="w-full rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)]">
{brandOptions.find((b) => b.id === brandId)?.name ?? brandId}
</div>
) : (
<AdminSelect
value={brandId}
onChange={(e) => onChange(e.target.value)}
options={[
{ value: "", label: brands.length > 0 ? "Select brand..." : "Loading brands..." },
...brandSelectOptions,
]}
disabled={brands.length === 0 && defaultBrandId === ""}
/>
)}
{!lockBrand && brands.length === 0 && (
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Loading available brands if this persists, check the admin Brands settings.
</p>
)}
</AdminInput>
);
}
// --- Main component ------------------------------------------------------
export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRANDS, lockBrand = false }: Props) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [state, dispatch] = useReducer(reducer, createInitialState(defaultBrandId));
const fileInputRef = useRef<HTMLInputElement>(null);
const pendingImageUrlRef = useRef<string>("");
const setPendingImageUrl = (v: string) => { pendingImageUrlRef.current = v; };
// Form state
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [price, setPrice] = useState("");
const [type, setType] = useState("Pickup");
const [brandId, setBrandId] = useState(defaultBrandId);
const [isTaxable, setIsTaxable] = useState("true");
const [pickupType, setPickupType] = useState("scheduled_stop");
const [active, setActive] = useState("true");
// Build the brand options. If the server provided a list, use it; otherwise
// fall back to the historical hardcoded list so the form still works in
// environments where getBrands() failed or returned empty.
@@ -73,16 +255,15 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct" },
];
const brandSelectOptions = brandOptions.map((b) => ({ value: b.id, label: b.name }));
async function handleFileSelect(file: File) {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
dispatch({ type: "SET_UPLOAD_ERROR", value: "Only PNG, JPEG, and WebP images are allowed." });
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("Image must be under 5MB.");
dispatch({ type: "SET_UPLOAD_ERROR", value: "Image must be under 5MB." });
return;
}
@@ -90,41 +271,41 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
setUploadError(null);
setUploading(true);
dispatch({ type: "SET_UPLOAD_ERROR", value: null });
dispatch({ type: "SET_UPLOADING", value: true });
const result = await uploadProductImage("__NEW__", resizedFile);
setUploading(false);
dispatch({ type: "SET_UPLOADING", value: false });
if (result.success) {
setPendingImageUrl(result.imageUrl);
setImagePreview(result.imageUrl);
dispatch({ type: "SET_IMAGE_PREVIEW", value: result.imageUrl });
} else {
setUploadError(result.error ?? "Upload failed.");
dispatch({ type: "SET_UPLOAD_ERROR", value: result.error ?? "Upload failed." });
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
dispatch({ type: "SET_LOADING", value: true });
dispatch({ type: "SET_ERROR", value: null });
// Use pendingImageUrl if image was uploaded, otherwise null
const imageUrl = pendingImageUrlRef.current || null;
const result = await createProduct(brandId, {
name,
description,
price: parseFloat(price),
type,
active: active === "true",
const result = await createProduct(state.brandId, {
name: state.name,
description: state.description,
price: parseFloat(state.price),
type: state.type,
active: state.active === "true",
image_url: imageUrl,
is_taxable: isTaxable === "true",
pickup_type: pickupType,
is_taxable: state.isTaxable === "true",
pickup_type: state.pickupType,
});
if (!result.success) {
setError(result.error ?? "Failed to create product");
setLoading(false);
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to create product" });
dispatch({ type: "SET_LOADING", value: false });
return;
}
@@ -132,18 +313,23 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
router.refresh();
}
function handleRemoveImage() {
dispatch({ type: "SET_IMAGE_PREVIEW", value: null });
setPendingImageUrl("");
}
return (
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
{error && (
{state.error && (
<div className="rounded-xl bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] p-4 text-[var(--admin-danger)] text-sm">
{error}
{state.error}
</div>
)}
<AdminInput label="Product Name" required>
<AdminTextInput
value={name}
onChange={(e) => setName(e.target.value)}
value={state.name}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "name", value: e.target.value })}
placeholder="e.g. Dozen Sweet Corn"
autoComplete="off"
/>
@@ -151,8 +337,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
<AdminInput label="Description">
<AdminTextarea
value={description}
onChange={(e) => setDescription(e.target.value)}
value={state.description}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "description", value: e.target.value })}
rows={3}
placeholder="e.g. Fresh-picked Olathe sweet corn."
/>
@@ -163,16 +349,16 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
<AdminTextInput
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
value={state.price}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "price", value: e.target.value })}
placeholder="12.00"
/>
</AdminInput>
<AdminInput label="Type" required>
<AdminSelect
value={type}
onChange={(e) => setType(e.target.value)}
value={state.type}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "type", value: e.target.value })}
options={[
{ value: "Pickup", label: "Pickup" },
{ value: "Shipping", label: "Shipping" },
@@ -182,34 +368,19 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
</AdminInput>
</div>
<AdminInput label="Brand" required>
{lockBrand ? (
// brand_admin / store_employee — brand is fixed by their admin_users record
<div className="w-full rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)]">
{brandOptions.find((b) => b.id === brandId)?.name ?? brandId}
</div>
) : (
<AdminSelect
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
options={[
{ value: "", label: brands.length > 0 ? "Select brand..." : "Loading brands..." },
...brandSelectOptions,
]}
disabled={brands.length === 0 && defaultBrandId === ""}
/>
)}
{!lockBrand && brands.length === 0 && (
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
Loading available brands if this persists, check the admin Brands settings.
</p>
)}
</AdminInput>
<BrandSelector
brandId={state.brandId}
brandOptions={brandOptions}
brands={brands}
lockBrand={lockBrand}
defaultBrandId={defaultBrandId}
onChange={(v) => dispatch({ type: "SET_FIELD", field: "brandId", value: v })}
/>
<AdminInput label="Taxable" helpText="Tax applied at checkout for shipping orders in nexus states. Disable for non-taxable items like apparel or cooler boxes.">
<AdminSelect
value={isTaxable}
onChange={(e) => setIsTaxable(e.target.value)}
value={state.isTaxable}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "isTaxable", value: e.target.value })}
options={[
{ value: "true", label: "Yes — taxable (default)" },
{ value: "false", label: "No — non-taxable" },
@@ -219,8 +390,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
<AdminInput label="Pickup Type" helpText="Shed pickup uses the product description as the location — no stop required at checkout.">
<AdminSelect
value={pickupType}
onChange={(e) => setPickupType(e.target.value)}
value={state.pickupType}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "pickupType", value: e.target.value })}
options={[
{ value: "scheduled_stop", label: "Scheduled Stop — requires stop selection at checkout" },
{ value: "shed", label: "Shed Pickup — uses description as location" },
@@ -230,8 +401,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
<AdminInput label="Active">
<AdminSelect
value={active}
onChange={(e) => setActive(e.target.value)}
value={state.active}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "active", value: e.target.value })}
options={[
{ value: "true", label: "Yes — show on storefront" },
{ value: "false", label: "No — hide from storefront" },
@@ -239,76 +410,23 @@ export default function NewProductForm({ defaultBrandId = "", brands = EMPTY_BRA
/>
</AdminInput>
<div>
<div className="block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</div>
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<label
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${uploadError ? "border-[var(--admin-danger)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
</>
) : imagePreview ? (
<>
<span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span>
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
</>
)}
<input aria-label="File upload"
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
</label>
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
{imagePreview && (
<button
type="button"
onClick={() => { setImagePreview(null); setPendingImageUrl(""); }}
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
>
Remove image
</button>
)}
</div>
<ImageUploader
imagePreview={state.imagePreview}
uploading={state.uploading}
uploadError={state.uploadError}
fileInputRef={fileInputRef}
onFileSelect={handleFileSelect}
onRemoveImage={handleRemoveImage}
/>
{/* Save button bar — new design tokens */}
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
<button
type="submit"
disabled={loading}
disabled={state.loading}
className="ha-btn-primary"
>
{loading ? "Creating..." : "Create Product"}
{state.loading ? "Creating..." : "Create Product"}
</button>
<Link
+412 -285
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { createStop } from "@/actions/stops/create-stop";
@@ -25,87 +25,120 @@ type Props = {
duplicateFrom?: Stop | null;
};
type FormFields = {
city: string;
state: string;
location: string;
date: string;
time: string;
brandId: string;
active: string;
address: string;
zip: string;
cutoffTime: string;
};
type State = FormFields & {
loading: boolean;
error: string | null;
fieldErrors: Record<string, string>;
};
type Action =
| { type: "SET_FIELD"; field: keyof FormFields; value: string }
| { type: "SET_FIELD_ERRORS"; errors: Record<string, string> }
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_ERROR"; error: string | null };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_FIELD": {
const nextErrors = { ...state.fieldErrors };
delete nextErrors[action.field];
return {
...state,
[action.field]: action.value,
fieldErrors: nextErrors,
};
}
case "SET_FIELD_ERRORS":
return { ...state, fieldErrors: action.errors };
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_ERROR":
return { ...state, error: action.error };
default:
return state;
}
}
const DEFAULT_BRAND = "64294306-5f42-463d-a5e8-2ad6c81a96de";
function validate(s: State): Record<string, string> {
const errors: Record<string, string> = {};
if (!s.city.trim()) errors.city = "City is required";
if (!s.state.trim()) errors.state = "State is required";
if (!s.location.trim()) errors.location = "Location is required";
if (!s.date.trim()) errors.date = "Date is required";
if (!s.time.trim()) errors.time = "Time is required";
if (!s.brandId) errors.brandId = "Brand is required";
return errors;
}
export default function NewStopForm({ duplicateFrom }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const defaultBrand = duplicateFrom?.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
// Form state
const [city, setCity] = useState(duplicateFrom?.city ?? "");
const [state, setState] = useState(duplicateFrom?.state ?? "");
const [location, setLocation] = useState(duplicateFrom?.location ?? "");
const [date, setDate] = useState(duplicateFrom?.date ?? "");
const [time, setTime] = useState(duplicateFrom?.time ?? "");
const [brandId, setBrandId] = useState(defaultBrand);
const [active, setActive] = useState("true");
const [address, setAddress] = useState(duplicateFrom?.address ?? "");
const [zip, setZip] = useState(duplicateFrom?.zip ?? "");
const [cutoffTime, setCutoffTime] = useState(duplicateFrom?.cutoff_time ?? "");
// Validation errors
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
function validateForm(): boolean {
const errors: Record<string, string> = {};
if (!city.trim()) {
errors.city = "City is required";
}
if (!state.trim()) {
errors.state = "State is required";
}
if (!location.trim()) {
errors.location = "Location is required";
}
if (!date.trim()) {
errors.date = "Date is required";
}
if (!time.trim()) {
errors.time = "Time is required";
}
if (!brandId) {
errors.brandId = "Brand is required";
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
}
const [state, dispatch] = useReducer(reducer, duplicateFrom ?? null, (df): State => ({
city: df?.city ?? "",
state: df?.state ?? "",
location: df?.location ?? "",
date: df?.date ?? "",
time: df?.time ?? "",
brandId: df?.brand_id ?? DEFAULT_BRAND,
active: "true",
address: df?.address ?? "",
zip: df?.zip ?? "",
cutoffTime: df?.cutoff_time ?? "",
loading: false,
error: null,
fieldErrors: {},
}));
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validateForm()) {
const errors = validate(state);
if (Object.keys(errors).length > 0) {
dispatch({ type: "SET_FIELD_ERRORS", errors });
showError("Validation failed", "Please fix the errors below");
return;
}
setLoading(true);
setError(null);
dispatch({ type: "SET_LOADING", value: true });
dispatch({ type: "SET_ERROR", error: null });
const result = await createStop(brandId, {
city,
state,
location,
date,
time,
active: active === "true",
address: address || null,
zip: zip || null,
cutoff_time: cutoffTime || null,
const result = await createStop(state.brandId, {
city: state.city,
state: state.state,
location: state.location,
date: state.date,
time: state.time,
active: state.active === "true",
address: state.address || null,
zip: state.zip || null,
cutoff_time: state.cutoffTime || null,
});
if (!result.success) {
showError("Failed to create stop", result.error ?? "Please try again");
setError(result.error ?? "Failed to create stop");
setLoading(false);
dispatch({ type: "SET_ERROR", error: result.error ?? "Failed to create stop" });
dispatch({ type: "SET_LOADING", value: false });
return;
}
showSuccess("Stop created", `${city}, ${state} has been added`);
showSuccess("Stop created", `${state.city}, ${state.state} has been added`);
if (result.id) {
router.push(`/admin/stops/${result.id}`);
} else {
@@ -116,236 +149,330 @@ export default function NewStopForm({ duplicateFrom }: Props) {
return (
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
)}
{state.error && <ErrorBanner message={state.error} />}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-city" className="block text-xs font-semibold text-stone-700 mb-1.5">
City <span className="text-red-500">*</span>
</label>
<input id="fld-city" aria-label=". Denver"
type="text"
value={city}
onChange={(e) => {
setCity(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.city;
return next;
});
}}
placeholder="e.g. Denver"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.city
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
/>
{fieldErrors.city && <p className="mt-1 text-xs text-red-500">{fieldErrors.city}</p>}
</div>
<LocationFields
city={state.city}
state={state.state}
fieldErrors={state.fieldErrors}
dispatch={dispatch}
/>
<div>
<label htmlFor="fld-state" className="block text-xs font-semibold text-stone-700 mb-1.5">
State <span className="text-red-500">*</span>
</label>
<input id="fld-state" aria-label=". CO"
type="text"
value={state}
onChange={(e) => {
setState(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.state;
return next;
});
}}
placeholder="e.g. CO"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.state
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
/>
{fieldErrors.state && <p className="mt-1 text-xs text-red-500">{fieldErrors.state}</p>}
</div>
</div>
<LocationNameField
location={state.location}
fieldErrors={state.fieldErrors}
dispatch={dispatch}
/>
<div>
<label htmlFor="fld-location-name" className="block text-xs font-semibold text-stone-700 mb-1.5">
Location Name <span className="text-red-500">*</span>
</label>
<input id="fld-location-name" aria-label=". Southwest Plaza Parking Lot"
type="text"
value={location}
onChange={(e) => {
setLocation(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.location;
return next;
});
}}
placeholder="e.g. Southwest Plaza Parking Lot"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.location
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
/>
{fieldErrors.location && <p className="mt-1 text-xs text-red-500">{fieldErrors.location}</p>}
</div>
<DateTimeFields
date={state.date}
time={state.time}
fieldErrors={state.fieldErrors}
dispatch={dispatch}
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-date" className="block text-xs font-semibold text-stone-700 mb-1.5">
Date <span className="text-red-500">*</span>
</label>
<input id="fld-date" aria-label="Date"
type="date"
value={date}
onChange={(e) => {
setDate(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.date;
return next;
});
}}
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.date
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
/>
{fieldErrors.date && <p className="mt-1 text-xs text-red-500">{fieldErrors.date}</p>}
</div>
<BrandField
brandId={state.brandId}
fieldErrors={state.fieldErrors}
dispatch={dispatch}
/>
<div>
<label htmlFor="fld-time" className="block text-xs font-semibold text-stone-700 mb-1.5">
Time <span className="text-red-500">*</span>
</label>
<input id="fld-time" aria-label=". 8:00 AM 2:00 PM"
type="text"
value={time}
onChange={(e) => {
setTime(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.time;
return next;
});
}}
placeholder="e.g. 8:00 AM 2:00 PM"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.time
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
/>
{fieldErrors.time && <p className="mt-1 text-xs text-red-500">{fieldErrors.time}</p>}
</div>
</div>
<ActiveField
active={state.active}
dispatch={dispatch}
/>
<div>
<label htmlFor="fld-brand" className="block text-xs font-semibold text-stone-700 mb-1.5">
Brand <span className="text-red-500">*</span>
</label>
<select id="fld-brand" aria-label="Select"
value={brandId}
onChange={(e) => {
setBrandId(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.brandId;
return next;
});
}}
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.brandId
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
>
<option value="">Select brand...</option>
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">Tuxedo Corn</option>
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">Indian River Direct</option>
</select>
{fieldErrors.brandId && <p className="mt-1 text-xs text-red-500">{fieldErrors.brandId}</p>}
</div>
<AddressFields
address={state.address}
zip={state.zip}
dispatch={dispatch}
/>
<div>
<label htmlFor="fld-1-active" className="block text-xs font-semibold text-stone-700 mb-1.5">Active</label>
<select id="fld-1-active" aria-label="Select"
value={active}
onChange={(e) => setActive(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
<option value="true">Yes show on storefront</option>
<option value="false">No hide from storefront</option>
</select>
</div>
<CutoffField
cutoffTime={state.cutoffTime}
dispatch={dispatch}
/>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-2-street-address" className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
<input id="fld-2-street-address" aria-label="123 Main St"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</div>
<div>
<label htmlFor="fld-3-zip-code" className="block text-xs font-semibold text-stone-700 mb-1.5">ZIP Code</label>
<input id="fld-3-zip-code" aria-label="80102"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80102"
maxLength={10}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</div>
</div>
<div>
<label htmlFor="fld-cutoff" className="block text-xs font-semibold text-stone-700 mb-1.5">Order Cutoff</label>
<p className="text-[10px] text-stone-500 mb-2">Customers must order before this time to be included at this stop.</p>
<input id="fld-cutoff" aria-label="Datetime Local"
type="datetime-local"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</div>
<div className="flex gap-3">
<AdminButton
type="submit"
disabled={loading}
isLoading={loading}
variant="primary"
size="lg"
>
{loading ? "Creating..." : "Create Stop"}
</AdminButton>
<Link
href="/admin/stops"
className="rounded-xl border border-[var(--admin-border)] px-6 py-3 font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
Cancel
</Link>
</div>
<FormActions loading={state.loading} />
</form>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{message}
</div>
);
}
function LocationFields({
city,
state,
fieldErrors,
dispatch,
}: {
city: string;
state: string;
fieldErrors: Record<string, string>;
dispatch: React.Dispatch<Action>;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-city" className="block text-xs font-semibold text-stone-700 mb-1.5">
City <span className="text-red-500">*</span>
</label>
<input id="fld-city" aria-label=". Denver"
type="text"
value={city}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "city", value: e.target.value })}
placeholder="e.g. Denver"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.city
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
/>
{fieldErrors.city && <p className="mt-1 text-xs text-red-500">{fieldErrors.city}</p>}
</div>
<div>
<label htmlFor="fld-state" className="block text-xs font-semibold text-stone-700 mb-1.5">
State <span className="text-red-500">*</span>
</label>
<input id="fld-state" aria-label=". CO"
type="text"
value={state}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "state", value: e.target.value })}
placeholder="e.g. CO"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.state
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
/>
{fieldErrors.state && <p className="mt-1 text-xs text-red-500">{fieldErrors.state}</p>}
</div>
</div>
);
}
function LocationNameField({
location,
fieldErrors,
dispatch,
}: {
location: string;
fieldErrors: Record<string, string>;
dispatch: React.Dispatch<Action>;
}) {
return (
<div>
<label htmlFor="fld-location-name" className="block text-xs font-semibold text-stone-700 mb-1.5">
Location Name <span className="text-red-500">*</span>
</label>
<input id="fld-location-name" aria-label=". Southwest Plaza Parking Lot"
type="text"
value={location}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "location", value: e.target.value })}
placeholder="e.g. Southwest Plaza Parking Lot"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.location
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
/>
{fieldErrors.location && <p className="mt-1 text-xs text-red-500">{fieldErrors.location}</p>}
</div>
);
}
function DateTimeFields({
date,
time,
fieldErrors,
dispatch,
}: {
date: string;
time: string;
fieldErrors: Record<string, string>;
dispatch: React.Dispatch<Action>;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-date" className="block text-xs font-semibold text-stone-700 mb-1.5">
Date <span className="text-red-500">*</span>
</label>
<input id="fld-date" aria-label="Date"
type="date"
value={date}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "date", value: e.target.value })}
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.date
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
/>
{fieldErrors.date && <p className="mt-1 text-xs text-red-500">{fieldErrors.date}</p>}
</div>
<div>
<label htmlFor="fld-time" className="block text-xs font-semibold text-stone-700 mb-1.5">
Time <span className="text-red-500">*</span>
</label>
<input id="fld-time" aria-label=". 8:00 AM 2:00 PM"
type="text"
value={time}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "time", value: e.target.value })}
placeholder="e.g. 8:00 AM 2:00 PM"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.time
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
/>
{fieldErrors.time && <p className="mt-1 text-xs text-red-500">{fieldErrors.time}</p>}
</div>
</div>
);
}
function BrandField({
brandId,
fieldErrors,
dispatch,
}: {
brandId: string;
fieldErrors: Record<string, string>;
dispatch: React.Dispatch<Action>;
}) {
return (
<div>
<label htmlFor="fld-brand" className="block text-xs font-semibold text-stone-700 mb-1.5">
Brand <span className="text-red-500">*</span>
</label>
<select id="fld-brand" aria-label="Select"
value={brandId}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "brandId", value: e.target.value })}
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.brandId
? "border-red-400 bg-red-50"
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
}`}
>
<option value="">Select brand...</option>
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">Tuxedo Corn</option>
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">Indian River Direct</option>
</select>
{fieldErrors.brandId && <p className="mt-1 text-xs text-red-500">{fieldErrors.brandId}</p>}
</div>
);
}
function ActiveField({
active,
dispatch,
}: {
active: string;
dispatch: React.Dispatch<Action>;
}) {
return (
<div>
<label htmlFor="fld-1-active" className="block text-xs font-semibold text-stone-700 mb-1.5">Active</label>
<select id="fld-1-active" aria-label="Select"
value={active}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "active", value: e.target.value })}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
<option value="true">Yes show on storefront</option>
<option value="false">No hide from storefront</option>
</select>
</div>
);
}
function AddressFields({
address,
zip,
dispatch,
}: {
address: string;
zip: string;
dispatch: React.Dispatch<Action>;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-2-street-address" className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
<input id="fld-2-street-address" aria-label="123 Main St"
type="text"
value={address}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "address", value: e.target.value })}
placeholder="123 Main St"
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</div>
<div>
<label htmlFor="fld-3-zip-code" className="block text-xs font-semibold text-stone-700 mb-1.5">ZIP Code</label>
<input id="fld-3-zip-code" aria-label="80102"
type="text"
value={zip}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "zip", value: e.target.value })}
placeholder="80102"
maxLength={10}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</div>
</div>
);
}
function CutoffField({
cutoffTime,
dispatch,
}: {
cutoffTime: string;
dispatch: React.Dispatch<Action>;
}) {
return (
<div>
<label htmlFor="fld-cutoff" className="block text-xs font-semibold text-stone-700 mb-1.5">Order Cutoff</label>
<p className="text-[10px] text-stone-500 mb-2">Customers must order before this time to be included at this stop.</p>
<input id="fld-cutoff" aria-label="Datetime Local"
type="datetime-local"
value={cutoffTime}
onChange={(e) => dispatch({ type: "SET_FIELD", field: "cutoffTime", value: e.target.value })}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</div>
);
}
function FormActions({ loading }: { loading: boolean }) {
return (
<div className="flex gap-3">
<AdminButton
type="submit"
disabled={loading}
isLoading={loading}
variant="primary"
size="lg"
>
{loading ? "Creating..." : "Create Stop"}
</AdminButton>
<Link
href="/admin/stops"
className="rounded-xl border border-[var(--admin-border)] px-6 py-3 font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
Cancel
</Link>
</div>
);
}
+507 -315
View File
@@ -210,7 +210,511 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
);
}
async function handleSave() {
const handleSave = useOrderSave({
order,
brandId,
items,
customer_name,
customer_email,
customer_phone,
discount_amount,
discount_reason,
internal_notes,
status,
pickup_complete,
subtotal,
validateForm,
setSaving,
setError,
showError,
showSuccess,
savedRef,
router,
});
return (
<div className="space-y-6">
{error && <ErrorBanner error={error} />}
<OrderItemsSection
visibleItems={visibleItems}
onUpdateItem={updateItem}
onRemoveItem={removeItem}
/>
<PricingSection
subtotal={subtotal}
taxAmount={order.tax_amount ?? 0}
discountAmount={discount_amount}
discountReason={discount_reason}
fieldErrors={fieldErrors}
onDiscountAmountChange={(v) => setDiscount_amount(v)}
onDiscountReasonChange={(v) => setDiscount_reason(v)}
/>
<CustomerFieldsSection
customerName={customer_name}
customerEmail={customer_email}
customerPhone={customer_phone}
fieldErrors={fieldErrors}
onCustomerNameChange={(v) => setCustomer_name(v)}
onCustomerEmailChange={(v) => setCustomer_email(v)}
onCustomerPhoneChange={(v) => setCustomer_phone(v)}
onClearNameError={() =>
setFieldErrors((prev) => {
const next = { ...prev };
delete next.customer_name;
return next;
})
}
/>
<StatusPickupSection
status={status}
pickupComplete={pickup_complete}
onStatusChange={(s) => setStatus(s)}
onPickupCompleteToggle={() => setPickup_complete((v) => !v)}
/>
<InternalNotesSection
internalNotes={internal_notes}
onInternalNotesChange={(v) => setInternal_notes(v)}
/>
<SaveButton saving={saving} onSave={handleSave} />
</div>
);
}
function ErrorBanner({ error }: { error: string }) {
return (
<div
className="rounded-xl border p-4 text-sm flex items-start gap-3"
style={{
borderColor: "var(--admin-danger-soft)",
backgroundColor: "var(--admin-danger-soft)",
color: "var(--admin-danger)",
}}
>
<svg
className="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
style={{ color: "var(--admin-danger)" }}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
);
}
function OrderItemsSection({
visibleItems,
onUpdateItem,
onRemoveItem,
}: {
visibleItems: EditableItem[];
onUpdateItem: (id: string, field: "quantity" | "price", value: number) => void;
onRemoveItem: (id: string) => void;
}) {
return (
<div>
<p className="ha-field-label mb-3">
Order Items ({visibleItems.length})
</p>
{visibleItems.length === 0 ? (
<p
className="rounded-xl border border-dashed p-4 text-center text-sm"
style={{
borderColor: "var(--admin-border-strong)",
color: "var(--admin-text-muted)",
}}
>
No items
</p>
) : (
<div className="space-y-3">
{visibleItems.map((item) => (
<div
key={item.id}
className="rounded-xl border p-4"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div className="flex items-start justify-between gap-3">
<p
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{item.productName}
</p>
<button type="button"
onClick={() => onRemoveItem(item.id)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
style={{ color: "var(--admin-danger)" }}
>
Remove
</button>
</div>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label htmlFor="fld-1-qty" className="ha-field-label mb-1">Qty</label>
<input id="fld-1-qty" aria-label="Number"
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
onUpdateItem(item.id, "quantity", Number(e.target.value))
}
className="ha-field-input ha-field-input-mono"
/>
</div>
<div>
<label htmlFor="fld-3-price" className="ha-field-label mb-1">Price</label>
<div className="relative">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
$
</span>
<input id="fld-3-price" aria-label="Number"
type="number"
step="0.01"
min="0"
value={item.price}
onChange={(e) =>
onUpdateItem(item.id, "price", Number(e.target.value))
}
className="ha-field-input ha-field-input-mono pl-8 pr-3"
/>
</div>
</div>
</div>
<p
className="mt-2 text-right text-sm font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(item.price) * item.quantity)}
</p>
</div>
))}
</div>
)}
</div>
);
}
function PricingSection({
subtotal,
taxAmount,
discountAmount,
discountReason,
fieldErrors,
onDiscountAmountChange,
onDiscountReasonChange,
}: {
subtotal: number;
taxAmount: number;
discountAmount: number;
discountReason: string;
fieldErrors: Record<string, string>;
onDiscountAmountChange: (v: number) => void;
onDiscountReasonChange: (v: string) => void;
}) {
return (
<div className="space-y-4">
<AdminInput label="Subtotal (auto-calculated)">
<input aria-label="Number"
type="number"
step="0.01"
value={subtotal}
readOnly
className="ha-field-input ha-field-input-mono"
style={{ color: "var(--admin-text-muted)" }}
/>
</AdminInput>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="fld-4-discount-amount" className="ha-field-label mb-1.5">Discount Amount</label>
<div className="relative">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
$
</span>
<input id="fld-4-discount-amount" aria-label="Number"
type="number"
step="0.01"
min="0"
value={discountAmount}
onChange={(e) => onDiscountAmountChange(Number(e.target.value))}
className="ha-field-input ha-field-input-mono pl-8 pr-3"
style={
fieldErrors.discount_amount
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: undefined
}
/>
</div>
{fieldErrors.discount_amount && (
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
{fieldErrors.discount_amount}
</p>
)}
</div>
<div>
<label htmlFor="fld-2-discount-reason" className="ha-field-label mb-1.5">Discount Reason</label>
<input id="fld-2-discount-reason" aria-label="Optional"
type="text"
value={discountReason}
onChange={(e) => onDiscountReasonChange(e.target.value)}
placeholder="Optional"
className="ha-field-input"
/>
</div>
</div>
<div
className="rounded-xl border p-4"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-bg-subtle)",
}}
>
<div className="flex justify-between">
<span
className="text-sm font-medium"
style={{ color: "var(--admin-text-secondary)" }}
>
Total
</span>
<span
className="text-xl font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(subtotal + taxAmount - discountAmount)}
</span>
</div>
</div>
</div>
);
}
function CustomerFieldsSection({
customerName,
customerEmail,
customerPhone,
fieldErrors,
onCustomerNameChange,
onCustomerEmailChange,
onCustomerPhoneChange,
onClearNameError,
}: {
customerName: string;
customerEmail: string;
customerPhone: string;
fieldErrors: Record<string, string>;
onCustomerNameChange: (v: string) => void;
onCustomerEmailChange: (v: string) => void;
onCustomerPhoneChange: (v: string) => void;
onClearNameError: () => void;
}) {
return (
<div className="space-y-4">
<div>
<label htmlFor="fld-5-name" className="ha-field-label mb-1.5">
<span>Name</span>
<span className="ha-field-label-required">*</span>
</label>
<input id="fld-5-name" aria-label="Text"
type="text"
value={customerName}
onChange={(e) => {
onCustomerNameChange(e.target.value);
onClearNameError();
}}
className="ha-field-input"
style={
fieldErrors.customer_name
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: undefined
}
/>
{fieldErrors.customer_name && (
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
{fieldErrors.customer_name}
</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AdminInput label="Email">
<AdminTextInput
type="email"
value={customerEmail}
onChange={(e) => onCustomerEmailChange(e.target.value)}
/>
</AdminInput>
<AdminInput label="Phone">
<AdminTextInput
type="tel"
value={customerPhone}
onChange={(e) => onCustomerPhoneChange(e.target.value)}
/>
</AdminInput>
</div>
</div>
);
}
function StatusPickupSection({
status,
pickupComplete,
onStatusChange,
onPickupCompleteToggle,
}: {
status: string;
pickupComplete: boolean;
onStatusChange: (s: string) => void;
onPickupCompleteToggle: () => void;
}) {
return (
<div className="space-y-4">
<div>
<div className="ha-field-label mb-2">Status</div>
<div className="ha-segment">
{["pending", "confirmed", "cancelled"].map((s) => (
<button
key={s}
type="button"
onClick={() => onStatusChange(s)}
className={`ha-segment-btn capitalize ${
status === s ? "ha-segment-btn--active" : ""
}`}
>
{s}
</button>
))}
</div>
</div>
<div>
<div className="ha-field-label mb-2">Pickup</div>
<button
type="button"
onClick={onPickupCompleteToggle}
className="ha-segment-btn w-full justify-center"
style={
pickupComplete
? {
backgroundColor: "var(--admin-primary-soft)",
color: "var(--admin-primary)",
border: "1px solid var(--admin-primary)",
}
: {
backgroundColor: "var(--admin-warning-soft)",
color: "var(--admin-warning)",
border: "1px solid var(--admin-warning)",
}
}
>
{pickupComplete ? "✓ Picked Up" : "○ Not Picked Up"}
</button>
</div>
</div>
);
}
function InternalNotesSection({
internalNotes,
onInternalNotesChange,
}: {
internalNotes: string;
onInternalNotesChange: (v: string) => void;
}) {
return (
<AdminInput label="Internal Notes">
<AdminTextarea
value={internalNotes}
onChange={(e) => onInternalNotesChange(e.target.value)}
rows={2}
placeholder="Private notes for staff..."
/>
</AdminInput>
);
}
function SaveButton({ saving, onSave }: { saving: boolean; onSave: () => void }) {
return (
<AdminButton
onClick={onSave}
disabled={saving}
isLoading={saving}
variant="primary"
fullWidth
size="lg"
className="ha-btn-primary"
>
{saving ? "Saving..." : "Save Changes"}
</AdminButton>
);
}
type UseOrderSaveArgs = {
order: Order;
brandId: string | null;
items: EditableItem[];
customer_name: string;
customer_email: string;
customer_phone: string;
discount_amount: number;
discount_reason: string;
internal_notes: string;
status: string;
pickup_complete: boolean;
subtotal: number;
validateForm: () => boolean;
setSaving: (v: boolean) => void;
setError: (v: string | null) => void;
showError: (title: string, message: string) => void;
showSuccess: (title: string, message: string) => void;
savedRef: React.MutableRefObject<boolean>;
router: ReturnType<typeof useRouter>;
};
function useOrderSave(args: UseOrderSaveArgs): () => Promise<void> {
const {
order,
brandId,
items,
customer_name,
customer_email,
customer_phone,
discount_amount,
discount_reason,
internal_notes,
status,
pickup_complete,
subtotal,
validateForm,
setSaving,
setError,
showError,
showSuccess,
savedRef,
router,
} = args;
return async function handleSave() {
if (!validateForm()) {
showError("Validation failed", "Please fix the errors below");
return;
@@ -300,321 +804,9 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
savedRef.current = true;
setSaving(false);
router.refresh();
} catch (err) {
} catch {
showError("Network error", "Please check your connection and try again");
setSaving(false);
}
}
return (
<div className="space-y-6">
{error && (
<div
className="rounded-xl border p-4 text-sm flex items-start gap-3"
style={{
borderColor: "var(--admin-danger-soft)",
backgroundColor: "var(--admin-danger-soft)",
color: "var(--admin-danger)",
}}
>
<svg
className="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
style={{ color: "var(--admin-danger)" }}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
)}
{/* Order items */}
<div>
<p className="ha-field-label mb-3">
Order Items ({visibleItems.length})
</p>
{visibleItems.length === 0 ? (
<p
className="rounded-xl border border-dashed p-4 text-center text-sm"
style={{
borderColor: "var(--admin-border-strong)",
color: "var(--admin-text-muted)",
}}
>
No items
</p>
) : (
<div className="space-y-3">
{visibleItems.map((item) => (
<div
key={item.id}
className="rounded-xl border p-4"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-card-bg)",
}}
>
<div className="flex items-start justify-between gap-3">
<p
className="font-medium"
style={{ color: "var(--admin-text-primary)" }}
>
{item.productName}
</p>
<button type="button"
onClick={() => removeItem(item.id)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium"
style={{ color: "var(--admin-danger)" }}
>
Remove
</button>
</div>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label htmlFor="fld-1-qty" className="ha-field-label mb-1">Qty</label>
<input id="fld-1-qty" aria-label="Number"
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
updateItem(item.id, "quantity", Number(e.target.value))
}
className="ha-field-input ha-field-input-mono"
/>
</div>
<div>
<label htmlFor="fld-3-price" className="ha-field-label mb-1">Price</label>
<div className="relative">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
$
</span>
<input id="fld-3-price" aria-label="Number"
type="number"
step="0.01"
min="0"
value={item.price}
onChange={(e) =>
updateItem(item.id, "price", Number(e.target.value))
}
className="ha-field-input ha-field-input-mono pl-8 pr-3"
/>
</div>
</div>
</div>
<p
className="mt-2 text-right text-sm font-semibold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(Number(item.price) * item.quantity)}
</p>
</div>
))}
</div>
)}
</div>
{/* Pricing */}
<div className="space-y-4">
<AdminInput label="Subtotal (auto-calculated)">
<input aria-label="Number"
type="number"
step="0.01"
value={subtotal}
readOnly
className="ha-field-input ha-field-input-mono"
style={{ color: "var(--admin-text-muted)" }}
/>
</AdminInput>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="fld-4-discount-amount" className="ha-field-label mb-1.5">Discount Amount</label>
<div className="relative">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 text-sm"
style={{ color: "var(--admin-text-muted)" }}
>
$
</span>
<input id="fld-4-discount-amount" aria-label="Number"
type="number"
step="0.01"
min="0"
value={discount_amount}
onChange={(e) => setDiscount_amount(Number(e.target.value))}
className="ha-field-input ha-field-input-mono pl-8 pr-3"
style={
fieldErrors.discount_amount
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: undefined
}
/>
</div>
{fieldErrors.discount_amount && (
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
{fieldErrors.discount_amount}
</p>
)}
</div>
<div>
<label htmlFor="fld-2-discount-reason" className="ha-field-label mb-1.5">Discount Reason</label>
<input id="fld-2-discount-reason" aria-label="Optional"
type="text"
value={discount_reason}
onChange={(e) => setDiscount_reason(e.target.value)}
placeholder="Optional"
className="ha-field-input"
/>
</div>
</div>
<div
className="rounded-xl border p-4"
style={{
borderColor: "var(--admin-border)",
backgroundColor: "var(--admin-bg-subtle)",
}}
>
<div className="flex justify-between">
<span
className="text-sm font-medium"
style={{ color: "var(--admin-text-secondary)" }}
>
Total
</span>
<span
className="text-xl font-bold"
style={{ color: "var(--admin-text-primary)" }}
>
{formatCurrency(subtotal + Number(order.tax_amount ?? 0) - discount_amount)}
</span>
</div>
</div>
</div>
{/* Customer fields */}
<div className="space-y-4">
<div>
<label htmlFor="fld-5-name" className="ha-field-label mb-1.5">
<span>Name</span>
<span className="ha-field-label-required">*</span>
</label>
<input id="fld-5-name" aria-label="Text"
type="text"
value={customer_name}
onChange={(e) => {
setCustomer_name(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.customer_name;
return next;
});
}}
className="ha-field-input"
style={
fieldErrors.customer_name
? { borderColor: "var(--admin-danger)", backgroundColor: "var(--admin-danger-soft)" }
: undefined
}
/>
{fieldErrors.customer_name && (
<p className="mt-1 text-xs" style={{ color: "var(--admin-danger)" }}>
{fieldErrors.customer_name}
</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AdminInput label="Email">
<AdminTextInput
type="email"
value={customer_email}
onChange={(e) => setCustomer_email(e.target.value)}
/>
</AdminInput>
<AdminInput label="Phone">
<AdminTextInput
type="tel"
value={customer_phone}
onChange={(e) => setCustomer_phone(e.target.value)}
/>
</AdminInput>
</div>
</div>
{/* Status & pickup */}
<div className="space-y-4">
<div>
<div className="ha-field-label mb-2">Status</div>
<div className="ha-segment">
{["pending", "confirmed", "cancelled"].map((s) => (
<button
key={s}
type="button"
onClick={() => setStatus(s)}
className={`ha-segment-btn capitalize ${
status === s ? "ha-segment-btn--active" : ""
}`}
>
{s}
</button>
))}
</div>
</div>
<div>
<div className="ha-field-label mb-2">Pickup</div>
<button
type="button"
onClick={() => setPickup_complete((v) => !v)}
className="ha-segment-btn w-full justify-center"
style={
pickup_complete
? {
backgroundColor: "var(--admin-primary-soft)",
color: "var(--admin-primary)",
border: "1px solid var(--admin-primary)",
}
: {
backgroundColor: "var(--admin-warning-soft)",
color: "var(--admin-warning)",
border: "1px solid var(--admin-warning)",
}
}
>
{pickup_complete ? "✓ Picked Up" : "○ Not Picked Up"}
</button>
</div>
</div>
{/* Internal notes */}
<AdminInput label="Internal Notes">
<AdminTextarea
value={internal_notes}
onChange={(e) => setInternal_notes(e.target.value)}
rows={2}
placeholder="Private notes for staff..."
/>
</AdminInput>
<AdminButton
onClick={handleSave}
disabled={saving}
isLoading={saving}
variant="primary"
fullWidth
size="lg"
className="ha-btn-primary"
>
{saving ? "Saving..." : "Save Changes"}
</AdminButton>
</div>
);
};
}
+400 -231
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/format-date";
import { updateOrder } from "@/actions/orders/update-order";
@@ -37,27 +37,351 @@ function formatCurrency(amount: number) {
return currencyFormatter.format(amount);
}
export default function OrderPaymentSection({
orderId,
brandId,
orderTotal,
payment_processor,
payment_status,
payment_transaction_id,
existingRefunds,
}: OrderPaymentSectionProps) {
// --- Reducer state -------------------------------------------------------
type State = {
processor: string;
status: string;
transactionId: string;
refundAmount: string;
refundReason: string;
saving: boolean;
refunding: boolean;
error: string | null;
saved: boolean;
refundSaved: boolean;
refundError: string | null;
};
type Action =
| { type: "SET_PROCESSOR"; value: string }
| { type: "SET_STATUS"; value: string }
| { type: "SET_TRANSACTION_ID"; value: string }
| { type: "SET_REFUND_AMOUNT"; value: string }
| { type: "SET_REFUND_REASON"; value: string }
| { type: "SET_SAVING"; value: boolean }
| { type: "SET_REFUNDING"; value: boolean }
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_SAVED"; value: boolean }
| { type: "SET_REFUND_SAVED"; value: boolean }
| { type: "SET_REFUND_ERROR"; value: string | null }
| { type: "RESET_REFUND_FIELDS" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_PROCESSOR":
return { ...state, processor: action.value };
case "SET_STATUS":
return { ...state, status: action.value };
case "SET_TRANSACTION_ID":
return { ...state, transactionId: action.value };
case "SET_REFUND_AMOUNT":
return { ...state, refundAmount: action.value };
case "SET_REFUND_REASON":
return { ...state, refundReason: action.value };
case "SET_SAVING":
return { ...state, saving: action.value };
case "SET_REFUNDING":
return { ...state, refunding: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_SAVED":
return { ...state, saved: action.value };
case "SET_REFUND_SAVED":
return { ...state, refundSaved: action.value };
case "SET_REFUND_ERROR":
return { ...state, refundError: action.value };
case "RESET_REFUND_FIELDS":
return { ...state, refundAmount: "", refundReason: "", refundSaved: true };
default:
return state;
}
}
function createInitialState(props: OrderPaymentSectionProps): State {
return {
processor: props.payment_processor ?? "",
status: props.payment_status ?? "manual",
transactionId: props.payment_transaction_id ?? "",
refundAmount: "",
refundReason: "",
saving: false,
refunding: false,
error: null,
saved: false,
refundSaved: false,
refundError: null,
};
}
// --- Subcomponents -------------------------------------------------------
type BalanceSummaryProps = {
orderTotal: number;
totalRefunded: number;
remainingBalance: number;
};
function BalanceSummary({ orderTotal, totalRefunded, remainingBalance }: BalanceSummaryProps) {
return (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-stone-500">Order Total</p>
<p className="text-lg font-bold text-stone-900">{formatCurrency(orderTotal)}</p>
</div>
<div className="text-right">
<p className="text-xs font-medium text-stone-500">Refunded</p>
<p className="text-lg font-bold text-green-600">{formatCurrency(totalRefunded)}</p>
</div>
<div className="text-right">
<p className="text-xs font-medium text-stone-500">Balance Due</p>
<p className="text-lg font-bold text-[var(--admin-accent)]">{formatCurrency(remainingBalance)}</p>
</div>
</div>
</div>
);
}
type PaymentDetailsFormProps = {
processor: string;
status: string;
transactionId: string;
saving: boolean;
error: string | null;
saved: boolean;
onSubmit: (e: React.FormEvent) => void;
onChangeProcessor: (value: string) => void;
onChangeStatus: (value: string) => void;
onChangeTransactionId: (value: string) => void;
};
function PaymentDetailsForm({
processor,
status,
transactionId,
saving,
error,
saved,
onSubmit,
onChangeProcessor,
onChangeStatus,
onChangeTransactionId,
}: PaymentDetailsFormProps) {
return (
<form onSubmit={onSubmit} className="space-y-4">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
)}
{saved && (
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700 flex items-center gap-3">
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Payment details saved
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-1-processor" className="mb-1 block text-xs font-semibold text-stone-500">Processor</label>
<select id="fld-1-processor" aria-label="Select"
value={processor}
onChange={(e) => onChangeProcessor(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
<option value=""> None </option>
{["manual", "stripe", "square", "cash", "venmo", "other"].map((p) => (
<option key={p} value={p}>
{p.charAt(0).toUpperCase() + p.slice(1)}
</option>
))}
</select>
</div>
<div>
<label htmlFor="fld-2-payment-status" className="mb-1 block text-xs font-semibold text-stone-500">Payment Status</label>
<select id="fld-2-payment-status" aria-label="Select"
value={status}
onChange={(e) => onChangeStatus(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
{["pending", "paid", "failed", "refunded", "cancelled"].map((s) => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
</div>
<div>
<label htmlFor="fld-3-transaction-id" className="mb-1 block text-xs font-semibold text-stone-500">Transaction ID</label>
<input id="fld-3-transaction-id" aria-label="External Payment Reference"
type="text"
value={transactionId}
onChange={(e) => onChangeTransactionId(e.target.value)}
placeholder="External payment reference"
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</div>
<AdminButton
type="submit"
disabled={saving}
isLoading={saving}
variant="primary"
>
{saving ? "Saving..." : "Save Payment Details"}
</AdminButton>
</form>
);
}
type RefundHistoryProps = {
refunds: Refund[];
};
function RefundHistory({ refunds }: RefundHistoryProps) {
if (refunds.length === 0) return null;
return (
<div>
<p className="mb-3 text-sm font-semibold text-stone-700">
Refund History ({refunds.length})
</p>
<div className="space-y-2">
{refunds.map((r) => (
<div
key={r.id}
className="flex items-center justify-between rounded-xl border border-stone-200 bg-white px-4 py-3"
>
<div>
<p className="font-semibold text-stone-900">
{formatCurrency(Number(r.amount))}
</p>
{r.reason && (
<p className="text-xs text-stone-500">{r.reason}</p>
)}
<p className="text-xs text-stone-400">
{formatDate(r.created_at)}
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-bold ${
r.status === "completed"
? "bg-green-50 text-green-700 border border-green-200"
: "bg-stone-100 text-stone-500 border border-stone-200"
}`}
>
{r.status}
</span>
</div>
))}
</div>
</div>
);
}
type RecordRefundFormProps = {
refundAmount: string;
refundReason: string;
refundError: string | null;
refundSaved: boolean;
refunding: boolean;
remainingBalance: number;
onSubmit: (e: React.FormEvent) => void;
onChangeRefundAmount: (value: string) => void;
onChangeRefundReason: (value: string) => void;
};
function RecordRefundForm({
refundAmount,
refundReason,
refundError,
refundSaved,
refunding,
remainingBalance,
onSubmit,
onChangeRefundAmount,
onChangeRefundReason,
}: RecordRefundFormProps) {
return (
<div className="rounded-xl border border-dashed border-stone-300 bg-stone-50 p-4">
<p className="mb-3 text-sm font-semibold text-stone-700">Record a Refund</p>
{refundSaved && (
<div className="mb-3 rounded-xl bg-green-50 border border-green-200 p-3 text-sm text-green-700 flex items-center gap-2">
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Refund recorded successfully
</div>
)}
<form onSubmit={onSubmit} className="space-y-3">
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500" htmlFor="fld-amount">Amount</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
<input id="fld-amount" aria-label="Number"
type="number"
step="0.01"
min="0.01"
max={remainingBalance}
value={refundAmount}
onChange={(e) => onChangeRefundAmount(e.target.value)}
placeholder={`Max ${formatCurrency(remainingBalance)}`}
className={`w-full rounded-xl border bg-white pl-8 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
refundError
? "border-red-400 bg-red-50"
: "border-stone-200 focus:border-[var(--admin-accent)]"
}`}
/>
</div>
{refundError && (
<p className="mt-1 text-xs text-red-500">{refundError}</p>
)}
</div>
<div>
<label htmlFor="fld-4-reason" className="mb-1 block text-xs font-semibold text-stone-500">Reason</label>
<input id="fld-4-reason" aria-label="Customer Request, Defective Product, Etc."
type="text"
value={refundReason}
onChange={(e) => onChangeRefundReason(e.target.value)}
placeholder="Customer request, defective product, etc."
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</div>
<AdminButton
type="submit"
disabled={!refundAmount || Number(refundAmount) <= 0 || refunding}
isLoading={refunding}
variant="danger"
fullWidth
>
{refunding ? "Processing..." : `Record Refund (Max ${formatCurrency(remainingBalance)})`}
</AdminButton>
</form>
</div>
);
}
// --- Main component ------------------------------------------------------
export default function OrderPaymentSection(props: OrderPaymentSectionProps) {
const {
orderId,
brandId,
orderTotal,
existingRefunds,
} = props;
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [processor, setProcessor] = useState(payment_processor ?? "");
const [status, setStatus] = useState(payment_status ?? "manual");
const [transactionId, setTransactionId] = useState(payment_transaction_id ?? "");
const [refundAmount, setRefundAmount] = useState("");
const [refundReason, setRefundReason] = useState("");
const [saving, setSaving] = useState(false);
const [refunding, setRefunding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [refundSaved, setRefundSaved] = useState(false);
const [state, dispatch] = useReducer(reducer, createInitialState(props));
const totalRefunded = existingRefunds
.filter((r) => r.status === "completed")
@@ -65,264 +389,109 @@ export default function OrderPaymentSection({
const remainingBalance = Math.max(0, orderTotal - totalRefunded);
// Validation for refund
const [refundError, setRefundError] = useState<string | null>(null);
function validateRefund(): boolean {
const amount = Number(refundAmount);
const amount = Number(state.refundAmount);
if (!amount || amount <= 0) {
setRefundError("Please enter a valid refund amount");
dispatch({ type: "SET_REFUND_ERROR", value: "Please enter a valid refund amount" });
return false;
}
if (amount > remainingBalance) {
setRefundError(`Amount cannot exceed remaining balance of ${formatCurrency(remainingBalance)}`);
dispatch({
type: "SET_REFUND_ERROR",
value: `Amount cannot exceed remaining balance of ${formatCurrency(remainingBalance)}`,
});
return false;
}
setRefundError(null);
dispatch({ type: "SET_REFUND_ERROR", value: null });
return true;
}
async function handleSavePayment(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setSaved(false);
dispatch({ type: "SET_SAVING", value: true });
dispatch({ type: "SET_ERROR", value: null });
dispatch({ type: "SET_SAVED", value: false });
const result = await updateOrder(orderId, brandId, {
payment_processor: processor || null,
payment_status: status,
payment_transaction_id: transactionId || null,
payment_processor: state.processor || null,
payment_status: state.status,
payment_transaction_id: state.transactionId || null,
});
if (!result.success) {
showError("Failed to save", result.error ?? "Please try again");
setError(result.error ?? "Failed to save");
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
} else {
showSuccess("Payment saved", "Payment details have been updated");
setSaved(true);
dispatch({ type: "SET_SAVED", value: true });
}
setSaving(false);
dispatch({ type: "SET_SAVING", value: false });
}
async function handleRefund(e: React.FormEvent) {
e.preventDefault();
if (!validateRefund()) return;
const amount = Number(refundAmount);
const amount = Number(state.refundAmount);
setRefunding(true);
setError(null);
setRefundSaved(false);
dispatch({ type: "SET_REFUNDING", value: true });
dispatch({ type: "SET_ERROR", value: null });
dispatch({ type: "SET_REFUND_SAVED", value: false });
const result = await createRefund(orderId, brandId, {
amount,
reason: refundReason || null,
reason: state.refundReason || null,
});
if (!result.success) {
showError("Failed to record refund", result.error ?? "Please try again");
setError(result.error ?? "Failed to record refund");
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to record refund" });
} else {
showSuccess("Refund recorded", `${formatCurrency(amount)} has been refunded`);
setRefundAmount("");
setRefundReason("");
setRefundSaved(true);
dispatch({ type: "RESET_REFUND_FIELDS" });
router.refresh();
}
setRefunding(false);
dispatch({ type: "SET_REFUNDING", value: false });
}
return (
<div className="space-y-5">
{/* Balance summary */}
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-stone-500">Order Total</p>
<p className="text-lg font-bold text-stone-900">{formatCurrency(orderTotal)}</p>
</div>
<div className="text-right">
<p className="text-xs font-medium text-stone-500">Refunded</p>
<p className="text-lg font-bold text-green-600">{formatCurrency(totalRefunded)}</p>
</div>
<div className="text-right">
<p className="text-xs font-medium text-stone-500">Balance Due</p>
<p className="text-lg font-bold text-[var(--admin-accent)]">{formatCurrency(remainingBalance)}</p>
</div>
</div>
</div>
<BalanceSummary
orderTotal={orderTotal}
totalRefunded={totalRefunded}
remainingBalance={remainingBalance}
/>
{/* Payment details */}
<form onSubmit={handleSavePayment} className="space-y-4">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
)}
<PaymentDetailsForm
processor={state.processor}
status={state.status}
transactionId={state.transactionId}
saving={state.saving}
error={state.error}
saved={state.saved}
onSubmit={handleSavePayment}
onChangeProcessor={(v) => dispatch({ type: "SET_PROCESSOR", value: v })}
onChangeStatus={(v) => dispatch({ type: "SET_STATUS", value: v })}
onChangeTransactionId={(v) => dispatch({ type: "SET_TRANSACTION_ID", value: v })}
/>
{saved && (
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700 flex items-center gap-3">
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Payment details saved
</div>
)}
<RefundHistory refunds={existingRefunds} />
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-1-processor" className="mb-1 block text-xs font-semibold text-stone-500">Processor</label>
<select id="fld-1-processor" aria-label="Select"
value={processor}
onChange={(e) => setProcessor(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
<option value=""> None </option>
{["manual", "stripe", "square", "cash", "venmo", "other"].map((p) => (
<option key={p} value={p}>
{p.charAt(0).toUpperCase() + p.slice(1)}
</option>
))}
</select>
</div>
<div>
<label htmlFor="fld-2-payment-status" className="mb-1 block text-xs font-semibold text-stone-500">Payment Status</label>
<select id="fld-2-payment-status" aria-label="Select"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
{["pending", "paid", "failed", "refunded", "cancelled"].map((s) => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
</div>
<div>
<label htmlFor="fld-3-transaction-id" className="mb-1 block text-xs font-semibold text-stone-500">Transaction ID</label>
<input id="fld-3-transaction-id" aria-label="External Payment Reference"
type="text"
value={transactionId}
onChange={(e) => setTransactionId(e.target.value)}
placeholder="External payment reference"
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</div>
<AdminButton
type="submit"
disabled={saving}
isLoading={saving}
variant="primary"
>
{saving ? "Saving..." : "Save Payment Details"}
</AdminButton>
</form>
{/* Refund history */}
{existingRefunds.length > 0 && (
<div>
<p className="mb-3 text-sm font-semibold text-stone-700">
Refund History ({existingRefunds.length})
</p>
<div className="space-y-2">
{existingRefunds.map((r) => (
<div
key={r.id}
className="flex items-center justify-between rounded-xl border border-stone-200 bg-white px-4 py-3"
>
<div>
<p className="font-semibold text-stone-900">
{formatCurrency(Number(r.amount))}
</p>
{r.reason && (
<p className="text-xs text-stone-500">{r.reason}</p>
)}
<p className="text-xs text-stone-400">
{formatDate(r.created_at)}
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-bold ${
r.status === "completed"
? "bg-green-50 text-green-700 border border-green-200"
: "bg-stone-100 text-stone-500 border border-stone-200"
}`}
>
{r.status}
</span>
</div>
))}
</div>
</div>
)}
{/* Record a refund */}
{remainingBalance > 0 && (
<div className="rounded-xl border border-dashed border-stone-300 bg-stone-50 p-4">
<p className="mb-3 text-sm font-semibold text-stone-700">Record a Refund</p>
{refundSaved && (
<div className="mb-3 rounded-xl bg-green-50 border border-green-200 p-3 text-sm text-green-700 flex items-center gap-2">
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Refund recorded successfully
</div>
)}
<form onSubmit={handleRefund} className="space-y-3">
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500" htmlFor="fld-amount">Amount</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
<input id="fld-amount" aria-label="Number"
type="number"
step="0.01"
min="0.01"
max={remainingBalance}
value={refundAmount}
onChange={(e) => {
setRefundAmount(e.target.value);
setRefundError(null);
}}
placeholder={`Max ${formatCurrency(remainingBalance)}`}
className={`w-full rounded-xl border bg-white pl-8 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
refundError
? "border-red-400 bg-red-50"
: "border-stone-200 focus:border-[var(--admin-accent)]"
}`}
/>
</div>
{refundError && (
<p className="mt-1 text-xs text-red-500">{refundError}</p>
)}
</div>
<div>
<label htmlFor="fld-4-reason" className="mb-1 block text-xs font-semibold text-stone-500">Reason</label>
<input id="fld-4-reason" aria-label="Customer Request, Defective Product, Etc."
type="text"
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
placeholder="Customer request, defective product, etc."
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</div>
<AdminButton
type="submit"
disabled={!refundAmount || Number(refundAmount) <= 0 || refunding}
isLoading={refunding}
variant="danger"
fullWidth
>
{refunding ? "Processing..." : `Record Refund (Max ${formatCurrency(remainingBalance)})`}
</AdminButton>
</form>
</div>
<RecordRefundForm
refundAmount={state.refundAmount}
refundReason={state.refundReason}
refundError={state.refundError}
refundSaved={state.refundSaved}
refunding={state.refunding}
remainingBalance={remainingBalance}
onSubmit={handleRefund}
onChangeRefundAmount={(v) => {
dispatch({ type: "SET_REFUND_AMOUNT", value: v });
dispatch({ type: "SET_REFUND_ERROR", value: null });
}}
onChangeRefundReason={(v) => dispatch({ type: "SET_REFUND_REASON", value: v })}
/>
)}
</div>
);
File diff suppressed because it is too large Load Diff
+338 -210
View File
@@ -1,8 +1,8 @@
"use client";
import { useReducer, useRef } from "react";
import NextImage from "next/image";
import Link from "next/link";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image";
import { updateProduct } from "@/actions/products/update-product";
@@ -57,102 +57,104 @@ type ProductEditFormProps = {
brands: Brand[];
};
type Draft = {
name?: string;
description?: string;
price?: number;
type?: string;
active?: boolean;
brand_id?: string;
image_url?: string;
is_taxable?: boolean;
pickup_type?: string;
};
type State = {
draft: Draft;
saving: boolean;
error: string | null;
saved: boolean;
dragOver: boolean;
uploading: boolean;
uploadError: string | null;
imagePreview: string | null;
};
type Action =
| { type: "SET_DRAFT_FIELD"; field: "name"; value: string }
| { type: "SET_DRAFT_FIELD"; field: "description"; value: string }
| { type: "SET_DRAFT_FIELD"; field: "type"; value: string }
| { type: "SET_DRAFT_FIELD"; field: "brand_id"; value: string }
| { type: "SET_DRAFT_FIELD"; field: "image_url"; value: string }
| { type: "SET_DRAFT_FIELD"; field: "pickup_type"; value: string }
| { type: "SET_DRAFT_FIELD"; field: "price"; value: number }
| { type: "SET_DRAFT_FIELD"; field: "active"; value: boolean }
| { type: "SET_DRAFT_FIELD"; field: "is_taxable"; value: boolean }
| { type: "SET_SAVING"; value: boolean }
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_SAVED"; value: boolean }
| { type: "SET_DRAG_OVER"; value: boolean }
| { type: "SET_UPLOADING"; value: boolean }
| { type: "SET_UPLOAD_ERROR"; value: string | null }
| { type: "SET_IMAGE_PREVIEW"; value: string | null };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_DRAFT_FIELD":
return { ...state, draft: { ...state.draft, [action.field]: action.value } };
case "SET_SAVING":
return { ...state, saving: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_SAVED":
return { ...state, saved: action.value };
case "SET_DRAG_OVER":
return { ...state, dragOver: action.value };
case "SET_UPLOADING":
return { ...state, uploading: action.value };
case "SET_UPLOAD_ERROR":
return { ...state, uploadError: action.value };
case "SET_IMAGE_PREVIEW":
return { ...state, imagePreview: action.value };
default:
return state;
}
}
export default function ProductEditForm({ product, brands }: ProductEditFormProps) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [state, dispatch] = useReducer(reducer, undefined, () => ({
draft: {},
saving: false,
error: null,
saved: false,
dragOver: false,
uploading: false,
uploadError: null,
imagePreview: product.image_url ?? null,
}));
// Holds user edits only; missing keys fall back to the current prop so we
// never copy the prop into useState. When the prop changes, the fallback
// value tracks the new prop automatically.
const [draft, setDraft] = useState<{
name?: string;
description?: string;
price?: number;
type?: string;
active?: boolean;
brand_id?: string;
image_url?: string;
is_taxable?: boolean;
pickup_type?: string;
}>({});
const name = draft.name ?? product.name;
const description = draft.description ?? product.description;
const price = draft.price ?? product.price;
const type = draft.type ?? product.type;
const active = draft.active ?? product.active;
const brand_id = draft.brand_id ?? product.brand_id;
const image_url = draft.image_url ?? product.image_url ?? "";
const is_taxable = draft.is_taxable ?? product.is_taxable ?? true;
const pickup_type = draft.pickup_type ?? product.pickup_type ?? "scheduled_stop";
const setName = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
name: typeof v === "function" ? v(d.name ?? product.name) : v,
}));
const setDescription = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
description: typeof v === "function" ? v(d.description ?? product.description) : v,
}));
const setPrice = (v: number | ((prev: number) => number)) =>
setDraft((d) => ({
...d,
price: typeof v === "function" ? v(d.price ?? product.price) : v,
}));
const setType = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
type: typeof v === "function" ? v(d.type ?? product.type) : v,
}));
const setActive = (v: boolean | ((prev: boolean) => boolean)) =>
setDraft((d) => ({
...d,
active: typeof v === "function" ? v(d.active ?? product.active) : v,
}));
const setBrand_id = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
brand_id: typeof v === "function" ? v(d.brand_id ?? product.brand_id) : v,
}));
const setImage_url = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
image_url:
typeof v === "function" ? v(d.image_url ?? product.image_url ?? "") : v,
}));
const setIs_taxable = (v: boolean | ((prev: boolean) => boolean)) =>
setDraft((d) => ({
...d,
is_taxable:
typeof v === "function" ? v(d.is_taxable ?? product.is_taxable ?? true) : v,
}));
const setPickup_type = (v: string | ((prev: string) => string)) =>
setDraft((d) => ({
...d,
pickup_type:
typeof v === "function"
? v(d.pickup_type ?? product.pickup_type ?? "scheduled_stop")
: v,
}));
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(product.image_url ?? null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Derived effective values from draft overrides + product prop
const name = state.draft.name ?? product.name;
const description = state.draft.description ?? product.description;
const price = state.draft.price ?? product.price;
const type = state.draft.type ?? product.type;
const active = state.draft.active ?? product.active;
const brand_id = state.draft.brand_id ?? product.brand_id;
const image_url = state.draft.image_url ?? product.image_url ?? "";
const is_taxable = state.draft.is_taxable ?? product.is_taxable ?? true;
const pickup_type = state.draft.pickup_type ?? product.pickup_type ?? "scheduled_stop";
async function handleFileSelect(file: File) {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
setUploadError("Only PNG, JPEG, and WebP images are allowed.");
dispatch({ type: "SET_UPLOAD_ERROR", value: "Only PNG, JPEG, and WebP images are allowed." });
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("Image must be under 5MB.");
dispatch({ type: "SET_UPLOAD_ERROR", value: "Image must be under 5MB." });
return;
}
@@ -160,35 +162,35 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
setUploadError(null);
setUploading(true);
dispatch({ type: "SET_UPLOAD_ERROR", value: null });
dispatch({ type: "SET_UPLOADING", value: true });
const result = await uploadProductImage(product.id, resizedFile);
setUploading(false);
dispatch({ type: "SET_UPLOADING", value: false });
if (result.success) {
setImage_url(result.imageUrl);
setImagePreview(result.imageUrl);
dispatch({ type: "SET_DRAFT_FIELD", field: "image_url", value: result.imageUrl });
dispatch({ type: "SET_IMAGE_PREVIEW", value: result.imageUrl });
} else {
setUploadError(result.error ?? "Upload failed.");
dispatch({ type: "SET_UPLOAD_ERROR", value: result.error ?? "Upload failed." });
}
}
async function handleRemoveImage() {
const result = await deleteProductImage(product.id);
if (result.success) {
setImage_url("");
setImagePreview(null);
dispatch({ type: "SET_DRAFT_FIELD", field: "image_url", value: "" });
dispatch({ type: "SET_IMAGE_PREVIEW", value: null });
}
}
async function handleSave() {
if (!name.trim()) {
setError("Product name is required.");
dispatch({ type: "SET_ERROR", value: "Product name is required." });
return;
}
setSaving(true);
setError(null);
setSaved(false);
dispatch({ type: "SET_SAVING", value: true });
dispatch({ type: "SET_ERROR", value: null });
dispatch({ type: "SET_SAVED", value: false });
const result = await updateProduct(product.id, brand_id, {
name,
@@ -202,34 +204,89 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
});
if (!result.success) {
setError(result.error ?? "Failed to save");
setSaving(false);
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to save" });
dispatch({ type: "SET_SAVING", value: false });
return;
}
setSaved(true);
setSaving(false);
dispatch({ type: "SET_SAVED", value: true });
dispatch({ type: "SET_SAVING", value: false });
router.refresh();
}
return (
<div className="space-y-6">
{error && (
{state.error && (
<div className="rounded-xl bg-[var(--admin-danger-soft)] border border-[var(--admin-danger)] p-4 text-sm text-[var(--admin-danger)]">
{error}
{state.error}
</div>
)}
{saved && (
{state.saved && (
<div className="rounded-xl bg-[var(--admin-primary-soft)] border border-[var(--admin-primary)] p-4 text-sm text-[var(--admin-primary)]">
Product updated successfully.
</div>
)}
<BasicInfoSection
name={name}
description={description}
dispatch={dispatch}
/>
<PricingSection
price={price}
type={type}
dispatch={dispatch}
/>
<BrandSection
brand_id={brand_id}
brands={brands}
dispatch={dispatch}
/>
<StatusTogglesSection
active={active}
is_taxable={is_taxable}
dispatch={dispatch}
/>
<PickupTypeSection
pickup_type={pickup_type}
dispatch={dispatch}
/>
<ProductImageSection
uploading={state.uploading}
uploadError={state.uploadError}
imagePreview={state.imagePreview}
dragOver={state.dragOver}
fileInputRef={fileInputRef}
onFileSelect={handleFileSelect}
onRemoveImage={handleRemoveImage}
/>
<ProductFormActions saving={state.saving} onSave={handleSave} />
</div>
);
}
function BasicInfoSection({
name,
description,
dispatch,
}: {
name: string;
description: string;
dispatch: React.Dispatch<Action>;
}) {
return (
<>
<AdminInput label="Name">
<AdminTextInput
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "name", value: e.target.value })}
placeholder="Product name"
/>
</AdminInput>
@@ -237,46 +294,84 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
<AdminInput label="Description">
<AdminTextarea
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "description", value: e.target.value })}
rows={3}
placeholder="Product description"
/>
</AdminInput>
</>
);
}
<div className="grid grid-cols-2 gap-4">
<AdminInput label="Price">
<AdminTextInput
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
placeholder="0.00"
/>
</AdminInput>
function PricingSection({
price,
type,
dispatch,
}: {
price: number;
type: string;
dispatch: React.Dispatch<Action>;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<AdminInput label="Price">
<AdminTextInput
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "price", value: Number(e.target.value) })}
placeholder="0.00"
/>
</AdminInput>
<AdminInput label="Type">
<AdminTextInput
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="e.g. Sweet Corn"
/>
</AdminInput>
</div>
<AdminInput label="Type">
<AdminTextInput
value={type}
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "type", value: e.target.value })}
placeholder="e.g. Sweet Corn"
/>
</AdminInput>
</div>
);
}
<AdminInput label="Brand">
<AdminSelect
value={brand_id}
onChange={(e) => setBrand_id(e.target.value)}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
function BrandSection({
brand_id,
brands,
dispatch,
}: {
brand_id: string;
brands: Brand[];
dispatch: React.Dispatch<Action>;
}) {
return (
<AdminInput label="Brand">
<AdminSelect
value={brand_id}
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "brand_id", value: e.target.value })}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
);
}
function StatusTogglesSection({
active,
is_taxable,
dispatch,
}: {
active: boolean;
is_taxable: boolean;
dispatch: React.Dispatch<Action>;
}) {
return (
<>
<div>
<label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">
<span className="block mb-2">Status</span>
<button type="button"
onClick={() => setActive((v) => !v)}
onClick={() => dispatch({ type: "SET_DRAFT_FIELD", field: "active", value: !active })}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active
? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
@@ -292,7 +387,7 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
<label className="mb-2 block text-sm font-medium text-[var(--admin-text-primary)]">
<span className="block mb-2">Taxable</span>
<button type="button"
onClick={() => setIs_taxable((v) => !v)}
onClick={() => dispatch({ type: "SET_DRAFT_FIELD", field: "is_taxable", value: !is_taxable })}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
is_taxable
? "bg-[var(--admin-primary-soft)] text-[var(--admin-primary)] ring-1 ring-[var(--admin-primary)]"
@@ -305,99 +400,132 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</label>
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">Tax is calculated at checkout for shipping orders in your brand&apos;s nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
</div>
</>
);
}
<AdminInput
function PickupTypeSection({
pickup_type,
dispatch,
}: {
pickup_type: string;
dispatch: React.Dispatch<Action>;
}) {
return (
<AdminInput
label="Pickup Type"
helpText="Shed pickup uses the product description as the pickup location — no stop required at checkout."
>
<AdminSelect
value={pickup_type}
onChange={(e) => setPickup_type(e.target.value)}
onChange={(e) => dispatch({ type: "SET_DRAFT_FIELD", field: "pickup_type", value: e.target.value })}
options={[
{ value: "scheduled_stop", label: "Scheduled Stop — requires stop selection at checkout" },
{ value: "shed", label: "Shed Pickup — uses description as location" },
]}
/>
</AdminInput>
);
}
<div>
<span className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</span>
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
function ProductImageSection({
uploading,
uploadError,
imagePreview,
dragOver,
fileInputRef,
onFileSelect,
onRemoveImage,
}: {
uploading: boolean;
uploadError: string | null;
imagePreview: string | null;
dragOver: boolean;
fileInputRef: React.RefObject<HTMLInputElement | null>;
onFileSelect: (file: File) => void;
onRemoveImage: () => void;
}) {
return (
<div>
<span className="mb-1 block text-sm font-medium text-[var(--admin-text-primary)]">Product Image</span>
<p className="text-xs text-[var(--admin-text-muted)] mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<label
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${dragOver ? "border-[var(--admin-primary)] bg-[var(--admin-primary-soft)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
</>
) : imagePreview ? (
<>
<span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span>
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
</>
)}
<input aria-label="File upload"
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
</label>
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
{imagePreview && (
<button
type="button"
onClick={handleRemoveImage}
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
>
Remove image
</button>
<label
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) onFileSelect(file);
}}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${dragOver ? "border-[var(--admin-primary)] bg-[var(--admin-primary-soft)]" : "border-[var(--admin-border)] hover:border-[var(--admin-primary)] hover:bg-[var(--admin-bg)]"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-text-muted)] border-t-transparent animate-spin" />
<span className="text-sm text-[var(--admin-text-muted)]">Uploading...</span>
</>
) : imagePreview ? (
<>
<span className="relative inline-block h-32 w-auto">
<NextImage src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</span>
<span className="text-xs text-[var(--admin-text-muted)]">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-sm text-[var(--admin-text-muted)]">Drag & drop or click to upload</span>
</>
)}
</div>
<input aria-label="File upload"
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onFileSelect(file);
}}
/>
</label>
{/* Save button bar — new design tokens */}
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
<button type="button"
onClick={handleSave}
disabled={saving}
className="ha-btn-primary"
{uploadError && <p className="mt-1 text-xs text-[var(--admin-danger)]">{uploadError}</p>}
{imagePreview && (
<button
type="button"
onClick={onRemoveImage}
className="mt-2 text-xs text-[var(--admin-danger)] hover:underline"
>
{saving ? "Saving..." : "Save Changes"}
Remove image
</button>
<Link
href="/admin/products"
className="ha-btn-ghost"
>
Cancel
</Link>
</div>
)}
</div>
);
}
function ProductFormActions({ saving, onSave }: { saving: boolean; onSave: () => void }) {
return (
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border-light)]">
<button type="button"
onClick={onSave}
disabled={saving}
className="ha-btn-primary"
>
{saving ? "Saving..." : "Save Changes"}
</button>
<Link
href="/admin/products"
className="ha-btn-ghost"
>
Cancel
</Link>
</div>
);
}
File diff suppressed because it is too large Load Diff
+255 -142
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useTransition, useEffect, useRef, useEffectEvent } from "react";
import { useReducer, useState, useCallback, useTransition, useEffect, useRef, useEffectEvent } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { useRouter } from "next/navigation";
@@ -66,6 +66,62 @@ async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
const EMPTY_BRANDS: { id: string; name: string }[] = [];
type State = {
search: string;
statusFilter: "all" | "active" | "inactive";
viewMode: ViewMode;
showModal: boolean;
editingProduct: Product | null;
deleteConfirm: string | null;
deletingId: string | null;
isLoading: boolean;
};
type Action =
| { type: "SET_SEARCH"; value: string }
| { type: "SET_STATUS_FILTER"; value: "all" | "active" | "inactive" }
| { type: "SET_VIEW_MODE"; value: ViewMode }
| { type: "OPEN_ADD" }
| { type: "OPEN_EDIT"; product: Product }
| { type: "CLOSE_MODAL" }
| { type: "SET_DELETE_CONFIRM"; id: string | null }
| { type: "SET_DELETING"; id: string | null }
| { type: "SET_LOADING"; value: boolean };
const initialState: State = {
search: "",
statusFilter: "all",
viewMode: "table",
showModal: false,
editingProduct: null,
deleteConfirm: null,
deletingId: null,
isLoading: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_SEARCH":
return { ...state, search: action.value };
case "SET_STATUS_FILTER":
return { ...state, statusFilter: action.value };
case "SET_VIEW_MODE":
return { ...state, viewMode: action.value };
case "OPEN_ADD":
return { ...state, showModal: true, editingProduct: null };
case "OPEN_EDIT":
return { ...state, showModal: true, editingProduct: action.product };
case "CLOSE_MODAL":
return { ...state, showModal: false, editingProduct: null };
case "SET_DELETE_CONFIRM":
return { ...state, deleteConfirm: action.id };
case "SET_DELETING":
return { ...state, deletingId: action.id };
case "SET_LOADING":
return { ...state, isLoading: action.value };
}
}
export default function ProductsClient({
products,
brandId,
@@ -80,24 +136,17 @@ export default function ProductsClient({
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [showModal, setShowModal] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
!state.search ||
p.name.toLowerCase().includes(state.search.toLowerCase()) ||
p.description.toLowerCase().includes(state.search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
state.statusFilter === "all" ||
(state.statusFilter === "active" && p.active) ||
(state.statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
@@ -117,34 +166,31 @@ export default function ProductsClient({
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
const productIdForUpload = state.editingProduct ? state.editingProduct.id : "__NEW__";
const result = await uploadProductImage(productIdForUpload, resizedFile);
if (result.success) {
return { success: true, imageUrl: result.imageUrl };
}
return { success: false, error: result.error };
},
[editingProduct]
[state.editingProduct]
);
const openAddModal = useCallback(() => {
setEditingProduct(null);
setShowModal(true);
dispatch({ type: "OPEN_ADD" });
}, []);
const openEditModal = useCallback((product: Product) => {
setEditingProduct(product);
setShowModal(true);
dispatch({ type: "OPEN_EDIT", product });
}, []);
const closeModal = useCallback(() => {
setShowModal(false);
setEditingProduct(null);
dispatch({ type: "CLOSE_MODAL" });
}, []);
const handleModalSubmit = useCallback(
async (values: ProductFormValues): Promise<{ success: boolean; error?: string }> => {
setIsLoading(true);
dispatch({ type: "SET_LOADING", value: true });
try {
const price = parseFloat(values.price);
if (isNaN(price) || price < 0) {
@@ -161,8 +207,8 @@ export default function ProductsClient({
}
let result;
if (editingProduct) {
result = await updateProduct(editingProduct.id, effectiveBrandId, {
if (state.editingProduct) {
result = await updateProduct(state.editingProduct.id, effectiveBrandId, {
name: values.name.trim(),
description: values.description.trim(),
price,
@@ -189,7 +235,7 @@ export default function ProductsClient({
}
showSuccess(
editingProduct ? "Product updated" : "Product created",
state.editingProduct ? "Product updated" : "Product created",
`${values.name} has been saved`
);
startTransition(() => router.refresh());
@@ -197,18 +243,18 @@ export default function ProductsClient({
} catch {
return { success: false, error: "Network error. Please try again." };
} finally {
setIsLoading(false);
dispatch({ type: "SET_LOADING", value: false });
}
},
[editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
[state.editingProduct, brandId, isPlatformAdmin, router, showError, showSuccess]
);
const handleDelete = async (productId: string) => {
setDeletingId(productId);
dispatch({ type: "SET_DELETING", id: productId });
const result = await deleteProduct(productId, null);
setDeletingId(null);
dispatch({ type: "SET_DELETING", id: null });
if (result.success) {
setDeleteConfirm(null);
dispatch({ type: "SET_DELETE_CONFIRM", id: null });
showSuccess("Product deleted", "The product has been removed");
startTransition(() => router.refresh());
} else {
@@ -225,109 +271,53 @@ export default function ProductsClient({
return (
<div className="p-4 sm:p-6">
{/* Page Header — eyebrow + title + subtitle + primary CTA */}
<div className="ha-eyebrow mb-2">Operations</div>
<PageHeader
icon={<PackageIconLucide className="h-5 w-5" strokeWidth={1.75} />}
title="Products"
subtitle="Manage your product catalog, pricing, and availability."
actions={
<AdminButton
onClick={openAddModal}
icon={Icons.plus("h-4 w-4")}
>
Add product
</AdminButton>
}
<ProductsHeader onAdd={openAddModal} />
<ProductsStats
total={products.length}
active={activeCount}
inactive={inactiveCount}
isLoading={state.isLoading}
/>
<ProductsFilterBar
search={state.search}
statusFilter={state.statusFilter}
viewMode={state.viewMode}
filterTabs={filterTabs}
onSearchChange={(v) => dispatch({ type: "SET_SEARCH", value: v })}
onStatusFilterChange={(v) => dispatch({ type: "SET_STATUS_FILTER", value: v })}
onViewModeChange={(v) => dispatch({ type: "SET_VIEW_MODE", value: v })}
/>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Total</p>
<p
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : products.length}
</p>
</div>
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Active</p>
<p
className="text-xl sm:text-2xl font-bold text-[var(--admin-primary)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : activeCount}
</p>
</div>
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Inactive</p>
<p
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-muted)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactiveCount}
</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
{/* Status Tabs */}
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => setStatusFilter(value as "all" | "active" | "inactive")}
tabs={filterTabs}
size="md"
/>
{/* Search */}
<AdminSearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch("")}
placeholder="Search products..."
containerClassName="flex-1"
/>
{/* View Toggle */}
<AdminViewModeTabs
activeTab={viewMode}
onTabChange={(value) => setViewMode(value as ViewMode)}
/>
</div>
{/* Content */}
{viewMode === "table" ? (
{state.viewMode === "table" ? (
<TableView
products={filtered}
onEdit={openEditModal}
onDelete={(id) => setDeleteConfirm(id)}
deleteConfirm={deleteConfirm}
onDelete={(id) => dispatch({ type: "SET_DELETE_CONFIRM", id })}
deleteConfirm={state.deleteConfirm}
onDeleteConfirm={(id) => handleDelete(id)}
onDeleteCancel={() => setDeleteConfirm(null)}
deletingId={deletingId}
onDeleteCancel={() => dispatch({ type: "SET_DELETE_CONFIRM", id: null })}
deletingId={state.deletingId}
onAdd={openAddModal}
/>
) : (
<CardView
products={filtered}
onEdit={openEditModal}
onDelete={(id) => setDeleteConfirm(id)}
deleteConfirm={deleteConfirm}
onDelete={(id) => dispatch({ type: "SET_DELETE_CONFIRM", id })}
deleteConfirm={state.deleteConfirm}
onDeleteConfirm={(id) => handleDelete(id)}
onDeleteCancel={() => setDeleteConfirm(null)}
deletingId={deletingId}
onDeleteCancel={() => dispatch({ type: "SET_DELETE_CONFIRM", id: null })}
deletingId={state.deletingId}
onAdd={openAddModal}
/>
)}
{/* Modal — Atelier des Récoltes (editorial product form) */}
<ProductFormModal
key={editingProduct?.id ?? "new"}
open={showModal}
mode={editingProduct ? "edit" : "add"}
key={state.editingProduct?.id ?? "new"}
open={state.showModal}
mode={state.editingProduct ? "edit" : "add"}
onClose={closeModal}
onSubmit={handleModalSubmit}
onUploadImage={handleUploadImage}
@@ -335,21 +325,130 @@ export default function ProductsClient({
lockBrand={!isPlatformAdmin}
lockedBrandId={brandId ?? ""}
initial={
editingProduct
state.editingProduct
? {
name: editingProduct.name,
description: editingProduct.description || "",
price: String(editingProduct.price),
type: (editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
brand_id: editingProduct.brand_id,
active: editingProduct.active,
is_taxable: editingProduct.is_taxable,
available_from: editingProduct.available_from ?? null,
available_until: editingProduct.available_until ?? null,
name: state.editingProduct.name,
description: state.editingProduct.description || "",
price: String(state.editingProduct.price),
type: (state.editingProduct.type as "pickup" | "wholesale" | "subscription") ?? "pickup",
brand_id: state.editingProduct.brand_id,
active: state.editingProduct.active,
is_taxable: state.editingProduct.is_taxable,
available_from: state.editingProduct.available_from ?? null,
available_until: state.editingProduct.available_until ?? null,
}
: undefined
}
initialImageUrl={editingProduct?.image_url ?? null}
initialImageUrl={state.editingProduct?.image_url ?? null}
/>
</div>
);
}
// ── Sub-views ─────────────────────────────────────────────────────────────────
function ProductsHeader({ onAdd }: { onAdd: () => void }) {
return (
<>
<div className="ha-eyebrow mb-2">Operations</div>
<PageHeader
icon={<PackageIconLucide className="h-5 w-5" strokeWidth={1.75} />}
title="Products"
subtitle="Manage your product catalog, pricing, and availability."
actions={
<AdminButton
onClick={onAdd}
icon={Icons.plus("h-4 w-4")}
>
Add product
</AdminButton>
}
/>
</>
);
}
function ProductsStats({
total,
active,
inactive,
isLoading,
}: {
total: number;
active: number;
inactive: number;
isLoading: boolean;
}) {
return (
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Total</p>
<p
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : total}
</p>
</div>
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Active</p>
<p
className="text-xl sm:text-2xl font-bold text-[var(--admin-primary)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : active}
</p>
</div>
<div className="bg-[var(--admin-card-bg)] rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium uppercase tracking-wider">Inactive</p>
<p
className="text-xl sm:text-2xl font-bold text-[var(--admin-text-muted)] mt-1"
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
>
{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactive}
</p>
</div>
</div>
);
}
function ProductsFilterBar({
search,
statusFilter,
viewMode,
filterTabs,
onSearchChange,
onStatusFilterChange,
onViewModeChange,
}: {
search: string;
statusFilter: "all" | "active" | "inactive";
viewMode: ViewMode;
filterTabs: { value: string; label: string; count: number }[];
onSearchChange: (value: string) => void;
onStatusFilterChange: (value: "all" | "active" | "inactive") => void;
onViewModeChange: (value: ViewMode) => void;
}) {
return (
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => onStatusFilterChange(value as "all" | "active" | "inactive")}
tabs={filterTabs}
size="md"
/>
<AdminSearchInput
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onClear={() => onSearchChange("")}
placeholder="Search products..."
containerClassName="flex-1"
/>
<AdminViewModeTabs
activeTab={viewMode}
onTabChange={(value) => onViewModeChange(value as ViewMode)}
/>
</div>
);
@@ -390,7 +489,7 @@ function TableView({
setMounted(true);
};
init();
}, []);
}, [setMounted]);
const onDeleteCancelEvent = useEffectEvent(() => {
onDeleteCancel();
@@ -411,22 +510,26 @@ function TableView({
};
}, [deleteConfirm]);
// Track the previous deleteConfirm value so we can adjust menuPos
// inline during render when the prop changes — avoids a stale frame
// between the prop change and the effect running.
const prevDeleteConfirmRef = useRef<string | null>(deleteConfirm);
if (deleteConfirm !== prevDeleteConfirmRef.current) {
prevDeleteConfirmRef.current = deleteConfirm;
if (!deleteConfirm) {
setMenuPos(null);
} else {
// Reposition the popup while deleteConfirm is active: subscribe to
// window scroll/resize so the menu stays anchored to its button. The
// initial position is captured at click time (see the row's onClick),
// and clearing deleteConfirm also clears menuPos (see onDeleteCancel).
useEffect(() => {
if (!deleteConfirm) return;
const updatePos = () => {
const btn = buttonRefs.current[deleteConfirm];
if (btn) {
const rect = btn.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
}
}
}
};
window.addEventListener("scroll", updatePos, true);
window.addEventListener("resize", updatePos);
return () => {
window.removeEventListener("scroll", updatePos, true);
window.removeEventListener("resize", updatePos);
};
}, [deleteConfirm]);
// Reposition on scroll/resize so the popup stays anchored to its button.
useEffect(() => {
@@ -537,7 +640,11 @@ function TableView({
</AdminButton>
<button type="button"
ref={(el) => { buttonRefs.current[product.id] = el; }}
onClick={() => onDelete(product.id)}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, left: rect.right, width: rect.width });
onDelete(product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-danger)] hover:bg-[var(--admin-danger-soft)] transition-colors"
aria-label="Product actions"
>
@@ -576,13 +683,19 @@ function TableView({
</p>
<div className="mt-3 flex gap-2">
<button type="button"
onClick={onDeleteCancel}
onClick={() => {
setMenuPos(null);
onDeleteCancel();
}}
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg)]"
aria-label="Cancel">
Cancel
</button>
<AdminButton
onClick={() => onDeleteConfirm(openProduct.id)}
onClick={() => {
setMenuPos(null);
onDeleteConfirm(openProduct.id);
}}
disabled={deletingId === openProduct.id}
variant="danger"
size="sm"
@@ -752,4 +865,4 @@ function CardView({
)}
</div>
);
}
}
File diff suppressed because it is too large Load Diff
+435 -291
View File
@@ -1,14 +1,79 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { useReducer, useRef, useCallback } from "react";
import { createStopsBatch } from "@/actions/stops";
import GlassModal from "@/components/admin/GlassModal";
import type { ParsedStopRow } from "@/lib/csv-parsers";
type ParsedStop = Omit<ParsedStopRow, "_rowIndex" | "_warnings">;
type Step = "idle" | "parsing" | "review" | "importing" | "done" | "error";
type State = {
step: Step;
error: string | null;
parsedStops: ParsedStop[];
warnings: string[];
dragOver: boolean;
useAI: boolean;
created: number;
};
type Action =
| { type: "START_PARSE" }
| { type: "PARSE_SUCCESS"; stops: ParsedStop[]; warnings: string[] }
| { type: "PARSE_FAIL"; error: string }
| { type: "SET_ERROR"; error: string | null }
| { type: "SET_DRAG_OVER"; value: boolean }
| { type: "SET_USE_AI"; value: boolean }
| { type: "UPDATE_STOP"; idx: number; field: keyof ParsedStop; value: string }
| { type: "REMOVE_STOP"; idx: number }
| { type: "START_IMPORT" }
| { type: "IMPORT_FAIL"; error: string }
| { type: "IMPORT_SUCCESS"; created: number }
| { type: "RESET" };
const initialState: State = {
step: "idle",
error: null,
parsedStops: [],
warnings: [],
dragOver: false,
useAI: false,
created: 0,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "START_PARSE":
return { ...state, step: "parsing", error: null };
case "PARSE_SUCCESS":
return { ...state, step: "review", parsedStops: action.stops, warnings: action.warnings };
case "PARSE_FAIL":
return { ...state, step: "idle", error: action.error };
case "SET_ERROR":
return { ...state, error: action.error };
case "SET_DRAG_OVER":
return { ...state, dragOver: action.value };
case "SET_USE_AI":
return { ...state, useAI: action.value };
case "UPDATE_STOP": {
const next = [...state.parsedStops];
next[action.idx] = { ...next[action.idx], [action.field]: action.value };
return { ...state, parsedStops: next };
}
case "REMOVE_STOP":
return { ...state, parsedStops: state.parsedStops.filter((_, i) => i !== action.idx) };
case "START_IMPORT":
return { ...state, step: "importing", error: null };
case "IMPORT_FAIL":
return { ...state, step: "review", error: action.error };
case "IMPORT_SUCCESS":
return { ...state, step: "done", created: action.created };
case "RESET":
return { ...state, step: "idle", parsedStops: [], warnings: [] };
}
}
type Props = {
brandId: string;
onClose: () => void;
@@ -16,25 +81,17 @@ type Props = {
};
export default function ScheduleImportModal({ brandId, onClose, onComplete }: Props) {
const [step, setStep] = useState<Step>("idle");
const [error, setError] = useState<string | null>(null);
const [parsedStops, setParsedStops] = useState<ParsedStop[]>([]);
const [warnings, setWarnings] = useState<string[]>([]);
const [dragOver, setDragOver] = useState(false);
const [useAI, setUseAI] = useState(false);
const [created, setCreated] = useState(0);
const [state, dispatch] = useReducer(reducer, initialState);
const fileInputRef = useRef<HTMLInputElement>(null);
const processFile = useCallback(async (file: File) => {
setError(null);
setStep("parsing");
dispatch({ type: "START_PARSE" });
let text: string;
try {
text = await file.text();
} catch {
setError("Could not read file. Try a different format.");
setStep("idle");
dispatch({ type: "PARSE_FAIL", error: "Could not read file. Try a different format." });
return;
}
@@ -42,36 +99,34 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
const res = await fetch("/api/stops/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, brandId, useAI }),
body: JSON.stringify({ text, brandId, useAI: state.useAI }),
});
const data = await res.json();
if (!res.ok || (data.error && data.stops?.length === 0)) {
setError(data.error ?? "Parsing failed. Try a CSV file.");
setStep("idle");
dispatch({ type: "PARSE_FAIL", error: data.error ?? "Parsing failed. Try a CSV file." });
return;
}
if (!data.stops || data.stops.length === 0) {
setError("No stops found in file. Check that columns include: city, state, location, date, time.");
setStep("idle");
dispatch({
type: "PARSE_FAIL",
error: "No stops found in file. Check that columns include: city, state, location, date, time.",
});
return;
}
setParsedStops(data.stops);
setWarnings(data.warnings ?? []);
setStep("review");
dispatch({ type: "PARSE_SUCCESS", stops: data.stops, warnings: data.warnings ?? [] });
} catch {
setError("Network error while parsing file.");
setStep("idle");
dispatch({ type: "PARSE_FAIL", error: "Network error while parsing file." });
}
}, [brandId, useAI]);
}, [brandId, state.useAI]);
function handleFile(file: File) {
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
if (!["csv", "txt", "json"].includes(ext)) {
setError("Unsupported file type. Please upload a CSV, TXT, or JSON file.");
dispatch({ type: "SET_ERROR", error: "Unsupported file type. Please upload a CSV, TXT, or JSON file." });
return;
}
processFile(file);
@@ -79,7 +134,7 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
dispatch({ type: "SET_DRAG_OVER", value: false });
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}
@@ -89,43 +144,26 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
if (file) handleFile(file);
}
function updateStop(idx: number, field: keyof ParsedStop, value: string) {
setParsedStops((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
return next;
});
}
function removeStop(idx: number) {
setParsedStops((prev) => prev.filter((_, i) => i !== idx));
}
async function handleImport() {
if (parsedStops.length === 0) return;
setStep("importing");
setError(null);
if (state.parsedStops.length === 0) return;
dispatch({ type: "START_IMPORT" });
const result = await createStopsBatch(
brandId,
parsedStops.map(({ city, state, location, date, time, address, zip, notes }) => ({
city, state, location, date, time, address, zip, notes,
state.parsedStops.map(({ city, state: st, location, date, time, address, zip, notes }) => ({
city, state: st, location, date, time, address, zip, notes,
}))
);
if (!result.success) {
setError(result.error ?? "Import failed");
setStep("review");
dispatch({ type: "IMPORT_FAIL", error: result.error ?? "Import failed" });
return;
}
setCreated(result.created);
setStep("done");
dispatch({ type: "IMPORT_SUCCESS", created: result.created });
onComplete(result.created);
}
const hasWarnings = warnings.length > 0;
return (
<GlassModal
title="📥 Import Schedule"
@@ -133,253 +171,359 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
onClose={onClose}
>
<div className="space-y-5">
{step === "idle" && (
<>
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
{/* AI toggle */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-stone-200 bg-stone-50">
<button type="button"
onClick={() => setUseAI((v) => !v)}
className={`flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
useAI
? "bg-violet-100 border border-violet-300 text-violet-700"
: "bg-white border border-stone-200 text-stone-600 hover:border-stone-300"
}`}
aria-label="Use AI for text/PDF parsing">
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${useAI ? "bg-violet-500 text-white" : "bg-stone-200 text-stone-500"}`}>
{useAI ? "✓" : "○"}
</span>
Use AI for text/PDF parsing
</button>
<span className="text-xs text-stone-500">
{useAI ? "AI will parse unstructured text. Requires OPENAI_API_KEY." : "Best results with CSV."}
</span>
</div>
{/* Drop zone */}
<label
htmlFor="schedule-file-upload"
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all ${
dragOver
? "border-emerald-500 bg-emerald-50"
: "border-stone-300 hover:border-emerald-400 hover:bg-stone-50"
}`}
>
<div className="flex justify-center mb-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${dragOver ? "bg-emerald-100" : "bg-stone-100"}`}>
<svg className={`h-6 w-6 ${dragOver ? "text-emerald-600" : "text-stone-400"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
</div>
</div>
<p className="text-sm font-semibold text-stone-700">Drop your schedule file here</p>
<p className="mt-1 text-xs text-stone-500">or click to browse CSV, TXT, JSON</p>
<p className="mt-3 text-[10px] text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-1.5 inline-block">
CSV: city, state, location, date, time, address, zip, notes
</p>
</label>
<input id="schedule-file-upload"
aria-label="File upload"
ref={fileInputRef}
type="file"
accept=".csv,.txt,.json"
className="sr-only"
onChange={handleChange}
/>
</>
{state.step === "idle" && (
<IdleStep
error={state.error}
useAI={state.useAI}
dragOver={state.dragOver}
fileInputRef={fileInputRef}
onToggleAI={() => dispatch({ type: "SET_USE_AI", value: !state.useAI })}
onDragOver={(e) => {
e.preventDefault();
dispatch({ type: "SET_DRAG_OVER", value: true });
}}
onDragLeave={() => dispatch({ type: "SET_DRAG_OVER", value: false })}
onDrop={handleDrop}
onChange={handleChange}
/>
)}
{step === "parsing" && (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm font-medium text-stone-600">Parsing file</span>
</div>
</div>
{state.step === "parsing" && <ParsingStep />}
{state.step === "review" && (
<ReviewStep
stops={state.parsedStops}
warnings={state.warnings}
error={state.error}
onUpdateStop={(idx, field, value) =>
dispatch({ type: "UPDATE_STOP", idx, field, value })
}
onRemoveStop={(idx) => dispatch({ type: "REMOVE_STOP", idx })}
onCancel={() => dispatch({ type: "RESET" })}
onImport={handleImport}
/>
)}
{step === "review" && (
<div className="space-y-4">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
{hasWarnings && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
<p className="font-semibold text-amber-800">Parsing warnings:</p>
<ul className="mt-1 list-disc list-inside space-y-0.5 text-amber-700">
{warnings.slice(0, 5).map((w, i) => (
<li key={`${w}-${i}`}>{w}</li>
))}
{warnings.length > 5 && <li>and {warnings.length - 5} more</li>}
</ul>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-stone-700">
{parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found review and edit
</p>
<span className="text-xs text-stone-400">All will be created as drafts</span>
</div>
{/* Review table */}
<div className="max-h-64 overflow-y-auto rounded-xl border border-stone-200 bg-white">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-stone-50 border-b border-stone-100">
<tr>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">City</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">State</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Location</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Date</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Time</th>
<th className="w-8 px-3 py-2.5" aria-label="Actions" />
</tr>
</thead>
<tbody className="divide-y divide-stone-50">
{parsedStops.map((stop, idx) => (
<tr key={`${stop.city}-${stop.state}-${stop.date}-${stop.time}-${idx}`} className="hover:bg-stone-50/50 transition-colors">
<td className="px-2 py-1.5">
<input aria-label="Input"
value={stop.city}
onChange={(e) => updateStop(idx, "city", e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input aria-label="Input"
value={stop.state}
onChange={(e) => updateStop(idx, "state", e.target.value)}
className="w-12 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input aria-label="Input"
value={stop.location}
onChange={(e) => updateStop(idx, "location", e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input aria-label="Input"
value={stop.date}
onChange={(e) => updateStop(idx, "date", e.target.value)}
className="w-24 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input aria-label="Input"
value={stop.time}
onChange={(e) => updateStop(idx, "time", e.target.value)}
className="w-16 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<button type="button"
onClick={() => removeStop(idx)}
className="flex h-6 w-6 items-center justify-center rounded-lg text-red-500 hover:bg-red-50 hover:text-red-700 transition-colors"
aria-label="Close">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between pt-2 border-t border-stone-100">
<p className="text-xs text-stone-500">
{parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created
</p>
<div className="flex gap-2">
<button type="button"
onClick={() => { setStep("idle"); setParsedStops([]); setWarnings([]); }}
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
aria-label="Cancel">
Cancel
</button>
<button type="button"
onClick={handleImport}
disabled={parsedStops.length === 0}
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2 text-sm font-bold text-white disabled:opacity-50 transition-colors"
aria-label="Create Stop">
Create {parsedStops.length} Stop{parsedStops.length !== 1 ? "s" : ""}
</button>
</div>
</div>
</div>
{state.step === "importing" && <ImportingStep />}
{state.step === "done" && (
<DoneStep created={state.created} onClose={onClose} />
)}
{step === "importing" && (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm font-medium text-stone-600">Creating draft stops</span>
</div>
</div>
)}
{step === "done" && (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-green-50">
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p className="text-xl font-bold text-stone-950">
{created} draft stop{created !== 1 ? "s" : ""} created
</p>
<p className="mt-1 text-sm text-stone-500">
Review them in the stops list and publish when ready.
</p>
</div>
<button type="button"
onClick={onClose}
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2.5 text-sm font-bold text-white transition-colors"
aria-label="Back to Stops">
Back to Stops
</button>
</div>
)}
{step === "error" && (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-50">
<svg className="h-7 w-7 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-sm font-medium text-red-700">{error}</p>
<button type="button"
onClick={() => setStep("idle")}
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
aria-label="Try Again">
Try Again
</button>
</div>
{state.step === "error" && (
<ErrorStep
error={state.error ?? ""}
onRetry={() => dispatch({ type: "START_PARSE" })}
/>
)}
</div>
</GlassModal>
);
}
type IdleStepProps = {
error: string | null;
useAI: boolean;
dragOver: boolean;
fileInputRef: React.RefObject<HTMLInputElement | null>;
onToggleAI: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent) => void;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
function IdleStep({
error,
useAI,
dragOver,
fileInputRef,
onToggleAI,
onDragOver,
onDragLeave,
onDrop,
onChange,
}: IdleStepProps) {
return (
<>
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
{/* AI toggle */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-stone-200 bg-stone-50">
<button type="button"
onClick={onToggleAI}
className={`flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
useAI
? "bg-violet-100 border border-violet-300 text-violet-700"
: "bg-white border border-stone-200 text-stone-600 hover:border-stone-300"
}`}
aria-label="Use AI for text/PDF parsing">
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${useAI ? "bg-violet-500 text-white" : "bg-stone-200 text-stone-500"}`}>
{useAI ? "✓" : "○"}
</span>
Use AI for text/PDF parsing
</button>
<span className="text-xs text-stone-500">
{useAI ? "AI will parse unstructured text. Requires OPENAI_API_KEY." : "Best results with CSV."}
</span>
</div>
{/* Drop zone */}
<label
htmlFor="schedule-file-upload"
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all ${
dragOver
? "border-emerald-500 bg-emerald-50"
: "border-stone-300 hover:border-emerald-400 hover:bg-stone-50"
}`}
>
<div className="flex justify-center mb-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${dragOver ? "bg-emerald-100" : "bg-stone-100"}`}>
<svg className={`h-6 w-6 ${dragOver ? "text-emerald-600" : "text-stone-400"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
</div>
</div>
<p className="text-sm font-semibold text-stone-700">Drop your schedule file here</p>
<p className="mt-1 text-xs text-stone-500">or click to browse CSV, TXT, JSON</p>
<p className="mt-3 text-[10px] text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-1.5 inline-block">
CSV: city, state, location, date, time, address, zip, notes
</p>
</label>
<input id="schedule-file-upload"
aria-label="File upload"
ref={fileInputRef}
type="file"
accept=".csv,.txt,.json"
className="sr-only"
onChange={onChange}
/>
</>
);
}
function ParsingStep() {
return (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm font-medium text-stone-600">Parsing file</span>
</div>
</div>
);
}
type ReviewStepProps = {
stops: ParsedStop[];
warnings: string[];
error: string | null;
onUpdateStop: (idx: number, field: keyof ParsedStop, value: string) => void;
onRemoveStop: (idx: number) => void;
onCancel: () => void;
onImport: () => void;
};
function ReviewStep({
stops,
warnings,
error,
onUpdateStop,
onRemoveStop,
onCancel,
onImport,
}: ReviewStepProps) {
const hasWarnings = warnings.length > 0;
return (
<div className="space-y-4">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
{hasWarnings && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
<p className="font-semibold text-amber-800">Parsing warnings:</p>
<ul className="mt-1 list-disc list-inside space-y-0.5 text-amber-700">
{warnings.slice(0, 5).map((w, i) => (
<li key={`${w}-${i}`}>{w}</li>
))}
{warnings.length > 5 && <li>and {warnings.length - 5} more</li>}
</ul>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-stone-700">
{stops.length} stop{stops.length !== 1 ? "s" : ""} found review and edit
</p>
<span className="text-xs text-stone-400">All will be created as drafts</span>
</div>
<ReviewTable stops={stops} onUpdateStop={onUpdateStop} onRemoveStop={onRemoveStop} />
<div className="flex items-center justify-between pt-2 border-t border-stone-100">
<p className="text-xs text-stone-500">
{stops.length} draft stop{stops.length !== 1 ? "s" : ""} will be created
</p>
<div className="flex gap-2">
<button type="button"
onClick={onCancel}
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
aria-label="Cancel">
Cancel
</button>
<button type="button"
onClick={onImport}
disabled={stops.length === 0}
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2 text-sm font-bold text-white disabled:opacity-50 transition-colors"
aria-label={`Create ${stops.length} Stop${stops.length !== 1 ? "s" : ""}`}>
Create {stops.length} Stop{stops.length !== 1 ? "s" : ""}
</button>
</div>
</div>
</div>
);
}
type ReviewTableProps = {
stops: ParsedStop[];
onUpdateStop: (idx: number, field: keyof ParsedStop, value: string) => void;
onRemoveStop: (idx: number) => void;
};
function ReviewTable({ stops, onUpdateStop, onRemoveStop }: ReviewTableProps) {
return (
<div className="max-h-64 overflow-y-auto rounded-xl border border-stone-200 bg-white">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-stone-50 border-b border-stone-100">
<tr>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">City</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">State</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Location</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Date</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Time</th>
<th className="w-8 px-3 py-2.5" aria-label="Actions" />
</tr>
</thead>
<tbody className="divide-y divide-stone-50">
{stops.map((stop, idx) => (
<tr key={`${stop.city}-${stop.state}-${stop.date}-${stop.time}-${idx}`} className="hover:bg-stone-50/50 transition-colors">
<td className="px-2 py-1.5">
<input aria-label="Input"
value={stop.city}
onChange={(e) => onUpdateStop(idx, "city", e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input aria-label="Input"
value={stop.state}
onChange={(e) => onUpdateStop(idx, "state", e.target.value)}
className="w-12 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input aria-label="Input"
value={stop.location}
onChange={(e) => onUpdateStop(idx, "location", e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input aria-label="Input"
value={stop.date}
onChange={(e) => onUpdateStop(idx, "date", e.target.value)}
className="w-24 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input aria-label="Input"
value={stop.time}
onChange={(e) => onUpdateStop(idx, "time", e.target.value)}
className="w-16 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<button type="button"
onClick={() => onRemoveStop(idx)}
className="flex h-6 w-6 items-center justify-center rounded-lg text-red-500 hover:bg-red-50 hover:text-red-700 transition-colors"
aria-label="Close">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function ImportingStep() {
return (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm font-medium text-stone-600">Creating draft stops</span>
</div>
</div>
);
}
function DoneStep({ created, onClose }: { created: number; onClose: () => void }) {
return (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-green-50">
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p className="text-xl font-bold text-stone-950">
{created} draft stop{created !== 1 ? "s" : ""} created
</p>
<p className="mt-1 text-sm text-stone-500">
Review them in the stops list and publish when ready.
</p>
</div>
<button type="button"
onClick={onClose}
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2.5 text-sm font-bold text-white transition-colors"
aria-label="Back to Stops">
Back to Stops
</button>
</div>
);
}
function ErrorStep({ error, onRetry }: { error: string; onRetry: () => void }) {
return (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-50">
<svg className="h-7 w-7 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-sm font-medium text-red-700">{error}</p>
<button type="button"
onClick={onRetry}
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
aria-label="Try Again">
Try Again
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+472 -303
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useReducer, useState, useEffect, useRef } from "react";
import {
getShippingSettings,
saveShippingSettings,
@@ -33,6 +33,114 @@ interface ValidationErrors {
const EMPTY_BRANDS: { id: string; name: string }[] = [];
type FormField =
| "fedexAccountNumber"
| "fedexApiKey"
| "fedexApiSecret"
| "fedexUseProduction"
| "defaultServiceType"
| "refrigeratedHandlingNotes"
| "fragileHandlingNotes";
type State = {
fedexAccountNumber: string;
fedexApiKey: string;
fedexApiSecret: string;
fedexUseProduction: boolean;
defaultServiceType: string;
refrigeratedHandlingNotes: string;
fragileHandlingNotes: string;
saving: boolean;
saved: boolean;
error: string | null;
testing: boolean;
testResult: { success: boolean; message: string } | null;
showSecret: boolean;
dirty: boolean;
errors: ValidationErrors;
};
type Action =
| { type: "FIELD_CHANGE"; field: FormField; value: string | boolean }
| { type: "SET_SHOW_SECRET"; value: boolean }
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_ERRORS"; value: ValidationErrors }
| { type: "SET_DIRTY"; value: boolean }
| { type: "SAVE_START" }
| { type: "SAVE_FAIL"; error: string }
| { type: "SAVE_SUCCESS" }
| { type: "CLEAR_SAVED" }
| { type: "TEST_INLINE_FAIL"; message: string }
| { type: "TEST_START" }
| { type: "TEST_DONE"; success: boolean; message: string };
function makeInitialState(settings: ShippingSettings | null): State {
return {
fedexAccountNumber: settings?.fedex_account_number ?? "",
fedexApiKey: settings?.fedex_api_key ?? "",
fedexApiSecret: settings?.fedex_api_secret ?? "",
fedexUseProduction: settings?.fedex_use_production ?? false,
defaultServiceType: settings?.default_service_type ?? "FEDEX_GROUND",
refrigeratedHandlingNotes:
settings?.refrigerated_handling_notes ??
"Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions.",
fragileHandlingNotes: settings?.fragile_handling_notes ?? "",
saving: false,
saved: false,
error: null,
testing: false,
testResult: null,
showSecret: false,
dirty: false,
errors: {},
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "FIELD_CHANGE": {
const nextErrors: ValidationErrors =
action.field === "fedexAccountNumber" ||
action.field === "fedexApiKey" ||
action.field === "fedexApiSecret"
? { ...state.errors, [action.field]: undefined }
: state.errors;
return {
...state,
[action.field]: action.value,
dirty: true,
errors: nextErrors,
};
}
case "SET_SHOW_SECRET":
return { ...state, showSecret: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_ERRORS":
return { ...state, errors: action.value };
case "SET_DIRTY":
return { ...state, dirty: action.value };
case "SAVE_START":
return { ...state, saving: true, error: null, saved: false, testResult: null };
case "SAVE_FAIL":
return { ...state, saving: false, error: action.error };
case "SAVE_SUCCESS":
return { ...state, saving: false, saved: true, dirty: false };
case "CLEAR_SAVED":
return { ...state, saved: false };
case "TEST_INLINE_FAIL":
return { ...state, testResult: { success: false, message: action.message } };
case "TEST_START":
return { ...state, testing: true, testResult: null };
case "TEST_DONE":
return {
...state,
testing: false,
testResult: { success: action.success, message: action.message },
};
}
}
export default function ShippingSettingsForm(props: Props) {
// Non-platform-admin case: brand is fixed, just pass props through.
// The body's `key` forces a remount if the brand id ever changes.
@@ -128,56 +236,40 @@ function ShippingSettingsFormBody({
brandId: string;
settings: ShippingSettings | null;
}) {
const [fedexAccountNumber, setFedexAccountNumber] = useState(settings?.fedex_account_number ?? "");
const [fedexApiKey, setFedexApiKey] = useState(settings?.fedex_api_key ?? "");
const [fedexApiSecret, setFedexApiSecret] = useState(settings?.fedex_api_secret ?? "");
const [fedexUseProduction, setFedexUseProduction] = useState(settings?.fedex_use_production ?? false);
const [defaultServiceType, setDefaultServiceType] = useState(settings?.default_service_type ?? "FEDEX_GROUND");
const [refrigeratedHandlingNotes, setRefrigeratedHandlingNotes] = useState(
settings?.refrigerated_handling_notes ?? "Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions."
);
const [fragileHandlingNotes, setFragileHandlingNotes] = useState(settings?.fragile_handling_notes ?? "");
const [state, dispatch] = useReducer(reducer, settings, makeInitialState);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [showSecret, setShowSecret] = useState(false);
const [dirty, setDirty] = useState(false);
const [errors, setErrors] = useState<ValidationErrors>({});
const isConfigured = !!settings?.fedex_api_key && !!settings?.fedex_api_secret && !!settings?.fedex_account_number;
const isConfigured =
!!settings?.fedex_api_key && !!settings?.fedex_api_secret && !!settings?.fedex_account_number;
// Track dirty state — derived during render per React docs.
const computedDirty =
fedexAccountNumber !== (settings?.fedex_account_number ?? "") ||
fedexApiKey !== (settings?.fedex_api_key ?? "") ||
fedexApiSecret !== (settings?.fedex_api_secret ?? "") ||
fedexUseProduction !== (settings?.fedex_use_production ?? false) ||
defaultServiceType !== (settings?.default_service_type ?? "FEDEX_GROUND") ||
refrigeratedHandlingNotes !== (settings?.refrigerated_handling_notes ?? "") ||
fragileHandlingNotes !== (settings?.fragile_handling_notes ?? "");
if (dirty !== computedDirty) {
setDirty(computedDirty);
state.fedexAccountNumber !== (settings?.fedex_account_number ?? "") ||
state.fedexApiKey !== (settings?.fedex_api_key ?? "") ||
state.fedexApiSecret !== (settings?.fedex_api_secret ?? "") ||
state.fedexUseProduction !== (settings?.fedex_use_production ?? false) ||
state.defaultServiceType !== (settings?.default_service_type ?? "FEDEX_GROUND") ||
state.refrigeratedHandlingNotes !== (settings?.refrigerated_handling_notes ?? "") ||
state.fragileHandlingNotes !== (settings?.fragile_handling_notes ?? "");
if (state.dirty !== computedDirty) {
dispatch({ type: "SET_DIRTY", value: computedDirty });
}
function validate(): boolean {
const newErrors: ValidationErrors = {};
if (fedexAccountNumber && fedexAccountNumber.length !== 12) {
if (state.fedexAccountNumber && state.fedexAccountNumber.length !== 12) {
newErrors.fedexAccountNumber = "FedEx account number must be 12 digits";
}
if (fedexApiKey && fedexApiKey.length < 10) {
if (state.fedexApiKey && state.fedexApiKey.length < 10) {
newErrors.fedexApiKey = "API Key appears too short - verify it from FedEx developer portal";
}
if (fedexApiSecret && fedexApiSecret.length < 10) {
if (state.fedexApiSecret && state.fedexApiSecret.length < 10) {
newErrors.fedexApiSecret = "API Secret appears too short - verify it from FedEx developer portal";
}
setErrors(newErrors);
dispatch({ type: "SET_ERRORS", value: newErrors });
return Object.keys(newErrors).length === 0;
}
@@ -185,87 +277,152 @@ function ShippingSettingsFormBody({
e.preventDefault();
if (!validate()) {
setError("Please correct the errors below before saving.");
dispatch({ type: "SET_ERROR", value: "Please correct the errors below before saving." });
return;
}
setSaving(true);
setError(null);
setSaved(false);
setTestResult(null);
dispatch({ type: "SAVE_START" });
const result = await saveShippingSettings({
brandId,
fedexAccountNumber: fedexAccountNumber || undefined,
fedexApiKey: fedexApiKey || undefined,
fedexApiSecret: fedexApiSecret || undefined,
fedexUseProduction,
defaultServiceType,
refrigeratedHandlingNotes: refrigeratedHandlingNotes || undefined,
fragileHandlingNotes: fragileHandlingNotes || undefined,
fedexAccountNumber: state.fedexAccountNumber || undefined,
fedexApiKey: state.fedexApiKey || undefined,
fedexApiSecret: state.fedexApiSecret || undefined,
fedexUseProduction: state.fedexUseProduction,
defaultServiceType: state.defaultServiceType,
refrigeratedHandlingNotes: state.refrigeratedHandlingNotes || undefined,
fragileHandlingNotes: state.fragileHandlingNotes || undefined,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
} else {
setSaved(true);
setDirty(false);
setTimeout(() => setSaved(false), 4000);
dispatch({ type: "SAVE_FAIL", error: result.error ?? "Failed to save" });
return;
}
dispatch({ type: "SAVE_SUCCESS" });
setTimeout(() => dispatch({ type: "CLEAR_SAVED" }), 4000);
}
async function handleTestConnection() {
if (!fedexApiKey || !fedexApiSecret) {
setTestResult({ success: false, message: "Please enter both API Key and API Secret before testing." });
if (!state.fedexApiKey || !state.fedexApiSecret) {
dispatch({
type: "TEST_INLINE_FAIL",
message: "Please enter both API Key and API Secret before testing.",
});
return;
}
setTesting(true);
setTestResult(null);
const result = await testFedExConnection(fedexApiKey, fedexApiSecret, fedexUseProduction);
setTesting(false);
setTestResult({ success: result.success, message: result.success ? result.message : result.error ?? "Connection failed" });
dispatch({ type: "TEST_START" });
const result = await testFedExConnection(
state.fedexApiKey,
state.fedexApiSecret,
state.fedexUseProduction
);
dispatch({
type: "TEST_DONE",
success: result.success,
message: result.success ? result.message : result.error ?? "Connection failed",
});
}
function handleFieldChange(field: FormField, value: string | boolean) {
dispatch({ type: "FIELD_CHANGE", field, value });
}
return (
<form onSubmit={handleSave} className="space-y-8">
<ConnectionStatusBanner isConfigured={isConfigured} useProduction={settings?.fedex_use_production ?? false} />
<FormMessages error={state.error} saved={state.saved} testResult={state.testResult} />
<FedExCredentialsSection
fedexAccountNumber={state.fedexAccountNumber}
fedexApiKey={state.fedexApiKey}
fedexApiSecret={state.fedexApiSecret}
fedexUseProduction={state.fedexUseProduction}
showSecret={state.showSecret}
testing={state.testing}
errors={state.errors}
onFieldChange={handleFieldChange}
onToggleShowSecret={() => dispatch({ type: "SET_SHOW_SECRET", value: !state.showSecret })}
onTestConnection={handleTestConnection}
/>
<DefaultShippingSection
defaultServiceType={state.defaultServiceType}
onFieldChange={handleFieldChange}
/>
<HandlingNotesSection
refrigeratedHandlingNotes={state.refrigeratedHandlingNotes}
fragileHandlingNotes={state.fragileHandlingNotes}
onFieldChange={handleFieldChange}
/>
<SaveButtonRow saving={state.saving} dirty={state.dirty} />
</form>
);
}
function ConnectionStatusBanner({
isConfigured,
useProduction,
}: {
isConfigured: boolean;
useProduction: boolean;
}) {
return (
<div
className="flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium border"
style={{
backgroundColor: isConfigured ? "rgba(16, 185, 129, 0.1)" : "rgba(245, 158, 11, 0.1)",
borderColor: isConfigured ? "var(--admin-success)" : "rgba(245, 158, 11, 0.3)",
color: isConfigured ? "var(--admin-success)" : "rgb(245, 158, 11)",
}}
>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: isConfigured ? "var(--admin-success)" : "rgb(245, 158, 11)" }}
/>
{isConfigured ? (
<div className="flex-1">
<span className="font-semibold">FedEx Connected</span>
<span className="ml-2 text-[var(--admin-text-muted)]">
{useProduction ? "Production Mode" : "Sandbox/Test Mode"}
</span>
</div>
) : (
<span>FedEx Not Configured enter credentials below to enable shipping rates</span>
)}
</div>
);
}
function FormMessages({
error,
saved,
testResult,
}: {
error: string | null;
saved: boolean;
testResult: { success: boolean; message: string } | null;
}) {
return (
<>
{error && (
<div className="rounded-xl bg-red-950/50 border border-red-800 p-4 text-sm text-red-300">{error}</div>
<div
className="rounded-xl bg-red-950/50 border border-red-800 p-4 text-sm text-red-300"
>
{error}
</div>
)}
{/* Connection status banner */}
<div
className="flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium border"
style={{
backgroundColor: isConfigured ? "rgba(16, 185, 129, 0.1)" : "rgba(245, 158, 11, 0.1)",
borderColor: isConfigured ? "var(--admin-success)" : "rgba(245, 158, 11, 0.3)",
color: isConfigured ? "var(--admin-success)" : "rgb(245, 158, 11)",
}}
>
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: isConfigured ? "var(--admin-success)" : "rgb(245, 158, 11)" }}
/>
{isConfigured ? (
<div className="flex-1">
<span className="font-semibold">FedEx Connected</span>
<span className="ml-2 text-[var(--admin-text-muted)]">
{settings?.fedex_use_production ? "Production Mode" : "Sandbox/Test Mode"}
</span>
</div>
) : (
<span>FedEx Not Configured enter credentials below to enable shipping rates</span>
)}
</div>
{/* Error/Success messages */}
{error && (
<div
<div
className="rounded-xl p-4 text-sm border flex items-start gap-3"
style={{
backgroundColor: "rgba(239, 68, 68, 0.1)",
borderColor: "rgba(239, 68, 68, 0.3)",
color: "rgb(239, 68, 68)"
style={{
backgroundColor: "rgba(239, 68, 68, 0.1)",
borderColor: "rgba(239, 68, 68, 0.3)",
color: "rgb(239, 68, 68)",
}}
>
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -275,12 +432,12 @@ function ShippingSettingsFormBody({
</div>
)}
{saved && (
<div
<div
className="rounded-xl p-4 text-sm border flex items-center gap-3"
style={{
backgroundColor: "rgba(16, 185, 129, 0.1)",
borderColor: "rgba(16, 185, 129, 0.3)",
color: "var(--admin-success)"
style={{
backgroundColor: "rgba(16, 185, 129, 0.1)",
borderColor: "rgba(16, 185, 129, 0.3)",
color: "var(--admin-success)",
}}
>
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -290,12 +447,12 @@ function ShippingSettingsFormBody({
</div>
)}
{testResult && (
<div
<div
className="rounded-xl p-4 text-sm border flex items-start gap-3"
style={{
backgroundColor: testResult.success ? "rgba(16, 185, 129, 0.1)" : "rgba(239, 68, 68, 0.1)",
borderColor: testResult.success ? "rgba(16, 185, 129, 0.3)" : "rgba(239, 68, 68, 0.3)",
color: testResult.success ? "var(--admin-success)" : "rgb(239, 68, 68)"
style={{
backgroundColor: testResult.success ? "rgba(16, 185, 129, 0.1)" : "rgba(239, 68, 68, 0.1)",
borderColor: testResult.success ? "rgba(16, 185, 129, 0.3)" : "rgba(239, 68, 68, 0.3)",
color: testResult.success ? "var(--admin-success)" : "rgb(239, 68, 68)",
}}
>
{testResult.success ? (
@@ -310,225 +467,237 @@ function ShippingSettingsFormBody({
{testResult.message}
</div>
)}
</>
);
}
{/* FedEx credentials */}
<div
className="space-y-5 rounded-xl border p-5"
style={{
backgroundColor: "var(--admin-bg-subtle)",
borderColor: "var(--admin-border)"
}}
>
<div>
<h3 className="font-semibold text-[var(--admin-text-primary)]">FedEx API Credentials</h3>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Get your credentials from{" "}
<a
href="https://developer.fedex.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
style={{ color: "var(--admin-accent)" }}
>
developer.fedex.com
</a>
. These are used for shipping fresh produce.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AdminInput
label="FedEx Account Number"
required
error={errors.fedexAccountNumber}
helpText="12-digit FedEx account number"
function FedExCredentialsSection({
fedexAccountNumber,
fedexApiKey,
fedexApiSecret,
fedexUseProduction,
showSecret,
testing,
errors,
onFieldChange,
onToggleShowSecret,
onTestConnection,
}: {
fedexAccountNumber: string;
fedexApiKey: string;
fedexApiSecret: string;
fedexUseProduction: boolean;
showSecret: boolean;
testing: boolean;
errors: ValidationErrors;
onFieldChange: (field: FormField, value: string | boolean) => void;
onToggleShowSecret: () => void;
onTestConnection: () => void;
}) {
return (
<div
className="space-y-5 rounded-xl border p-5"
style={{
backgroundColor: "var(--admin-bg-subtle)",
borderColor: "var(--admin-border)",
}}
>
<div>
<h3 className="font-semibold text-[var(--admin-text-primary)]">FedEx API Credentials</h3>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Get your credentials from{" "}
<a
href="https://developer.fedex.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
style={{ color: "var(--admin-accent)" }}
>
<AdminTextInput
value={fedexAccountNumber}
onChange={(e) => {
setFedexAccountNumber(e.target.value);
setDirty(true);
if (errors.fedexAccountNumber) {
setErrors({ ...errors, fedexAccountNumber: undefined });
}
}}
placeholder="000000000000"
maxLength={12}
/>
</AdminInput>
<AdminInput
label="API Key"
required
error={errors.fedexApiKey}
helpText="From FedEx Developer Portal"
>
<AdminTextInput
value={fedexApiKey}
onChange={(e) => {
setFedexApiKey(e.target.value);
setDirty(true);
if (errors.fedexApiKey) {
setErrors({ ...errors, fedexApiKey: undefined });
}
}}
placeholder="Your FedEx API key"
/>
</AdminInput>
</div>
developer.fedex.com
</a>
. These are used for shipping fresh produce.
</p>
</div>
<AdminInput
label="API Secret"
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<AdminInput
label="FedEx Account Number"
required
error={errors.fedexApiSecret}
helpText="Password field — click Show/Hide to reveal"
error={errors.fedexAccountNumber}
helpText="12-digit FedEx account number"
>
<div className="relative">
<AdminTextInput
type={showSecret ? "text" : "password"}
value={fedexApiSecret}
onChange={(e) => {
setFedexApiSecret(e.target.value);
setDirty(true);
if (errors.fedexApiSecret) {
setErrors({ ...errors, fedexApiSecret: undefined });
}
}}
placeholder="Your FedEx API secret"
/>
<button
type="button"
onClick={() => setShowSecret(!showSecret)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs px-2 py-1 rounded-lg hover:bg-[var(--admin-bg)] transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
{showSecret ? "Hide" : "Show"}
</button>
</div>
</AdminInput>
{/* Production toggle */}
<div
className="flex items-center gap-4 rounded-xl px-4 py-3 border"
style={{
backgroundColor: "var(--admin-bg)",
borderColor: "var(--admin-border)"
}}
>
<AdminToggle
checked={fedexUseProduction}
onChange={(checked) => {
setFedexUseProduction(checked);
setDirty(true);
}}
label="Use Production Mode"
description={fedexUseProduction ? "Live rates, real labels" : "Sandbox/test mode — safe for testing"}
<AdminTextInput
value={fedexAccountNumber}
onChange={(e) => onFieldChange("fedexAccountNumber", e.target.value)}
placeholder="000000000000"
maxLength={12}
/>
</div>
</AdminInput>
<AdminInput
label="API Key"
required
error={errors.fedexApiKey}
helpText="From FedEx Developer Portal"
>
<AdminTextInput
value={fedexApiKey}
onChange={(e) => onFieldChange("fedexApiKey", e.target.value)}
placeholder="Your FedEx API key"
/>
</AdminInput>
</div>
{/* Test connection */}
<div className="flex items-center gap-4 pt-2">
<AdminButton
variant="secondary"
size="sm"
onClick={handleTestConnection}
disabled={testing || !fedexApiKey || !fedexApiSecret}
<AdminInput
label="API Secret"
required
error={errors.fedexApiSecret}
helpText="Password field — click Show/Hide to reveal"
>
<div className="relative">
<AdminTextInput
type={showSecret ? "text" : "password"}
value={fedexApiSecret}
onChange={(e) => onFieldChange("fedexApiSecret", e.target.value)}
placeholder="Your FedEx API secret"
/>
<button
type="button"
onClick={onToggleShowSecret}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs px-2 py-1 rounded-lg hover:bg-[var(--admin-bg)] transition-colors"
style={{ color: "var(--admin-text-muted)" }}
>
{testing ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
Testing...
</span>
) : (
<>
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.55 4.05l7.97 7.97-2.12 2.12-7.85-7.85-2.12 2.12 7.97 7.97-2.12 2.12-7.97-7.97 2.12-2.12 7.97 7.97L3.58 6.17l7.97-7.97 2.12 2.12-7.22 7.73z" />
</svg>
Test Connection
</>
)}
</AdminButton>
{testResult?.success && (
<span className="text-sm font-medium flex items-center gap-1.5" style={{ color: "var(--admin-success)" }}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Connection verified
</span>
)}
{showSecret ? "Hide" : "Show"}
</button>
</div>
</div>
</AdminInput>
{/* Default shipping options */}
<div
className="space-y-5 rounded-xl border p-5"
style={{
backgroundColor: "var(--admin-bg-subtle)",
borderColor: "var(--admin-border)"
{/* Production toggle */}
<div
className="flex items-center gap-4 rounded-xl px-4 py-3 border"
style={{
backgroundColor: "var(--admin-bg)",
borderColor: "var(--admin-border)",
}}
>
<div>
<h3 className="font-semibold text-[var(--admin-text-primary)]">Default Shipping Service</h3>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Applied automatically for non-perishable orders. Perishable orders always require Overnight or 2-Day Air.
</p>
</div>
<AdminInput label="Default for non-perishable orders" helpText="Used when creating shipments for non-perishable items">
<AdminSelect
value={defaultServiceType}
onChange={(e) => {
setDefaultServiceType(e.target.value);
setDirty(true);
}}
options={SERVICE_OPTIONS}
/>
</AdminInput>
<AdminToggle
checked={fedexUseProduction}
onChange={(checked) => onFieldChange("fedexUseProduction", checked)}
label="Use Production Mode"
description={fedexUseProduction ? "Live rates, real labels" : "Sandbox/test mode — safe for testing"}
/>
</div>
{/* Handling notes */}
<div
className="space-y-5 rounded-xl border p-5"
style={{
backgroundColor: "var(--admin-bg-subtle)",
borderColor: "var(--admin-border)"
}}
>
<div>
<h3 className="font-semibold text-[var(--admin-text-primary)]">Handling Instructions</h3>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
These notes are attached to shipments for perishable or fragile items. Appear on carrier labels and in warehouse.
</p>
</div>
<AdminInput
label="Refrigerated / Perishable Notes"
helpText="Applied to all shipments with perishable items (sweet corn, onions, etc.)"
{/* Test connection */}
<div className="flex items-center gap-4 pt-2">
<AdminButton
variant="secondary"
size="sm"
onClick={onTestConnection}
disabled={testing || !fedexApiKey || !fedexApiSecret}
>
<AdminTextarea
value={refrigeratedHandlingNotes}
onChange={(e) => {
setRefrigeratedHandlingNotes(e.target.value);
setDirty(true);
}}
rows={3}
placeholder="e.g. Keep refrigerated. Do not freeze. Contains fresh sweet corn and/or onions."
/>
</AdminInput>
{testing ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
Testing...
</span>
) : (
<>
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.55 4.05l7.97 7.97-2.12 2.12-7.85-7.85-2.12 2.12 7.97 7.97-2.12 2.12-7.97-7.97 2.12-2.12 7.97 7.97L3.58 6.17l7.97-7.97 2.12 2.12-7.22 7.73z" />
</svg>
Test Connection
</>
)}
</AdminButton>
</div>
</div>
);
}
<AdminInput label="Fragile Items Notes" helpText="Applied to shipments with fragile items">
<AdminTextarea
value={fragileHandlingNotes}
onChange={(e) => {
setFragileHandlingNotes(e.target.value);
setDirty(true);
}}
rows={2}
placeholder="e.g. Fragile — handle with care. Do not stack heavy items on top."
/>
</AdminInput>
function DefaultShippingSection({
defaultServiceType,
onFieldChange,
}: {
defaultServiceType: string;
onFieldChange: (field: FormField, value: string | boolean) => void;
}) {
return (
<div
className="space-y-5 rounded-xl border p-5"
style={{
backgroundColor: "var(--admin-bg-subtle)",
borderColor: "var(--admin-border)",
}}
>
<div>
<h3 className="font-semibold text-[var(--admin-text-primary)]">Default Shipping Service</h3>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Applied automatically for non-perishable orders. Perishable orders always require Overnight or 2-Day Air.
</p>
</div>
{/* Save/Cancel buttons */}
<AdminInput label="Default for non-perishable orders" helpText="Used when creating shipments for non-perishable items">
<AdminSelect
value={defaultServiceType}
onChange={(e) => onFieldChange("defaultServiceType", e.target.value)}
options={SERVICE_OPTIONS}
/>
</AdminInput>
</div>
);
}
function HandlingNotesSection({
refrigeratedHandlingNotes,
fragileHandlingNotes,
onFieldChange,
}: {
refrigeratedHandlingNotes: string;
fragileHandlingNotes: string;
onFieldChange: (field: FormField, value: string | boolean) => void;
}) {
return (
<div
className="space-y-5 rounded-xl border p-5"
style={{
backgroundColor: "var(--admin-bg-subtle)",
borderColor: "var(--admin-border)",
}}
>
<div>
<h3 className="font-semibold text-[var(--admin-text-primary)]">Handling Instructions</h3>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
These notes are attached to shipments for perishable or fragile items. Appear on carrier labels and in warehouse.
</p>
</div>
<AdminInput
label="Refrigerated / Perishable Notes"
helpText="Applied to all shipments with perishable items (sweet corn, onions, etc.)"
>
<AdminTextarea
value={refrigeratedHandlingNotes}
onChange={(e) => onFieldChange("refrigeratedHandlingNotes", e.target.value)}
rows={3}
placeholder="e.g. Keep refrigerated. Do not freeze. Contains fresh sweet corn and/or onions."
/>
</AdminInput>
<AdminInput label="Fragile Items Notes" helpText="Applied to shipments with fragile items">
<AdminTextarea
value={fragileHandlingNotes}
onChange={(e) => onFieldChange("fragileHandlingNotes", e.target.value)}
rows={2}
placeholder="e.g. Fragile — handle with care. Do not stack heavy items on top."
/>
</AdminInput>
</div>
);
}
function SaveButtonRow({ saving, dirty }: { saving: boolean; dirty: boolean }) {
return (
<>
{saving ? (
<div className="flex items-center gap-3" style={{ color: "var(--admin-text-muted)" }}>
<div className="h-5 w-5 rounded-full border-2 border-current border-t-transparent animate-spin" />
@@ -561,6 +730,6 @@ function ShippingSettingsFormBody({
)}
</div>
)}
</form>
</>
);
}
}
+168 -91
View File
@@ -3,7 +3,7 @@
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { updateStop } from "@/actions/stops/update-stop";
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system/AdminFormElements";
import { AdminInput, AdminSelect } from "./design-system/AdminFormElements";
import AdminButton from "./design-system/AdminButton";
import { useToast } from "./Toast";
@@ -33,6 +33,8 @@ type StopEditFormProps = {
onSaved?: () => void;
};
type DraftSetter<T> = (v: T | ((prev: T) => T)) => void;
export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProps) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
@@ -67,52 +69,52 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
const zip = draft.zip ?? stop.zip ?? "";
const cutoff_time = draft.cutoff_time ?? stop.cutoff_time ?? "";
const setCity = (v: string | ((prev: string) => string)) =>
const setCity: DraftSetter<string> = (v) =>
setDraft((d) => ({
...d,
city: typeof v === "function" ? v(d.city ?? stop.city) : v,
}));
const setState = (v: string | ((prev: string) => string)) =>
const setState: DraftSetter<string> = (v) =>
setDraft((d) => ({
...d,
state: typeof v === "function" ? v(d.state ?? stop.state) : v,
}));
const setDate = (v: string | ((prev: string) => string)) =>
const setDate: DraftSetter<string> = (v) =>
setDraft((d) => ({
...d,
date: typeof v === "function" ? v(d.date ?? stop.date) : v,
}));
const setTime = (v: string | ((prev: string) => string)) =>
const setTime: DraftSetter<string> = (v) =>
setDraft((d) => ({
...d,
time: typeof v === "function" ? v(d.time ?? stop.time) : v,
}));
const setLocation = (v: string | ((prev: string) => string)) =>
const setLocation: DraftSetter<string> = (v) =>
setDraft((d) => ({
...d,
location: typeof v === "function" ? v(d.location ?? stop.location) : v,
}));
const setActive = (v: boolean | ((prev: boolean) => boolean)) =>
const setActive: DraftSetter<boolean> = (v) =>
setDraft((d) => ({
...d,
active: typeof v === "function" ? v(d.active ?? stop.active) : v,
}));
const setBrand_id = (v: string | ((prev: string) => string)) =>
const setBrand_id: DraftSetter<string> = (v) =>
setDraft((d) => ({
...d,
brand_id: typeof v === "function" ? v(d.brand_id ?? stop.brand_id) : v,
}));
const setAddress = (v: string | ((prev: string) => string)) =>
const setAddress: DraftSetter<string> = (v) =>
setDraft((d) => ({
...d,
address: typeof v === "function" ? v(d.address ?? stop.address ?? "") : v,
}));
const setZip = (v: string | ((prev: string) => string)) =>
const setZip: DraftSetter<string> = (v) =>
setDraft((d) => ({
...d,
zip: typeof v === "function" ? v(d.zip ?? stop.zip ?? "") : v,
}));
const setCutoff_time = (v: string | ((prev: string) => string)) =>
const setCutoff_time: DraftSetter<string> = (v) =>
setDraft((d) => ({
...d,
cutoff_time:
@@ -144,7 +146,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
showError("Validation failed", "Please fix the errors below");
return;
}
setSaving(true);
setError(null);
savedRef.current = false;
@@ -177,15 +179,109 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
)}
<FormErrorBanner error={error} />
<LocationIdentityFields
city={city}
state={state}
date={date}
time={time}
fieldErrors={fieldErrors}
setCity={setCity}
setState={setState}
setDate={setDate}
setTime={setTime}
clearFieldError={(field) =>
setFieldErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
})
}
/>
<AddressFields
location={location}
address={address}
zip={zip}
setLocation={setLocation}
setAddress={setAddress}
setZip={setZip}
/>
<AdminInput
label="Order Cutoff"
helpText="Customers must order before this time to be included at this stop."
>
<input aria-label="Datetime Local"
type="datetime-local"
value={cutoff_time}
onChange={(e) => setCutoff_time(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</AdminInput>
<AdminInput label="Brand">
<AdminSelect
value={brand_id}
onChange={(e) => setBrand_id(e.target.value)}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
<StatusToggle active={active} onToggle={() => setActive((v) => !v)} />
<AdminButton
onClick={handleSave}
disabled={saving}
isLoading={saving}
variant="primary"
fullWidth
size="lg"
>
{saving ? "Saving..." : "Save Changes"}
</AdminButton>
</div>
);
}
function FormErrorBanner({ error }: { error: string | null }) {
if (!error) return null;
return (
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
);
}
function LocationIdentityFields({
city,
state,
date,
time,
fieldErrors,
setCity,
setState,
setDate,
setTime,
clearFieldError,
}: {
city: string;
state: string;
date: string;
time: string;
fieldErrors: Record<string, string>;
setCity: DraftSetter<string>;
setState: DraftSetter<string>;
setDate: DraftSetter<string>;
setTime: DraftSetter<string>;
clearFieldError: (field: string) => void;
}) {
return (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5" htmlFor="fld-city-">
@@ -196,11 +292,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
value={city}
onChange={(e) => {
setCity(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.city;
return next;
});
clearFieldError("city");
}}
placeholder="City name"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
@@ -221,11 +313,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
value={state}
onChange={(e) => {
setState(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.state;
return next;
});
clearFieldError("state");
}}
placeholder="State code"
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
@@ -248,11 +336,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
value={date}
onChange={(e) => {
setDate(e.target.value);
setFieldErrors((prev) => {
const next = { ...prev };
delete next.date;
return next;
});
clearFieldError("date");
}}
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
fieldErrors.date
@@ -264,8 +348,8 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
</div>
<div>
<label htmlFor="fld-1-time" className="block text-xs font-semibold text-stone-700 mb-1.5">Time</label>
<input id="fld-1-time" aria-label=". 8:00 AM 2:00 PM"
<label htmlFor="fld-1-time" className="block text-xs font-semibold text-stone-700 mb-1.5">Time</label>
<input id="fld-1-time" aria-label=". 8:00 AM 2:00 PM"
type="text"
value={time}
onChange={(e) => setTime(e.target.value)}
@@ -274,10 +358,30 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
/>
</div>
</div>
</>
);
}
function AddressFields({
location,
address,
zip,
setLocation,
setAddress,
setZip,
}: {
location: string;
address: string;
zip: string;
setLocation: DraftSetter<string>;
setAddress: DraftSetter<string>;
setZip: DraftSetter<string>;
}) {
return (
<>
<div>
<label htmlFor="fld-2-location-name" className="block text-xs font-semibold text-stone-700 mb-1.5">Location Name</label>
<input id="fld-2-location-name" aria-label="Street Address Or Intersection"
<label htmlFor="fld-2-location-name" className="block text-xs font-semibold text-stone-700 mb-1.5">Location Name</label>
<input id="fld-2-location-name" aria-label="Street Address Or Intersection"
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
@@ -288,8 +392,8 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="fld-3-street-address" className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
<input id="fld-3-street-address" aria-label="123 Main St"
<label htmlFor="fld-3-street-address" className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
<input id="fld-3-street-address" aria-label="123 Main St"
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
@@ -299,8 +403,8 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
</div>
<div>
<label htmlFor="fld-4-zip-code" className="block text-xs font-semibold text-stone-700 mb-1.5">ZIP Code</label>
<input id="fld-4-zip-code" aria-label="80102"
<label htmlFor="fld-4-zip-code" className="block text-xs font-semibold text-stone-700 mb-1.5">ZIP Code</label>
<input id="fld-4-zip-code" aria-label="80102"
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
@@ -310,55 +414,28 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp
/>
</div>
</div>
</>
);
}
<AdminInput
label="Order Cutoff"
helpText="Customers must order before this time to be included at this stop."
>
<input aria-label="Datetime Local"
type="datetime-local"
value={cutoff_time}
onChange={(e) => setCutoff_time(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</AdminInput>
<AdminInput label="Brand">
<AdminSelect
value={brand_id}
onChange={(e) => setBrand_id(e.target.value)}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
<div>
<label className="mb-2 block text-sm font-medium text-stone-700">
<span className="block mb-2">Status</span>
<button
type="button"
onClick={() => setActive((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active
? "bg-green-100 text-green-700 border border-green-200"
: "bg-stone-100 text-stone-500 border border-stone-200 hover:bg-stone-50"
}`}
>
<span aria-hidden="true">{active ? "✓ " : "○ "}</span>
{active ? "Active — visible on storefront" : "Inactive — hidden from storefront"}
</button>
</label>
</div>
<AdminButton
onClick={handleSave}
disabled={saving}
isLoading={saving}
variant="primary"
fullWidth
size="lg"
>
{saving ? "Saving..." : "Save Changes"}
</AdminButton>
function StatusToggle({ active, onToggle }: { active: boolean; onToggle: () => void }) {
return (
<div>
<label className="mb-2 block text-sm font-medium text-stone-700">
<span className="block mb-2">Status</span>
<button
type="button"
onClick={onToggle}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active
? "bg-green-100 text-green-700 border border-green-200"
: "bg-stone-100 text-stone-500 border border-stone-200 hover:bg-stone-50"
}`}
>
<span aria-hidden="true">{active ? "✓ " : "○ "}</span>
{active ? "Active — visible on storefront" : "Inactive — hidden from storefront"}
</button>
</label>
</div>
);
}
+398 -232
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import Image from "next/image";
import NextImage from "next/image";
import { useReducer, useMemo, useEffect } from "react";
import { logAuditEvent } from "@/actions/audit";
import { assignProductToStop, unassignProductFromStop } from "@/actions/stops/manage-stop-products";
@@ -40,6 +40,48 @@ const TYPE_LABEL: Record<string, string> = {
subscription: "Sub",
};
type State = {
products: AssignedProduct[];
search: string;
filter: Filter;
pendingId: string | null;
removingId: string | null;
error: string | null;
};
type Action =
| { type: "ADD_PRODUCT"; product: AssignedProduct }
| { type: "REMOVE_PRODUCT"; productId: string }
| { type: "SET_SEARCH"; value: string }
| { type: "SET_FILTER"; value: Filter }
| { type: "SET_PENDING"; value: string | null }
| { type: "SET_REMOVING"; value: string | null }
| { type: "SET_ERROR"; value: string | null };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "ADD_PRODUCT":
if (state.products.some((p) => p.product_id === action.product.product_id)) {
return state;
}
return { ...state, products: [...state.products, action.product] };
case "REMOVE_PRODUCT":
return { ...state, products: state.products.filter((p) => p.product_id !== action.productId) };
case "SET_SEARCH":
return { ...state, search: action.value };
case "SET_FILTER":
return { ...state, filter: action.value };
case "SET_PENDING":
return { ...state, pendingId: action.value };
case "SET_REMOVING":
return { ...state, removingId: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
default:
return state;
}
}
export default function StopProductAssignment({
stopId,
allProducts,
@@ -49,78 +91,77 @@ export default function StopProductAssignment({
// Use a lazy initializer so the lint's static "useState(prop)" check does
// not fire — the seed value is computed once on first render, and user
// mutations (assign/remove) drive subsequent updates locally.
const [products, setProducts] = useState<AssignedProduct[]>(() => assignedProducts);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [pendingId, setPendingId] = useState<string | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [state, dispatch] = useReducer(reducer, assignedProducts, (initial): State => ({
products: initial,
search: "",
filter: "all",
pendingId: null,
removingId: null,
error: null,
}));
const assignedIds = useMemo(
() => new Set(products.map((p) => p.product_id)),
[products]
() => new Set(state.products.map((p) => p.product_id)),
[state.products]
);
// Counters for the filter chips
const counts = useMemo(() => {
const assignedCount = products.length;
const assignedCount = state.products.length;
const availableCount = allProducts.filter((p) => !assignedIds.has(p.id)).length;
return {
all: allProducts.length,
available: availableCount,
assigned: assignedCount,
};
}, [allProducts, products, assignedIds]);
}, [allProducts, state.products, assignedIds]);
// Apply search + filter
const visibleProducts = useMemo(() => {
const q = search.trim().toLowerCase();
const q = state.search.trim().toLowerCase();
return allProducts.filter((p) => {
const isAssigned = assignedIds.has(p.id);
if (filter === "available" && isAssigned) return false;
if (filter === "assigned" && !isAssigned) return false;
if (state.filter === "available" && isAssigned) return false;
if (state.filter === "assigned" && !isAssigned) return false;
if (!q) return true;
return p.name.toLowerCase().includes(q) || p.type.toLowerCase().includes(q);
});
}, [allProducts, search, filter, assignedIds]);
}, [allProducts, state.search, state.filter, assignedIds]);
async function assign(productId: string) {
if (!productId || assignedIds.has(productId) || pendingId) return;
if (!productId || assignedIds.has(productId) || state.pendingId) return;
const product = allProducts.find((p) => p.id === productId);
if (!product) return;
setPendingId(productId);
setError(null);
dispatch({ type: "SET_PENDING", value: productId });
dispatch({ type: "SET_ERROR", value: null });
const result = await assignProductToStop({ stopId, productId });
if (!result.success) {
setError(`Failed to assign: ${result.error}`);
setPendingId(null);
dispatch({ type: "SET_ERROR", value: `Failed to assign: ${result.error}` });
dispatch({ type: "SET_PENDING", value: null });
return;
}
// Optimistic insert: build the entry from the product we already have
// locally, keyed by the row id returned from the RPC. Use functional
// setState so concurrent calls compose correctly.
setProducts((prev) => {
if (prev.some((p) => p.product_id === productId)) return prev;
return [
...prev,
{
id: result.id,
product_id: productId,
products: {
name: product.name,
type: product.type,
price: product.price,
image_url: product.image_url ?? null,
},
dispatch({
type: "ADD_PRODUCT",
product: {
id: result.id,
product_id: productId,
products: {
name: product.name,
type: product.type,
price: product.price,
image_url: product.image_url ?? null,
},
];
},
});
setPendingId(null);
dispatch({ type: "SET_PENDING", value: null });
logAuditEvent({
table_name: "product_stops",
@@ -133,23 +174,23 @@ export default function StopProductAssignment({
}
async function remove(productId: string) {
if (removingId) return;
const entry = products.find((p) => p.product_id === productId);
if (state.removingId) return;
const entry = state.products.find((p) => p.product_id === productId);
if (!entry) return;
setRemovingId(productId);
setError(null);
dispatch({ type: "SET_REMOVING", value: productId });
dispatch({ type: "SET_ERROR", value: null });
const result = await unassignProductFromStop({ stopId, productId });
if (!result.success) {
setError(`Failed to remove: ${result.error}`);
setRemovingId(null);
dispatch({ type: "SET_ERROR", value: `Failed to remove: ${result.error}` });
dispatch({ type: "SET_REMOVING", value: null });
return;
}
setProducts((prev) => prev.filter((p) => p.product_id !== productId));
setRemovingId(null);
dispatch({ type: "REMOVE_PRODUCT", productId });
dispatch({ type: "SET_REMOVING", value: null });
logAuditEvent({
table_name: "product_stops",
@@ -170,15 +211,15 @@ export default function StopProductAssignment({
}
function clearAll() {
if (!products.length || removingId || pendingId) return;
if (!state.products.length || state.removingId || state.pendingId) return;
// Snapshot ids at click time, then remove each one. Iterations run in
// parallel; each callback checks for a prior error and short-circuits
// when one is already set so subsequent calls stop early.
const idsToRemove = products.map((p) => p.product_id);
const idsToRemove = state.products.map((p) => p.product_id);
void Promise.all(
idsToRemove.map(async (id) => {
// Stop if a per-item failure already surfaced an error
if (error) return;
if (state.error) return;
try {
await remove(id);
} catch {
@@ -202,208 +243,333 @@ export default function StopProductAssignment({
return () => window.removeEventListener("keydown", onKey);
}, []);
const hasAssigned = products.length > 0;
const hasAssigned = state.products.length > 0;
const hasProducts = allProducts.length > 0;
return (
<div className="ha-picker">
{error && (
<div
role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{
background: "var(--admin-danger-soft)",
border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)",
}}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
</svg>
<span className="font-mono text-[11px]">{error}</span>
</div>
)}
{state.error && <PickerErrorBanner error={state.error} />}
{/* Header: editorial title + search */}
<div className="ha-picker-header">
<div className="ha-picker-title">
<span className="ha-picker-title-num font-display">
{String(products.length).padStart(2, "0")}
</span>
<span className="ha-picker-title-label">
{products.length === 1 ? "Product on this stop" : "Products on this stop"}
</span>
</div>
<div className="ha-picker-search">
<span className="ha-picker-search-icon" aria-hidden="true">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
</svg>
</span>
<input aria-label="Search Products…"
id="stop-product-search"
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search products…"
className="ha-picker-search-input"
autoComplete="off"
/>
</div>
</div>
<PickerHeader
count={state.products.length}
search={state.search}
dispatch={dispatch}
/>
{/* Filter chips */}
<div className="ha-picker-filterbar">
<button
type="button"
onClick={() => setFilter("all")}
className={`ha-picker-chip ${filter === "all" ? "ha-picker-chip--active" : ""}`}
>
All
<span className="ha-picker-chip-count">{counts.all}</span>
</button>
<button
type="button"
onClick={() => setFilter("available")}
className={`ha-picker-chip ${filter === "available" ? "ha-picker-chip--active" : ""}`}
>
Available
<span className="ha-picker-chip-count">{counts.available}</span>
</button>
<button
type="button"
onClick={() => setFilter("assigned")}
className={`ha-picker-chip ${filter === "assigned" ? "ha-picker-chip--active" : ""}`}
>
On this stop
<span className="ha-picker-chip-count">{counts.assigned}</span>
</button>
<span className="ha-modal-footer-hint ml-auto">
Press <kbd>/</kbd> to search
</span>
</div>
<FilterChips
filter={state.filter}
counts={counts}
dispatch={dispatch}
/>
{/* Product grid */}
{!hasProducts ? (
<div className="ha-picker-empty">
<p className="ha-picker-empty-mark">No products yet</p>
<p className="ha-picker-empty-text">
Create products in the catalog first, then come back here to assign them to this stop.
</p>
</div>
<PickerEmptyState variant="no-products" />
) : visibleProducts.length === 0 ? (
<div className="ha-picker-empty">
<p className="ha-picker-empty-mark">
{filter === "assigned" ? "Nothing assigned" : "No matches"}
</p>
<p className="ha-picker-empty-text">
{search.trim()
? `No products match "${search.trim()}". Try a different term.`
: filter === "assigned"
? "This stop has no products assigned yet. Click a card in the All tab to add one."
: "All products are already assigned to this stop."}
</p>
</div>
<PickerEmptyState
variant="no-matches"
filter={state.filter}
search={state.search}
/>
) : (
<div className="ha-picker-grid">
{visibleProducts.map((product) => {
const isAssigned = assignedIds.has(product.id);
const isBusy = pendingId === product.id || removingId === product.id;
return (
<button
key={product.id}
type="button"
onClick={() => !isBusy && toggle(product.id)}
disabled={isBusy}
aria-pressed={isAssigned}
className={`ha-product-card ${isAssigned ? "ha-product-card--assigned" : ""} ${
isBusy ? "ha-product-card--disabled" : ""
}`}
>
{/* Image / Initial */}
<span className="ha-product-card-image">
{product.image_url ? (
<Image
src={product.image_url}
alt={product.name}
fill
sizes="48px"
style={{ objectFit: "cover" }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
initialOf(product.name)
)}
</span>
{/* Body */}
<span className="ha-product-card-body">
<span className="ha-product-card-name">{product.name}</span>
<span className="ha-product-card-meta">
<span className="ha-product-card-type">{TYPE_LABEL[product.type] ?? product.type}</span>
<span className="ha-product-card-price">{formatPrice(product.price)}</span>
</span>
</span>
{/* Status icon */}
{isBusy ? (
<span className="ha-product-card-plus" aria-hidden="true">
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
</span>
) : isAssigned ? (
<>
<span className="ha-product-card-check" aria-hidden="true">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="ha-product-card-remove" aria-hidden="true">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M6 6l12 12M6 18 18 6" strokeLinecap="round" />
</svg>
</span>
</>
) : (
<span className="ha-product-card-plus" aria-hidden="true">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</span>
)}
</button>
);
})}
</div>
<ProductGrid
products={visibleProducts}
assignedIds={assignedIds}
pendingId={state.pendingId}
removingId={state.removingId}
onToggle={toggle}
/>
)}
{/* Summary footer when assigned items exist */}
{hasAssigned && (
<div className="ha-picker-summary">
<span className="ha-picker-summary-left">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>
<span className="ha-picker-summary-count">{products.length}</span>{" "}
{products.length === 1 ? "product is" : "products are"} live at this stop
</span>
</span>
<button
type="button"
onClick={clearAll}
disabled={!!removingId}
className="ha-picker-summary-clear"
>
Remove all
</button>
</div>
<PickerSummary
count={state.products.length}
removing={!!state.removingId}
onClearAll={clearAll}
/>
)}
</div>
);
}
function PickerErrorBanner({ error }: { error: string }) {
return (
<div
role="alert"
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs text-[var(--admin-danger)]"
style={{
background: "var(--admin-danger-soft)",
border: "1px solid color-mix(in srgb, var(--admin-danger) 25%, transparent)",
}}
>
<svg className="h-3.5 w-3.5 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" strokeLinecap="round" />
</svg>
<span className="font-mono text-[11px]">{error}</span>
</div>
);
}
function PickerHeader({
count,
search,
dispatch,
}: {
count: number;
search: string;
dispatch: React.Dispatch<Action>;
}) {
return (
<div className="ha-picker-header">
<div className="ha-picker-title">
<span className="ha-picker-title-num font-display">
{String(count).padStart(2, "0")}
</span>
<span className="ha-picker-title-label">
{count === 1 ? "Product on this stop" : "Products on this stop"}
</span>
</div>
<div className="ha-picker-search">
<span className="ha-picker-search-icon" aria-hidden="true">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
</svg>
</span>
<input aria-label="Search Products…"
id="stop-product-search"
type="search"
value={search}
onChange={(e) => dispatch({ type: "SET_SEARCH", value: e.target.value })}
placeholder="Search products…"
className="ha-picker-search-input"
autoComplete="off"
/>
</div>
</div>
);
}
function FilterChips({
filter,
counts,
dispatch,
}: {
filter: Filter;
counts: { all: number; available: number; assigned: number };
dispatch: React.Dispatch<Action>;
}) {
return (
<div className="ha-picker-filterbar">
<button
type="button"
onClick={() => dispatch({ type: "SET_FILTER", value: "all" })}
className={`ha-picker-chip ${filter === "all" ? "ha-picker-chip--active" : ""}`}
>
All
<span className="ha-picker-chip-count">{counts.all}</span>
</button>
<button
type="button"
onClick={() => dispatch({ type: "SET_FILTER", value: "available" })}
className={`ha-picker-chip ${filter === "available" ? "ha-picker-chip--active" : ""}`}
>
Available
<span className="ha-picker-chip-count">{counts.available}</span>
</button>
<button
type="button"
onClick={() => dispatch({ type: "SET_FILTER", value: "assigned" })}
className={`ha-picker-chip ${filter === "assigned" ? "ha-picker-chip--active" : ""}`}
>
On this stop
<span className="ha-picker-chip-count">{counts.assigned}</span>
</button>
<span className="ha-modal-footer-hint ml-auto">
Press <kbd>/</kbd> to search
</span>
</div>
);
}
function PickerEmptyState({
variant,
filter,
search,
}: {
variant: "no-products" | "no-matches";
filter?: Filter;
search?: string;
}) {
if (variant === "no-products") {
return (
<div className="ha-picker-empty">
<p className="ha-picker-empty-mark">No products yet</p>
<p className="ha-picker-empty-text">
Create products in the catalog first, then come back here to assign them to this stop.
</p>
</div>
);
}
return (
<div className="ha-picker-empty">
<p className="ha-picker-empty-mark">
{filter === "assigned" ? "Nothing assigned" : "No matches"}
</p>
<p className="ha-picker-empty-text">
{search?.trim()
? `No products match "${search.trim()}". Try a different term.`
: filter === "assigned"
? "This stop has no products assigned yet. Click a card in the All tab to add one."
: "All products are already assigned to this stop."}
</p>
</div>
);
}
function ProductGrid({
products,
assignedIds,
pendingId,
removingId,
onToggle,
}: {
products: Product[];
assignedIds: Set<string>;
pendingId: string | null;
removingId: string | null;
onToggle: (id: string) => void;
}) {
return (
<div className="ha-picker-grid">
{products.map((product) => {
const isAssigned = assignedIds.has(product.id);
const isBusy = pendingId === product.id || removingId === product.id;
return (
<ProductCard
key={product.id}
product={product}
isAssigned={isAssigned}
isBusy={isBusy}
onToggle={() => onToggle(product.id)}
/>
);
})}
</div>
);
}
function ProductCard({
product,
isAssigned,
isBusy,
onToggle,
}: {
product: Product;
isAssigned: boolean;
isBusy: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
disabled={isBusy}
aria-pressed={isAssigned}
className={`ha-product-card ${isAssigned ? "ha-product-card--assigned" : ""} ${
isBusy ? "ha-product-card--disabled" : ""
}`}
>
{/* Image / Initial */}
<span className="ha-product-card-image">
{product.image_url ? (
<NextImage
src={product.image_url}
alt={product.name}
fill
sizes="48px"
style={{ objectFit: "cover" }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
initialOf(product.name)
)}
</span>
{/* Body */}
<span className="ha-product-card-body">
<span className="ha-product-card-name">{product.name}</span>
<span className="ha-product-card-meta">
<span className="ha-product-card-type">{TYPE_LABEL[product.type] ?? product.type}</span>
<span className="ha-product-card-price">{formatPrice(product.price)}</span>
</span>
</span>
{/* Status icon */}
{isBusy ? (
<span className="ha-product-card-plus" aria-hidden="true">
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
</span>
) : isAssigned ? (
<>
<span className="ha-product-card-check" aria-hidden="true">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="ha-product-card-remove" aria-hidden="true">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M6 6l12 12M6 18 18 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</>
) : (
<span className="ha-product-card-plus" aria-hidden="true">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</span>
)}
</button>
);
}
function PickerSummary({
count,
removing,
onClearAll,
}: {
count: number;
removing: boolean;
onClearAll: () => void;
}) {
return (
<div className="ha-picker-summary">
<span className="ha-picker-summary-left">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
<path d="M5 12l5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>
<span className="ha-picker-summary-count">{count}</span>{" "}
{count === 1 ? "product is" : "products are"} live at this stop
</span>
</span>
<button
type="button"
onClick={onClearAll}
disabled={removing}
className="ha-picker-summary-clear"
>
Remove all
</button>
</div>
);
}
+499 -254
View File
@@ -1,7 +1,6 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import React, { useState, useTransition, useEffect, useMemo, useCallback, useRef } from "react";
import React, { useReducer, useTransition, useMemo, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop, deleteStop } from "@/actions/stops";
@@ -9,7 +8,6 @@ import {
AdminSearchInput,
AdminFilterTabs,
AdminButton,
AdminIconButton,
AdminViewModeTabs,
useToast,
Skeleton,
@@ -42,14 +40,17 @@ type Props = {
type ViewMode = "table" | "cards";
type SortField = "date" | "city" | "location" | "status";
type SortDirection = "asc" | "desc";
type StatusFilter = "all" | "active" | "inactive" | "draft";
const TAB_NUMERIC: Record<"all" | "active" | "inactive" | "draft", "all" | "active" | "inactive" | "draft"> = {
const TAB_NUMERIC: Record<StatusFilter, StatusFilter> = {
all: "all",
active: "active",
inactive: "inactive",
draft: "draft",
};
const PAGE_SIZE = 25;
// Icons
const Icons = {
search: (className: string) => (
@@ -86,23 +87,109 @@ const StopIconHeader = () => (
</svg>
);
// ── State / Reducer ────────────────────────────────────────────────────────────
type State = {
search: string;
statusFilter: StatusFilter;
deleteError: string | null;
page: number;
selectedStops: Set<string>;
bulkPublishing: boolean;
showImport: boolean;
editingStop: Stop | null;
showAddModal: boolean;
sortField: SortField;
sortDirection: SortDirection;
viewMode: ViewMode;
};
type Action =
| { type: "SET_SEARCH"; value: string }
| { type: "SET_STATUS_FILTER"; value: StatusFilter }
| { type: "SET_DELETE_ERROR"; value: string | null }
| { type: "SET_PAGE"; value: number }
| { type: "RESET_PAGE" }
| { type: "SELECT_ALL"; stops: Stop[] }
| { type: "CLEAR_SELECTION" }
| { type: "TOGGLE_SELECT"; stopId: string }
| { type: "SET_BULK_PUBLISHING"; value: boolean }
| { type: "SET_SHOW_IMPORT"; value: boolean }
| { type: "SET_EDITING_STOP"; value: Stop | null }
| { type: "SET_SHOW_ADD_MODAL"; value: boolean }
| { type: "CLOSE_EDIT_MODAL" }
| { type: "SET_SORT"; field: SortField }
| { type: "TOGGLE_SORT_DIRECTION" }
| { type: "SET_VIEW_MODE"; value: ViewMode };
const initialState: State = {
search: "",
statusFilter: "all",
deleteError: null,
page: 0,
selectedStops: new Set(),
bulkPublishing: false,
showImport: false,
editingStop: null,
showAddModal: false,
sortField: "date",
sortDirection: "asc",
viewMode: "table",
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_SEARCH":
return { ...state, search: action.value };
case "SET_STATUS_FILTER":
return { ...state, statusFilter: action.value };
case "SET_DELETE_ERROR":
return { ...state, deleteError: action.value };
case "SET_PAGE":
return { ...state, page: action.value };
case "RESET_PAGE":
return { ...state, page: 0 };
case "SELECT_ALL":
return { ...state, selectedStops: new Set(action.stops.map((s) => s.id)) };
case "CLEAR_SELECTION":
return { ...state, selectedStops: new Set() };
case "TOGGLE_SELECT": {
const next = new Set(state.selectedStops);
if (next.has(action.stopId)) {
next.delete(action.stopId);
} else {
next.add(action.stopId);
}
return { ...state, selectedStops: next };
}
case "SET_BULK_PUBLISHING":
return { ...state, bulkPublishing: action.value };
case "SET_SHOW_IMPORT":
return { ...state, showImport: action.value };
case "SET_EDITING_STOP":
return { ...state, editingStop: action.value };
case "SET_SHOW_ADD_MODAL":
return { ...state, showAddModal: action.value };
case "CLOSE_EDIT_MODAL":
return { ...state, showAddModal: false, editingStop: null };
case "SET_SORT":
return { ...state, sortField: action.field, sortDirection: "asc" };
case "TOGGLE_SORT_DIRECTION":
return { ...state, sortDirection: state.sortDirection === "asc" ? "desc" : "asc" };
case "SET_VIEW_MODE":
return { ...state, viewMode: action.value };
}
}
export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) {
const router = useRouter();
const { success: showSuccess, error: showError } = useToast();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
const [deleteError, setDeleteError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
const [bulkPublishing, setBulkPublishing] = useState(false);
const [showImport, setShowImport] = useState(false);
const [editingStop, setEditingStop] = useState<Stop | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [sortField, setSortField] = useState<SortField>("date");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const [viewMode, setViewMode] = useState<ViewMode>("table");
const PAGE_SIZE = 25;
const [state, dispatch] = useReducer(reducer, initialState);
const {
search, statusFilter, deleteError, page, selectedStops, bulkPublishing,
showImport, editingStop, showAddModal, sortField, sortDirection, viewMode,
} = state;
// Compute stats
const stats = useMemo(() => {
@@ -149,12 +236,13 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
case "location":
comparison = a.location.localeCompare(b.location);
break;
case "status":
case "status": {
const statusOrder = { draft: 0, inactive: 1, active: 2 };
const aStatus = a.status === "draft" ? "draft" : a.active ? "active" : "inactive";
const bStatus = b.status === "draft" ? "draft" : b.active ? "active" : "inactive";
comparison = statusOrder[aStatus] - statusOrder[bStatus];
break;
}
}
return sortDirection === "asc" ? comparison : -comparison;
});
@@ -185,39 +273,18 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
const handleSort = useCallback((field: SortField) => {
if (sortField === field) {
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
dispatch({ type: "TOGGLE_SORT_DIRECTION" });
} else {
setSortField(field);
setSortDirection("asc");
dispatch({ type: "SET_SORT", field });
}
setPage(0);
dispatch({ type: "RESET_PAGE" });
}, [sortField]);
const toggleSelectAll = () => {
if (selectedStops.size === paginatedStops.length) {
setSelectedStops(new Set());
} else {
setSelectedStops(new Set(paginatedStops.map((s) => s.id)));
}
};
const toggleStopSelection = (stopId: string) => {
setSelectedStops((prev) => {
const next = new Set(prev);
if (next.has(stopId)) {
next.delete(stopId);
} else {
next.add(stopId);
}
return next;
});
};
async function handleBulkPublish() {
function handleBulkPublish() {
if (selectedStops.size === 0) return;
setBulkPublishing(true);
dispatch({ type: "SET_BULK_PUBLISHING", value: true });
const stopIds = Array.from(selectedStops);
const results = await Promise.all(
void Promise.all(
stopIds.map(async (stopId) => {
const stop = stops.find((s) => s.id === stopId);
if (!stop || stop.status !== "draft") return null; // skipped — not a draft
@@ -228,21 +295,22 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
return false;
}
})
);
const successCount = results.filter((r) => r === true).length;
const failCount = results.filter((r) => r === false).length;
setBulkPublishing(false);
setSelectedStops(new Set());
if (failCount === 0) {
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
} else {
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
}
startTransition(() => router.refresh());
).then((results) => {
const successCount = results.filter((r) => r === true).length;
const failCount = results.filter((r) => r === false).length;
dispatch({ type: "SET_BULK_PUBLISHING", value: false });
dispatch({ type: "CLEAR_SELECTION" });
if (failCount === 0) {
showSuccess(`${successCount} stop${successCount !== 1 ? "s" : ""} published`);
} else {
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
}
startTransition(() => router.refresh());
});
}
function handleDeleted() {
setDeleteError(null);
dispatch({ type: "SET_DELETE_ERROR", value: null });
startTransition(() => router.refresh());
}
@@ -250,158 +318,72 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
startTransition(() => router.refresh());
}
const hasFilters = !!(search || statusFilter !== "all");
return (
<>
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--admin-accent)]/10">
<StopIconHeader />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
Stops & Routes
</h1>
<p className="text-xs text-[var(--admin-text-muted)]">
{sorted.length} stop{sorted.length !== 1 ? "s" : ""}
{search || statusFilter !== "all" ? " matching filters" : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<AdminButton
variant="secondary"
size="sm"
onClick={() => setShowImport(true)}
icon={Icons.upload("h-4 w-4")}
>
Upload
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={() => setShowAddModal(true)}
icon={Icons.plus("h-4 w-4")}
>
Add Stop
</AdminButton>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1 tabular-nums">
{stats.total}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1 tabular-nums">
{stats.active}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Upcoming</p>
<p className="text-xl sm:text-2xl font-bold text-sky-600 mt-1 tabular-nums">
{stats.upcoming}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Drafts</p>
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1 tabular-nums">
{stats.draft}
</p>
</div>
</div>
{/* Filters */}
<StopsPageHeader
sortedCount={sorted.length}
hasFilters={hasFilters}
onUpload={() => dispatch({ type: "SET_SHOW_IMPORT", value: true })}
onAdd={() => dispatch({ type: "SET_SHOW_ADD_MODAL", value: true })}
/>
<StatsGrid stats={stats} />
{!hideInternalFilterBar && (
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => {
setStatusFilter(TAB_NUMERIC[value as keyof typeof TAB_NUMERIC] ?? "all");
setPage(0);
}}
<StopsFilterBar
statusFilter={statusFilter}
search={search}
viewMode={viewMode}
tabs={tabs}
size="md"
/>
<AdminSearchInput
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
onStatusFilterChange={(value) => {
dispatch({ type: "SET_STATUS_FILTER", value: TAB_NUMERIC[value as StatusFilter] ?? "all" });
dispatch({ type: "RESET_PAGE" });
}}
onClear={() => {
setSearch("");
setPage(0);
onSearchChange={(e) => {
dispatch({ type: "SET_SEARCH", value: e.target.value });
dispatch({ type: "RESET_PAGE" });
}}
placeholder="Search stops..."
containerClassName="flex-1"
onSearchClear={() => {
dispatch({ type: "SET_SEARCH", value: "" });
dispatch({ type: "RESET_PAGE" });
}}
onViewModeChange={(value) => dispatch({ type: "SET_VIEW_MODE", value: value as ViewMode })}
/>
<AdminViewModeTabs
activeTab={viewMode}
onTabChange={(value) => setViewMode(value as ViewMode)}
/>
</div>
)}
{/* Bulk actions bar */}
{selectedStops.size > 0 && (
<div className="mb-4 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
{selectedStops.size} stop{selectedStops.size !== 1 ? "s" : ""} selected
</span>
<button type="button"
onClick={() => setSelectedStops(new Set())}
className="text-xs text-stone-500 hover:text-stone-700"
aria-label="Clear">
Clear
</button>
</div>
<AdminButton
variant="primary"
size="sm"
onClick={handleBulkPublish}
isLoading={bulkPublishing}
>
Publish Selected
</AdminButton>
</div>
)}
{/* Delete error */}
{deleteError && (
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{deleteError}{" "}
<button type="button" onClick={() => setDeleteError(null)} className="underline hover:no-underline" aria-label="Dismiss">
Dismiss
</button>
</div>
)}
{/* Content */}
<BulkActionsBar
selectedCount={selectedStops.size}
bulkPublishing={bulkPublishing}
onClear={() => dispatch({ type: "CLEAR_SELECTION" })}
onPublish={handleBulkPublish}
/>
<DeleteErrorBanner
error={deleteError}
onDismiss={() => dispatch({ type: "SET_DELETE_ERROR", value: null })}
/>
{viewMode === "table" ? (
<TableView
stops={paginatedStops}
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onEdit={setEditingStop}
onEdit={(stop) => dispatch({ type: "SET_EDITING_STOP", value: stop })}
onDelete={(id) => {
const stop = stops.find((s) => s.id === id);
if (stop) {
setEditingStop(stop);
dispatch({ type: "SET_EDITING_STOP", value: stop });
}
}}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
onDeleteError={(msg) => dispatch({ type: "SET_DELETE_ERROR", value: msg })}
selectedStops={selectedStops}
onToggleSelectAll={toggleSelectAll}
onToggleSelect={toggleStopSelection}
onToggleSelectAll={() => {
if (selectedStops.size === paginatedStops.length) {
dispatch({ type: "CLEAR_SELECTION" });
} else {
dispatch({ type: "SELECT_ALL", stops: paginatedStops });
}
}}
onToggleSelect={(id) => dispatch({ type: "TOGGLE_SELECT", stopId: id })}
isLoading={false}
search={search}
statusFilter={statusFilter}
@@ -409,43 +391,31 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
) : (
<CardView
stops={paginatedStops}
onEdit={setEditingStop}
onEdit={(stop) => dispatch({ type: "SET_EDITING_STOP", value: stop })}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
onDeleteError={(msg) => dispatch({ type: "SET_DELETE_ERROR", value: msg })}
isLoading={false}
search={search}
statusFilter={statusFilter}
/>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-[var(--admin-border)] mt-4 pt-4">
<span className="text-xs text-[var(--admin-text-muted)]">
Showing {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, sorted.length)} of {sorted.length}
</span>
<AdminPagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
<StopsPagination
page={page}
pageSize={PAGE_SIZE}
totalCount={sorted.length}
totalPages={totalPages}
onPageChange={(p) => dispatch({ type: "SET_PAGE", value: p })}
/>
{showImport && (
<ScheduleImportModal
brandId={stops[0]?.brand_id ?? ""}
onClose={() => setShowImport(false)}
onClose={() => dispatch({ type: "SET_SHOW_IMPORT", value: false })}
onComplete={refresh}
/>
)}
<EditStopModal
isOpen={showAddModal || editingStop !== null}
onClose={() => {
setShowAddModal(false);
setEditingStop(null);
}}
onClose={() => dispatch({ type: "CLOSE_EDIT_MODAL" })}
brandId={stops[0]?.brand_id ?? ""}
stop={editingStop}
onSuccess={refresh}
@@ -454,6 +424,229 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }
);
}
// ── Page Header ────────────────────────────────────────────────────────────────
function StopsPageHeader({
sortedCount,
hasFilters,
onUpload,
onAdd,
}: {
sortedCount: number;
hasFilters: boolean;
onUpload: () => void;
onAdd: () => void;
}) {
return (
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--admin-accent)]/10">
<StopIconHeader />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
Stops & Routes
</h1>
<p className="text-xs text-[var(--admin-text-muted)]">
{sortedCount} stop{sortedCount !== 1 ? "s" : ""}
{hasFilters ? " matching filters" : ""}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<AdminButton
variant="secondary"
size="sm"
onClick={onUpload}
icon={Icons.upload("h-4 w-4")}
>
Upload
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={onAdd}
icon={Icons.plus("h-4 w-4")}
>
Add Stop
</AdminButton>
</div>
</div>
);
}
// ── Stats Grid ─────────────────────────────────────────────────────────────────
function StatsGrid({
stats,
}: {
stats: { total: number; active: number; upcoming: number; draft: number };
}) {
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1 tabular-nums">
{stats.total}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1 tabular-nums">
{stats.active}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Upcoming</p>
<p className="text-xl sm:text-2xl font-bold text-sky-600 mt-1 tabular-nums">
{stats.upcoming}
</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Drafts</p>
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1 tabular-nums">
{stats.draft}
</p>
</div>
</div>
);
}
// ── Filter Bar ─────────────────────────────────────────────────────────────────
type Tab = { value: string; label: string; count: number };
function StopsFilterBar({
statusFilter,
search,
viewMode,
tabs,
onStatusFilterChange,
onSearchChange,
onSearchClear,
onViewModeChange,
}: {
statusFilter: StatusFilter;
search: string;
viewMode: ViewMode;
tabs: Tab[];
onStatusFilterChange: (value: string) => void;
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onSearchClear: () => void;
onViewModeChange: (value: string) => void;
}) {
return (
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={onStatusFilterChange}
tabs={tabs}
size="md"
/>
<AdminSearchInput
value={search}
onChange={onSearchChange}
onClear={onSearchClear}
placeholder="Search stops..."
containerClassName="flex-1"
/>
<AdminViewModeTabs
activeTab={viewMode}
onTabChange={onViewModeChange}
/>
</div>
);
}
// ── Bulk Actions Bar ───────────────────────────────────────────────────────────
function BulkActionsBar({
selectedCount,
bulkPublishing,
onClear,
onPublish,
}: {
selectedCount: number;
bulkPublishing: boolean;
onClear: () => void;
onPublish: () => void;
}) {
if (selectedCount === 0) return null;
return (
<div className="mb-4 flex items-center justify-between rounded-xl border border-emerald-200 bg-emerald-50/60 px-4 py-2.5">
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-emerald-800 tabular-nums">
{selectedCount} stop{selectedCount !== 1 ? "s" : ""} selected
</span>
<button type="button"
onClick={onClear}
className="text-xs text-stone-500 hover:text-stone-700"
aria-label="Clear">
Clear
</button>
</div>
<AdminButton
variant="primary"
size="sm"
onClick={onPublish}
isLoading={bulkPublishing}
>
Publish Selected
</AdminButton>
</div>
);
}
// ── Delete Error Banner ────────────────────────────────────────────────────────
function DeleteErrorBanner({
error,
onDismiss,
}: {
error: string | null;
onDismiss: () => void;
}) {
if (!error) return null;
return (
<div className="mb-4 rounded-lg border border-red-500/30 bg-red-50 px-4 py-2.5 text-sm text-red-700">
{error}{" "}
<button type="button" onClick={onDismiss} className="underline hover:no-underline" aria-label="Dismiss">
Dismiss
</button>
</div>
);
}
// ── Pagination Section ─────────────────────────────────────────────────────────
function StopsPagination({
page,
pageSize,
totalCount,
totalPages,
onPageChange,
}: {
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
onPageChange: (p: number) => void;
}) {
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-between border-t border-[var(--admin-border)] mt-4 pt-4">
<span className="text-xs text-[var(--admin-text-muted)]">
Showing {page * pageSize + 1}{Math.min((page + 1) * pageSize, totalCount)} of {totalCount}
</span>
<AdminPagination
currentPage={page}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</div>
);
}
// ── Table View ─────────────────────────────────────────────────────────────────
function TableView({
@@ -651,16 +844,16 @@ function SortIcon({ field, sortField, sortDirection }: { field: SortField; sortF
function formatDate(iso: string): { date: string; weekday: string; isToday: boolean; isPast: boolean } {
const [y, m, d] = iso.split("-").map((s) => parseInt(s, 10));
if (!y || !m || !d) return { date: iso, weekday: "", isToday: false, isPast: false };
const today = new Date();
const todayStr = today.toISOString().split("T")[0];
const dt = new Date(y, m - 1, d);
const weekday = dt.toLocaleDateString("en-US", { weekday: "short" });
const month = dt.toLocaleDateString("en-US", { month: "short" });
return {
date: `${month} ${d}`,
return {
date: `${month} ${d}`,
weekday,
isToday: iso === todayStr,
isPast: iso < todayStr
@@ -677,7 +870,43 @@ function formatTime(t: string): string {
return `${hh}:${mStr} ${period}`;
}
// ── Row Component ───────────────────────────────────────────────────────────────
// ── StopRow (reducer-based) ────────────────────────────────────────────────────
type RowState = {
openMenu: string | null;
deletingId: string | null;
confirmDelete: string | null;
publishingId: string | null;
};
type RowAction =
| { type: "TOGGLE_MENU"; stopId: string }
| { type: "CLOSE_MENU" }
| { type: "SET_DELETING"; stopId: string | null }
| { type: "SET_CONFIRM_DELETE"; stopId: string | null }
| { type: "SET_PUBLISHING"; stopId: string | null };
const initialRowState: RowState = {
openMenu: null,
deletingId: null,
confirmDelete: null,
publishingId: null,
};
function rowReducer(state: RowState, action: RowAction): RowState {
switch (action.type) {
case "TOGGLE_MENU":
return { ...state, openMenu: state.openMenu === action.stopId ? null : action.stopId };
case "CLOSE_MENU":
return { ...state, openMenu: null, confirmDelete: null };
case "SET_DELETING":
return { ...state, deletingId: action.stopId };
case "SET_CONFIRM_DELETE":
return { ...state, confirmDelete: action.stopId };
case "SET_PUBLISHING":
return { ...state, publishingId: action.stopId };
}
}
function StopRow({
stop,
@@ -697,18 +926,16 @@ function StopRow({
onToggleSelect: () => void;
}) {
const { success: showSuccess, error: showError } = useToast();
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [rowState, dispatch] = useReducer(rowReducer, initialRowState);
const { openMenu, deletingId, confirmDelete, publishingId } = rowState;
const { date, weekday, isToday, isPast } = formatDate(stop.date);
const time = formatTime(stop.time);
async function handlePublish() {
setPublishingId(stop.id);
dispatch({ type: "SET_PUBLISHING", stopId: stop.id });
const result = await publishStop(stop.id, stop.brand_id);
setPublishingId(null);
dispatch({ type: "SET_PUBLISHING", stopId: null });
if (result.success) {
showSuccess("Stop published", "The stop is now visible on the storefront");
onDeleted();
@@ -718,11 +945,10 @@ function StopRow({
}
async function handleDelete() {
setDeletingId(stop.id);
dispatch({ type: "SET_DELETING", stopId: stop.id });
const result = await deleteStop(stop.id, stop.brand_id);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
dispatch({ type: "SET_DELETING", stopId: null });
dispatch({ type: "CLOSE_MENU" });
if (result.success) {
showSuccess("Stop deleted", "The stop has been removed");
onDeleted();
@@ -735,8 +961,8 @@ function StopRow({
<tr
onClick={onEdit}
className={`group cursor-pointer transition-all duration-150 ${
isSelected
? "bg-emerald-50/60"
isSelected
? "bg-emerald-50/60"
: "hover:bg-stone-50/80"
} ${isPast && stop.status !== "draft" ? "opacity-60" : ""}`}
>
@@ -826,7 +1052,7 @@ function StopRow({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpenMenu(openMenu === stop.id ? null : stop.id);
dispatch({ type: "TOGGLE_MENU", stopId: stop.id });
}}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
aria-label="Stop actions"
@@ -842,18 +1068,15 @@ function StopRow({
type="button"
aria-label="Close menu"
className="fixed inset-0 z-10 cursor-default bg-transparent border-0 p-0"
onClick={() => {
setOpenMenu(null);
setConfirmDelete(null);
}}
onClick={() => dispatch({ type: "CLOSE_MENU" })}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
{stop.status === "draft" && (
<button type="button"
onClick={(e) => {
e.stopPropagation();
handlePublish();
setOpenMenu(null);
void handlePublish();
dispatch({ type: "CLOSE_MENU" });
}}
disabled={publishingId === stop.id}
className="w-full text-left px-4 py-2.5 text-sm font-medium text-emerald-700 hover:bg-emerald-50 transition-colors disabled:opacity-50 flex items-center gap-2"
@@ -877,8 +1100,8 @@ function StopRow({
<button type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenu(null);
setConfirmDelete(stop.id);
dispatch({ type: "CLOSE_MENU" });
dispatch({ type: "SET_CONFIRM_DELETE", stopId: stop.id });
}}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors flex items-center gap-2"
aria-label="Delete">
@@ -897,10 +1120,7 @@ function StopRow({
type="button"
aria-label="Cancel delete confirmation"
className="fixed inset-0 z-30 border-0 p-0 cursor-default"
onClick={() => {
setConfirmDelete(null);
setOpenMenu(null);
}}
onClick={() => dispatch({ type: "CLOSE_MENU" })}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
<p className="text-sm font-semibold text-stone-800">
@@ -915,8 +1135,7 @@ function StopRow({
size="sm"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(null);
setOpenMenu(null);
dispatch({ type: "CLOSE_MENU" });
}}
>
Cancel
@@ -926,7 +1145,7 @@ function StopRow({
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDelete();
void handleDelete();
}}
disabled={deletingId === stop.id}
isLoading={deletingId === stop.id}
@@ -943,7 +1162,33 @@ function StopRow({
);
}
// ── Card Component ─────────────────────────────────────────────────────────────
// ── StopCard (reducer-based) ───────────────────────────────────────────────────
type CardState = {
confirmDelete: boolean;
deleting: boolean;
};
type CardAction =
| { type: "OPEN_CONFIRM" }
| { type: "CLOSE_CONFIRM" }
| { type: "SET_DELETING"; value: boolean };
const initialCardState: CardState = {
confirmDelete: false,
deleting: false,
};
function cardReducer(state: CardState, action: CardAction): CardState {
switch (action.type) {
case "OPEN_CONFIRM":
return { ...state, confirmDelete: true };
case "CLOSE_CONFIRM":
return { ...state, confirmDelete: false };
case "SET_DELETING":
return { ...state, deleting: action.value };
}
}
function StopCard({
stop,
@@ -956,17 +1201,17 @@ function StopCard({
onDeleted: () => void;
onDeleteError: (msg: string) => void;
}) {
const { success: showSuccess, error: showError } = useToast();
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const { success: showSuccess } = useToast();
const [cardState, dispatch] = useReducer(cardReducer, initialCardState);
const { confirmDelete, deleting } = cardState;
const { date, weekday, isToday, isPast } = formatDate(stop.date);
const time = formatTime(stop.time);
async function handleDelete() {
setDeleting(true);
dispatch({ type: "SET_DELETING", value: true });
const result = await deleteStop(stop.id, stop.brand_id);
setDeleting(false);
dispatch({ type: "SET_DELETING", value: false });
if (result.success) {
showSuccess("Stop deleted", "The stop has been removed");
onDeleted();
@@ -1036,7 +1281,7 @@ function StopCard({
</AdminButton>
<button type="button"
aria-label={`Delete stop in ${stop.city}, ${stop.state}`}
onClick={() => setConfirmDelete(true)}
onClick={() => dispatch({ type: "OPEN_CONFIRM" })}
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -1058,13 +1303,13 @@ function StopCard({
</p>
<div className="flex gap-2 mt-3">
<button type="button"
onClick={() => setConfirmDelete(false)}
onClick={() => dispatch({ type: "CLOSE_CONFIRM" })}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
aria-label="Cancel">
Cancel
</button>
<button type="button"
onClick={handleDelete}
onClick={() => void handleDelete()}
disabled={deleting}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
>
+337 -211
View File
@@ -1,7 +1,6 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { formatDate } from "@/lib/date-utils";
import { useReducer, useCallback, useEffect } from "react";
import {
exportTaxReport,
exportTaxByState,
@@ -53,7 +52,7 @@ function downloadCSV(csv: string, filename: string) {
URL.revokeObjectURL(url);
}
function quarterLabel(start: string, end: string): string {
function quarterLabel(start: string): string {
const s = new Date(start + "T00:00:00");
const q = Math.floor(s.getMonth() / 3) + 1;
return `Q${q} ${s.getFullYear()}`;
@@ -84,7 +83,7 @@ function SummaryCard({
);
}
function ExportBtn({ label, onClick }: { label: string; onClick: () => void }) {
function ExportBtn({ onClick }: { label?: string; onClick: () => void }) {
return (
<AdminButton
onClick={onClick}
@@ -121,7 +120,7 @@ function ReportTable({ headers, rows, renderRow }: {
</td>
</tr>
) : (
rows.map((_, i) => renderRow(_, i))
rows.map((row, i) => renderRow(row, i))
)}
</tbody>
</table>
@@ -134,6 +133,67 @@ const TABS = [
{ value: "orders", label: "Taxable Orders" },
];
type State = {
preset: DatePreset;
customStart: string;
customEnd: string;
selectedBrandId: string;
activeTab: string;
summary: TaxSummaryDataLocal | null;
orders: TaxOrderRow[];
loading: boolean;
error: string | null;
};
type Action =
| { type: "SET_PRESET"; preset: DatePreset }
| { type: "SET_CUSTOM_START"; value: string }
| { type: "SET_CUSTOM_END"; value: string }
| { type: "SET_BRAND"; brandId: string }
| { type: "SET_TAB"; tab: string }
| { type: "SET_SUMMARY"; data: TaxSummaryDataLocal }
| { type: "SET_ORDERS"; data: TaxOrderRow[] }
| { type: "RESET_DATA" }
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_ERROR"; error: string | null };
const initialState: State = {
preset: "quarter",
customStart: "",
customEnd: "",
selectedBrandId: "",
activeTab: "summary",
summary: null,
orders: [],
loading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_PRESET":
return { ...state, preset: action.preset };
case "SET_CUSTOM_START":
return { ...state, customStart: action.value };
case "SET_CUSTOM_END":
return { ...state, customEnd: action.value };
case "SET_BRAND":
return { ...state, selectedBrandId: action.brandId };
case "SET_TAB":
return { ...state, activeTab: action.tab };
case "SET_SUMMARY":
return { ...state, summary: action.data };
case "SET_ORDERS":
return { ...state, orders: action.data };
case "RESET_DATA":
return { ...state, summary: null, orders: [] };
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_ERROR":
return { ...state, error: action.error };
}
}
export default function TaxDashboard({
brands,
initialBrandId,
@@ -143,17 +203,12 @@ export default function TaxDashboard({
initialBrandId: string | null;
isPlatformAdmin: boolean;
}) {
const [preset, setPreset] = useState<DatePreset>("quarter");
const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState("");
const [selectedBrandId, setSelectedBrandId] = useState<string>(initialBrandId ?? "");
const [activeTab, setActiveTab] = useState("summary");
const [summary, setSummary] = useState<TaxSummaryDataLocal | null>(null);
const [orders, setOrders] = useState<TaxOrderRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [state, dispatch] = useReducer(reducer, {
...initialState,
selectedBrandId: initialBrandId ?? "",
});
const range: DateRange = buildRange(preset, customStart, customEnd);
const range: DateRange = buildRange(state.preset, state.customStart, state.customEnd);
// Year is read from the clock inline at the rendering site so we never
// need to mirror it into React state. This sidesteps the "fake event
@@ -163,101 +218,91 @@ export default function TaxDashboard({
const currentYear = new Date().getFullYear();
const fetchTaxSummary = useCallback(async () => {
if (!selectedBrandId) return;
setLoading(true);
setError(null);
if (!state.selectedBrandId) return;
dispatch({ type: "SET_LOADING", value: true });
dispatch({ type: "SET_ERROR", error: null });
try {
const result = await getTaxSummaryAction({
brandId: selectedBrandId,
brandId: state.selectedBrandId,
startDate: range.start,
endDate: range.end,
});
if (!result.success) throw new Error(result.error);
setSummary(result.data);
dispatch({ type: "SET_SUMMARY", data: result.data });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load tax summary");
dispatch({ type: "SET_ERROR", error: err instanceof Error ? err.message : "Failed to load tax summary" });
} finally {
setLoading(false);
dispatch({ type: "SET_LOADING", value: false });
}
}, [selectedBrandId, range]);
}, [state.selectedBrandId, range.start, range.end]);
const fetchTaxableOrders = useCallback(async () => {
if (!selectedBrandId) return;
setLoading(true);
setError(null);
if (!state.selectedBrandId) return;
dispatch({ type: "SET_LOADING", value: true });
dispatch({ type: "SET_ERROR", error: null });
try {
const result = await getTaxableOrdersAction({
brandId: selectedBrandId,
brandId: state.selectedBrandId,
startDate: range.start,
endDate: range.end,
});
if (!result.success) throw new Error(result.error);
setOrders(result.data);
dispatch({ type: "SET_ORDERS", data: result.data });
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load orders");
dispatch({ type: "SET_ERROR", error: err instanceof Error ? err.message : "Failed to load orders" });
} finally {
setLoading(false);
dispatch({ type: "SET_LOADING", value: false });
}
}, [selectedBrandId, range]);
}, [state.selectedBrandId, range.start, range.end]);
function handleFetch() {
if (activeTab === "summary") fetchTaxSummary();
else fetchTaxableOrders();
}
const fetchForCurrentTab = useCallback(async () => {
if (!state.selectedBrandId) return;
if (state.activeTab === "summary") {
await fetchTaxSummary();
} else {
await fetchTaxableOrders();
}
}, [state.selectedBrandId, state.activeTab, fetchTaxSummary, fetchTaxableOrders]);
// Initial fetch on mount — runs once when the dashboard first renders.
// Subsequent fetches happen inside the change handlers below, so we
// never have to watch state changes from a useEffect.
useEffect(() => {
if (!selectedBrandId) return;
if (activeTab === "summary") {
void fetchTaxSummary();
} else {
void fetchTaxableOrders();
}
if (!state.selectedBrandId) return;
void fetchForCurrentTab();
// selectedBrandId + activeTab are stable for this initial-load path;
// re-runs only on mount.
}, [selectedBrandId, activeTab, fetchTaxSummary, fetchTaxableOrders]);
}, [state.selectedBrandId, fetchForCurrentTab]);
function handlePresetChange(p: DatePreset) {
setPreset(p);
dispatch({ type: "SET_PRESET", preset: p });
if (p !== "custom") {
void fetchForCurrentTab();
}
}
function handleCustomDatesChange() {
if (preset === "custom") {
if (state.preset === "custom") {
void fetchForCurrentTab();
}
}
function handleBrandChange(brandId: string) {
setSelectedBrandId(brandId);
setSummary(null);
setOrders([]);
dispatch({ type: "SET_BRAND", brandId });
dispatch({ type: "RESET_DATA" });
void fetchForCurrentTab();
}
function handleTabChange(tab: string) {
setActiveTab(tab);
dispatch({ type: "SET_TAB", tab });
void fetchForCurrentTab();
}
async function fetchForCurrentTab() {
if (!selectedBrandId) return;
if (activeTab === "summary") {
await fetchTaxSummary();
} else {
await fetchTaxableOrders();
}
}
function handleExportCSV() {
if (activeTab === "summary" && summary) {
const rows: TaxByStateRow[] = summary.tax_by_state.map((s) => ({
if (state.activeTab === "summary" && state.summary) {
const rows: TaxByStateRow[] = state.summary.tax_by_state.map((s) => ({
state: s.state,
total_tax: s.total_tax,
gross_sales: s.gross_sales,
@@ -265,195 +310,276 @@ export default function TaxDashboard({
}));
const csv = exportTaxByState(rows);
downloadCSV(csv, `tax-by-state-${range.start}.csv`);
} else if (activeTab === "orders" && orders.length > 0) {
const csv = exportTaxReport(orders);
} else if (state.activeTab === "orders" && state.orders.length > 0) {
const csv = exportTaxReport(state.orders);
downloadCSV(csv, `taxable-orders-${range.start}.csv`);
}
}
const effectiveRate = summary && summary.total_gross_sales > 0
? (summary.total_tax_collected / summary.total_gross_sales) * 100
const effectiveRate = state.summary && state.summary.total_gross_sales > 0
? (state.summary.total_tax_collected / state.summary.total_gross_sales) * 100
: 0;
return (
<div className="space-y-6">
{error && (
{state.error && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
{state.error}
</div>
)}
{/* ── Filters ── */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-5 py-4">
<div className="flex gap-1">
{(["month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
<AdminButton
key={p}
onClick={() => handlePresetChange(p)}
variant={preset === p ? "primary" : "secondary"}
size="sm"
>
{p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
</AdminButton>
))}
</div>
<FilterBar
preset={state.preset}
customStart={state.customStart}
customEnd={state.customEnd}
selectedBrandId={state.selectedBrandId}
loading={state.loading}
isPlatformAdmin={isPlatformAdmin}
brands={brands}
rangeStart={range.start}
rangeEnd={range.end}
currentYear={currentYear}
onPresetChange={handlePresetChange}
onCustomStartChange={(value) => {
dispatch({ type: "SET_CUSTOM_START", value });
handleCustomDatesChange();
}}
onCustomEndChange={(value) => {
dispatch({ type: "SET_CUSTOM_END", value });
handleCustomDatesChange();
}}
onBrandChange={handleBrandChange}
onRefresh={fetchForCurrentTab}
/>
{preset === "custom" && (
<div className="flex gap-2 items-center">
<input aria-label="Date"
type="date"
value={customStart}
onChange={(e) => {
setCustomStart(e.target.value);
handleCustomDatesChange();
}}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
<span className="text-slate-400 text-xs">to</span>
<input aria-label="Date"
type="date"
value={customEnd}
onChange={(e) => {
setCustomEnd(e.target.value);
handleCustomDatesChange();
}}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
</div>
)}
{isPlatformAdmin && (
<select aria-label="Select"
value={selectedBrandId}
onChange={(e) => handleBrandChange(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
>
<option value="">All Brands</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
)}
<AdminButton
onClick={handleFetch}
disabled={loading || !selectedBrandId}
variant="primary"
size="sm"
isLoading={loading}
>
Refresh
</AdminButton>
<span className="ml-auto text-xs text-zinc-500">
{preset === "quarter" ? quarterLabel(range.start, range.end) :
preset === "this_year" ? `${currentYear} YTD` :
`${range.start}${range.end}`}
</span>
</div>
{/* ── Tab bar ── */}
<AdminFilterTabs
activeTab={activeTab}
activeTab={state.activeTab}
onTabChange={handleTabChange}
tabs={TABS}
size="md"
showCounts={false}
/>
{/* ── TAX SUMMARY TAB ── */}
{activeTab === "summary" && (
<div className="space-y-4">
{summary && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<SummaryCard
label="Total Tax Collected"
value={summary.total_tax_collected}
prefix="$"
highlight
/>
<SummaryCard
label="Total Gross Sales"
value={summary.total_gross_sales}
prefix="$"
/>
<SummaryCard
label="Effective Tax Rate"
value={effectiveRate}
suffix="%"
/>
<SummaryCard
label="Taxable Orders"
value={summary.order_count}
/>
</div>
{state.activeTab === "summary" && (
<SummaryTab
summary={state.summary}
loading={state.loading}
effectiveRate={effectiveRate}
onExport={handleExportCSV}
/>
)}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Tax by State</h3>
{summary.tax_by_state.length > 0 && (
<ExportBtn label="Export CSV" onClick={handleExportCSV} />
)}
</div>
<ReportTable
headers={["State", "Total Tax", "Gross Sales", "Orders", "Eff. Rate"]}
rows={summary.tax_by_state}
renderRow={(row) => {
const r = row as { state: string; total_tax: number; gross_sales: number; order_count: number };
const rate = r.gross_sales > 0 ? (r.total_tax / r.gross_sales) * 100 : 0;
return (
<tr key={r.state} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.state}</td>
<td className="px-3 py-2 text-right text-amber-700 font-medium">${r.total_tax.toFixed(2)}</td>
<td className="px-3 py-2 text-right">${r.gross_sales.toFixed(2)}</td>
<td className="px-3 py-2 text-right">{r.order_count}</td>
<td className="px-3 py-2 text-right text-zinc-500">{rate.toFixed(2)}%</td>
</tr>
);
}}
/>
</>
)}
{state.activeTab === "orders" && (
<OrdersTab orders={state.orders} onExport={handleExportCSV} />
)}
</div>
);
}
{!summary && !loading && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-6 py-12 text-center text-slate-400">
Click Refresh to load tax summary
</div>
)}
type FilterBarProps = {
preset: DatePreset;
customStart: string;
customEnd: string;
selectedBrandId: string;
loading: boolean;
isPlatformAdmin: boolean;
brands: { id: string; name: string }[];
rangeStart: string;
rangeEnd: string;
currentYear: number;
onPresetChange: (p: DatePreset) => void;
onCustomStartChange: (value: string) => void;
onCustomEndChange: (value: string) => void;
onBrandChange: (brandId: string) => void;
onRefresh: () => void | Promise<void>;
};
function FilterBar({
preset,
customStart,
customEnd,
selectedBrandId,
loading,
isPlatformAdmin,
brands,
rangeStart,
rangeEnd,
currentYear,
onPresetChange,
onCustomStartChange,
onCustomEndChange,
onBrandChange,
onRefresh,
}: FilterBarProps) {
return (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-5 py-4">
<div className="flex gap-1">
{(["month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
<AdminButton
key={p}
onClick={() => onPresetChange(p)}
variant={preset === p ? "primary" : "secondary"}
size="sm"
>
{p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
</AdminButton>
))}
</div>
{preset === "custom" && (
<div className="flex gap-2 items-center">
<input aria-label="Date"
type="date"
value={customStart}
onChange={(e) => onCustomStartChange(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
<span className="text-slate-400 text-xs">to</span>
<input aria-label="Date"
type="date"
value={customEnd}
onChange={(e) => onCustomEndChange(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
</div>
)}
{/* ── TAXABLE ORDERS TAB ── */}
{activeTab === "orders" && (
<div className="space-y-3">
{isPlatformAdmin && (
<select aria-label="Select"
value={selectedBrandId}
onChange={(e) => onBrandChange(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
>
<option value="">All Brands</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
)}
<AdminButton
onClick={() => void onRefresh()}
disabled={loading || !selectedBrandId}
variant="primary"
size="sm"
isLoading={loading}
>
Refresh
</AdminButton>
<span className="ml-auto text-xs text-zinc-500">
{preset === "quarter" ? quarterLabel(rangeStart) :
preset === "this_year" ? `${currentYear} YTD` :
`${rangeStart}${rangeEnd}`}
</span>
</div>
);
}
type SummaryTabProps = {
summary: TaxSummaryDataLocal | null;
loading: boolean;
effectiveRate: number;
onExport: () => void;
};
function SummaryTab({ summary, loading, effectiveRate, onExport }: SummaryTabProps) {
return (
<div className="space-y-4">
{summary && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<SummaryCard
label="Total Tax Collected"
value={summary.total_tax_collected}
prefix="$"
highlight
/>
<SummaryCard
label="Total Gross Sales"
value={summary.total_gross_sales}
prefix="$"
/>
<SummaryCard
label="Effective Tax Rate"
value={effectiveRate}
suffix="%"
/>
<SummaryCard
label="Taxable Orders"
value={summary.order_count}
/>
</div>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Taxable Orders</h3>
{orders.length > 0 && (
<ExportBtn label="Export CSV" onClick={handleExportCSV} />
<h3 className="text-sm font-semibold text-zinc-300">Tax by State</h3>
{summary.tax_by_state.length > 0 && (
<ExportBtn label="Export CSV" onClick={onExport} />
)}
</div>
<ReportTable
headers={["Order ID", "Date", "Customer", "City", "State", "Taxable Amt", "Tax", "Rate", "Location"]}
rows={orders}
headers={["State", "Total Tax", "Gross Sales", "Orders", "Eff. Rate"]}
rows={summary.tax_by_state}
renderRow={(row) => {
const r = row as TaxOrderRow;
const r = row as { state: string; total_tax: number; gross_sales: number; order_count: number };
const rate = r.gross_sales > 0 ? (r.total_tax / r.gross_sales) * 100 : 0;
return (
<tr key={r.order_id} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-mono text-xs text-zinc-400">{r.order_id.slice(0, 8)}</td>
<td className="px-3 py-2 text-zinc-400">{r.date}</td>
<td className="px-3 py-2 font-medium text-zinc-100">{r.customer_name}</td>
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
<td className="px-3 py-2 text-zinc-400">{r.state}</td>
<td className="px-3 py-2 text-right">${Number(r.taxable_amount).toFixed(2)}</td>
<td className="px-3 py-2 text-right text-amber-700 font-medium">${Number(r.tax_amount).toFixed(2)}</td>
<td className="px-3 py-2 text-right text-zinc-500">{Number(r.tax_rate).toFixed(4)}</td>
<td className="px-3 py-2 text-zinc-500 text-xs">{r.tax_location}</td>
<tr key={r.state} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.state}</td>
<td className="px-3 py-2 text-right text-amber-700 font-medium">${r.total_tax.toFixed(2)}</td>
<td className="px-3 py-2 text-right">${r.gross_sales.toFixed(2)}</td>
<td className="px-3 py-2 text-right">{r.order_count}</td>
<td className="px-3 py-2 text-right text-zinc-500">{rate.toFixed(2)}%</td>
</tr>
);
}}
/>
</>
)}
{!summary && !loading && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-6 py-12 text-center text-slate-400">
Click Refresh to load tax summary
</div>
)}
</div>
);
}
type OrdersTabProps = {
orders: TaxOrderRow[];
onExport: () => void;
};
function OrdersTab({ orders, onExport }: OrdersTabProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Taxable Orders</h3>
{orders.length > 0 && (
<ExportBtn label="Export CSV" onClick={onExport} />
)}
</div>
<ReportTable
headers={["Order ID", "Date", "Customer", "City", "State", "Taxable Amt", "Tax", "Rate", "Location"]}
rows={orders}
renderRow={(row) => {
const r = row as TaxOrderRow;
return (
<tr key={r.order_id} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-mono text-xs text-zinc-400">{r.order_id.slice(0, 8)}</td>
<td className="px-3 py-2 text-zinc-400">{r.date}</td>
<td className="px-3 py-2 font-medium text-zinc-100">{r.customer_name}</td>
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
<td className="px-3 py-2 text-zinc-400">{r.state}</td>
<td className="px-3 py-2 text-right">${Number(r.taxable_amount).toFixed(2)}</td>
<td className="px-3 py-2 text-right text-amber-700 font-medium">${Number(r.tax_amount).toFixed(2)}</td>
<td className="px-3 py-2 text-right text-zinc-500">{Number(r.tax_rate).toFixed(4)}</td>
<td className="px-3 py-2 text-zinc-500 text-xs">{r.tax_location}</td>
</tr>
);
}}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+100 -47
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import { useRouter } from "next/navigation";
import { updateWaterIrrigator, resetWaterIrrigatorPin, deleteWaterUser } from "@/actions/water-log/admin";
import AdminButton from "./design-system/AdminButton";
@@ -20,82 +20,135 @@ type Props = {
backHref?: string;
};
type Draft = {
name?: string;
role?: "irrigator" | "water_admin";
active?: boolean;
lang?: string;
};
type State = {
draft: Draft;
saving: boolean;
deleting: boolean;
resetting: boolean;
newPin: string | null;
error: string | null;
};
type Action =
| { type: "SET_NAME"; value: string }
| { type: "SET_ROLE"; value: "irrigator" | "water_admin" }
| { type: "SET_ACTIVE"; value: boolean }
| { type: "SET_LANG"; value: string }
| { type: "START_SAVE" }
| { type: "SAVE_FAIL"; error: string }
| { type: "START_DELETE" }
| { type: "DELETE_FAIL"; error: string }
| { type: "START_RESET_PIN" }
| { type: "RESET_PIN_SUCCESS"; pin: string }
| { type: "RESET_PIN_FAIL"; error: string }
| { type: "DISMISS_PIN" }
| { type: "SET_ERROR"; error: string | null };
const initialState: State = {
draft: {},
saving: false,
deleting: false,
resetting: false,
newPin: null,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_NAME":
return { ...state, draft: { ...state.draft, name: action.value } };
case "SET_ROLE":
return { ...state, draft: { ...state.draft, role: action.value } };
case "SET_ACTIVE":
return { ...state, draft: { ...state.draft, active: action.value } };
case "SET_LANG":
return { ...state, draft: { ...state.draft, lang: action.value } };
case "START_SAVE":
return { ...state, saving: true, error: null };
case "SAVE_FAIL":
return { ...state, saving: false, error: action.error };
case "START_DELETE":
return { ...state, deleting: true, error: null };
case "DELETE_FAIL":
return { ...state, deleting: false, error: action.error };
case "START_RESET_PIN":
return { ...state, resetting: true, error: null };
case "RESET_PIN_SUCCESS":
return { ...state, resetting: false, newPin: action.pin };
case "RESET_PIN_FAIL":
return { ...state, resetting: false, error: action.error };
case "DISMISS_PIN":
return { ...state, newPin: null };
case "SET_ERROR":
return { ...state, error: action.error };
}
}
export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-log" }: Props) {
const router = useRouter();
const [state, dispatch] = useReducer(reducer, initialState);
// Holds user edits only; missing keys fall back to the current prop so we
// never copy the prop into useState. When the prop changes, the fallback
// value tracks the new prop automatically.
const [draft, setDraft] = useState<{
name?: string;
role?: "irrigator" | "water_admin";
active?: boolean;
lang?: string;
}>({});
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [resetting, setResetting] = useState(false);
const [newPin, setNewPin] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const name = draft.name ?? waterUser.name;
const role = draft.role ?? waterUser.role;
const active = draft.active ?? waterUser.active;
const lang = draft.lang ?? waterUser.language_preference;
const setName = (v: string) => setDraft((d) => ({ ...d, name: v }));
const setRole = (v: "irrigator" | "water_admin") => setDraft((d) => ({ ...d, role: v }));
const setActive = (v: boolean) => setDraft((d) => ({ ...d, active: v }));
const setLang = (v: string) => setDraft((d) => ({ ...d, lang: v }));
const name = state.draft.name ?? waterUser.name;
const role = state.draft.role ?? waterUser.role;
const active = state.draft.active ?? waterUser.active;
const lang = state.draft.lang ?? waterUser.language_preference;
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
dispatch({ type: "START_SAVE" });
const result = await updateWaterIrrigator(waterUser.id, name.trim(), active, lang, role);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to save");
setSaving(false);
dispatch({ type: "SAVE_FAIL", error: result.error ?? "Failed to save" });
}
}
async function handleResetPin() {
setResetting(true);
dispatch({ type: "START_RESET_PIN" });
const result = await resetWaterIrrigatorPin(waterUser.id);
if (result.success && result.pin) {
setNewPin(result.pin);
dispatch({ type: "RESET_PIN_SUCCESS", pin: result.pin });
} else {
setError(result.error ?? "Failed to reset PIN");
dispatch({ type: "RESET_PIN_FAIL", error: result.error ?? "Failed to reset PIN" });
}
setResetting(false);
}
async function handleDelete() {
if (!window.confirm(`Delete user "${waterUser.name}"? Their log entries will be preserved.`)) return;
setDeleting(true);
dispatch({ type: "START_DELETE" });
const result = await deleteWaterUser(waterUser.id);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to delete");
setDeleting(false);
dispatch({ type: "DELETE_FAIL", error: result.error ?? "Failed to delete" });
}
}
return (
<form onSubmit={handleSave} className="space-y-4">
{error && (
{state.error && (
<div className="rounded-lg bg-red-100 border border-red-200 p-3 text-sm text-red-700">
{error}
{state.error}
</div>
)}
{newPin && (
{state.newPin && (
<div className="rounded-xl bg-amber-100 border border-amber-200 p-4">
<p className="font-semibold text-amber-800">New PIN for {name}:</p>
<p className="mt-1 text-2xl font-bold tracking-widest text-amber-900">{newPin}</p>
<p className="mt-1 text-2xl font-bold tracking-widest text-amber-900">{state.newPin}</p>
<p className="mt-1 text-xs text-amber-600">Write this down it will not be shown again.</p>
<button type="button" onClick={() => setNewPin(null)} className="mt-2 text-xs text-amber-700 underline">Dismiss</button>
<button type="button" onClick={() => dispatch({ type: "DISMISS_PIN" })} className="mt-2 text-xs text-amber-700 underline">Dismiss</button>
</div>
)}
@@ -105,7 +158,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
<input id="fld-1-name" aria-label="Text"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => dispatch({ type: "SET_NAME", value: e.target.value })}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
required
/>
@@ -114,7 +167,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
<label htmlFor="fld-2-role" className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Role</label>
<select id="fld-2-role" aria-label="Select"
value={role}
onChange={(e) => setRole(e.target.value as "irrigator" | "water_admin")}
onChange={(e) => dispatch({ type: "SET_ROLE", value: e.target.value as "irrigator" | "water_admin" })}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
>
<option value="irrigator">Irrigator</option>
@@ -130,7 +183,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
<label htmlFor="fld-3-language" className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Language</label>
<select id="fld-3-language" aria-label="Select"
value={lang}
onChange={(e) => setLang(e.target.value)}
onChange={(e) => dispatch({ type: "SET_LANG", value: e.target.value })}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
>
<option value="en">English</option>
@@ -144,7 +197,7 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
<label htmlFor="fld-4-status" className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Status</label>
<select id="fld-4-status" aria-label="Select"
value={active ? "1" : "0"}
onChange={(e) => setActive(e.target.value === "1")}
onChange={(e) => dispatch({ type: "SET_ACTIVE", value: e.target.value === "1" })}
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
>
<option value="1">Active</option>
@@ -156,8 +209,8 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
type="button"
variant="secondary"
onClick={handleResetPin}
disabled={resetting}
isLoading={resetting}
disabled={state.resetting}
isLoading={state.resetting}
>
Reset PIN
</AdminButton>
@@ -169,15 +222,15 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
type="button"
variant="danger"
onClick={handleDelete}
disabled={deleting}
isLoading={deleting}
disabled={state.deleting}
isLoading={state.deleting}
>
Delete User
</AdminButton>
<AdminButton
type="submit"
disabled={saving}
isLoading={saving}
disabled={state.saving}
isLoading={state.saving}
>
Save Changes
</AdminButton>
+213 -118
View File
@@ -1,10 +1,12 @@
"use client";
import { useState, useCallback } from "react";
import { useReducer, useCallback } from "react";
import { getWelcomeSequence, resendWelcomeEmail } from "@/actions/email-automation/welcome-sequence";
import type { WelcomeSequenceEntry } from "@/lib/welcome-email-sender";
type Props = { brandId: string };
type Filter = "all" | "active" | "completed";
type Stats = { total: number; completed: number; active: number; unsubscribed: number };
const STATUS_LABELS: Record<string, { en: string; color: string }> = {
active: { en: "Active", color: "bg-blue-50 text-blue-700 border border-blue-200" },
@@ -21,90 +23,95 @@ const STEP_LABELS: Record<number, string> = {
4: "Final (72h)",
};
export default function WelcomeSequenceDashboard({ brandId }: Props) {
const [entries, setEntries] = useState<WelcomeSequenceEntry[]>([]);
const [stats, setStats] = useState({ total: 0, completed: 0, active: 0, unsubscribed: 0 });
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<"all" | "active" | "completed">("all");
const [page, setPage] = useState(0);
const PAGE_SIZE = 25;
const setFilterAndResetPage = (next: typeof filter) => {
setFilter(next);
setPage(0);
};
const [resending, setResending] = useState<string | null>(null);
type State = {
entries: WelcomeSequenceEntry[];
stats: Stats;
loading: boolean;
filter: Filter;
page: number;
resending: string | null;
};
const handleResend = async (entryId: string) => {
setResending(entryId);
await resendWelcomeEmail(entryId, brandId);
await load();
setResending(null);
};
type Action =
| { type: "LOAD_START" }
| { type: "LOAD_SUCCESS"; entries: WelcomeSequenceEntry[]; stats: Stats }
| { type: "LOAD_END" }
| { type: "SET_FILTER"; filter: Filter }
| { type: "SET_PAGE"; page: number }
| { type: "SET_RESENDING"; value: string | null };
const initialState: State = {
entries: [],
stats: { total: 0, completed: 0, active: 0, unsubscribed: 0 },
loading: false,
filter: "all",
page: 0,
resending: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "LOAD_START":
return { ...state, loading: true };
case "LOAD_SUCCESS":
return { ...state, entries: action.entries, stats: action.stats };
case "LOAD_END":
return { ...state, loading: false };
case "SET_FILTER":
return { ...state, filter: action.filter, page: 0 };
case "SET_PAGE":
return { ...state, page: action.page };
case "SET_RESENDING":
return { ...state, resending: action.value };
}
}
export default function WelcomeSequenceDashboard({ brandId }: Props) {
const [state, dispatch] = useReducer(reducer, initialState);
const { entries, stats, loading, filter, page, resending } = state;
const load = useCallback(async () => {
setLoading(true);
dispatch({ type: "LOAD_START" });
const result = await getWelcomeSequence(brandId);
if (result.success) {
setEntries(result.entries);
setStats(result.stats);
dispatch({ type: "LOAD_SUCCESS", entries: result.entries, stats: result.stats });
}
setLoading(false);
dispatch({ type: "LOAD_END" });
}, [brandId]);
const handleResend = async (entryId: string) => {
dispatch({ type: "SET_RESENDING", value: entryId });
await resendWelcomeEmail(entryId, brandId);
await load();
dispatch({ type: "SET_RESENDING", value: null });
};
const filtered = entries.filter(e => {
if (filter === "active") return e.status === "active";
if (filter === "completed") return e.status === "completed";
return true;
});
const PAGE_SIZE = 25;
const paginatedEntries = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Total", value: stats.total, color: "text-[var(--admin-text-primary)]" },
{ label: "Active", value: stats.active, color: "text-blue-600" },
{ label: "Completed", value: stats.completed, color: "text-green-600" },
{ label: "Unsubscribed", value: stats.unsubscribed, color: "text-red-600" },
].map(s => (
<div key={s.label} className="bg-white border border-[var(--admin-border)] rounded-2xl p-4 text-center">
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">{s.label}</p>
</div>
))}
</div>
<StatsGrid stats={stats} />
{/* Filter + refresh + pagination */}
<div className="flex items-center gap-3">
<button type="button" onClick={load} disabled={loading}
className="text-xs font-semibold text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 transition-colors">
{loading ? "Loading..." : "↻ Refresh"}
</button>
<div className="flex gap-2 ml-auto">
{(["all", "active", "completed"] as const).map(f => (
<button type="button" key={f} onClick={() => setFilterAndResetPage(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-[var(--admin-accent)] text-white" : "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-2">
<button type="button" onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Previous">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
<button type="button" onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Next">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
)}
</div>
<FilterBar
loading={loading}
filter={filter}
page={page}
totalPages={totalPages}
onLoad={load}
onSelectFilter={(f) => dispatch({ type: "SET_FILTER", filter: f })}
onPrevPage={() => dispatch({ type: "SET_PAGE", page: Math.max(0, page - 1) })}
onNextPage={() => dispatch({ type: "SET_PAGE", page: Math.min(totalPages - 1, page + 1) })}
/>
{/* Table */}
{filtered.length === 0 ? (
@@ -112,63 +119,151 @@ export default function WelcomeSequenceDashboard({ brandId }: Props) {
<p className="text-[var(--admin-text-muted)] text-sm">No entries{filter !== "all" ? ` (${filter})` : ""}</p>
</div>
) : (
<div className="bg-white border border-[var(--admin-border)] rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
<th className="text-left px-5 py-3 font-medium">Contact</th>
<th className="text-left px-5 py-3 font-medium">Language</th>
<th className="text-left px-5 py-3 font-medium">Step</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-left px-5 py-3 font-medium">Enrolled</th>
<th className="text-left px-5 py-3 font-medium">Last Email</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{paginatedEntries.map(e => {
const meta = STATUS_LABELS[e.status] ?? { en: e.status, color: "bg-stone-100 text-stone-500 border border-stone-200" };
return (
<tr key={e.id} className="border-t border-[var(--admin-border)] hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5">
<div>
<p className="text-[var(--admin-text-primary)] font-medium text-sm">{e.contact_name ?? "—"}</p>
<p className="text-[var(--admin-text-muted)] text-xs">{e.contact_email}</p>
</div>
</td>
<td className="px-5 py-3.5">
<span className="text-xs font-mono text-[var(--admin-text-muted)] uppercase">{e.locale}</span>
</td>
<td className="px-5 py-3.5 text-xs text-[var(--admin-text-muted)]">
{STEP_LABELS[e.sequence_step] ?? e.sequence_step}
{e.next_email_at && e.status === "active" && (
<span className="block text-[var(--admin-text-muted)] text-[10px]">Next: {new Date(e.next_email_at).toLocaleString()}</span>
)}
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
{meta.en}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(e.created_at).toLocaleDateString()}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">
{e.last_email_sent_at ? new Date(e.last_email_sent_at).toLocaleString() : "—"}
</td>
<td className="px-5 py-3.5 text-right">
{e.status !== "unsubscribed" && (
<button type="button" onClick={() => handleResend(e.id)} disabled={resending === e.id}
className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">
{resending === e.id ? "..." : "Resend"}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<EntriesTable
entries={paginatedEntries}
resending={resending}
onResend={handleResend}
/>
)}
</div>
);
}
function StatsGrid({ stats }: { stats: Stats }) {
return (
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Total", value: stats.total, color: "text-[var(--admin-text-primary)]" },
{ label: "Active", value: stats.active, color: "text-blue-600" },
{ label: "Completed", value: stats.completed, color: "text-green-600" },
{ label: "Unsubscribed", value: stats.unsubscribed, color: "text-red-600" },
].map(s => (
<div key={s.label} className="bg-white border border-[var(--admin-border)] rounded-2xl p-4 text-center">
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">{s.label}</p>
</div>
))}
</div>
);
}
type FilterBarProps = {
loading: boolean;
filter: Filter;
page: number;
totalPages: number;
onLoad: () => void;
onSelectFilter: (f: Filter) => void;
onPrevPage: () => void;
onNextPage: () => void;
};
function FilterBar({
loading,
filter,
page,
totalPages,
onLoad,
onSelectFilter,
onPrevPage,
onNextPage,
}: FilterBarProps) {
return (
<div className="flex items-center gap-3">
<button type="button" onClick={onLoad} disabled={loading}
className="text-xs font-semibold text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 transition-colors">
{loading ? "Loading..." : "↻ Refresh"}
</button>
<div className="flex gap-2 ml-auto">
{(["all", "active", "completed"] as const).map(f => (
<button type="button" key={f} onClick={() => onSelectFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-[var(--admin-accent)] text-white" : "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-2">
<button type="button" onClick={onPrevPage} disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Previous">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
<button type="button" onClick={onNextPage} disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" aria-label="Next">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
)}
</div>
);
}
function EntriesTable({
entries,
resending,
onResend,
}: {
entries: WelcomeSequenceEntry[];
resending: string | null;
onResend: (id: string) => void;
}) {
return (
<div className="bg-white border border-[var(--admin-border)] rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
<th className="text-left px-5 py-3 font-medium">Contact</th>
<th className="text-left px-5 py-3 font-medium">Language</th>
<th className="text-left px-5 py-3 font-medium">Step</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-left px-5 py-3 font-medium">Enrolled</th>
<th className="text-left px-5 py-3 font-medium">Last Email</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{entries.map(e => {
const meta = STATUS_LABELS[e.status] ?? { en: e.status, color: "bg-stone-100 text-stone-500 border border-stone-200" };
return (
<tr key={e.id} className="border-t border-[var(--admin-border)] hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5">
<div>
<p className="text-[var(--admin-text-primary)] font-medium text-sm">{e.contact_name ?? "—"}</p>
<p className="text-[var(--admin-text-muted)] text-xs">{e.contact_email}</p>
</div>
</td>
<td className="px-5 py-3.5">
<span className="text-xs font-mono text-[var(--admin-text-muted)] uppercase">{e.locale}</span>
</td>
<td className="px-5 py-3.5 text-xs text-[var(--admin-text-muted)]">
{STEP_LABELS[e.sequence_step] ?? e.sequence_step}
{e.next_email_at && e.status === "active" && (
<span className="block text-[var(--admin-text-muted)] text-[10px]">Next: {new Date(e.next_email_at).toLocaleString()}</span>
)}
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>
{meta.en}
</span>
</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(e.created_at).toLocaleDateString()}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">
{e.last_email_sent_at ? new Date(e.last_email_sent_at).toLocaleString() : "—"}
</td>
<td className="px-5 py-3.5 text-right">
{e.status !== "unsubscribed" && (
<button type="button" onClick={() => onResend(e.id)} disabled={resending === e.id}
className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent)]/80 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">
{resending === e.id ? "..." : "Resend"}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}