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