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>
);
}
+68 -30
View File
@@ -1,33 +1,71 @@
"use client";
import { useState, FormEvent } from "react";
import { useReducer, FormEvent } from "react";
interface WaitlistFormProps {
className?: string;
onSuccess?: () => void;
}
type State = {
email: string;
name: string;
referralSource: string;
isSubmitting: boolean;
error: string | null;
success: boolean;
};
type Action =
| { type: "SET_EMAIL"; value: string }
| { type: "SET_NAME"; value: string }
| { type: "SET_REFERRAL_SOURCE"; value: string }
| { type: "SET_SUBMITTING"; value: boolean }
| { type: "SET_ERROR"; value: string | null }
| { type: "SUBMIT_SUCCESS" };
const initialState: State = {
email: "",
name: "",
referralSource: "",
isSubmitting: false,
error: null,
success: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_EMAIL":
return { ...state, email: action.value };
case "SET_NAME":
return { ...state, name: action.value };
case "SET_REFERRAL_SOURCE":
return { ...state, referralSource: action.value };
case "SET_SUBMITTING":
return { ...state, isSubmitting: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SUBMIT_SUCCESS":
return { ...state, email: "", name: "", referralSource: "", success: true };
}
}
export default function WaitlistForm({ className = "", onSuccess }: WaitlistFormProps) {
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [referralSource, setReferralSource] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
dispatch({ type: "SET_SUBMITTING", value: true });
dispatch({ type: "SET_ERROR", value: null });
try {
const res = await fetch("/api/waitlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: email.trim(),
name: name.trim() || undefined,
referral_source: referralSource || undefined,
email: state.email.trim(),
name: state.name.trim() || undefined,
referral_source: state.referralSource || undefined,
}),
});
@@ -37,19 +75,19 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
throw new Error(data.error || "Failed to join waitlist");
}
setSuccess(true);
setEmail("");
setName("");
setReferralSource("");
dispatch({ type: "SUBMIT_SUCCESS" });
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
dispatch({
type: "SET_ERROR",
value: err instanceof Error ? err.message : "Something went wrong",
});
} finally {
setIsSubmitting(false);
dispatch({ type: "SET_SUBMITTING", value: false });
}
};
if (success) {
if (state.success) {
return (
<div className={`bg-gradient-to-br from-[#1a4d2e]/10 to-[#2d6a4f]/5 rounded-2xl p-8 text-center border border-[#6b8f71]/20 ${className}`}>
<div className="w-16 h-16 bg-[#1a4d2e]/10 rounded-full flex items-center justify-center mx-auto mb-4">
@@ -77,8 +115,8 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
<input aria-label="Your Name"
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
value={state.name}
onChange={(e) => dispatch({ type: "SET_NAME", value: e.target.value })}
placeholder="Your name"
className="w-full px-4 py-3 rounded-xl border border-[#6b8f71]/30 bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
/>
@@ -90,8 +128,8 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
<input aria-label="You@farm.com"
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
value={state.email}
onChange={(e) => dispatch({ type: "SET_EMAIL", value: e.target.value })}
placeholder="you@farm.com"
required
className="w-full px-4 py-3 rounded-xl border border-[#6b8f71]/30 bg-white text-[#1a1a1a] placeholder-[#888] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
@@ -105,8 +143,8 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
</label>
<select aria-label="Referral"
id="referral"
value={referralSource}
onChange={(e) => setReferralSource(e.target.value)}
value={state.referralSource}
onChange={(e) => dispatch({ type: "SET_REFERRAL_SOURCE", value: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-[#6b8f71]/30 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1a4d2e]/50 focus:border-[#1a4d2e] transition-all"
>
<option value="">Select an option</option>
@@ -120,18 +158,18 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
</select>
</div>
{error && (
{state.error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 text-sm">
{error}
{state.error}
</div>
)}
<button
type="submit"
disabled={isSubmitting || !email}
disabled={state.isSubmitting || !state.email}
className="w-full px-6 py-4 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] text-white rounded-xl font-semibold text-lg hover:from-[#2d6a4f] hover:to-[#1a4d2e] transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-[#1a4d2e]/20"
>
{isSubmitting ? (
{state.isSubmitting ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
@@ -149,4 +187,4 @@ export default function WaitlistForm({ className = "", onSuccess }: WaitlistForm
</p>
</form>
);
}
}
+64 -30
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useReducer } from "react";
import { searchHarvestLots, HarvestLot } from "@/actions/route-trace/lots";
import StatusBadge from "./StatusBadge";
import Link from "next/link";
@@ -38,30 +38,64 @@ const Icons = {
),
};
type State = {
query: string;
results: HarvestLot[];
searched: boolean;
loading: boolean;
showScanModal: boolean;
};
type Action =
| { type: "SET_QUERY"; value: string }
| { type: "SET_RESULTS"; results: HarvestLot[] }
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_SEARCHED"; value: boolean }
| { type: "SET_SHOW_SCAN_MODAL"; value: boolean };
const initialState: State = {
query: "",
results: [],
searched: false,
loading: false,
showScanModal: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_QUERY":
return { ...state, query: action.value };
case "SET_RESULTS":
return { ...state, results: action.results };
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_SEARCHED":
return { ...state, searched: action.value };
case "SET_SHOW_SCAN_MODAL":
return { ...state, showScanModal: action.value };
}
}
export default function AdminLookupPage({ brandId }: { brandId: string }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<HarvestLot[]>([]);
const [searched, setSearched] = useState(false);
const [loading, setLoading] = useState(false);
const [showScanModal, setShowScanModal] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
const res = await searchHarvestLots(brandId, query.trim());
setResults(res.success ? res.lots : []);
setSearched(true);
setLoading(false);
if (!state.query.trim()) return;
dispatch({ type: "SET_LOADING", value: true });
const res = await searchHarvestLots(brandId, state.query.trim());
dispatch({ type: "SET_RESULTS", results: res.success ? res.lots : [] });
dispatch({ type: "SET_SEARCHED", value: true });
dispatch({ type: "SET_LOADING", value: false });
}
function handleScanResult(lotNumber: string) {
setQuery(lotNumber);
setLoading(true);
dispatch({ type: "SET_QUERY", value: lotNumber });
dispatch({ type: "SET_LOADING", value: true });
searchHarvestLots(brandId, lotNumber).then((res) => {
setResults(res.success ? res.lots : []);
setSearched(true);
setLoading(false);
dispatch({ type: "SET_RESULTS", results: res.success ? res.lots : [] });
dispatch({ type: "SET_SEARCHED", value: true });
dispatch({ type: "SET_LOADING", value: false });
});
}
@@ -69,7 +103,7 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
<div className="space-y-5">
{/* Scan button */}
<button type="button"
onClick={() => setShowScanModal(true)}
onClick={() => dispatch({ type: "SET_SHOW_SCAN_MODAL", value: true })}
className="w-full rounded-xl bg-emerald-600 px-5 py-4 text-sm font-bold text-white hover:bg-emerald-700 transition-colors flex items-center justify-center gap-2 shadow-sm"
>
<span className="text-white">{Icons.camera("h-5 w-5")}</span>
@@ -77,9 +111,9 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
</button>
{/* QRScanModal */}
{showScanModal && (
{state.showScanModal && (
<QRScanModal
onClose={() => setShowScanModal(false)}
onClose={() => dispatch({ type: "SET_SHOW_SCAN_MODAL", value: false })}
onScanResult={handleScanResult}
/>
)}
@@ -93,39 +127,39 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
<form onSubmit={handleSearch} className="flex gap-3 p-6">
<input aria-label=". TC 20260519 001 Or Sweet Corn"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
value={state.query}
onChange={(e) => dispatch({ type: "SET_QUERY", value: e.target.value })}
placeholder="e.g. TC-20260519-001 or Sweet Corn"
className="flex-1 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
<button
type="submit"
disabled={loading || !query.trim()}
disabled={state.loading || !state.query.trim()}
className="rounded-xl bg-emerald-600 px-5 py-3 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? "Searching..." : <>{Icons.search("h-4 w-4")} Search</>}
{state.loading ? "Searching..." : <>{Icons.search("h-4 w-4")} Search</>}
</button>
</form>
</div>
{/* Results */}
{searched && (
{state.searched && (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden">
{results.length === 0 ? (
{state.results.length === 0 ? (
<div className="p-10 text-center">
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
<p className="text-sm text-stone-500">No lots found for &quot;{query}&quot;</p>
<p className="text-sm text-stone-500">No lots found for &quot;{state.query}&quot;</p>
</div>
) : (
<>
<div className="px-5 py-3 bg-stone-50 border-b border-stone-100">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">
{results.length} result{results.length !== 1 ? "s" : ""}
{state.results.length} result{state.results.length !== 1 ? "s" : ""}
</p>
</div>
<table className="w-full">
<tbody>
{results.map((lot) => (
{state.results.map((lot) => (
<tr key={lot.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors">
<td className="px-5 py-4">
<Link href={`/admin/route-trace/lots/${lot.id}`} className="font-mono text-sm font-bold text-stone-900 hover:text-blue-600">
@@ -150,4 +184,4 @@ export default function AdminLookupPage({ brandId }: { brandId: string }) {
)}
</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
+320 -206
View File
@@ -176,220 +176,334 @@ export default function LotListTable({
return (
<div className="space-y-5">
{/* Search and filters */}
<LotsHeader
count={filtered.length}
onCreateNew={onCreateNew}
/>
<div className="rounded-2xl border border-stone-200 bg-white">
<div className="border-b border-stone-100 px-6 py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent-light)] shadow-sm">
{Icons.clipboard("w-5 h-5 text-[var(--admin-accent)]")}
</div>
<div>
<h2 className="text-lg font-semibold text-stone-900">All Lots</h2>
<p className="text-sm text-stone-500">{filtered.length} lot{filtered.length !== 1 ? "s" : ""}</p>
</div>
</div>
<button type="button"
onClick={onCreateNew}
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors shadow-sm"
aria-label="New Lot">
{Icons.plus("h-4 w-4")}
<span>New Lot</span>
</button>
</div>
</div>
<div className="p-6 space-y-4">
{/* Filter bar */}
<div className="flex gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{STATUS_FILTERS.map((f) => (
<button type="button"
key={f.value}
onClick={() => setFilter(f.value)}
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors flex-1 ${
filter === f.value
? "bg-emerald-600 text-white"
: "text-stone-600 hover:bg-white"
}`}
>
{f.label}
</button>
))}
</div>
{/* Search */}
<div className="flex gap-3">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400">{Icons.search("h-4 w-4")}</span>
<input aria-label="Search Lot #, Crop, Field, Or Bin..."
type="text"
placeholder="Search lot #, crop, field, or bin..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-9 pr-4 py-3 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<button type="button"
onClick={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
className={`rounded-xl border px-4 py-3 text-sm font-semibold transition-colors flex items-center gap-2 ${
showBulk ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
aria-label="Bulk">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points={showBulk ? "9 11 12 14 22 4" : "3 6 9 12 15 18"}/>
</svg>
<span className="hidden sm:inline">Bulk {showBulk ? "On" : "Off"}</span>
</button>
</div>
<StatusFilterBar filter={filter} onChange={setFilter} />
<SearchAndBulkToggle
query={query}
onQueryChange={setQuery}
showBulk={showBulk}
onToggleBulk={() => { setShowBulk(!showBulk); setSelected(new Set()); }}
/>
</div>
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="rounded-2xl border-2 border-emerald-600 bg-emerald-600 px-5 py-3 flex items-center justify-between">
<span className="text-sm font-bold text-white">{selected.size} lot{selected.size !== 1 ? "s" : ""} selected</span>
<div className="flex gap-2 flex-wrap">
<button type="button"
onClick={handleBulkMarkLoaded}
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Mark Loaded">
{Icons.truck("h-4 w-4")}
<span>Mark Loaded</span>
</button>
<button type="button"
onClick={handleBulkMarkUsed}
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Mark as Used">
{Icons.package("h-4 w-4")}
<span>Mark as Used</span>
</button>
<button type="button"
onClick={() => handleBulkStickers("field")}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Field Stickers">
{Icons.printer("h-4 w-4")}
<span>Field Stickers</span>
</button>
<button type="button"
onClick={() => handleBulkStickers("shed")}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Shed Stickers">
{Icons.printer("h-4 w-4")}
<span>Shed Stickers</span>
</button>
<button type="button"
onClick={handleBulkExport}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Reports`}">
{Icons.file("h-4 w-4")}
<span>{selected.size === 1 ? "Report" : `${selected.size} Reports`}</span>
</button>
<button type="button"
onClick={() => setSelected(new Set())}
className="rounded-xl border border-white/30 px-4 py-2 text-sm font-medium text-white/70 hover:text-white transition-colors"
aria-label="Clear">
Clear
</button>
</div>
</div>
)}
<BulkActionBar
selected={selected}
onMarkLoaded={handleBulkMarkLoaded}
onMarkUsed={handleBulkMarkUsed}
onFieldStickers={() => handleBulkStickers("field")}
onShedStickers={() => handleBulkStickers("shed")}
onExport={handleBulkExport}
onClear={() => setSelected(new Set())}
/>
{/* Table */}
{filtered.length === 0 ? (
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center">
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
<p className="text-sm text-stone-500">No lots found</p>
</div>
<EmptyState />
) : (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<table className="w-full">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
{showBulk && (
<th className="px-5 py-3.5 w-10">
<input aria-label="Checkbox"
type="checkbox"
checked={selected.size === filtered.length && filtered.length > 0}
onChange={toggleSelectAll}
className="h-4 w-4 rounded border-stone-300"
/>
</th>
)}
{["Lot #", "Crop / Variety", "Harvest Date", "Age", "Field", "Bins", "Qty", "Status"].map((h) => (
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-stone-400 uppercase tracking-wider">
{h}
</th>
))}
<th className="px-5 py-3.5" aria-label="Actions" />
</tr>
</thead>
<tbody>
{filtered.map((lot) => (
<tr key={lot.lot_id} className={`border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors ${selected.has(lot.lot_id) ? "bg-green-50" : ""}`}>
{showBulk && (
<td className="px-5 py-4">
<input aria-label="Checkbox"
type="checkbox"
checked={selected.has(lot.lot_id)}
onChange={() => toggleSelect(lot.lot_id)}
className="h-4 w-4 rounded border-stone-300"
/>
</td>
)}
<td className="px-5 py-4">
<Link href={`/admin/route-trace/lots/${lot.lot_id}`} className="font-mono text-sm font-black text-stone-900 hover:text-blue-600">
{lot.lot_number}
</Link>
</td>
<td className="px-5 py-4">
<div className="text-sm font-semibold text-stone-700">{lot.crop_type}</div>
</td>
<td className="px-5 py-4">
{(() => {
const age = getAgeStatus(lot.harvest_date);
return age ? (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-bold ${age.className}`}>
{age.label}
</span>
) : (
<span className="text-stone-300 text-xs"></span>
);
})()}
</td>
<td className="px-5 py-4">
<div className="text-sm text-stone-700">{lot.field_location ?? "—"}</div>
{lot.field_block && <div className="text-xs text-stone-400">Block: {lot.field_block}</div>}
</td>
<td className="px-5 py-4">
<div className="flex gap-1 flex-wrap">
{lot.bin_id && (
<span className="inline-flex rounded-full bg-amber-100 px-2 py-0.5 text-xs font-bold text-amber-700">
{lot.bin_id}
</span>
)}
{lot.pallets != null && (
<span className="inline-flex rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600">
{lot.pallets} PLT
</span>
)}
{!lot.bin_id && lot.pallets == null && <span className="text-stone-300 text-xs"></span>}
</div>
</td>
<td className="px-5 py-4 text-sm text-stone-600">
{lot.quantity_lbs != null ? `${lot.quantity_lbs.toLocaleString()} ${lot.yield_unit ?? "lbs"}` : "—"}
</td>
<td className="px-5 py-4">
<StatusBadge status={lot.status} />
</td>
<td className="px-5 py-4 text-right">
<Link href={`/admin/route-trace/lots/${lot.lot_id}`} className="text-sm font-semibold text-blue-600 hover:text-blue-800">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
<LotsTable
lots={filtered}
showBulk={showBulk}
selected={selected}
onToggleSelect={toggleSelect}
onToggleSelectAll={toggleSelectAll}
/>
)}
</div>
);
}
}
function LotsHeader({ count, onCreateNew }: { count: number; onCreateNew?: () => void }) {
return (
<div className="rounded-2xl border border-stone-200 bg-white">
<div className="border-b border-stone-100 px-6 py-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent-light)] shadow-sm">
{Icons.clipboard("w-5 h-5 text-[var(--admin-accent)]")}
</div>
<div>
<h2 className="text-lg font-semibold text-stone-900">All Lots</h2>
<p className="text-sm text-stone-500">{count} lot{count !== 1 ? "s" : ""}</p>
</div>
</div>
<button type="button"
onClick={onCreateNew}
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors shadow-sm"
aria-label="New Lot">
{Icons.plus("h-4 w-4")}
<span>New Lot</span>
</button>
</div>
</div>
</div>
);
}
function StatusFilterBar({ filter, onChange }: { filter: string; onChange: (v: string) => void }) {
return (
<div className="flex gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{STATUS_FILTERS.map((f) => (
<button type="button"
key={f.value}
onClick={() => onChange(f.value)}
className={`rounded-lg px-3 py-2 text-xs font-semibold transition-colors flex-1 ${
filter === f.value
? "bg-emerald-600 text-white"
: "text-stone-600 hover:bg-white"
}`}
>
{f.label}
</button>
))}
</div>
);
}
function SearchAndBulkToggle({
query,
onQueryChange,
showBulk,
onToggleBulk,
}: {
query: string;
onQueryChange: (v: string) => void;
showBulk: boolean;
onToggleBulk: () => void;
}) {
return (
<div className="flex gap-3">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400">{Icons.search("h-4 w-4")}</span>
<input aria-label="Search Lot #, Crop, Field, Or Bin..."
type="text"
placeholder="Search lot #, crop, field, or bin..."
value={query}
onChange={(e) => onQueryChange(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-9 pr-4 py-3 text-base outline-none focus:border-emerald-600 focus:bg-white transition-colors"
/>
</div>
<button type="button"
onClick={onToggleBulk}
className={`rounded-xl border px-4 py-3 text-sm font-semibold transition-colors flex items-center gap-2 ${
showBulk ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
aria-label="Bulk">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points={showBulk ? "9 11 12 14 22 4" : "3 6 9 12 15 18"}/>
</svg>
<span className="hidden sm:inline">Bulk {showBulk ? "On" : "Off"}</span>
</button>
</div>
);
}
function BulkActionBar({
selected,
onMarkLoaded,
onMarkUsed,
onFieldStickers,
onShedStickers,
onExport,
onClear,
}: {
selected: Set<string>;
onMarkLoaded: () => void;
onMarkUsed: () => void;
onFieldStickers: () => void;
onShedStickers: () => void;
onExport: () => void;
onClear: () => void;
}) {
if (selected.size === 0) return null;
const count = selected.size;
return (
<div className="rounded-2xl border-2 border-emerald-600 bg-emerald-600 px-5 py-3 flex items-center justify-between">
<span className="text-sm font-bold text-white">{count} lot{count !== 1 ? "s" : ""} selected</span>
<div className="flex gap-2 flex-wrap">
<button type="button"
onClick={onMarkLoaded}
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Mark Loaded">
{Icons.truck("h-4 w-4")}
<span>Mark Loaded</span>
</button>
<button type="button"
onClick={onMarkUsed}
className="rounded-xl bg-amber-600 hover:bg-amber-500 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Mark as Used">
{Icons.package("h-4 w-4")}
<span>Mark as Used</span>
</button>
<button type="button"
onClick={onFieldStickers}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Field Stickers">
{Icons.printer("h-4 w-4")}
<span>Field Stickers</span>
</button>
<button type="button"
onClick={onShedStickers}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Shed Stickers">
{Icons.printer("h-4 w-4")}
<span>Shed Stickers</span>
</button>
<button type="button"
onClick={onExport}
className="rounded-xl bg-white/20 hover:bg-white/30 px-4 py-2 text-sm font-bold text-white transition-colors flex items-center gap-1.5"
aria-label="Reports`}">
{Icons.file("h-4 w-4")}
<span>{count === 1 ? "Report" : `${count} Reports`}</span>
</button>
<button type="button"
onClick={onClear}
className="rounded-xl border border-white/30 px-4 py-2 text-sm font-medium text-white/70 hover:text-white transition-colors"
aria-label="Clear">
Clear
</button>
</div>
</div>
);
}
function EmptyState() {
return (
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center">
<div className="text-stone-300 mb-3">{Icons.search("h-10 w-10")}</div>
<p className="text-sm text-stone-500">No lots found</p>
</div>
);
}
function LotsTable({
lots,
showBulk,
selected,
onToggleSelect,
onToggleSelectAll,
}: {
lots: HaulingLot[];
showBulk: boolean;
selected: Set<string>;
onToggleSelect: (id: string) => void;
onToggleSelectAll: () => void;
}) {
return (
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<table className="w-full">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
{showBulk && (
<th className="px-5 py-3.5 w-10">
<input aria-label="Checkbox"
type="checkbox"
checked={selected.size === lots.length && lots.length > 0}
onChange={onToggleSelectAll}
className="h-4 w-4 rounded border-stone-300"
/>
</th>
)}
{["Lot #", "Crop / Variety", "Harvest Date", "Age", "Field", "Bins", "Qty", "Status"].map((h) => (
<th key={h} className="px-5 py-3.5 text-left text-xs font-semibold text-stone-400 uppercase tracking-wider">
{h}
</th>
))}
<th className="px-5 py-3.5" aria-label="Actions" />
</tr>
</thead>
<tbody>
{lots.map((lot) => (
<LotRow
key={lot.lot_id}
lot={lot}
showBulk={showBulk}
isSelected={selected.has(lot.lot_id)}
onToggleSelect={() => onToggleSelect(lot.lot_id)}
/>
))}
</tbody>
</table>
</div>
);
}
function LotRow({
lot,
showBulk,
isSelected,
onToggleSelect,
}: {
lot: HaulingLot;
showBulk: boolean;
isSelected: boolean;
onToggleSelect: () => void;
}) {
const age = getAgeStatus(lot.harvest_date);
return (
<tr className={`border-b border-stone-50 last:border-0 hover:bg-stone-50/50 transition-colors ${isSelected ? "bg-green-50" : ""}`}>
{showBulk && (
<td className="px-5 py-4">
<input aria-label="Checkbox"
type="checkbox"
checked={isSelected}
onChange={onToggleSelect}
className="h-4 w-4 rounded border-stone-300"
/>
</td>
)}
<td className="px-5 py-4">
<Link href={`/admin/route-trace/lots/${lot.lot_id}`} className="font-mono text-sm font-black text-stone-900 hover:text-blue-600">
{lot.lot_number}
</Link>
</td>
<td className="px-5 py-4">
<div className="text-sm font-semibold text-stone-700">{lot.crop_type}</div>
</td>
<td className="px-5 py-4">
{age ? (
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-bold ${age.className}`}>
{age.label}
</span>
) : (
<span className="text-stone-300 text-xs"></span>
)}
</td>
<td className="px-5 py-4">
<div className="text-sm text-stone-700">{lot.field_location ?? "—"}</div>
{lot.field_block && <div className="text-xs text-stone-400">Block: {lot.field_block}</div>}
</td>
<td className="px-5 py-4">
<div className="flex gap-1 flex-wrap">
{lot.bin_id && (
<span className="inline-flex rounded-full bg-amber-100 px-2 py-0.5 text-xs font-bold text-amber-700">
{lot.bin_id}
</span>
)}
{lot.pallets != null && (
<span className="inline-flex rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600">
{lot.pallets} PLT
</span>
)}
{!lot.bin_id && lot.pallets == null && <span className="text-stone-300 text-xs"></span>}
</div>
</td>
<td className="px-5 py-4 text-sm text-stone-600">
{lot.quantity_lbs != null ? `${lot.quantity_lbs.toLocaleString()} ${lot.yield_unit ?? "lbs"}` : "—"}
</td>
<td className="px-5 py-4">
<StatusBadge status={lot.status} />
</td>
<td className="px-5 py-4 text-right">
<Link href={`/admin/route-trace/lots/${lot.lot_id}`} className="text-sm font-semibold text-blue-600 hover:text-blue-800">
View
</Link>
</td>
</tr>
);
}
+206 -115
View File
@@ -9,7 +9,7 @@ declare global {
}
}
import { useState, useEffect, useRef } from "react";
import { useEffect, useRef, useReducer } from "react";
import GlassModal from "@/components/admin/GlassModal";
// One-color outline icons
@@ -57,13 +57,182 @@ interface QRScanModalProps {
onScanResult: (lotNumber: string) => void;
}
// --- Reducer state -------------------------------------------------------
type State = {
mode: ScanMode;
manualInput: string;
cameraError: string | null;
cameraStarting: boolean;
scanSuccess: boolean;
};
type Action =
| { type: "SET_MODE"; mode: ScanMode }
| { type: "SET_MANUAL_INPUT"; value: string }
| { type: "SET_CAMERA_STARTING"; value: boolean }
| { type: "SET_CAMERA_ERROR"; value: string | null }
| { type: "SET_SCAN_SUCCESS"; value: boolean };
const initialState: State = {
mode: "camera",
manualInput: "",
cameraError: null,
cameraStarting: true,
scanSuccess: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_MODE":
return { ...state, mode: action.mode };
case "SET_MANUAL_INPUT":
return { ...state, manualInput: action.value };
case "SET_CAMERA_STARTING":
return { ...state, cameraStarting: action.value };
case "SET_CAMERA_ERROR":
return { ...state, cameraError: action.value };
case "SET_SCAN_SUCCESS":
return { ...state, scanSuccess: action.value };
default:
return state;
}
}
// --- Subcomponents -------------------------------------------------------
type ModeTabsProps = {
mode: ScanMode;
onChange: (mode: ScanMode) => void;
};
function ModeTabs({ mode, onChange }: ModeTabsProps) {
return (
<div className="flex border-b border-[var(--admin-border)] mb-4 -mx-6 px-6">
<button type="button"
onClick={() => onChange("camera")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "camera" ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"
}`}
aria-label="Camera">
<svg className="w-4 h-4 inline-block mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
Camera
</button>
<button type="button"
onClick={() => onChange("manual")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "manual" ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"
}`}
aria-label="Manual">
<svg className="w-4 h-4 inline-block mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h8M6 16h12" />
</svg>
Manual
</button>
</div>
);
}
type CameraViewProps = {
videoRef: React.RefObject<HTMLVideoElement | null>;
cameraStarting: boolean;
cameraError: string | null;
scanSuccess: boolean;
};
function CameraView({ videoRef, cameraStarting, cameraError, scanSuccess }: CameraViewProps) {
return (
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden bg-stone-900 aspect-square">
{cameraStarting ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white text-sm text-center">
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin mb-3" />
<p className="text-xs text-white/70">Starting camera...</p>
</div>
) : cameraError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white text-sm text-center px-6">
<span className="mb-3 text-stone-400">{Icons.cameraOff("h-12 w-12")}</span>
<p className="text-xs text-red-300 font-medium leading-relaxed">{cameraError}</p>
</div>
) : (
<>
<video
ref={videoRef}
aria-label="QR code scanner camera feed"
className="w-full h-full object-cover"
playsInline
muted
/>
{/* Viewfinder overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{scanSuccess ? (
<div className="w-44 h-44 rounded-2xl bg-green-500 flex items-center justify-center">
<span className="text-white">{Icons.check("h-12 w-12")}</span>
</div>
) : (
<div className="w-44 h-44 border-2 border-white/70 rounded-2xl" />
)}
</div>
{!scanSuccess && (
<div className="absolute inset-x-0 bottom-4 text-center">
<span className="inline-block text-white text-xs bg-black/60 px-4 py-1.5 rounded-full font-medium">
Align QR within the frame
</span>
</div>
)}
</>
)}
</div>
{!cameraStarting && !cameraError && (
<p className="text-center text-xs text-stone-500">
Hold steady QR will scan automatically
</p>
)}
</div>
);
}
type ManualFormProps = {
manualInput: string;
onChange: (value: string) => void;
onSubmit: (e: React.FormEvent) => void;
};
function ManualForm({ manualInput, onChange, onSubmit }: ManualFormProps) {
return (
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label htmlFor="fld-1-lot-number" className="block text-sm font-semibold text-stone-700 mb-1.5">Lot Number</label>
<input id="fld-1-lot-number" aria-label=". TC 20260520 001"
type="text"
value={manualInput}
onChange={(e) => onChange(e.target.value.toUpperCase())}
placeholder="e.g. TC-20260520-001"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400"
/>
<p className="text-[10px] text-stone-400 mt-1.5">Enter the lot number from any Route Trace sticker</p>
</div>
<button
type="submit"
disabled={!manualInput.trim()}
className="w-full rounded-xl bg-stone-800 py-3.5 text-base font-semibold text-white hover:bg-stone-700 disabled:opacity-50 transition-colors"
aria-label="Trace Lot}">
<span className="inline-flex items-center gap-1.5">{Icons.search("h-4 w-4")} Trace Lot</span>
</button>
</form>
);
}
// --- Main component ------------------------------------------------------
export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps) {
const [mode, setMode] = useState<ScanMode>("camera");
const [manualInput, setManualInput] = useState("");
const [cameraError, setCameraError] = useState<string | null>(null);
const [cameraStarting, setCameraStarting] = useState(true);
const [state, dispatch] = useReducer(reducer, initialState);
const { mode, manualInput, cameraError, cameraStarting, scanSuccess } = state;
const detectedRef = useRef(false);
const [scanSuccess, setScanSuccess] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const detectorRef = useRef<InstanceType<NonNullable<typeof window.BarcodeDetector>> | null>(null);
@@ -74,8 +243,10 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
// without re-subscribing the camera effect on every parent render.
const onCloseRef = useRef(onClose);
const onScanResultRef = useRef(onScanResult);
onCloseRef.current = onClose;
onScanResultRef.current = onScanResult;
useEffect(() => {
onCloseRef.current = onClose;
onScanResultRef.current = onScanResult;
});
// Start camera on mount for camera mode
useEffect(() => {
@@ -84,8 +255,8 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
let mounted = true;
async function startCamera() {
setCameraStarting(true);
setCameraError(null);
dispatch({ type: "SET_CAMERA_STARTING", value: true });
dispatch({ type: "SET_CAMERA_ERROR", value: null });
// Check for BarcodeDetector support
if (!window.BarcodeDetector) {
@@ -99,7 +270,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setCameraStarting(false);
dispatch({ type: "SET_CAMERA_STARTING", value: false });
} catch (err) {
if (!mounted) return;
const msg = err instanceof DOMException && err.name === "NotAllowedError"
@@ -107,9 +278,9 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
: err instanceof DOMException && err.name === "NotFoundError"
? "No camera found. Use manual entry."
: "Camera not available. Use manual entry.";
setCameraError(msg);
setCameraStarting(false);
setTimeout(() => { if (mounted) setMode("manual"); }, 800);
dispatch({ type: "SET_CAMERA_ERROR", value: msg });
dispatch({ type: "SET_CAMERA_STARTING", value: false });
setTimeout(() => { if (mounted) dispatch({ type: "SET_MODE", mode: "manual" }); }, 800);
}
return;
}
@@ -124,7 +295,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setCameraStarting(false);
dispatch({ type: "SET_CAMERA_STARTING", value: false });
} catch (err) {
if (!mounted) return;
const msg = err instanceof DOMException && err.name === "NotAllowedError"
@@ -132,9 +303,9 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
: err instanceof DOMException && err.name === "NotFoundError"
? "No camera found. Use manual entry."
: "Camera not available. Use manual entry.";
setCameraError(msg);
setCameraStarting(false);
setTimeout(() => { if (mounted) setMode("manual"); }, 800);
dispatch({ type: "SET_CAMERA_ERROR", value: msg });
dispatch({ type: "SET_CAMERA_STARTING", value: false });
setTimeout(() => { if (mounted) dispatch({ type: "SET_MODE", mode: "manual" }); }, 800);
return;
}
@@ -156,7 +327,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
const barcodes = await detectorRef.current.detect(videoRef.current);
if (barcodes.length > 0 && !detectedRef.current) {
detectedRef.current = true;
setScanSuccess(true);
dispatch({ type: "SET_SCAN_SUCCESS", value: true });
const raw = barcodes[0].rawValue;
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
// Notify parent after a brief success animation. We
@@ -202,104 +373,24 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps)
return (
<GlassModal title="Scan QR Code" subtitle="Point camera at Route Trace sticker" onClose={handleClose}>
{/* Mode toggle */}
<div className="flex border-b border-[var(--admin-border)] mb-4 -mx-6 px-6">
<button type="button"
onClick={() => setMode("camera")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "camera" ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"
}`}
aria-label="Camera">
<svg className="w-4 h-4 inline-block mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
Camera
</button>
<button type="button"
onClick={() => setMode("manual")}
className={`flex-1 py-3 text-sm font-semibold transition-colors ${
mode === "manual" ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"
}`}
aria-label="Manual">
<svg className="w-4 h-4 inline-block mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h8M6 16h12" />
</svg>
Manual
</button>
</div>
<ModeTabs
mode={mode}
onChange={(m) => dispatch({ type: "SET_MODE", mode: m })}
/>
{mode === "camera" ? (
<div className="space-y-4">
<div className="relative rounded-xl overflow-hidden bg-stone-900 aspect-square">
{cameraStarting ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white text-sm text-center">
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin mb-3" />
<p className="text-xs text-white/70">Starting camera...</p>
</div>
) : cameraError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center text-white text-sm text-center px-6">
<span className="mb-3 text-stone-400">{Icons.cameraOff("h-12 w-12")}</span>
<p className="text-xs text-red-300 font-medium leading-relaxed">{cameraError}</p>
</div>
) : (
<>
<video
ref={videoRef}
aria-label="QR code scanner camera feed"
className="w-full h-full object-cover"
playsInline
muted
/>
{/* Viewfinder overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{scanSuccess ? (
<div className="w-44 h-44 rounded-2xl bg-green-500 flex items-center justify-center">
<span className="text-white">{Icons.check("h-12 w-12")}</span>
</div>
) : (
<div className="w-44 h-44 border-2 border-white/70 rounded-2xl" />
)}
</div>
{!scanSuccess && (
<div className="absolute inset-x-0 bottom-4 text-center">
<span className="inline-block text-white text-xs bg-black/60 px-4 py-1.5 rounded-full font-medium">
Align QR within the frame
</span>
</div>
)}
</>
)}
</div>
{!cameraStarting && !cameraError && (
<p className="text-center text-xs text-stone-500">
Hold steady QR will scan automatically
</p>
)}
</div>
<CameraView
videoRef={videoRef}
cameraStarting={cameraStarting}
cameraError={cameraError}
scanSuccess={scanSuccess}
/>
) : (
<form onSubmit={handleManualSubmit} className="space-y-4">
<div>
<label htmlFor="fld-1-lot-number" className="block text-sm font-semibold text-stone-700 mb-1.5">Lot Number</label>
<input id="fld-1-lot-number" aria-label=". TC 20260520 001"
type="text"
value={manualInput}
onChange={(e) => setManualInput(e.target.value.toUpperCase())}
placeholder="e.g. TC-20260520-001"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3.5 text-base font-mono text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400"
/>
<p className="text-[10px] text-stone-400 mt-1.5">Enter the lot number from any Route Trace sticker</p>
</div>
<button
type="submit"
disabled={!manualInput.trim()}
className="w-full rounded-xl bg-stone-800 py-3.5 text-base font-semibold text-white hover:bg-stone-700 disabled:opacity-50 transition-colors"
aria-label="Trace Lot}">
<span className="inline-flex items-center gap-1.5">{Icons.search("h-4 w-4")} Trace Lot</span>
</button>
</form>
<ManualForm
manualInput={manualInput}
onChange={(v) => dispatch({ type: "SET_MANUAL_INPUT", value: v })}
onSubmit={handleManualSubmit}
/>
)}
</GlassModal>
);
}
}
+86 -45
View File
@@ -1,18 +1,18 @@
"use client";
import { useState, useTransition, useEffect } from "react";
import { useReducer, useTransition } from "react";
import { createHarvestLot } from "@/actions/route-trace/lots";
import GlassModal from "@/components/admin/GlassModal";
// Plant icon for the modal title - consistent one-color outline style
const PlantIcon = ({ className }: { className?: string }) => (
<svg
className={className ?? "w-5 h-5"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
<svg
className={className ?? "w-5 h-5"}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: "var(--admin-accent)" }}
>
@@ -31,50 +31,91 @@ type Props = {
const TODAY = new Date().toISOString().split("T")[0];
type State = {
error: string | null;
crop_type: string;
harvest_date: string;
field_location: string;
worker_name: string;
quantity_lbs: string;
variety: string;
};
type Action =
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_CROP_TYPE"; value: string }
| { type: "SET_HARVEST_DATE"; value: string }
| { type: "SET_FIELD_LOCATION"; value: string }
| { type: "SET_WORKER_NAME"; value: string }
| { type: "SET_QUANTITY_LBS"; value: string }
| { type: "SET_VARIETY"; value: string };
const initialState: State = {
error: null,
crop_type: "",
harvest_date: TODAY,
field_location: "",
worker_name: "",
quantity_lbs: "",
variety: "",
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_CROP_TYPE":
return { ...state, crop_type: action.value };
case "SET_HARVEST_DATE":
return { ...state, harvest_date: action.value };
case "SET_FIELD_LOCATION":
return { ...state, field_location: action.value };
case "SET_WORKER_NAME":
return { ...state, worker_name: action.value };
case "SET_QUANTITY_LBS":
return { ...state, quantity_lbs: action.value };
case "SET_VARIETY":
return { ...state, variety: action.value };
}
}
export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [crop_type, setCropType] = useState("");
const [harvest_date, setHarvestDate] = useState(TODAY);
const [field_location, setFieldLocation] = useState("");
const [worker_name, setWorkerName] = useState("");
const [quantity_lbs, setQuantityLbs] = useState("");
const [variety, setVariety] = useState("");
const [state, dispatch] = useReducer(reducer, initialState);
function submit(e: React.FormEvent) {
e.preventDefault();
if (!crop_type.trim()) return;
setError(null);
if (!state.crop_type.trim()) return;
dispatch({ type: "SET_ERROR", value: null });
startTransition(async () => {
const result = await createHarvestLot(brandId, {
crop_type: crop_type.trim(),
harvest_date: harvest_date || TODAY,
field_location: field_location.trim() || undefined,
worker_name: worker_name.trim() || undefined,
variety: variety.trim() || undefined,
quantity_lbs: quantity_lbs ? Number(quantity_lbs) : undefined,
crop_type: state.crop_type.trim(),
harvest_date: state.harvest_date || TODAY,
field_location: state.field_location.trim() || undefined,
worker_name: state.worker_name.trim() || undefined,
variety: state.variety.trim() || undefined,
quantity_lbs: state.quantity_lbs ? Number(state.quantity_lbs) : undefined,
});
if (result.success && result.lot) {
onCreated(result.lot.id);
} else {
setError(result.error ?? "Failed to create lot");
dispatch({ type: "SET_ERROR", value: result.error ?? "Failed to create lot" });
}
});
}
return (
<GlassModal
title="New Harvest Lot"
<GlassModal
title="New Harvest Lot"
titleIcon={<PlantIcon className="w-5 h-5" />}
subtitle="Quick entry — scan or fill in the fields below"
subtitle="Quick entry — scan or fill in the fields below"
onClose={onClose}
>
<form onSubmit={submit} className="space-y-4">
{error && (
{state.error && (
<div className="rounded-xl bg-red-50 border border-red-200 p-3 text-sm text-red-700">
{error}
{state.error}
</div>
)}
@@ -84,8 +125,8 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
</label>
<input id="fld-crop-type-" aria-label=". Sweet Corn"
type="text"
value={crop_type}
onChange={(e) => setCropType(e.target.value)}
value={state.crop_type}
onChange={(e) => dispatch({ type: "SET_CROP_TYPE", value: e.target.value })}
placeholder="e.g. Sweet Corn"
required
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
@@ -96,8 +137,8 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
<label htmlFor="fld-1-harvest-date" className="block text-sm font-medium text-stone-700 mb-1.5">Harvest Date</label>
<input id="fld-1-harvest-date" aria-label="Date"
type="date"
value={harvest_date}
onChange={(e) => setHarvestDate(e.target.value)}
value={state.harvest_date}
onChange={(e) => dispatch({ type: "SET_HARVEST_DATE", value: e.target.value })}
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
@@ -106,8 +147,8 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
<label htmlFor="fld-2-field-location" className="block text-sm font-medium text-stone-700 mb-1.5">Field / Location</label>
<input id="fld-2-field-location" aria-label=". North Field"
type="text"
value={field_location}
onChange={(e) => setFieldLocation(e.target.value)}
value={state.field_location}
onChange={(e) => dispatch({ type: "SET_FIELD_LOCATION", value: e.target.value })}
placeholder="e.g. North Field"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
@@ -118,8 +159,8 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
<label htmlFor="fld-3-worker" className="block text-sm font-medium text-stone-700 mb-1.5">Worker</label>
<input id="fld-3-worker" aria-label="Name"
type="text"
value={worker_name}
onChange={(e) => setWorkerName(e.target.value)}
value={state.worker_name}
onChange={(e) => dispatch({ type: "SET_WORKER_NAME", value: e.target.value })}
placeholder="Name"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
@@ -128,8 +169,8 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
<label htmlFor="fld-4-variety" className="block text-sm font-medium text-stone-700 mb-1.5">Variety</label>
<input id="fld-4-variety" aria-label="Type"
type="text"
value={variety}
onChange={(e) => setVariety(e.target.value)}
value={state.variety}
onChange={(e) => dispatch({ type: "SET_VARIETY", value: e.target.value })}
placeholder="Type"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
@@ -140,17 +181,17 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
<label htmlFor="fld-5-quantity-lbs" className="block text-sm font-medium text-stone-700 mb-1.5">Quantity (lbs)</label>
<input id="fld-5-quantity-lbs" aria-label="0"
type="number"
value={quantity_lbs}
onChange={(e) => setQuantityLbs(e.target.value)}
value={state.quantity_lbs}
onChange={(e) => dispatch({ type: "SET_QUANTITY_LBS", value: e.target.value })}
placeholder="0"
min="0"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-base text-stone-900 focus:outline-none focus:border-stone-400 focus:bg-white transition-colors"
/>
</div>
<button
type="submit"
disabled={isPending || !crop_type.trim()}
disabled={isPending || !state.crop_type.trim()}
className="w-full rounded-xl bg-stone-800 py-3.5 text-base font-semibold text-white hover:bg-stone-700 disabled:opacity-50 transition-colors mt-2"
>
{isPending ? "Creating..." : "Create Lot"}
@@ -158,4 +199,4 @@ export default function QuickNewLotModal({ brandId, onCreated, onClose }: Props)
</form>
</GlassModal>
);
}
}
+380 -234
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useEffectEvent } from "react";
import { useEffect, useReducer, useRef, useEffectEvent } from "react";
import Image from "next/image";
import { LotDetail } from "@/actions/route-trace/lots";
@@ -34,12 +34,54 @@ type StickerSize = "4x2" | "4x3";
const COPY_OPTIONS = [1, 2, 3, 5, 10];
type State = {
stickerType: StickerType;
stickerSize: StickerSize;
copies: number;
loading: boolean;
qrDataUrl: string | null;
};
type Action =
| { type: "SET_TYPE"; value: StickerType }
| { type: "SET_SIZE"; value: StickerSize }
| { type: "SET_COPIES"; value: number }
| { type: "START_PRINT" }
| { type: "END_PRINT" }
| { type: "SET_QR"; dataUrl: string };
const initialState: State = {
stickerType: "field",
stickerSize: "4x2",
copies: 1,
loading: false,
qrDataUrl: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_TYPE":
return { ...state, stickerType: action.value };
case "SET_SIZE":
return { ...state, stickerSize: action.value };
case "SET_COPIES":
return { ...state, copies: action.value };
case "START_PRINT":
return { ...state, loading: true };
case "END_PRINT":
return { ...state, loading: false };
case "SET_QR":
return { ...state, qrDataUrl: action.dataUrl };
default: {
const _exhaustive: never = action;
return _exhaustive;
}
}
}
export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail; onClose: () => void }) {
const [stickerType, setStickerType] = useState<StickerType>("field");
const [stickerSize, setStickerSize] = useState<StickerSize>("4x2");
const [copies, setCopies] = useState(1);
const [loading, setLoading] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [state, dispatch] = useReducer(reducer, initialState);
const { stickerType, stickerSize, copies, loading, qrDataUrl } = state;
const baseUrl = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000";
const traceUrl = `${baseUrl}/trace/${lot.lot_number}`;
@@ -47,13 +89,13 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
useEffect(() => {
import("qrcode").then(({ toDataURL }) => {
toDataURL(traceUrl, { width: 200, margin: 1, color: { dark: "#000000", light: "#FFFFFF" } })
.then(setQrDataUrl)
.then((dataUrl: string) => dispatch({ type: "SET_QR", dataUrl }))
.catch(() => {});
});
}, [traceUrl]);
async function handlePrint() {
setLoading(true);
dispatch({ type: "START_PRINT" });
try {
const url = `/api/route-trace/sticker-pdf?lotId=${lot.lot_id}&type=${stickerType}&size=${stickerSize}&copies=${copies}`;
const res = await fetch(url);
@@ -69,7 +111,7 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
} catch (e) {
alert("Failed to generate PDF. Please try again.");
} finally {
setLoading(false);
dispatch({ type: "END_PRINT" });
}
}
@@ -108,20 +150,6 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
return () => dialog.removeEventListener("cancel", handleCancel);
}, []);
const handleBackdropClick = (e: React.MouseEvent) => {
const dialog = dialogRef.current;
if (!dialog) return;
const rect = dialog.getBoundingClientRect();
if (
e.clientX < rect.left ||
e.clientX > rect.right ||
e.clientY < rect.top ||
e.clientY > rect.bottom
) {
onClose();
}
};
useEffect(() => {
function onPointerDown(e: PointerEvent) {
const dialog = dialogRef.current;
@@ -147,223 +175,341 @@ export default function StickerPreviewModal({ lot, onClose }: { lot: LotDetail;
className="bg-transparent backdrop:bg-black/40 p-4 border-0 max-w-none max-h-none"
>
<div className="w-full max-w-lg rounded-2xl bg-white shadow-xl mx-auto my-auto">
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
<div>
<h3 className="text-base font-semibold text-stone-900 flex items-center gap-2">{Icons.printer("h-5 w-5")} Print Sticker</h3>
<p className="text-xs text-stone-400 mt-0.5">{lot.lot_number} · {lot.crop_type}</p>
</div>
<button type="button" onClick={onClose} className="text-stone-400 hover:text-stone-600" aria-label="Close">
<svg className="h-5 w-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>
</div>
<ModalHeader lot={lot} onClose={onClose} />
<div className="p-6 space-y-5">
{/* Type + Size selectors */}
<div className="grid grid-cols-3 gap-4">
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Sticker Type</p>
<div className="space-y-1.5">
{(["field", "shed"] as StickerType[]).map((t) => {
const label = t === "field" ? "Field" : "Shed";
const desc = t === "field" ? "After harvest" : "At shed arrival";
return (
<button type="button"
key={t}
onClick={() => setStickerType(t)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerType === t
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
aria-label="Sticker">
<div className="text-sm font-semibold">{label} Sticker</div>
<div className={`text-[10px] mt-0.5 ${stickerType === t ? "text-stone-300" : "text-stone-400"}`}>{desc}</div>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Label Size</p>
<div className="space-y-1.5">
{(["4x2", "4x3"] as StickerSize[]).map((s) => {
const w = s === "4x2" ? "2 in" : "3 in";
const h = s === "4x2" ? "2 in" : "3 in";
return (
<button type="button"
key={s}
onClick={() => setStickerSize(s)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerSize === s
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
aria-label="×">
<div className="text-sm font-semibold">{w} × {h}</div>
<div className={`text-[10px] mt-0.5 ${stickerSize === s ? "text-stone-300" : "text-stone-400"}`}>
{s === "4x2" ? "Field sticker" : "Shed sticker"}
</div>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Copies</p>
<div className="grid grid-cols-5 gap-1.5">
{COPY_OPTIONS.map((n) => (
<button type="button"
key={n}
onClick={() => setCopies(n)}
className={`rounded-lg border py-2 text-center text-sm font-bold transition-colors ${
copies === n
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
>
{n}
</button>
))}
</div>
<p className="mt-1.5 text-[10px] text-stone-400 text-center">labels per sheet</p>
</div>
</div>
<StickerOptionSelectors
stickerType={stickerType}
stickerSize={stickerSize}
copies={copies}
onTypeChange={(t) => dispatch({ type: "SET_TYPE", value: t })}
onSizeChange={(s) => dispatch({ type: "SET_SIZE", value: s })}
onCopiesChange={(n) => dispatch({ type: "SET_COPIES", value: n })}
/>
{/* Sticker preview — actual proportions, scaled */}
<div className="flex flex-col items-center gap-2">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Preview</p>
<div
className="relative rounded-lg border-2 border-stone-300 overflow-hidden bg-white"
style={{ width: previewW, height: previewH }}
>
<div className="absolute inset-0 p-3.5 flex flex-col text-black select-none">
{/* Brand row */}
<div className="flex items-center justify-between">
<span className="text-[6px] font-bold uppercase tracking-widest text-stone-400">Route Trace</span>
<span className="text-[5px] text-stone-300">{stickerSize}&quot;</span>
</div>
<StickerPreview
lot={lot}
stickerType={stickerType}
stickerSize={stickerSize}
qrDataUrl={qrDataUrl}
previewW={previewW}
previewH={previewH}
qrPreviewSize={qrPreviewSize}
/>
{/* Lot number — dominant */}
<div className="text-[18px] font-black leading-tight text-stone-950 tracking-tight mt-0.5">
{lot.lot_number}
</div>
{/* Crop */}
<div className="text-[9px] font-bold text-stone-700 mt-0.5">{lot.crop_type}</div>
{/* Data rows */}
<div className="flex flex-1 mt-1 gap-2">
<div className="flex-1 space-y-0.5">
{stickerType === "field" ? (
<>
{lot.harvest_date && <div className="text-[6px] text-stone-600">Harvested: {lot.harvest_date}</div>}
{lot.quantity_lbs && (
<div className="text-[8px] font-black text-stone-950 mt-1">
{Number(lot.quantity_lbs).toLocaleString()} lbs
</div>
)}
{lot.field_location && <div className="text-[6px] text-stone-600">{lot.field_location}</div>}
{lot.field_block && <div className="text-[6px] text-stone-500">Block: {lot.field_block}</div>}
{lot.worker_name && <div className="text-[6px] text-stone-600">{lot.worker_name}</div>}
{lot.yield_estimate_lbs && (
<div className="text-[6px] text-stone-600">Est: {Number(lot.yield_estimate_lbs).toLocaleString()} {lot.yield_unit ?? "lbs"}</div>
)}
</>
) : (
<>
{lot.quantity_lbs && (
<div className="text-[10px] font-black text-stone-950">
{Number(lot.quantity_lbs).toLocaleString()} {lot.yield_unit ?? "lbs"}
</div>
)}
{lot.harvest_date && <div className="text-[6px] text-stone-600">Packed: {lot.harvest_date}</div>}
{lot.field_location && <div className="text-[6px] text-stone-600">From: {lot.field_location}</div>}
{lot.worker_name && <div className="text-[6px] text-stone-600">{lot.worker_name}</div>}
{lot.destination_stop_id && (
<div className="text-[6px] text-stone-600">Dest: #{lot.destination_stop_id.slice(0, 8)}</div>
)}
</>
)}
</div>
{/* Right — bin/container */}
<div className="w-20 text-right space-y-0.5">
{lot.bin_id && (
<div className="text-[6px] font-black text-stone-950">BIN {lot.bin_id}</div>
)}
{lot.container_id && (
<div className="text-[6px] font-black text-stone-950">CONT {lot.container_id}</div>
)}
{lot.pallets && (
<div className="text-[5px] text-stone-500">{lot.pallets} PLT</div>
)}
{lot.field_block && stickerType === "shed" && (
<div className="text-[5px] text-stone-400">Block: {lot.field_block}</div>
)}
</div>
</div>
{/* QR — bottom right */}
<div className="absolute bottom-1.5 right-1.5">
{qrDataUrl ? (
<Image
src={qrDataUrl}
alt="QR"
width={qrPreviewSize}
height={qrPreviewSize}
unoptimized
className="rounded border border-stone-300"
style={{ width: qrPreviewSize, height: qrPreviewSize }}
/>
) : (
<div
className="bg-stone-100 border border-stone-300 rounded flex items-center justify-center"
style={{ width: qrPreviewSize, height: qrPreviewSize }}
>
<span className="text-[5px] text-stone-400">QR</span>
</div>
)}
</div>
{/* /trace url — bottom left */}
<div className="absolute bottom-1.5 left-1.5 text-[4px] text-stone-300 truncate max-w-[90px]">
/trace/{lot.lot_number}
</div>
</div>
</div>
</div>
{/* Print specs */}
<div className="rounded-xl bg-stone-50 border border-stone-200 px-4 py-2.5 flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-stone-700">Direct Thermal · {stickerSize}&quot; · Black on White · Large QR</p>
<p className="text-[10px] text-stone-400 mt-0.5">Helvetica Bold · High-contrast QR · No margins · 2 per sheet</p>
</div>
<div className="text-right">
<p className="text-xs font-bold text-stone-600">{lot.lot_number}</p>
<p className="text-[10px] text-stone-400">2 per sheet</p>
</div>
</div>
<PrintSpecs lot={lot} stickerSize={stickerSize} />
</div>
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button type="button"
onClick={onClose}
className="rounded-xl border border-stone-200 px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50"
aria-label="Cancel">
Cancel
</button>
<button type="button"
onClick={handlePrint}
disabled={loading}
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
aria-label="Print × `} Sticker}">
{loading ? "Generating PDF..." : <><span className="inline-flex items-center gap-1.5">{Icons.printer("h-4 w-4")} Print {copies === 1 ? "" : `${copies}× `}{stickerType === "field" ? "Field" : "Shed"} Sticker{copies > 1 ? "s" : ""}</span></>}
</button>
</div>
<ModalActions
loading={loading}
copies={copies}
stickerType={stickerType}
onCancel={onClose}
onPrint={handlePrint}
/>
</div>
</dialog>
);
}
}
function ModalHeader({ lot, onClose }: { lot: LotDetail; onClose: () => void }) {
return (
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
<div>
<h3 className="text-base font-semibold text-stone-900 flex items-center gap-2">{Icons.printer("h-5 w-5")} Print Sticker</h3>
<p className="text-xs text-stone-400 mt-0.5">{lot.lot_number} · {lot.crop_type}</p>
</div>
<button type="button" onClick={onClose} className="text-stone-400 hover:text-stone-600" aria-label="Close">
<svg className="h-5 w-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>
</div>
);
}
function StickerOptionSelectors({
stickerType,
stickerSize,
copies,
onTypeChange,
onSizeChange,
onCopiesChange,
}: {
stickerType: StickerType;
stickerSize: StickerSize;
copies: number;
onTypeChange: (t: StickerType) => void;
onSizeChange: (s: StickerSize) => void;
onCopiesChange: (n: number) => void;
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Sticker Type</p>
<div className="space-y-1.5">
{(["field", "shed"] as StickerType[]).map((t) => {
const label = t === "field" ? "Field" : "Shed";
const desc = t === "field" ? "After harvest" : "At shed arrival";
return (
<button type="button"
key={t}
onClick={() => onTypeChange(t)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerType === t
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
aria-label="Sticker">
<div className="text-sm font-semibold">{label} Sticker</div>
<div className={`text-[10px] mt-0.5 ${stickerType === t ? "text-stone-300" : "text-stone-400"}`}>{desc}</div>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Label Size</p>
<div className="space-y-1.5">
{(["4x2", "4x3"] as StickerSize[]).map((s) => {
const w = s === "4x2" ? "2 in" : "3 in";
const h = s === "4x2" ? "2 in" : "3 in";
return (
<button type="button"
key={s}
onClick={() => onSizeChange(s)}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition-colors ${
stickerSize === s
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 hover:bg-stone-50"
}`}
aria-label="×">
<div className="text-sm font-semibold">{w} × {h}</div>
<div className={`text-[10px] mt-0.5 ${stickerSize === s ? "text-stone-300" : "text-stone-400"}`}>
{s === "4x2" ? "Field sticker" : "Shed sticker"}
</div>
</button>
);
})}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold text-stone-500 uppercase tracking-wider">Copies</p>
<div className="grid grid-cols-5 gap-1.5">
{COPY_OPTIONS.map((n) => (
<button type="button"
key={n}
onClick={() => onCopiesChange(n)}
className={`rounded-lg border py-2 text-center text-sm font-bold transition-colors ${
copies === n
? "border-emerald-600 bg-emerald-600 text-white"
: "border-stone-200 text-stone-600 hover:bg-stone-50"
}`}
>
{n}
</button>
))}
</div>
<p className="mt-1.5 text-[10px] text-stone-400 text-center">labels per sheet</p>
</div>
</div>
);
}
function StickerPreview({
lot,
stickerType,
stickerSize,
qrDataUrl,
previewW,
previewH,
qrPreviewSize,
}: {
lot: LotDetail;
stickerType: StickerType;
stickerSize: StickerSize;
qrDataUrl: string | null;
previewW: number;
previewH: number;
qrPreviewSize: number;
}) {
return (
<div className="flex flex-col items-center gap-2">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Preview</p>
<div
className="relative rounded-lg border-2 border-stone-300 overflow-hidden bg-white"
style={{ width: previewW, height: previewH }}
>
<div className="absolute inset-0 p-3.5 flex flex-col text-black select-none">
<StickerTopRow stickerSize={stickerSize} />
<div className="text-[18px] font-black leading-tight text-stone-950 tracking-tight mt-0.5">
{lot.lot_number}
</div>
<div className="text-[9px] font-bold text-stone-700 mt-0.5">{lot.crop_type}</div>
<div className="flex flex-1 mt-1 gap-2">
<StickerDataColumn lot={lot} stickerType={stickerType} />
<StickerRightColumn lot={lot} stickerType={stickerType} />
</div>
<StickerFooter
lot={lot}
qrDataUrl={qrDataUrl}
qrPreviewSize={qrPreviewSize}
/>
</div>
</div>
</div>
);
}
function StickerTopRow({ stickerSize }: { stickerSize: StickerSize }) {
return (
<div className="flex items-center justify-between">
<span className="text-[6px] font-bold uppercase tracking-widest text-stone-400">Route Trace</span>
<span className="text-[5px] text-stone-300">{stickerSize}&quot;</span>
</div>
);
}
function StickerDataColumn({ lot, stickerType }: { lot: LotDetail; stickerType: StickerType }) {
if (stickerType === "field") {
return (
<div className="flex-1 space-y-0.5">
{lot.harvest_date && <div className="text-[6px] text-stone-600">Harvested: {lot.harvest_date}</div>}
{lot.quantity_lbs && (
<div className="text-[8px] font-black text-stone-950 mt-1">
{Number(lot.quantity_lbs).toLocaleString()} lbs
</div>
)}
{lot.field_location && <div className="text-[6px] text-stone-600">{lot.field_location}</div>}
{lot.field_block && <div className="text-[6px] text-stone-500">Block: {lot.field_block}</div>}
{lot.worker_name && <div className="text-[6px] text-stone-600">{lot.worker_name}</div>}
{lot.yield_estimate_lbs && (
<div className="text-[6px] text-stone-600">Est: {Number(lot.yield_estimate_lbs).toLocaleString()} {lot.yield_unit ?? "lbs"}</div>
)}
</div>
);
}
return (
<div className="flex-1 space-y-0.5">
{lot.quantity_lbs && (
<div className="text-[10px] font-black text-stone-950">
{Number(lot.quantity_lbs).toLocaleString()} {lot.yield_unit ?? "lbs"}
</div>
)}
{lot.harvest_date && <div className="text-[6px] text-stone-600">Packed: {lot.harvest_date}</div>}
{lot.field_location && <div className="text-[6px] text-stone-600">From: {lot.field_location}</div>}
{lot.worker_name && <div className="text-[6px] text-stone-600">{lot.worker_name}</div>}
{lot.destination_stop_id && (
<div className="text-[6px] text-stone-600">Dest: #{lot.destination_stop_id.slice(0, 8)}</div>
)}
</div>
);
}
function StickerRightColumn({ lot, stickerType }: { lot: LotDetail; stickerType: StickerType }) {
return (
<div className="w-20 text-right space-y-0.5">
{lot.bin_id && (
<div className="text-[6px] font-black text-stone-950">BIN {lot.bin_id}</div>
)}
{lot.container_id && (
<div className="text-[6px] font-black text-stone-950">CONT {lot.container_id}</div>
)}
{lot.pallets && (
<div className="text-[5px] text-stone-500">{lot.pallets} PLT</div>
)}
{lot.field_block && stickerType === "shed" && (
<div className="text-[5px] text-stone-400">Block: {lot.field_block}</div>
)}
</div>
);
}
function StickerFooter({
lot,
qrDataUrl,
qrPreviewSize,
}: {
lot: LotDetail;
qrDataUrl: string | null;
qrPreviewSize: number;
}) {
return (
<>
<div className="absolute bottom-1.5 right-1.5">
{qrDataUrl ? (
<Image
src={qrDataUrl}
alt="QR"
width={qrPreviewSize}
height={qrPreviewSize}
unoptimized
className="rounded border border-stone-300"
style={{ width: qrPreviewSize, height: qrPreviewSize }}
/>
) : (
<div
className="bg-stone-100 border border-stone-300 rounded flex items-center justify-center"
style={{ width: qrPreviewSize, height: qrPreviewSize }}
>
<span className="text-[5px] text-stone-400">QR</span>
</div>
)}
</div>
<div className="absolute bottom-1.5 left-1.5 text-[4px] text-stone-300 truncate max-w-[90px]">
/trace/{lot.lot_number}
</div>
</>
);
}
function PrintSpecs({ lot, stickerSize }: { lot: LotDetail; stickerSize: StickerSize }) {
return (
<div className="rounded-xl bg-stone-50 border border-stone-200 px-4 py-2.5 flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-stone-700">Direct Thermal · {stickerSize}&quot; · Black on White · Large QR</p>
<p className="text-[10px] text-stone-400 mt-0.5">Helvetica Bold · High-contrast QR · No margins · 2 per sheet</p>
</div>
<div className="text-right">
<p className="text-xs font-bold text-stone-600">{lot.lot_number}</p>
<p className="text-[10px] text-stone-400">2 per sheet</p>
</div>
</div>
);
}
function ModalActions({
loading,
copies,
stickerType,
onCancel,
onPrint,
}: {
loading: boolean;
copies: number;
stickerType: StickerType;
onCancel: () => void;
onPrint: () => void;
}) {
return (
<div className="flex justify-end gap-3 border-t border-stone-100 px-6 py-4">
<button type="button"
onClick={onCancel}
className="rounded-xl border border-stone-200 px-4 py-2.5 text-sm font-semibold text-stone-600 hover:bg-stone-50"
aria-label="Cancel">
Cancel
</button>
<button type="button"
onClick={onPrint}
disabled={loading}
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50 flex items-center gap-2"
aria-label="Print × `} Sticker}">
{loading ? "Generating PDF..." : <><span className="inline-flex items-center gap-1.5">{Icons.printer("h-4 w-4")} Print {copies === 1 ? "" : `${copies}× `}{stickerType === "field" ? "Field" : "Shed"} Sticker{copies > 1 ? "s" : ""}</span></>}
</button>
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useReducer, useState } from "react";
import { useRouter } from "next/navigation";
import { Elements, ExpressCheckoutElement, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import type { StripeExpressCheckoutElementConfirmEvent } from "@stripe/stripe-js";
@@ -25,6 +25,24 @@ export type CheckoutItem = {
fulfillment?: "pickup" | "ship";
};
type State = {
clientSecret: string | null;
paymentIntentId: string | null;
intentAmount: number | null;
error: string | null;
loading: boolean;
available: boolean;
};
type Action =
| { type: "INIT_START" }
| { type: "INIT_DONE_NO_KEY"; error: string }
| { type: "INIT_DONE_NO_ITEMS" }
| { type: "INIT_DONE_BLOCKED" }
| { type: "INIT_SUCCESS"; clientSecret: string; paymentIntentId: string; intentAmount: number }
| { type: "INIT_FAILED"; error: string }
| { type: "SET_ERROR"; error: string };
type Props = {
items: CheckoutItem[];
brandId: string | null;
@@ -42,6 +60,41 @@ type Props = {
checkoutSessionKey?: string;
};
const initialState: State = {
clientSecret: null,
paymentIntentId: null,
intentAmount: null,
error: null,
loading: true,
available: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "INIT_START":
return { ...state, loading: true, error: null };
case "INIT_DONE_NO_KEY":
return { ...state, loading: false, available: false, error: action.error };
case "INIT_DONE_NO_ITEMS":
case "INIT_DONE_BLOCKED":
return { ...state, loading: false };
case "INIT_SUCCESS":
return {
...state,
loading: false,
available: true,
clientSecret: action.clientSecret,
paymentIntentId: action.paymentIntentId,
intentAmount: action.intentAmount,
error: null,
};
case "INIT_FAILED":
return { ...state, loading: false, available: false, error: action.error };
case "SET_ERROR":
return { ...state, error: action.error };
}
}
/**
* Renders the embedded Stripe Elements (Express Checkout + Payment Element)
* on top of the existing hosted Stripe Checkout fallback.
@@ -58,12 +111,7 @@ type Props = {
*/
export default function StripeExpressCheckout(props: Props) {
const router = useRouter();
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [paymentIntentId, setPaymentIntentId] = useState<string | null>(null);
const [intentAmount, setIntentAmount] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [available, setAvailable] = useState(false);
const [state, dispatch] = useReducer(reducer, initialState);
const hasShip = props.items.some((i) => i.fulfillment === "ship");
const hasPickup = props.items.some(
@@ -84,23 +132,23 @@ export default function StripeExpressCheckout(props: Props) {
let cancelled = false;
async function initCheckout() {
setLoading(true);
setError(null);
dispatch({ type: "INIT_START" });
if (!hasStripePublishableKey()) {
setAvailable(false);
setError("Stripe publishable key not configured. Use the secure checkout button below.");
setLoading(false);
dispatch({
type: "INIT_DONE_NO_KEY",
error: "Stripe publishable key not configured. Use the secure checkout button below.",
});
return;
}
if (props.items.length === 0) {
setLoading(false);
dispatch({ type: "INIT_DONE_NO_ITEMS" });
return;
}
if (stopBlocked) {
setLoading(false);
dispatch({ type: "INIT_DONE_BLOCKED" });
return;
}
@@ -128,16 +176,15 @@ export default function StripeExpressCheckout(props: Props) {
if (cancelled) return;
if (result.success) {
setClientSecret(result.clientSecret);
setPaymentIntentId(result.paymentIntentId);
setIntentAmount(result.amount);
setAvailable(true);
setError(null);
dispatch({
type: "INIT_SUCCESS",
clientSecret: result.clientSecret,
paymentIntentId: result.paymentIntentId,
intentAmount: result.amount,
});
} else {
setAvailable(false);
setError(result.error);
dispatch({ type: "INIT_FAILED", error: result.error });
}
setLoading(false);
}
initCheckout().catch(console.error);
@@ -190,7 +237,7 @@ export default function StripeExpressCheckout(props: Props) {
: undefined,
idempotencyKey: sessionKey,
paymentIntentId: intentId,
paymentIntentAmount: intentAmount,
paymentIntentAmount: state.intentAmount,
})
);
}
@@ -198,7 +245,7 @@ export default function StripeExpressCheckout(props: Props) {
// Stripe.js loader promise
const stripePromise = useMemo(() => getStripe(), []);
if (loading) {
if (state.loading) {
return (
<ExpressShell>
<div className="flex items-center gap-2 text-stone-500 text-sm py-3">
@@ -209,12 +256,12 @@ export default function StripeExpressCheckout(props: Props) {
);
}
if (!available || !clientSecret || !stripePromise) {
if (!state.available || !state.clientSecret || !stripePromise) {
return (
<ExpressShell>
{error && (
{state.error && (
<p className="text-xs text-stone-500 mb-2">
{error}
{state.error}
</p>
)}
<button
@@ -233,7 +280,7 @@ export default function StripeExpressCheckout(props: Props) {
<Elements
stripe={stripePromise}
options={{
clientSecret,
clientSecret: state.clientSecret,
appearance: {
theme: "flat",
variables: {
@@ -257,13 +304,12 @@ export default function StripeExpressCheckout(props: Props) {
{...props}
emailBlocked={emailBlocked}
stopBlocked={stopBlocked}
paymentIntentId={paymentIntentId}
intentAmount={intentAmount}
paymentIntentId={state.paymentIntentId}
persistPendingCheckout={persistPendingCheckout}
onError={setError}
onError={(msg) => dispatch({ type: "SET_ERROR", error: msg })}
onSuccess={() => {
// Express / Card confirmed in-page. Navigate to success.
router.push("/checkout/success?payment_intent=" + (paymentIntentId ?? ""));
router.push("/checkout/success?payment_intent=" + (state.paymentIntentId ?? ""));
}}
onHostedCheckout={props.onHostedCheckout}
/>
@@ -292,19 +338,15 @@ type InnerProps = Props & {
emailBlocked: boolean;
stopBlocked: boolean;
paymentIntentId: string | null;
intentAmount: number | null;
persistPendingCheckout: (intentId: string) => void;
onError: (msg: string) => void;
onSuccess: () => void;
};
function CheckoutInner({
items,
brandId,
customerName,
customerEmail,
customerPhone,
selectedStop,
shippingState,
shippingPostal,
shippingCity,
@@ -507,4 +549,4 @@ function CheckoutInner({
)}
</div>
);
}
}
File diff suppressed because it is too large Load Diff
+464 -333
View File
@@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { useEffect, useReducer, useRef } from "react";
import { m as motion } from "framer-motion";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
@@ -24,6 +24,29 @@ type TuxedoVideoHeroProps = {
const VIDEO_URL = "/videos/tuxedo-hero.mp4";
const HERO_STATS = [
{ stat: "40+", label: "Years Growing" },
{ stat: "3", label: "Generations" },
{ stat: "100%", label: "Hand-Picked" },
] as const;
type State = {
scrollProgress: number;
};
type Action = { type: "SET_SCROLL_PROGRESS"; value: number };
const initialState: State = {
scrollProgress: 0,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_SCROLL_PROGRESS":
return { ...state, scrollProgress: action.value };
}
}
// ─────────────────────────────────────────────────────────────────────────────
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
// ─────────────────────────────────────────────────────────────────────────────
@@ -41,7 +64,7 @@ export default function TuxedoVideoHero({
const sectionRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [scrollProgress, setScrollProgress] = useState(0);
const [state, dispatch] = useReducer(reducer, initialState);
// ─── GSAP SCROLL ANIMATIONS ────────────────────────────────────────────────
// Motion pass: the original used a 1.15 video scale, an 80px y-shift on the
@@ -63,13 +86,11 @@ export default function TuxedoVideoHero({
end: "bottom top",
scrub: true,
onUpdate: (self) => {
setScrollProgress(self.progress);
dispatch({ type: "SET_SCROLL_PROGRESS", value: self.progress });
},
});
// Hero content — gentle fade on scroll, NO positional shift.
// (Previously: y: -80. Multi-axis scroll-driven motion is the worst
// trigger for motion sickness. The content just dissolves as you scroll.)
if (contentRef.current) {
gsap.to(contentRef.current, {
opacity: 0,
@@ -84,8 +105,6 @@ export default function TuxedoVideoHero({
}
// Video — no scale, no parallax. Just a static background.
// (Previously: scale 1.15 + y 100. The scale made the video breathe as
// the user scrolled, which was cinematic but disorienting.)
// Staggered entrance for content elements — small Y, no scale, light ease.
const contentElements = gsap.utils.toArray<Element>(".hero-reveal");
@@ -107,11 +126,6 @@ export default function TuxedoVideoHero({
);
});
// Parallax floating elements — disabled. The .parallax-float class is
// kept in the markup so the CSS keyframe animation (`float-slow` /
// `float-delayed`) still runs as a gentle ambient pulse, but the
// scroll-driven Y shift is gone.
// Scroll indicator — just fades, no bounce, no positional shift.
gsap.to(".scroll-indicator", {
opacity: 0,
@@ -139,343 +153,460 @@ export default function TuxedoVideoHero({
return (
<>
{/* ─── SCROLL PROGRESS BAR ──────────────────────────────────────────── */}
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar">
<div
className="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400"
style={{
transform: `scaleX(${scrollProgress})`,
transformOrigin: "left",
}}
/>
</div>
<ScrollProgressBar scrollProgress={state.scrollProgress} />
{/* ─── CINEMATIC HERO SECTION ──────────────────────────────────────── */}
<section
ref={sectionRef}
className="relative min-h-screen flex items-center overflow-hidden"
>
{/* Background video with parallax */}
<video
ref={videoRef}
autoPlay
muted
loop
playsInline
tabIndex={-1}
aria-hidden="true"
className="absolute inset-0 w-full h-[120%] object-cover"
style={{ zIndex: 0 }}
src={VIDEO_URL}
/>
<HeroBackground videoRef={videoRef} />
{/* Gradient overlays */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 1,
background: "linear-gradient(135deg, rgba(255,215,0,0.12) 0%, rgba(255,193,7,0.06) 50%, rgba(255,235,0,0.04) 100%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 2,
background: "radial-gradient(ellipse at center, rgba(255,200,0,0.08) 0%, rgba(0,0,0,0.4) 50%, rgba(0,0,0,0.9) 100%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 3,
background: "linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0.05) 100%)",
}}
/>
{/* ─── FLOATING DECORATIVE ELEMENTS ──────────────────────────────── */}
<div className="absolute inset-0 pointer-events-none z-[4]">
{/* Golden orb top-left */}
<div
className="parallax-float absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20"
style={{
background: "radial-gradient(circle, rgba(255,215,0,0.4) 0%, transparent 70%)",
filter: "blur(8px)",
animation: "tvh-float-slow 950ms ease-in-out infinite",
}}
/>
{/* Emerald orb bottom-right */}
<div
className="parallax-float absolute bottom-1/3 right-1/4 w-48 h-48 rounded-full opacity-15"
style={{
background: "radial-gradient(circle, rgba(16,185,129,0.4) 0%, transparent 70%)",
filter: "blur(8px)",
animation: "tvh-float-delayed 950ms ease-in-out infinite",
}}
/>
{/* Subtle grain overlay */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
</div>
{/* ─── CONTENT CONTAINER ──────────────────────────────────────────── */}
<div
ref={contentRef}
className="relative z-10 mx-auto w-full max-w-6xl px-6 pb-32 pt-32 flex flex-col justify-end"
>
<div className="max-w-3xl">
{/* Logo with glow effect calmer entrance. Was a 1s scale 0.91
* with a custom ease. Now a 320ms opacity-only fade. */}
<motion.div
className="hero-reveal mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.32, delay: 0.05, ease: "easeOut" }}
>
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
{logoSrc ? (
<Image
src={logoSrc}
alt="Olathe Sweet"
fill
sizes="(max-width: 1024px) 80vw, 50vw"
style={{ objectFit: "contain" }}
className="drop-shadow-2xl"
priority
/>
) : null}
</motion.div>
{/* Eyebrow with animated underline */}
<div className="hero-reveal mb-6">
{eyebrow && (
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-400/80">
{eyebrow}
</p>
)}
<div className="mt-3 h-px w-16 bg-gradient-to-r from-emerald-500 to-transparent" />
</div>
{/* Main Title - Apple-style reveal */}
<h1
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-tight text-white leading-[0.95] mb-6"
style={{
textShadow: "0 4px 30px rgba(0,0,0,0.3), 0 0 60px rgba(255,215,0,0.1)",
}}
>
{title.split(" ").map((word, idx) => (
<span
key={`${idx}-${word}`}
className="inline-block mr-4"
style={{
animationDelay: `${0.2 + idx * 0.1}s`,
}}
>
{word}
</span>
))}
</h1>
{/* Description with typewriter-like reveal */}
<p
className="hero-reveal text-xl md:text-2xl text-white/70 leading-relaxed max-w-xl mb-10"
style={{ fontWeight: 300 }}
>
{description}
</p>
{/* CTAs with hover effects */}
<div className="hero-reveal flex flex-wrap items-center gap-5">
{primaryButton && (
<motion.button
onClick={handlePrimaryClick}
className="group relative rounded-full px-10 py-4 text-sm font-bold tracking-widest uppercase overflow-hidden"
style={{
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
/* whileHover / whileTap dropped the gradient background
* already shifts on hover, and the SVG arrow inside the
* button still nudges right on hover. No additional scale. */
>
<span className="relative z-10 flex items-center gap-3">
<span>{primaryButton}</span>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</span>
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
/>
</motion.button>
)}
{secondaryButton && (
<button type="button"
onClick={onSecondaryClick}
className="group flex items-center gap-3 text-sm font-semibold"
style={{ color: "rgba(255,255,255,0.8)" }}
aria-label="Next">
<span className="uppercase tracking-widest text-[11px]">{secondaryButton}</span>
<span
className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/30"
style={{ borderColor: "rgba(255,255,255,0.3)" }}
>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</span>
</button>
)}
</div>
{/* Stats row */}
<div className="hero-reveal mt-16 flex flex-wrap gap-12">
{[
{ stat: "40+", label: "Years Growing" },
{ stat: "3", label: "Generations" },
{ stat: "100%", label: "Hand-Picked" },
].map((item) => (
<div key={item.label} className="text-center">
<div
className="text-3xl md:text-4xl font-black text-white"
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.3)" }}
>
{item.stat}
</div>
<div
className="text-[10px] uppercase tracking-[0.2em] mt-1"
style={{ color: "rgba(255,255,255,0.5)" }}
>
{item.label}
</div>
</div>
))}
</div>
<HeroContent
logoSrc={logoSrc ?? null}
eyebrow={eyebrow}
title={title}
description={description}
primaryButton={primaryButton}
secondaryButton={secondaryButton}
onPrimaryClick={handlePrimaryClick}
onSecondaryClick={onSecondaryClick}
/>
</div>
</div>
{/* ─── SCROLL INDICATOR ────────────────────────────────────────────── */}
<div
className="scroll-indicator absolute bottom-10 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-3"
style={{ pointerEvents: "auto" }}
>
<button type="button"
onClick={handlePrimaryClick}
className="flex flex-col items-center gap-2 text-white/50 hover:text-white/80 transition-colors duration-200"
aria-label="Scroll to stops"
>
<span className="text-[10px] uppercase tracking-[0.25em] font-medium">Explore</span>
<div
className="w-6 h-10 rounded-full border border-white/30 flex items-start justify-center p-1.5"
>
<div
className="w-1.5 h-3 rounded-full bg-white tvh-scroll-indicator"
style={{ animationDuration: "950ms" }}
/>
</div>
</button>
</div>
{/* ─── CORNER DECORATIONS ───────────────────────────────────────────── */}
<div
className="parallax-float absolute top-8 right-8 opacity-30"
style={{ animationDelay: "1s" }}
>
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none">
<path
d="M32 4C48 16, 56 32, 48 56C40 64, 24 64, 8 56C0 32, 16 16, 32 4"
stroke="rgba(255,215,0,0.5)"
strokeWidth="1"
fill="none"
/>
<path
d="M32 12C44 22, 48 36, 42 52C36 58, 26 58, 18 52C12 36, 20 22, 32 12"
stroke="rgba(255,215,0,0.3)"
strokeWidth="1"
fill="none"
/>
</svg>
</div>
<div
className="parallax-float absolute bottom-20 left-8 opacity-20"
style={{ animationDelay: "1.5s" }}
>
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="20" stroke="rgba(16,185,129,0.4)" strokeWidth="1" />
<circle cx="24" cy="24" r="14" stroke="rgba(16,185,129,0.3)" strokeWidth="1" />
<circle cx="24" cy="24" r="8" stroke="rgba(16,185,129,0.2)" strokeWidth="1" />
</svg>
</div>
<ScrollIndicator onClick={handlePrimaryClick} />
<CornerDecorations />
</section>
{/* ─── GLOBAL ANIMATIONS ──────────────────────────────────────────────── */}
<style dangerouslySetInnerHTML={{ __html: `
/* Ambient orb drift gentle, slow, predictable.
* Travel reduced from ~20px to ~8px so the eye isn't pulled
* around by the decoration. */
@keyframes tvh-float-slow {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(6px, -8px); }
}
@keyframes tvh-float-delayed {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-8px, 6px); }
}
/* Scroll-indicator dot was a 1.5s ease-in-out bounce. Now a
* slower 2.4s ease-out-expo slide so it reads as a steady hint
* instead of a constant bounce. */
@keyframes tvh-scroll-indicator {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
}
.tvh-scroll-indicator {
animation: tvh-scroll-indicator 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
.parallax-float {
will-change: transform;
}
.hero-reveal {
will-change: opacity, transform;
}
button, a {
-webkit-tap-highlight-color: transparent;
}
@media (prefers-reduced-motion: reduce) {
.parallax-float, .animate-bounce, .hero-reveal {
animation: none !important;
transition: none !important;
}
.hero-reveal {
opacity: 1 !important;
transform: none !important;
}
}
`}} />
<HeroAnimations />
</>
);
}
// ── Scroll Progress Bar ───────────────────────────────────────────────────────
function ScrollProgressBar({ scrollProgress }: { scrollProgress: number }) {
return (
<div className="fixed top-0 left-0 right-0 h-1 z-[1000] bg-black/20 scroll-progress-bar">
<div
className="h-full bg-gradient-to-r from-emerald-600 via-emerald-400 to-amber-400"
style={{
transform: `scaleX(${scrollProgress})`,
transformOrigin: "left",
}}
/>
</div>
);
}
// ── Hero Background (video + overlays + orbs) ────────────────────────────────
function HeroBackground({ videoRef }: { videoRef: React.RefObject<HTMLVideoElement | null> }) {
return (
<>
<video
ref={videoRef}
autoPlay
muted
loop
playsInline
tabIndex={-1}
aria-hidden="true"
className="absolute inset-0 w-full h-[120%] object-cover"
style={{ zIndex: 0 }}
src={VIDEO_URL}
/>
{/* Gradient overlays */}
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 1,
background: "linear-gradient(135deg, rgba(255,215,0,0.12) 0%, rgba(255,193,7,0.06) 50%, rgba(255,235,0,0.04) 100%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 2,
background: "radial-gradient(ellipse at center, rgba(255,200,0,0.08) 0%, rgba(0,0,0,0.4) 50%, rgba(0,0,0,0.9) 100%)",
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
zIndex: 3,
background: "linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0.05) 100%)",
}}
/>
<FloatingOrbs />
</>
);
}
// ── Floating Orbs ─────────────────────────────────────────────────────────────
function FloatingOrbs() {
return (
<div className="absolute inset-0 pointer-events-none z-[4]">
{/* Golden orb top-left */}
<div
className="parallax-float absolute top-1/4 left-1/4 w-64 h-64 rounded-full opacity-20"
style={{
background: "radial-gradient(circle, rgba(255,215,0,0.4) 0%, transparent 70%)",
filter: "blur(8px)",
animation: "tvh-float-slow 950ms ease-in-out infinite",
}}
/>
{/* Emerald orb bottom-right */}
<div
className="parallax-float absolute bottom-1/3 right-1/4 w-48 h-48 rounded-full opacity-15"
style={{
background: "radial-gradient(circle, rgba(16,185,129,0.4) 0%, transparent 70%)",
filter: "blur(8px)",
animation: "tvh-float-delayed 950ms ease-in-out infinite",
}}
/>
{/* Subtle grain overlay */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
</div>
);
}
// ── Hero Content (logo, eyebrow, title, description, CTAs, stats) ────────────
type HeroContentProps = {
logoSrc: string | null;
eyebrow?: string;
title: string;
description: string;
primaryButton?: string;
secondaryButton?: string;
onPrimaryClick: () => void;
onSecondaryClick?: () => void;
};
function HeroContent({
logoSrc,
eyebrow,
title,
description,
primaryButton,
secondaryButton,
onPrimaryClick,
onSecondaryClick,
}: HeroContentProps) {
return (
<>
<HeroLogo logoSrc={logoSrc} />
<div className="hero-reveal mb-6">
{eyebrow && (
<p className="text-[11px] font-bold uppercase tracking-[0.3em] text-amber-400/80">
{eyebrow}
</p>
)}
<div className="mt-3 h-px w-16 bg-gradient-to-r from-emerald-500 to-transparent" />
</div>
<HeroTitle title={title} />
<HeroDescription description={description} />
<HeroCTAs
primaryButton={primaryButton}
secondaryButton={secondaryButton}
onPrimaryClick={onPrimaryClick}
onSecondaryClick={onSecondaryClick}
/>
<HeroStats />
</>
);
}
function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
return (
<motion.div
className="hero-reveal mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.32, delay: 0.05, ease: "easeOut" }}
>
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
{logoSrc ? (
<Image
src={logoSrc}
alt="Olathe Sweet"
fill
sizes="(max-width: 1024px) 80vw, 50vw"
style={{ objectFit: "contain" }}
className="drop-shadow-2xl"
priority
/>
) : null}
</motion.div>
);
}
function HeroTitle({ title }: { title: string }) {
return (
<h1
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-tight text-white leading-[0.95] mb-6"
style={{
textShadow: "0 4px 30px rgba(0,0,0,0.3), 0 0 60px rgba(255,215,0,0.1)",
}}
>
{title.split(" ").map((word, idx) => (
<span
key={`${idx}-${word}`}
className="inline-block mr-4"
style={{
animationDelay: `${0.2 + idx * 0.1}s`,
}}
>
{word}
</span>
))}
</h1>
);
}
function HeroDescription({ description }: { description: string }) {
return (
<p
className="hero-reveal text-xl md:text-2xl text-white/70 leading-relaxed max-w-xl mb-10"
style={{ fontWeight: 300 }}
>
{description}
</p>
);
}
function HeroCTAs({
primaryButton,
secondaryButton,
onPrimaryClick,
onSecondaryClick,
}: {
primaryButton?: string;
secondaryButton?: string;
onPrimaryClick: () => void;
onSecondaryClick?: () => void;
}) {
return (
<div className="hero-reveal flex flex-wrap items-center gap-5">
{primaryButton && (
<PrimaryButton label={primaryButton} onClick={onPrimaryClick} />
)}
{secondaryButton && (
<SecondaryButton label={secondaryButton} onClick={onSecondaryClick} />
)}
</div>
);
}
function PrimaryButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<motion.button
onClick={onClick}
className="group relative rounded-full px-10 py-4 text-sm font-bold tracking-widest uppercase overflow-hidden"
style={{
background: "linear-gradient(135deg, #059669 0%, #10b981 100%)",
boxShadow: "0 8px 32px rgba(16, 185, 129, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)",
}}
>
<span className="relative z-10 flex items-center gap-3">
<span>{label}</span>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</span>
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{
background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
/>
</motion.button>
);
}
function SecondaryButton({
label,
onClick,
}: {
label: string;
onClick?: () => void;
}) {
return (
<button type="button"
onClick={onClick}
className="group flex items-center gap-3 text-sm font-semibold"
style={{ color: "rgba(255,255,255,0.8)" }}
aria-label="Next">
<span className="uppercase tracking-widest text-[11px]">{label}</span>
<span
className="w-10 h-10 rounded-full border flex items-center justify-center transition-all duration-300 group-hover:bg-white/10 group-hover:border-white/30"
style={{ borderColor: "rgba(255,255,255,0.3)" }}
>
<svg
className="w-4 h-4 transition-transform group-hover:translate-x-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</span>
</button>
);
}
function HeroStats() {
return (
<div className="hero-reveal mt-16 flex flex-wrap gap-12">
{HERO_STATS.map((item) => (
<div key={item.label} className="text-center">
<div
className="text-3xl md:text-4xl font-black text-white"
style={{ textShadow: "0 2px 20px rgba(0,0,0,0.3)" }}
>
{item.stat}
</div>
<div
className="text-[10px] uppercase tracking-[0.2em] mt-1"
style={{ color: "rgba(255,255,255,0.5)" }}
>
{item.label}
</div>
</div>
))}
</div>
);
}
// ── Scroll Indicator ──────────────────────────────────────────────────────────
function ScrollIndicator({ onClick }: { onClick: () => void }) {
return (
<div
className="scroll-indicator absolute bottom-10 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-3"
style={{ pointerEvents: "auto" }}
>
<button type="button"
onClick={onClick}
className="flex flex-col items-center gap-2 text-white/50 hover:text-white/80 transition-colors duration-200"
aria-label="Scroll to stops"
>
<span className="text-[10px] uppercase tracking-[0.25em] font-medium">Explore</span>
<div
className="w-6 h-10 rounded-full border border-white/30 flex items-start justify-center p-1.5"
>
<div
className="w-1.5 h-3 rounded-full bg-white tvh-scroll-indicator"
style={{ animationDuration: "950ms" }}
/>
</div>
</button>
</div>
);
}
// ── Corner Decorations ────────────────────────────────────────────────────────
function CornerDecorations() {
return (
<>
<div
className="parallax-float absolute top-8 right-8 opacity-30"
style={{ animationDelay: "1s" }}
>
<svg className="w-16 h-16" viewBox="0 0 64 64" fill="none">
<path
d="M32 4C48 16, 56 32, 48 56C40 64, 24 64, 8 56C0 32, 16 16, 32 4"
stroke="rgba(255,215,0,0.5)"
strokeWidth="1"
fill="none"
/>
<path
d="M32 12C44 22, 48 36, 42 52C36 58, 26 58, 18 52C12 36, 20 22, 32 12"
stroke="rgba(255,215,0,0.3)"
strokeWidth="1"
fill="none"
/>
</svg>
</div>
<div
className="parallax-float absolute bottom-20 left-8 opacity-20"
style={{ animationDelay: "1.5s" }}
>
<svg className="w-12 h-12" viewBox="0 0 48 48" fill="none">
<circle cx="24" cy="24" r="20" stroke="rgba(16,185,129,0.4)" strokeWidth="1" />
<circle cx="24" cy="24" r="14" stroke="rgba(16,185,129,0.3)" strokeWidth="1" />
<circle cx="24" cy="24" r="8" stroke="rgba(16,185,129,0.2)" strokeWidth="1" />
</svg>
</div>
</>
);
}
// ── Hero Animations (global keyframes + reduced-motion overrides) ─────────────
function HeroAnimations() {
return (
<style dangerouslySetInnerHTML={{ __html: `
@keyframes tvh-float-slow {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(6px, -8px); }
}
@keyframes tvh-float-delayed {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-8px, 6px); }
}
@keyframes tvh-scroll-indicator {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
}
.tvh-scroll-indicator {
animation: tvh-scroll-indicator 2.4s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
.parallax-float {
will-change: transform;
}
.hero-reveal {
will-change: opacity, transform;
}
button, a {
-webkit-tap-highlight-color: transparent;
}
@media (prefers-reduced-motion: reduce) {
.parallax-float, .animate-bounce, .hero-reveal {
animation: none !important;
transition: none !important;
}
.hero-reveal {
opacity: 1 !important;
transform: none !important;
}
}
`}} />
);
}
@@ -1,8 +1,7 @@
"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import Image from "next/image";
import { useState, useEffect, useRef } from "react";
import React, { useReducer, useEffect, useRef, useState } from "react";
import LayoutContainer from "@/components/layout/LayoutContainer";
import {
verifyTimeTrackingPin,
@@ -159,6 +158,93 @@ interface OpenEntry {
elapsed_minutes: number;
}
// ── State / Reducer ────────────────────────────────────────────────────────────
type State = {
lang: "en" | "es";
screen: Screen;
tasks: TimeTaskField[];
openEntry: OpenEntry | null;
payPeriod: PayPeriodHours | null;
clockOutResult: { clock_out: string; total_minutes: number } | null;
selectedTaskId: string;
lunchMinutes: number;
notes: string;
error: string | null;
submitting: boolean;
};
type Action =
| { type: "SET_LANG"; value: "en" | "es" }
| { type: "SET_SCREEN"; value: Screen }
| { type: "SET_TASKS"; value: TimeTaskField[] }
| { type: "SET_OPEN_ENTRY"; value: OpenEntry | null }
| { type: "SET_PAY_PERIOD"; value: PayPeriodHours | null }
| { type: "SET_CLOCK_OUT_RESULT"; value: { clock_out: string; total_minutes: number } | null }
| { type: "SET_SELECTED_TASK_ID"; value: string }
| { type: "SET_LUNCH_MINUTES"; value: number }
| { type: "SET_NOTES"; value: string }
| { type: "SET_ERROR"; value: string | null }
| { type: "SET_SUBMITTING"; value: boolean }
| { type: "UPDATE_OPEN_ENTRY_ELAPSED"; minutes: number }
| { type: "RESET_FOR_LOGOUT" };
const initialState: State = {
lang: (getLangCookie() as "en" | "es") ?? "en",
screen: "pin",
tasks: [],
openEntry: null,
payPeriod: null,
clockOutResult: null,
selectedTaskId: "",
lunchMinutes: 0,
notes: "",
error: null,
submitting: false,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_LANG":
return { ...state, lang: action.value };
case "SET_SCREEN":
return { ...state, screen: action.value };
case "SET_TASKS":
return { ...state, tasks: action.value };
case "SET_OPEN_ENTRY":
return { ...state, openEntry: action.value };
case "SET_PAY_PERIOD":
return { ...state, payPeriod: action.value };
case "SET_CLOCK_OUT_RESULT":
return { ...state, clockOutResult: action.value };
case "SET_SELECTED_TASK_ID":
return { ...state, selectedTaskId: action.value };
case "SET_LUNCH_MINUTES":
return { ...state, lunchMinutes: action.value };
case "SET_NOTES":
return { ...state, notes: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_SUBMITTING":
return { ...state, submitting: action.value };
case "UPDATE_OPEN_ENTRY_ELAPSED":
return state.openEntry
? { ...state, openEntry: { ...state.openEntry, elapsed_minutes: action.minutes } }
: state;
case "RESET_FOR_LOGOUT":
return {
...state,
openEntry: null,
payPeriod: null,
clockOutResult: null,
selectedTaskId: "",
lunchMinutes: 0,
notes: "",
screen: "pin",
};
}
}
// ── Main Component ─────────────────────────────────────────────────────────────
export default function TimeTrackingFieldClient({
@@ -172,21 +258,12 @@ export default function TimeTrackingFieldClient({
brandAccent: string;
logoUrl?: string | null;
}) {
const [lang, setLang] = useState<"en" | "es">(() => (getLangCookie() as "en" | "es") ?? "en");
const [screen, setScreen] = useState<Screen>("pin");
const [state, dispatch] = useReducer(reducer, initialState);
const {
lang, screen, tasks, openEntry, payPeriod, clockOutResult,
selectedTaskId, lunchMinutes, notes, error, submitting,
} = state;
const sessionRef = useRef<TimeTrackingSession | null>(null);
const [tasks, setTasks] = useState<TimeTaskField[]>([]);
const [openEntry, setOpenEntry] = useState<OpenEntry | null>(null);
const [payPeriod, setPayPeriod] = useState<PayPeriodHours | null>(null);
const [clockOutResult, setClockOutResult] = useState<{ clock_out: string; total_minutes: number } | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string>("");
const [lunchMinutes, setLunchMinutes] = useState(0);
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const t = lang === "en" ? TRANS_EN : TRANS_ES;
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const initRef = useRef(false);
// Keep latest loadPayPeriod reachable from polling + init effects without
@@ -194,10 +271,12 @@ export default function TimeTrackingFieldClient({
// every render since it's a plain const).
const loadPayPeriodRef = useRef<() => Promise<void>>(() => Promise.resolve());
const t = lang === "en" ? TRANS_EN : TRANS_ES;
// Fetch pay period hours
const loadPayPeriod = async () => {
const result = await getWorkerPayPeriodHours(brandId);
if (result.success) setPayPeriod(result);
if (result.success) dispatch({ type: "SET_PAY_PERIOD", value: result });
};
// Keep ref pointing at the latest loadPayPeriod so effects below can call
// it without re-running on every render. Updated in an effect (not during
@@ -215,7 +294,7 @@ export default function TimeTrackingFieldClient({
// Only check for open clock-in if session exists
const stored = await getTimeTrackingSession();
if (!stored) {
setScreen("pin");
dispatch({ type: "SET_SCREEN", value: "pin" });
await loadPayPeriodRef.current();
return;
}
@@ -224,15 +303,18 @@ export default function TimeTrackingFieldClient({
await loadPayPeriodRef.current();
if (open.success && open.open && open.log_id) {
setOpenEntry({
log_id: open.log_id!,
task_name: open.task_name ?? t.general_labor,
clock_in: open.clock_in!,
elapsed_minutes: open.elapsed_minutes ?? 0,
dispatch({
type: "SET_OPEN_ENTRY",
value: {
log_id: open.log_id!,
task_name: open.task_name ?? t.general_labor,
clock_in: open.clock_in!,
elapsed_minutes: open.elapsed_minutes ?? 0,
},
});
setScreen("working");
dispatch({ type: "SET_SCREEN", value: "working" });
} else {
setScreen("task_select");
dispatch({ type: "SET_SCREEN", value: "task_select" });
}
}
@@ -251,9 +333,10 @@ export default function TimeTrackingFieldClient({
pollRef.current = setInterval(async () => {
const open = await getOpenClockIn(brandId);
if (open.success && open.open && open.log_id) {
setOpenEntry(prev =>
prev ? { ...prev, elapsed_minutes: open.elapsed_minutes ?? prev.elapsed_minutes } : null
);
const minutes = open.elapsed_minutes;
if (typeof minutes === "number") {
dispatch({ type: "UPDATE_OPEN_ENTRY_ELAPSED", minutes });
}
}
await loadPayPeriodRef.current();
}, 30000);
@@ -267,14 +350,14 @@ export default function TimeTrackingFieldClient({
const toggleLang = () => {
const next = lang === "en" ? "es" : "en";
setLangCookie(next);
setLang(next);
dispatch({ type: "SET_LANG", value: next });
};
const handlePinSubmit = async (pin: string) => {
setError(null);
dispatch({ type: "SET_ERROR", value: null });
const result = await verifyTimeTrackingPin(brandId, pin);
if (!result.success) {
setError(result.error ?? t.invalid_pin);
dispatch({ type: "SET_ERROR", value: result.error ?? t.invalid_pin });
return;
}
sessionRef.current = result.session!;
@@ -282,18 +365,21 @@ export default function TimeTrackingFieldClient({
getOpenClockIn(brandId),
getTimeTrackingTasksField(brandId),
]);
setTasks(taskList);
dispatch({ type: "SET_TASKS", value: taskList });
await loadPayPeriod();
if (open.success && open.open && open.log_id) {
setOpenEntry({
log_id: open.log_id!,
task_name: open.task_name ?? t.general_labor,
clock_in: open.clock_in!,
elapsed_minutes: open.elapsed_minutes ?? 0,
dispatch({
type: "SET_OPEN_ENTRY",
value: {
log_id: open.log_id!,
task_name: open.task_name ?? t.general_labor,
clock_in: open.clock_in!,
elapsed_minutes: open.elapsed_minutes ?? 0,
},
});
setScreen("working");
dispatch({ type: "SET_SCREEN", value: "working" });
} else {
setScreen("task_select");
dispatch({ type: "SET_SCREEN", value: "task_select" });
}
};
@@ -311,81 +397,170 @@ export default function TimeTrackingFieldClient({
};
const handleSelectTask = async (taskName: string) => {
setError(null);
setSubmitting(true);
dispatch({ type: "SET_ERROR", value: null });
dispatch({ type: "SET_SUBMITTING", value: true });
const result = await clockInWorker(brandId, undefined, taskName);
setSubmitting(false);
dispatch({ type: "SET_SUBMITTING", value: false });
if (!result.success) {
if (result.error === "Already clocked in") {
const open = await getOpenClockIn(brandId);
if (open.success && open.open) {
setOpenEntry({
log_id: open.log_id!,
task_name: open.task_name ?? taskName,
clock_in: open.clock_in!,
elapsed_minutes: open.elapsed_minutes ?? 0,
dispatch({
type: "SET_OPEN_ENTRY",
value: {
log_id: open.log_id!,
task_name: open.task_name ?? taskName,
clock_in: open.clock_in!,
elapsed_minutes: open.elapsed_minutes ?? 0,
},
});
setScreen("working");
dispatch({ type: "SET_SCREEN", value: "working" });
return;
}
}
setError(result.error ?? "Clock in failed");
dispatch({ type: "SET_ERROR", value: result.error ?? "Clock in failed" });
return;
}
setOpenEntry({
log_id: result.log_id!,
task_name: taskName,
clock_in: result.clock_in!,
elapsed_minutes: 0,
dispatch({
type: "SET_OPEN_ENTRY",
value: {
log_id: result.log_id!,
task_name: taskName,
clock_in: result.clock_in!,
elapsed_minutes: 0,
},
});
await loadPayPeriod();
if (payPeriod) {
dispatchNotification(sessionRef.current?.name ?? taskName, payPeriod.daily_hours, payPeriod.weekly_hours);
void dispatchNotification(sessionRef.current?.name ?? taskName, payPeriod.daily_hours, payPeriod.weekly_hours);
}
setScreen("working");
dispatch({ type: "SET_SCREEN", value: "working" });
};
const handleClockOut = async () => {
setError(null);
setSubmitting(true);
dispatch({ type: "SET_ERROR", value: null });
dispatch({ type: "SET_SUBMITTING", value: true });
const result = await clockOutWorker(lunchMinutes, notes || undefined);
setSubmitting(false);
dispatch({ type: "SET_SUBMITTING", value: false });
if (!result.success) {
setError(result.error ?? "Clock out failed");
dispatch({ type: "SET_ERROR", value: result.error ?? "Clock out failed" });
return;
}
setClockOutResult({ clock_out: result.clock_out!, total_minutes: result.total_minutes ?? 0 });
dispatch({
type: "SET_CLOCK_OUT_RESULT",
value: { clock_out: result.clock_out!, total_minutes: result.total_minutes ?? 0 },
});
await loadPayPeriod();
if (payPeriod) {
dispatchNotification(sessionRef.current?.name ?? openEntry?.task_name ?? "Worker", payPeriod.daily_hours, payPeriod.weekly_hours);
void dispatchNotification(sessionRef.current?.name ?? openEntry?.task_name ?? "Worker", payPeriod.daily_hours, payPeriod.weekly_hours);
}
setScreen("clocked_out");
dispatch({ type: "SET_SCREEN", value: "clocked_out" });
};
const handleLogout = async () => {
await logoutTimeTracking();
sessionRef.current = null;
setOpenEntry(null);
setPayPeriod(null);
setClockOutResult(null);
setSelectedTaskId("");
setLunchMinutes(0);
setNotes("");
setScreen("pin");
dispatch({ type: "RESET_FOR_LOGOUT" });
};
const handleBackToPin = () => {
setClockOutResult(null);
setOpenEntry(null);
setPayPeriod(null);
setSelectedTaskId("");
setLunchMinutes(0);
setNotes("");
setScreen("pin");
dispatch({ type: "RESET_FOR_LOGOUT" });
};
const showOvertimeWarning = payPeriod && (payPeriod.daily_overtime || payPeriod.weekly_overtime);
return (
<TimeTrackingScreens
brandName={brandName}
brandAccent={brandAccent}
logoUrl={logoUrl}
lang={lang}
screen={screen}
tasks={tasks}
openEntry={openEntry}
payPeriod={payPeriod}
clockOutResult={clockOutResult}
selectedTaskId={selectedTaskId}
lunchMinutes={lunchMinutes}
notes={notes}
error={error}
submitting={submitting}
t={t}
showOvertimeWarning={showOvertimeWarning ?? false}
onToggleLang={toggleLang}
onPinSubmit={handlePinSubmit}
onSelectTask={handleSelectTask}
onClockOutSubmit={handleClockOut}
onClockOut={() => dispatch({ type: "SET_SCREEN", value: "clock_out_form" })}
onLogout={handleLogout}
onBackToPin={handleBackToPin}
onBack={() => dispatch({ type: "SET_SCREEN", value: "working" })}
onChangeSelectedTaskId={(v) => dispatch({ type: "SET_SELECTED_TASK_ID", value: v })}
onChangeLunchMinutes={(v) => dispatch({ type: "SET_LUNCH_MINUTES", value: v })}
onChangeNotes={(v) => dispatch({ type: "SET_NOTES", value: v })}
/>
);
}
// ── Time Tracking Screens (presentation) ──────────────────────────────────────
function TimeTrackingScreens({
brandName,
brandAccent,
logoUrl,
lang,
screen,
tasks,
openEntry,
payPeriod,
clockOutResult,
selectedTaskId,
lunchMinutes,
notes,
error,
submitting,
t,
showOvertimeWarning,
onToggleLang,
onPinSubmit,
onSelectTask,
onClockOutSubmit,
onClockOut,
onLogout,
onBackToPin,
onBack,
onChangeSelectedTaskId,
onChangeLunchMinutes,
onChangeNotes,
}: {
brandName: string;
brandAccent: string;
logoUrl?: string | null;
lang: "en" | "es";
screen: Screen;
tasks: TimeTaskField[];
openEntry: OpenEntry | null;
payPeriod: PayPeriodHours | null;
clockOutResult: { clock_out: string; total_minutes: number } | null;
selectedTaskId: string;
lunchMinutes: number;
notes: string;
error: string | null;
submitting: boolean;
t: Translations;
showOvertimeWarning: boolean;
onToggleLang: () => void;
onPinSubmit: (pin: string) => void;
onSelectTask: (name: string) => void;
onClockOutSubmit: () => void;
onClockOut: () => void;
onLogout: () => void;
onBackToPin: () => void;
onBack: () => void;
onChangeSelectedTaskId: (id: string) => void;
onChangeLunchMinutes: (m: number) => void;
onChangeNotes: (n: string) => void;
}) {
return (
<div className="min-h-screen bg-stone-950 text-zinc-100">
{/* Header */}
@@ -397,7 +572,7 @@ export default function TimeTrackingFieldClient({
<span className="text-sm font-semibold text-zinc-400">{brandName}</span>
</div>
<button type="button"
onClick={toggleLang}
onClick={onToggleLang}
className="text-xs font-mono text-zinc-500 hover:text-zinc-300 border border-zinc-700 rounded px-2 py-1 transition-colors"
>
{lang === "en" ? "ES" : "EN"}
@@ -414,7 +589,7 @@ export default function TimeTrackingFieldClient({
{screen === "pin" && (
<PinEntry
t={t}
onSubmit={handlePinSubmit}
onSubmit={onPinSubmit}
error={error}
submitting={submitting}
logoUrl={logoUrl}
@@ -426,7 +601,7 @@ export default function TimeTrackingFieldClient({
t={t}
tasks={tasks}
lang={lang}
onSelectTask={handleSelectTask}
onSelectTask={onSelectTask}
error={error}
submitting={submitting}
logoUrl={logoUrl}
@@ -439,9 +614,9 @@ export default function TimeTrackingFieldClient({
lang={lang}
openEntry={openEntry}
payPeriod={payPeriod}
showOvertimeWarning={showOvertimeWarning ?? false}
onClockOut={() => setScreen("clock_out_form")}
onLogout={handleLogout}
showOvertimeWarning={showOvertimeWarning}
onClockOut={onClockOut}
onLogout={onLogout}
logoUrl={logoUrl}
/>
)}
@@ -453,13 +628,13 @@ export default function TimeTrackingFieldClient({
lang={lang}
openEntryTaskName={openEntry.task_name}
selectedTaskId={selectedTaskId}
setSelectedTaskId={setSelectedTaskId}
setSelectedTaskId={onChangeSelectedTaskId}
lunchMinutes={lunchMinutes}
setLunchMinutes={setLunchMinutes}
setLunchMinutes={onChangeLunchMinutes}
notes={notes}
setNotes={setNotes}
onSubmit={handleClockOut}
onBack={() => setScreen("working")}
setNotes={onChangeNotes}
onSubmit={onClockOutSubmit}
onBack={onBack}
error={error}
submitting={submitting}
logoUrl={logoUrl}
@@ -472,7 +647,7 @@ export default function TimeTrackingFieldClient({
lang={lang}
payPeriod={payPeriod}
clockOutResult={clockOutResult}
onBackToPin={handleBackToPin}
onBackToPin={onBackToPin}
logoUrl={logoUrl}
/>
)}
File diff suppressed because it is too large Load Diff
+513 -241
View File
@@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { useState, useEffect, Suspense, useMemo, useEffectEvent, useRef } from "react";
import { useReducer, useEffect, Suspense, useMemo, useRef } from "react";
import {
verifyWaterPin,
submitWaterEntry,
@@ -22,6 +22,10 @@ type Headgate = {
type Language = "en" | "es";
type Step = "loading" | "lang" | "role" | "pin" | "form";
type GpsStatus = "idle" | "capturing" | "success" | "error" | "denied";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const UNITS = ["CFS", "GPM", "Inches", "AF/Day"];
@@ -99,6 +103,163 @@ const LABELS = {
},
};
// ──────────────────────────────────────────────────────────────────────────
// State (useReducer)
// ──────────────────────────────────────────────────────────────────────────
type State = {
step: Step;
lang: Language;
pin: string;
irrigatorName: string;
headgates: Headgate[];
selectedHeadgate: string;
headgateLocked: boolean;
measurement: string;
unit: string;
notes: string;
loading: boolean;
error: string;
success: boolean;
photoPreview: string | null;
latitude: number | null;
longitude: number | null;
gpsStatus: GpsStatus;
};
type Action =
| { type: "SET_STEP"; value: Step }
| { type: "SET_LANG"; value: Language }
| { type: "SET_PIN"; value: string }
| { type: "SET_IRRIGATOR_NAME"; value: string }
| { type: "SET_HEADGATES"; value: Headgate[] }
| { type: "SET_SELECTED_HEADGATE"; value: string }
| { type: "SET_HEADGATE_LOCKED"; value: boolean }
| { type: "SET_MEASUREMENT"; value: string }
| { type: "SET_UNIT"; value: string }
| { type: "SET_NOTES"; value: string }
| { type: "SET_LOADING"; value: boolean }
| { type: "SET_ERROR"; value: string }
| { type: "SET_SUCCESS"; value: boolean }
| { type: "SET_PHOTO_PREVIEW"; value: string | null }
| { type: "SET_LATITUDE"; value: number | null }
| { type: "SET_LONGITUDE"; value: number | null }
| { type: "SET_GPS_STATUS"; value: GpsStatus }
| { type: "RESET_FORM_FIELDS" }
| { type: "RESET_FULL" };
function makeInitialState(): State {
// Read cookie preferences on first render. Component is client-only via
// <Suspense>, so accessing document.cookie in the lazy initializer is safe.
if (typeof document === "undefined") {
return {
step: "loading",
lang: "en",
pin: "",
irrigatorName: "",
headgates: [],
selectedHeadgate: "",
headgateLocked: false,
measurement: "",
unit: "CFS",
notes: "",
loading: false,
error: "",
success: false,
photoPreview: null,
latitude: null,
longitude: null,
gpsStatus: "idle",
};
}
const savedLang = document.cookie.match(/wl_lang=(en|es)/)?.[1];
const hasSession = !!document.cookie.match(/wl_session=([^;]+)/);
return {
step: hasSession ? "form" : "loading",
lang: (savedLang as Language) || "en",
pin: "",
irrigatorName: "",
headgates: [],
selectedHeadgate: "",
headgateLocked: false,
measurement: "",
unit: "CFS",
notes: "",
loading: false,
error: "",
success: false,
photoPreview: null,
latitude: null,
longitude: null,
gpsStatus: "idle",
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_STEP":
return { ...state, step: action.value };
case "SET_LANG":
return { ...state, lang: action.value };
case "SET_PIN":
return { ...state, pin: action.value };
case "SET_IRRIGATOR_NAME":
return { ...state, irrigatorName: action.value };
case "SET_HEADGATES":
return { ...state, headgates: action.value };
case "SET_SELECTED_HEADGATE":
return { ...state, selectedHeadgate: action.value };
case "SET_HEADGATE_LOCKED":
return { ...state, headgateLocked: action.value };
case "SET_MEASUREMENT":
return { ...state, measurement: action.value };
case "SET_UNIT":
return { ...state, unit: action.value };
case "SET_NOTES":
return { ...state, notes: action.value };
case "SET_LOADING":
return { ...state, loading: action.value };
case "SET_ERROR":
return { ...state, error: action.value };
case "SET_SUCCESS":
return { ...state, success: action.value };
case "SET_PHOTO_PREVIEW":
return { ...state, photoPreview: action.value };
case "SET_LATITUDE":
return { ...state, latitude: action.value };
case "SET_LONGITUDE":
return { ...state, longitude: action.value };
case "SET_GPS_STATUS":
return { ...state, gpsStatus: action.value };
case "RESET_FORM_FIELDS":
return {
...state,
measurement: "",
notes: "",
photoPreview: null,
latitude: null,
longitude: null,
gpsStatus: "idle",
};
case "RESET_FULL":
return {
...state,
step: "lang",
pin: "",
irrigatorName: "",
headgates: [],
selectedHeadgate: "",
measurement: "",
notes: "",
photoPreview: null,
latitude: null,
longitude: null,
gpsStatus: "idle",
headgateLocked: false,
};
}
}
function ThresholdBadge({ high, low, t }: { high?: number | null; low?: number | null; t: typeof LABELS.en }) {
if (!high && !low) return null;
return (
@@ -121,40 +282,10 @@ function WaterFieldInner() {
const searchParams = useSearchParams();
const qrHeadgateToken = searchParams.get("h");
// Read cookie preferences on first render. Component is client-only via
// <Suspense>, so accessing document.cookie in the lazy initializer is safe.
const [lang, setLang] = useState<Language>(() => {
if (typeof document === "undefined") return "en";
const saved = document.cookie.match(/wl_lang=(en|es)/)?.[1];
return (saved as Language) || "en";
});
const [step, setStep] = useState<"loading" | "lang" | "role" | "pin" | "form">(() => {
if (typeof document === "undefined") return "loading";
const hasSession = document.cookie.match(/wl_session=([^;]+)/);
return hasSession ? "form" : "lang";
});
const [pin, setPin] = useState("");
const [irrigatorName, setIrrigatorName] = useState("");
const [headgates, setHeadgates] = useState<Headgate[]>([]);
const [selectedHeadgate, setSelectedHeadgate] = useState("");
const [headgateLocked, setHeadgateLocked] = useState(false);
const [measurement, setMeasurement] = useState("");
const [unit, setUnit] = useState("CFS");
const [notes, setNotes] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
// Photo state
const [state, dispatch] = useReducer(reducer, undefined, makeInitialState);
const photoFileRef = useRef<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
// GPS state
const [latitude, setLatitude] = useState<number | null>(null);
const [longitude, setLongitude] = useState<number | null>(null);
const [gpsStatus, setGpsStatus] = useState<"idle" | "capturing" | "success" | "error" | "denied">("idle");
const t = LABELS[lang];
const t = LABELS[state.lang];
// On mount, if wl_session cookie is present, load headgates so the
// form is populated by the time the user lands. The step itself is
@@ -172,42 +303,42 @@ function WaterFieldInner() {
// selection and lock the dropdown. Otherwise fall back to whatever the
// user picked (or none).
const qrMatchedHeadgate = useMemo(() => {
if (!qrHeadgateToken || headgates.length === 0) return null;
if (!qrHeadgateToken || state.headgates.length === 0) return null;
return (
headgates.find((hg) => hg.id === qrHeadgateToken || hg.name === qrHeadgateToken) ?? null
state.headgates.find((hg) => hg.id === qrHeadgateToken || hg.name === qrHeadgateToken) ?? null
);
}, [qrHeadgateToken, headgates]);
}, [qrHeadgateToken, state.headgates]);
const effectiveSelectedHeadgate = qrMatchedHeadgate?.id ?? selectedHeadgate;
const isHeadgateLocked = qrMatchedHeadgate ? true : headgateLocked;
const effectiveSelectedHeadgate = qrMatchedHeadgate?.id ?? state.selectedHeadgate;
const isHeadgateLocked = qrMatchedHeadgate ? true : state.headgateLocked;
async function loadHeadgates() {
const hgs = await getWaterHeadgates(TUXEDO_BRAND_ID, true);
setHeadgates(hgs.filter((h) => h.active));
dispatch({ type: "SET_HEADGATES", value: hgs.filter((h) => h.active) });
}
// Show loading spinner next to headgate dropdown
const isLoadingHeadgates = step === "form" && headgates.length === 0;
const isLoadingHeadgates = state.step === "form" && state.headgates.length === 0;
async function handleLangSelect(lang: Language) {
setLang(lang);
dispatch({ type: "SET_LANG", value: lang });
await setWaterLang(lang);
setStep("role");
setError("");
dispatch({ type: "SET_STEP", value: "role" });
dispatch({ type: "SET_ERROR", value: "" });
}
async function handlePinSubmit(e: React.FormEvent) {
e.preventDefault();
if (pin.length !== 4) return;
setLoading(true);
setError("");
if (state.pin.length !== 4) return;
dispatch({ type: "SET_LOADING", value: true });
dispatch({ type: "SET_ERROR", value: "" });
const result = await verifyWaterPin(TUXEDO_BRAND_ID, pin);
const result = await verifyWaterPin(TUXEDO_BRAND_ID, state.pin);
if (!result.success) {
setError(result.error === "Invalid PIN" ? LABELS[lang].invalidPin : result.error);
setPin("");
setLoading(false);
dispatch({ type: "SET_ERROR", value: result.error === "Invalid PIN" ? LABELS[state.lang].invalidPin : result.error });
dispatch({ type: "SET_PIN", value: "" });
dispatch({ type: "SET_LOADING", value: false });
return;
}
@@ -216,29 +347,29 @@ function WaterFieldInner() {
return;
}
setIrrigatorName(result.name ?? "");
dispatch({ type: "SET_IRRIGATOR_NAME", value: result.name ?? "" });
await loadHeadgates();
setStep("form");
setLoading(false);
dispatch({ type: "SET_STEP", value: "form" });
dispatch({ type: "SET_LOADING", value: false });
}
async function handleCaptureGps() {
if (!navigator.geolocation) {
setGpsStatus("error");
dispatch({ type: "SET_GPS_STATUS", value: "error" });
return;
}
setGpsStatus("capturing");
dispatch({ type: "SET_GPS_STATUS", value: "capturing" });
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatitude(pos.coords.latitude);
setLongitude(pos.coords.longitude);
setGpsStatus("success");
dispatch({ type: "SET_LATITUDE", value: pos.coords.latitude });
dispatch({ type: "SET_LONGITUDE", value: pos.coords.longitude });
dispatch({ type: "SET_GPS_STATUS", value: "success" });
},
(err) => {
if (err.code === err.PERMISSION_DENIED) {
setGpsStatus("denied");
dispatch({ type: "SET_GPS_STATUS", value: "denied" });
} else {
setGpsStatus("error");
dispatch({ type: "SET_GPS_STATUS", value: "error" });
}
},
{ enableHighAccuracy: true, timeout: 10000 }
@@ -249,29 +380,29 @@ function WaterFieldInner() {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
setError("Photo must be under 5MB");
dispatch({ type: "SET_ERROR", value: "Photo must be under 5MB" });
return;
}
if (!["image/jpeg", "image/png"].includes(file.type)) {
setError("Only JPG or PNG photos allowed");
dispatch({ type: "SET_ERROR", value: "Only JPG or PNG photos allowed" });
return;
}
photoFileRef.current = file;
const reader = new FileReader();
reader.onload = (ev) => setPhotoPreview(ev.target?.result as string);
reader.onload = (ev) => dispatch({ type: "SET_PHOTO_PREVIEW", value: ev.target?.result as string });
reader.readAsDataURL(file);
}
function removePhoto() {
photoFileRef.current = null;
setPhotoPreview(null);
dispatch({ type: "SET_PHOTO_PREVIEW", value: null });
}
async function handleSubmitEntry(e: React.FormEvent) {
e.preventDefault();
if (!effectiveSelectedHeadgate || !measurement) return;
setLoading(true);
setError("");
if (!effectiveSelectedHeadgate || !state.measurement) return;
dispatch({ type: "SET_LOADING", value: true });
dispatch({ type: "SET_ERROR", value: "" });
let photoUrl: string | undefined;
if (photoFileRef.current) {
@@ -294,196 +425,337 @@ function WaterFieldInner() {
const result = await submitWaterEntry(
effectiveSelectedHeadgate,
parseFloat(measurement),
unit,
notes,
parseFloat(state.measurement),
state.unit,
state.notes,
photoUrl,
latitude ?? undefined,
longitude ?? undefined,
state.latitude ?? undefined,
state.longitude ?? undefined,
isHeadgateLocked
);
if (!result.success) {
if (result.error === "Session expired or invalid" || result.error === "Not logged in") {
setStep("lang");
setPin("");
setError(LABELS[lang].sessionExpired);
dispatch({ type: "SET_STEP", value: "lang" });
dispatch({ type: "SET_PIN", value: "" });
dispatch({ type: "SET_ERROR", value: LABELS[state.lang].sessionExpired });
} else {
setError(result.error);
dispatch({ type: "SET_ERROR", value: result.error });
}
setLoading(false);
dispatch({ type: "SET_LOADING", value: false });
return;
}
setSuccess(true);
setMeasurement("");
setNotes("");
dispatch({ type: "SET_SUCCESS", value: true });
dispatch({ type: "RESET_FORM_FIELDS" });
photoFileRef.current = null;
setPhotoPreview(null);
setLatitude(null);
setLongitude(null);
setGpsStatus("idle");
setLoading(false);
dispatch({ type: "SET_LOADING", value: false });
setTimeout(() => setSuccess(false), 3000);
setTimeout(() => dispatch({ type: "SET_SUCCESS", value: false }), 3000);
}
async function handleLogout() {
await logoutWater();
setStep("lang");
setPin("");
setIrrigatorName("");
setHeadgates([]);
setSelectedHeadgate("");
setMeasurement("");
setNotes("");
photoFileRef.current = null;
setPhotoPreview(null);
setLatitude(null);
setLongitude(null);
setGpsStatus("idle");
setHeadgateLocked(false);
dispatch({ type: "RESET_FULL" });
}
// Language selection screen
if (step === "loading") {
if (state.step === "loading") {
return <LoadingScreen />;
}
if (state.step === "lang") {
return <LanguageSelectionScreen t={t} onSelect={handleLangSelect} />;
}
if (state.step === "role") {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs text-center">
<div className="mx-auto mb-4 flex justify-center">
<WaterGauge size={64} level={null} status="open" />
</div>
<div className="w-12 h-12 rounded-full border-[3px] border-[#d4d9d3] border-t-[#1a4d2e] animate-spin mx-auto mb-4" />
<p className="text-[#57694e] text-base font-medium">Loading</p>
</div>
</div>
<RoleSelectionScreen
t={t}
onSelectRole={() => dispatch({ type: "SET_STEP", value: "pin" })}
onBack={() => dispatch({ type: "SET_STEP", value: "lang" })}
/>
);
}
// Language selection screen
if (step === "lang") {
if (state.step === "pin") {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<div className="mb-3 flex justify-center">
<WaterGauge size={56} level={null} status="open" />
</div>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-1 text-xs font-mono uppercase tracking-[0.25em]">
Tuxedo Ditch Co.
</p>
<p className="text-[#57694e] text-center mb-8 text-sm">{t.selectLang}</p>
<div className="flex flex-col gap-3">
<button type="button"
onClick={() => handleLangSelect("en")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
English
</button>
<button type="button"
onClick={() => handleLangSelect("es")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Español
</button>
</div>
</div>
</div>
);
}
// Role selection screen
if (step === "role") {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button type="button"
onClick={() => setStep("lang")}
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.whoAreYou}</p>
<div className="flex flex-col gap-3">
<button type="button"
onClick={() => { setStep("pin"); }}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Irrigator
</button>
<button type="button"
onClick={() => { setStep("pin"); }}
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-[#1a4d2e] hover:bg-[#14532d] transition min-h-[64px]"
>
Admin
</button>
</div>
</div>
</div>
);
}
// PIN entry screen
if (step === "pin") {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button type="button"
onClick={() => setStep("role")}
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.enterPin}</p>
<form onSubmit={handlePinSubmit}>
<input aria-label="Password"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder={t.pinPlaceholder}
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
style={{ letterSpacing: "0.3em" }}
/>
{error && (
<p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
)}
<button
type="submit"
disabled={pin.length !== 4 || loading}
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-bold text-white disabled:opacity-50 hover:bg-[#14532d] transition min-h-[64px]"
>
{loading ? t.loggingIn : t.submit}
</button>
</form>
</div>
</div>
<PinEntryScreen
t={t}
pin={state.pin}
loading={state.loading}
error={state.error}
onSetPin={(v) => dispatch({ type: "SET_PIN", value: v })}
onBack={() => dispatch({ type: "SET_STEP", value: "role" })}
onSubmit={handlePinSubmit}
/>
);
}
// Main log form
const selectedHg = headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
const selectedHg = state.headgates.find((hg) => hg.id === effectiveSelectedHeadgate);
return (
<WaterLogForm
t={t}
step={state.step}
loading={state.loading}
error={state.error}
success={state.success}
irrigatorName={state.irrigatorName}
measurement={state.measurement}
unit={state.unit}
notes={state.notes}
latitude={state.latitude}
longitude={state.longitude}
gpsStatus={state.gpsStatus}
photoPreview={state.photoPreview}
headgates={state.headgates}
selectedHeadgate={effectiveSelectedHeadgate}
isHeadgateLocked={isHeadgateLocked}
isLoadingHeadgates={isLoadingHeadgates}
selectedHg={selectedHg ?? null}
onSetMeasurement={(v) => dispatch({ type: "SET_MEASUREMENT", value: v })}
onSetUnit={(v) => dispatch({ type: "SET_UNIT", value: v })}
onSetNotes={(v) => dispatch({ type: "SET_NOTES", value: v })}
onSetSelectedHeadgate={(v) => dispatch({ type: "SET_SELECTED_HEADGATE", value: v })}
onCaptureGps={handleCaptureGps}
onPhotoChange={handlePhotoChange}
onRemovePhoto={removePhoto}
onSubmit={handleSubmitEntry}
onLogout={handleLogout}
/>
);
}
// ── LoadingScreen subcomponent ──────────────────────────────────────────────
function LoadingScreen() {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs text-center">
<div className="mx-auto mb-4 flex justify-center">
<WaterGauge size={64} level={null} status="open" />
</div>
<div className="w-12 h-12 rounded-full border-[3px] border-[#d4d9d3] border-t-[#1a4d2e] animate-spin mx-auto mb-4" />
<p className="text-[#57694e] text-base font-medium">Loading</p>
</div>
</div>
);
}
// ── LanguageSelectionScreen subcomponent ────────────────────────────────────
function LanguageSelectionScreen({
t,
onSelect,
}: {
t: typeof LABELS.en;
onSelect: (lang: Language) => void;
}) {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<div className="mb-3 flex justify-center">
<WaterGauge size={56} level={null} status="open" />
</div>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-1 text-xs font-mono uppercase tracking-[0.25em]">
Tuxedo Ditch Co.
</p>
<p className="text-[#57694e] text-center mb-8 text-sm">{t.selectLang}</p>
<div className="flex flex-col gap-3">
<button type="button"
onClick={() => onSelect("en")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
English
</button>
<button type="button"
onClick={() => onSelect("es")}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Español
</button>
</div>
</div>
</div>
);
}
// ── RoleSelectionScreen subcomponent ────────────────────────────────────────
function RoleSelectionScreen({
t,
onSelectRole,
onBack,
}: {
t: typeof LABELS.en;
onSelectRole: () => void;
onBack: () => void;
}) {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button type="button"
onClick={onBack}
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.whoAreYou}</p>
<div className="flex flex-col gap-3">
<button type="button"
onClick={onSelectRole}
className="w-full rounded-xl bg-white px-6 py-5 text-xl font-semibold text-[#1d1d1f] shadow-sm ring-1 ring-[#d4d9d3] hover:bg-[#f0fdf4] hover:ring-[#1a4d2e] transition min-h-[64px]"
>
Irrigator
</button>
<button type="button"
onClick={onSelectRole}
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-semibold text-white shadow-sm ring-1 ring-[#1a4d2e] hover:bg-[#14532d] transition min-h-[64px]"
>
Admin
</button>
</div>
</div>
</div>
);
}
// ── PinEntryScreen subcomponent ─────────────────────────────────────────────
function PinEntryScreen({
t,
pin,
loading,
error,
onSetPin,
onBack,
onSubmit,
}: {
t: typeof LABELS.en;
pin: string;
loading: boolean;
error: string;
onSetPin: (v: string) => void;
onBack: () => void;
onSubmit: (e: React.FormEvent) => void;
}) {
return (
<div className="min-h-screen bg-[#fdfaf2] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs">
<button type="button"
onClick={onBack}
className="text-sm text-[#57694e] mb-6 hover:text-[#1a4d2e]"
>
{t.back}
</button>
<h1
className="text-3xl font-bold text-[#1a4d2e] mb-1 text-center tracking-tight"
style={{ fontFamily: "var(--font-display, Georgia, serif)" }}
>
{t.title}
</h1>
<p className="text-[#57694e] text-center mb-8">{t.enterPin}</p>
<form onSubmit={onSubmit}>
<input aria-label="Password"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={pin}
onChange={(e) => onSetPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder={t.pinPlaceholder}
className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4"
style={{ letterSpacing: "0.3em" }}
/>
{error && (
<p className="text-center text-[#b91c1c] text-sm mb-4">{error}</p>
)}
<button
type="submit"
disabled={pin.length !== 4 || loading}
className="w-full rounded-xl bg-[#1a4d2e] px-6 py-5 text-xl font-bold text-white disabled:opacity-50 hover:bg-[#14532d] transition min-h-[64px]"
>
{loading ? t.loggingIn : t.submit}
</button>
</form>
</div>
</div>
);
}
// ── WaterLogForm subcomponent ───────────────────────────────────────────────
function WaterLogForm({
t,
step,
loading,
error,
success,
irrigatorName,
measurement,
unit,
notes,
latitude,
longitude,
gpsStatus,
photoPreview,
headgates,
selectedHeadgate,
isHeadgateLocked,
isLoadingHeadgates,
selectedHg,
onSetMeasurement,
onSetUnit,
onSetNotes,
onSetSelectedHeadgate,
onCaptureGps,
onPhotoChange,
onRemovePhoto,
onSubmit,
onLogout,
}: {
t: typeof LABELS.en;
step: Step;
loading: boolean;
error: string;
success: boolean;
irrigatorName: string;
measurement: string;
unit: string;
notes: string;
latitude: number | null;
longitude: number | null;
gpsStatus: GpsStatus;
photoPreview: string | null;
headgates: Headgate[];
selectedHeadgate: string;
isHeadgateLocked: boolean;
isLoadingHeadgates: boolean;
selectedHg: Headgate | null;
onSetMeasurement: (v: string) => void;
onSetUnit: (v: string) => void;
onSetNotes: (v: string) => void;
onSetSelectedHeadgate: (v: string) => void;
onCaptureGps: () => void;
onPhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onRemovePhoto: () => void;
onSubmit: (e: React.FormEvent) => void;
onLogout: () => void;
}) {
return (
<div className="min-h-screen bg-[#fdfaf2]">
{/* Header */}
@@ -504,7 +776,7 @@ function WaterFieldInner() {
</div>
</div>
<button type="button"
onClick={handleLogout}
onClick={onLogout}
className="rounded-lg bg-[#14532d] px-4 py-2 text-sm font-semibold text-white hover:bg-[#166534] transition-colors min-h-[44px]"
>
{t.logout}
@@ -532,7 +804,7 @@ function WaterFieldInner() {
</div>
<form
onSubmit={handleSubmitEntry}
onSubmit={onSubmit}
className="mx-auto max-w-lg px-4 py-6 space-y-5"
>
{/* Success banner */}
@@ -560,7 +832,7 @@ function WaterFieldInner() {
<svg className="w-5 h-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
{selectedHg?.name ?? effectiveSelectedHeadgate}
{selectedHg?.name ?? selectedHeadgate}
<span className="ml-1 inline-flex items-center gap-0.5 rounded bg-amber-200 px-2 py-0.5 text-xs font-bold text-amber-800">
🔒 {t.locked}
</span>
@@ -576,8 +848,8 @@ function WaterFieldInner() {
</div>
) : (
<select id="fld-tselectheadgate" aria-label="Select"
value={effectiveSelectedHeadgate}
onChange={(e) => setSelectedHeadgate(e.target.value)}
value={selectedHeadgate}
onChange={(e) => onSetSelectedHeadgate(e.target.value)}
required
className="w-full rounded-xl border-2 border-stone-300 bg-white px-4 py-4 text-lg outline-none focus:border-stone-900 min-h-[56px] appearance-none"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2371717b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 16px center", backgroundSize: "20px" }}
@@ -606,7 +878,7 @@ function WaterFieldInner() {
type="number"
step="any"
value={measurement}
onChange={(e) => setMeasurement(e.target.value)}
onChange={(e) => onSetMeasurement(e.target.value)}
required
placeholder="0.00"
className="w-full rounded-xl border-2 border-stone-300 px-4 py-4 text-2xl font-bold text-stone-900 outline-none focus:border-stone-900 min-h-[60px]"
@@ -617,7 +889,7 @@ function WaterFieldInner() {
<label htmlFor="fld-2-tunit" className="block text-base font-semibold text-stone-700 mb-2">{t.unit}</label>
<select id="fld-2-tunit" aria-label="Select"
value={unit}
onChange={(e) => setUnit(e.target.value)}
onChange={(e) => onSetUnit(e.target.value)}
className="w-full rounded-xl border-2 border-stone-300 bg-white px-4 py-4 text-lg font-semibold outline-none focus:border-stone-900 min-h-[60px]"
>
{UNITS.map((u) => (
@@ -634,7 +906,7 @@ function WaterFieldInner() {
</label>
<button
type="button"
onClick={handleCaptureGps}
onClick={onCaptureGps}
disabled={gpsStatus === "capturing"}
className={`w-full rounded-xl border-2 px-4 py-4 text-base font-semibold transition-colors flex items-center justify-center gap-2 min-h-[56px] ${
gpsStatus === "success"
@@ -666,7 +938,7 @@ function WaterFieldInner() {
<Image src={photoPreview} alt="Preview" fill sizes="(max-width: 640px) 100vw, 480px" style={{ objectFit: "cover" }} />
<button
type="button"
onClick={removePhoto}
onClick={onRemovePhoto}
className="absolute top-2 right-2 rounded-full bg-black/70 text-white text-xs px-3 py-1.5 font-semibold hover:bg-black/90 min-h-[36px]"
>
{t.removePhoto}
@@ -678,7 +950,7 @@ function WaterFieldInner() {
<input
type="file"
accept="image/jpeg,image/png"
onChange={handlePhotoChange}
onChange={onPhotoChange}
className="hidden"
/>
</label>
@@ -690,7 +962,7 @@ function WaterFieldInner() {
<label htmlFor="fld-3-tnotes" className="block text-base font-semibold text-stone-700 mb-2">{t.notes}</label>
<textarea id="fld-3-tnotes" aria-label="Textarea"
value={notes}
onChange={(e) => setNotes(e.target.value)}
onChange={(e) => onSetNotes(e.target.value)}
placeholder={t.notesPlaceholder}
rows={2}
className="w-full rounded-xl border-2 border-stone-300 px-4 py-3 text-base outline-none focus:border-stone-900 resize-none min-h-[80px]"
@@ -700,7 +972,7 @@ function WaterFieldInner() {
{/* Submit */}
<button
type="submit"
disabled={!effectiveSelectedHeadgate || !measurement || loading}
disabled={!selectedHeadgate || !measurement || loading}
className="w-full rounded-xl bg-green-600 px-6 py-5 text-xl font-bold text-white disabled:opacity-50 active:bg-green-700 min-h-[64px] shadow-lg shadow-green-900/20 flex items-center justify-center gap-2"
>
{loading ? (
@@ -731,4 +1003,4 @@ export default function WaterFieldClient() {
<WaterFieldInner />
</Suspense>
);
}
}