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:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'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
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
+935
-524
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 { 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user