Initial commit - Route Commerce platform

This commit is contained in:
2026-06-01 19:40:55 +00:00
commit 53a9671461
617 changed files with 106132 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
"use client";
import { useState, useEffect } from "react";
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
const PROVIDER_MODELS: Record<Exclude<AIProvider, "custom">, string[]> = {
openai: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
anthropic: ["claude-3-5-sonnet-20241002", "claude-3-5-sonnet-20250611", "claude-3-opus-20240229", "claude-3-haiku-20240307"],
google: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-pro"],
xai: ["grok-2", "grok-2-mini", "grok-1.5"],
};
const PROVIDERS: { id: AIProvider; name: string; icon: string; description: string; website: string }[] = [
{ id: "openai", name: "OpenAI", icon: "🤖", description: "GPT-4o, GPT-4o-mini, GPT-4 Turbo. Industry standard for general AI tasks.", website: "openai.com" },
{ id: "anthropic", name: "Anthropic Claude", icon: "🧠", description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for long-form reasoning and analysis.", website: "anthropic.com" },
{ id: "google", name: "Google Gemini", icon: "🔶", description: "Gemini 2.0 Flash, Gemini 1.5 Pro. Fast, cost-effective, strong multimodal.", website: "ai.google" },
{ id: "xai", name: "xAI Grok", icon: "🌪️", description: "Grok-2, Grok-1.5. Real-time data, humor, unconventional reasoning.", website: "x.ai" },
{ id: "custom", name: "Custom / Other", icon: "🔧", description: "Connect any OpenAI-compatible API or custom LLM endpoint.", website: "" },
];
type Props = {
brandId: string;
};
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);
useEffect(() => {
async function load() {
try {
const res = await fetch(`/api/integrations/ai-provider?brandId=${brandId}`);
if (res.ok) {
const data = await res.json();
setProvider(data.provider ?? "openai");
setApiKey(data.apiKey ?? "");
setOrgId(data.orgId ?? "");
setModel(data.model ?? "gpt-4o-mini");
setCustomEndpoint(data.customEndpoint ?? "");
}
} catch {}
setLoading(false);
}
load();
}, [brandId]);
async function handleSave() {
setSaving(true);
setTestResult(null);
try {
const res = await fetch("/api/integrations/ai-provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId, provider, apiKey, orgId, model, customEndpoint }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Save failed");
setTestResult({ ok: true, message: "AI provider settings saved" });
} catch (err) {
setTestResult({ ok: false, message: err instanceof Error ? err.message : "Save failed" });
}
setSaving(false);
}
async function handleTest() {
setTestResult(null);
setSaving(true);
try {
const res = await fetch("/api/integrations/ai-provider/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brandId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Connection failed");
setTestResult({ ok: true, message: data.message ?? "Connection successful" });
} catch (err) {
setTestResult({ ok: false, message: err instanceof Error ? err.message : "Test failed" });
}
setSaving(false);
}
const models = provider === "custom" ? ["gpt-4o-mini"] : (PROVIDER_MODELS[provider] ?? []);
if (loading) {
return (
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700 animate-pulse">
<div className="h-6 bg-slate-200 rounded w-1/3 mb-4" />
<div className="h-4 bg-slate-200 rounded w-2/3 mb-6" />
<div className="grid grid-cols-5 gap-3">
{[1,2,3,4,5].map(i => <div key={i} className="h-16 bg-zinc-950 rounded-xl" />)}
</div>
</div>
);
}
return (
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-violet-200 overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-violet-600 to-indigo-600 px-6 py-5 flex items-center gap-4">
<span className="text-2xl">🤖</span>
<div>
<h2 className="text-base font-bold text-white">AI Provider</h2>
<p className="text-xs text-violet-200 mt-0.5">Choose your AI model provider for all AI tools</p>
</div>
<span className="ml-auto text-xs text-violet-200 bg-zinc-900/20 rounded-full px-3 py-1">
{PROVIDERS.find(p => p.id === provider)?.name}
</span>
</div>
<div className="p-6 space-y-6">
{/* Provider selector */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-3">
Select Provider
</label>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{PROVIDERS.map((p) => (
<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");
}}
className={`rounded-xl p-3 text-center transition-all border-2 ${
provider === p.id
? "border-violet-500 bg-violet-50 shadow-black/20"
: "border-zinc-800 hover:border-violet-200 hover:bg-zinc-800"
}`}
>
<span className="text-2xl block mb-1">{p.icon}</span>
<p className="text-xs font-semibold text-slate-800">{p.name}</p>
{p.website && (
<p className="text-[10px] text-slate-400 mt-0.5">{p.website}</p>
)}
</button>
))}
</div>
</div>
{/* Credentials — only for non-custom */}
{provider !== "custom" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">
API Key {provider === "anthropic" ? "(Anthropic)" : provider === "google" ? "(Google AI)" : provider === "xai" ? "(xAI)" : "(OpenAI)"}
</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === "anthropic" ? "sk-ant-..." : provider === "google" ? "AIza..." : provider === "xai" ? "xai-..." : "sk-..."}
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm pr-20 outline-none focus:border-violet-500"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400"
>
{showKey ? "Hide" : "Show"}
</button>
</div>
</div>
{provider === "openai" && (
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">Organization ID (optional)</label>
<input
type="text"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
placeholder="org-..."
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm outline-none focus:border-violet-500"
/>
</div>
)}
</div>
)}
{/* Custom endpoint fields */}
{provider === "custom" && (
<div className="space-y-4 rounded-xl bg-slate-50 border border-zinc-800 p-4">
<div className="flex items-start gap-3">
<span className="text-lg mt-0.5">🔧</span>
<div className="flex-1">
<p className="text-sm font-semibold text-zinc-300">Custom API Endpoint</p>
<p className="text-xs text-zinc-500 mt-0.5">Connect any OpenAI-compatible API (Ollama, LM Studio, custom proxy, etc.)</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">API Key</label>
<div className="relative">
<input
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm pr-20 outline-none focus:border-violet-500"
/>
<button type="button" onClick={() => setShowKey(!showKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400">
{showKey ? "Hide" : "Show"}
</button>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">Base URL</label>
<input
type="text"
value={customEndpoint}
onChange={(e) => setCustomEndpoint(e.target.value)}
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
className="w-full rounded-xl border border-zinc-600 px-4 py-2.5 text-sm outline-none focus:border-violet-500"
/>
</div>
</div>
</div>
)}
{/* Model selector */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-2">
Model
</label>
<div className="flex flex-wrap gap-2">
{models.map((m) => (
<button
key={m}
onClick={() => setModel(m)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
model === m
? "bg-violet-600 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{m}
</button>
))}
</div>
</div>
{/* Test result */}
{testResult && (
<div className={`rounded-xl px-4 py-3 text-sm flex items-center gap-2 ${
testResult.ok ? "bg-green-900/30 text-green-400 border border-green-200" : "bg-red-900/30 text-red-400 border border-red-200"
}`}>
<span>{testResult.ok ? "✓" : "✗"}</span>
<span>{testResult.message}</span>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2 border-t border-slate-100">
<button
onClick={handleTest}
disabled={saving}
className="rounded-xl border border-zinc-600 px-5 py-2.5 text-sm font-medium text-zinc-400 hover:bg-zinc-800 disabled:opacity-50"
>
Test Connection
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-xl bg-violet-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-violet-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Provider Settings"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,252 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { getAbandonedCarts, manuallyCloseAbandonedCart, resendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart";
type Props = { brandId: string };
const STATUS_LABELS: Record<string, { en: string; es?: string; color: string }> = {
active: { en: "Active", color: "bg-blue-900/40 text-blue-400 border border-blue-800" },
recovered: { en: "Recovered", color: "bg-emerald-900/40 text-emerald-400 border border-emerald-800" },
expired: { en: "Expired", color: "bg-zinc-800 text-zinc-400 border border-zinc-700" },
manually_closed: { en: "Manually Closed", color: "bg-amber-900/40 text-amber-400 border border-amber-800" },
};
const STEP_LABELS: Record<number, string> = {
0: "Detected",
1: "Email 1 (1h)",
2: "Email 2 (24h)",
3: "Email 3 (48h)",
};
function CartItemRow({ item }: { item: { name: string; quantity: number; unit_price: number } }) {
return (
<tr className="border-t border-zinc-800/60">
<td className="px-4 py-2 text-sm text-zinc-200">{item.name}</td>
<td className="px-4 py-2 text-sm text-zinc-400 text-right">{item.quantity}</td>
<td className="px-4 py-2 text-sm text-zinc-400 text-right">${Number(item.unit_price).toFixed(2)}</td>
</tr>
);
}
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);
const [filter, setFilter] = useState<"all" | "active" | "recovered">("all");
const [page, setPage] = useState(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);
};
useEffect(() => { setPage(0); }, [filter]);
const filteredCarts = carts.filter(c => {
if (filter === "active") return c.status === "active";
if (filter === "recovered") return c.status === "recovered";
return true;
});
const paginatedCarts = filteredCarts.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(filteredCarts.length / PAGE_SIZE);
const handleResend = async (cartId: string) => {
setResending(cartId);
await resendAbandonedCartEmail(cartId, brandId);
await load();
setResending(null);
};
const cart = selectedCart;
return (
<div className="space-y-6">
{/* Stats row */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Total", value: stats.total, color: "text-zinc-100" },
{ label: "Active", value: stats.active, color: "text-blue-400" },
{ label: "Recovered", value: stats.recovered, color: "text-emerald-400" },
{ label: "Expired", value: stats.expired, color: "text-zinc-400" },
].map(s => (
<div key={s.label} className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 text-center">
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
<p className="text-xs text-zinc-500 uppercase tracking-widest mt-1">{s.label}</p>
</div>
))}
</div>
{/* Filter + refresh + pagination */}
<div className="flex items-center gap-3">
<button onClick={load} disabled={loading}
className="text-xs font-semibold text-violet-400 hover:text-violet-300 transition-colors">
{loading ? "Loading..." : "↻ Refresh"}
</button>
<div className="flex gap-2 ml-auto">
{(["all", "active", "recovered"] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-violet-600 text-white" : "bg-zinc-800 text-zinc-400 hover:text-zinc-200"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-2">
<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-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<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-zinc-500 px-1">{page + 1}/{totalPages}</span>
<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-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<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>
{/* Carts table */}
{filteredCarts.length === 0 ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl py-16 text-center">
<p className="text-zinc-600 text-sm">No abandoned carts{filter !== "all" ? ` (${filter})` : ""}</p>
</div>
) : (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-zinc-500 uppercase tracking-widest border-b border-zinc-800">
<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-zinc-800/60 hover:bg-zinc-800/30 transition-colors">
<td className="px-5 py-3.5">
<div>
<p className="text-zinc-200 font-medium text-sm">{c.contact_name ?? "—"}</p>
<p className="text-zinc-500 text-xs">{c.contact_email}</p>
</div>
</td>
<td className="px-5 py-3.5 text-zinc-400 text-xs">{c.cart_snapshot.item_count} items</td>
<td className="px-5 py-3.5 text-zinc-200 font-semibold text-sm">${Number(c.cart_snapshot.subtotal).toFixed(2)}</td>
<td className="px-5 py-3.5">
<span className="text-xs text-zinc-500">{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-zinc-500 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 onClick={() => setSelectedCart(c)}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded-lg hover:bg-zinc-800 transition-all">View</button>
{c.status === "active" && (
<button onClick={() => handleClose(c.id)} disabled={closing}
className="text-xs text-amber-400 hover:text-amber-300 px-2 py-1 rounded-lg hover:bg-amber-900/20 transition-all">Close</button>
)}
{c.status === "active" && (
<button onClick={() => handleResend(c.id)} disabled={resending === c.id}
className="text-xs text-violet-400 hover:text-violet-300 px-2 py-1 rounded-lg hover:bg-violet-900/20 transition-all">
{resending === c.id ? "..." : "Resend"}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Cart detail modal */}
{cart && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={() => setSelectedCart(null)}>
<div className="bg-zinc-900 border border-zinc-700 rounded-2xl w-full max-w-lg shadow-2xl"
onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-zinc-100">Abandoned Cart</h3>
<p className="text-xs text-zinc-500">{cart.contact_email}</p>
</div>
<button onClick={() => setSelectedCart(null)} className="text-zinc-500 hover:text-zinc-200 text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
<div className="flex items-start gap-3 bg-zinc-800/50 rounded-xl p-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_LABELS[cart.status]?.color ?? ""}`}>
{STATUS_LABELS[cart.status]?.en ?? cart.status}
</span>
<div className="flex-1">
<p className="text-sm text-zinc-200 font-medium">{cart.contact_name ?? "—"}</p>
<p className="text-xs text-zinc-500">{cart.contact_email}</p>
<p className="text-xs text-zinc-600 mt-1">Step: {STEP_LABELS[cart.sequence_step] ?? cart.sequence_step}</p>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-2">Cart Items</p>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-zinc-600 border-b border-zinc-800">
<th className="text-left pb-2 font-medium">Item</th>
<th className="text-right pb-2 font-medium">Qty</th>
<th className="text-right pb-2 font-medium">Price</th>
</tr>
</thead>
<tbody>
{cart.cart_snapshot.items.map((item, i) => (
<CartItemRow key={i} item={item} />
))}
</tbody>
<tfoot>
<tr className="border-t border-zinc-700">
<td className="pt-2 text-zinc-200 font-semibold" colSpan={2}>Total</td>
<td className="pt-2 text-zinc-200 font-bold text-right">${Number(cart.cart_snapshot.subtotal).toFixed(2)}</td>
</tr>
</tfoot>
</table>
</div>
{cart.last_email_sent_at && (
<p className="text-xs text-zinc-500">Last email sent: {new Date(cart.last_email_sent_at).toLocaleString()}</p>
)}
{cart.next_email_at && (
<p className="text-xs text-zinc-500">Next email: {new Date(cart.next_email_at).toLocaleString()}</p>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import Link from "next/link";
type AccessDeniedProps = {
message?: string;
};
export default function AdminAccessDenied({
message = "You do not have permission to view this page.",
}: AccessDeniedProps) {
return (
<main className="min-h-screen flex items-center justify-center px-6 py-12 bg-stone-100">
<div className="w-full max-w-md">
<div className="card p-8 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-red-50 border border-red-200">
<svg
className="h-7 w-7 text-red-500"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
<h1 className="mt-6 text-xl font-semibold text-stone-950">
Access Denied
</h1>
<p className="mt-2 text-sm text-stone-500">{message}</p>
<Link
href="/admin"
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium text-white transition-all shadow-sm"
>
Back to Admin
</Link>
</div>
</div>
</main>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { supabase } from "@/lib/supabase";
type AdminHeaderProps = {
userRole?: string | null;
canManageUsers?: boolean;
routeTraceEnabled?: boolean;
};
type NavLink = {
href: string;
label: string;
external?: boolean;
};
type SettingsItem = {
href: string;
label: string;
description: string;
};
const SETTINGS_ITEMS: SettingsItem[] = [
{ href: "/admin/settings", label: "General", description: "Brand name, logo, timezone" },
{ href: "/admin/settings/brand", label: "Brand", description: "Brand profile and preferences" },
{ href: "/admin/users", label: "Users & Permissions", description: "Team members and access roles" },
{ href: "/admin/settings/integrations", label: "Integrations", description: "Stripe, Square, Resend, Twilio" },
{ href: "/admin/settings/apps", label: "Add-ons", description: "Enable and manage feature add-ons" },
{ href: "/admin/settings/billing", label: "Billing & Plans", description: "Subscription, invoices, usage" },
{ href: "/admin/settings/payments", label: "Payments", description: "Stripe, Square, payment methods" },
{ href: "/admin/communications/abandoned-carts", label: "Abandoned Cart Recovery", description: "3-email recovery sequence, recovered carts" },
{ href: "/admin/communications/welcome-sequence", label: "Welcome Sequence", description: "4-email onboarding for new subscribers" },
];
const BASE_NAV_LINKS: NavLink[] = [
{ href: "/admin/orders", label: "Orders" },
{ href: "/admin/stops", label: "Stops & Routes" },
{ href: "/admin/products", label: "Products" },
{ href: "/admin/time-tracking", label: "Time Tracking" },
{ href: "/admin/communications", label: "Harvest Reach" },
];
const EMPLOYEE_NAV_LINKS: NavLink[] = [
{ href: "/admin/wholesale", label: "Wholesale" },
{ href: "/admin/pickup", label: "Pickup" },
{ href: "/wholesale/employee", label: "Pickup Portal", external: true },
];
export default function AdminHeader({ userRole, canManageUsers, routeTraceEnabled }: AdminHeaderProps) {
const router = useRouter();
const pathname = usePathname();
const isStoreEmployee = userRole === "store_employee";
const [settingsOpen, setSettingsOpen] = useState(false);
const settingsRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (settingsRef.current && !settingsRef.current.contains(e.target as Node)) {
setSettingsOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Close dropdown on route change
useEffect(() => {
setSettingsOpen(false);
}, [pathname]);
const moduleLinks: NavLink[] = routeTraceEnabled
? [{ href: "/admin/route-trace", label: "Route Trace" }]
: [];
const fullNavLinks = BASE_NAV_LINKS.concat(
userRole === "platform_admin" ? [{ href: "/admin/command-center", label: "Command Center" }] : []
).concat(moduleLinks);
const navLinks = isStoreEmployee ? EMPLOYEE_NAV_LINKS : fullNavLinks;
const homeHref = isStoreEmployee ? "/admin/wholesale" : "/admin";
const homeLabel = isStoreEmployee ? "Pickup" : "Admin";
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
router.push("/login");
router.refresh();
}
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "store_employee" ? "Store Employee"
: null;
const isActive = (href: string) => {
if (href === "/admin") return pathname === "/admin";
if (href === "/admin/settings") {
return pathname === "/admin/settings" || pathname.startsWith("/admin/settings/");
}
return pathname.startsWith(href);
};
const isSettingsActive =
pathname.startsWith("/admin/settings") ||
pathname === "/admin/settings" ||
pathname.startsWith("/admin/communications/abandoned-carts") ||
pathname.startsWith("/admin/communications/welcome-sequence");
return (
<header className="border-b border-white/5 bg-gradient-to-b from-zinc-900/80 to-transparent backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-0">
<div className="flex items-center h-16">
{/* Logo mark */}
<Link
href={homeHref}
className="flex items-center gap-3 mr-8 text-white hover:text-emerald-400 transition-colors"
>
<div className="relative">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/30">
<svg className="h-4.5 w-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l.707-.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
</div>
<span className="text-sm font-semibold tracking-tight">{homeLabel}</span>
</Link>
<nav className="flex h-full items-center gap-1">
{navLinks.map((link) => {
const active = isActive(link.href);
return (
<Link
key={link.href}
href={link.href}
{...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200",
active
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
{link.label}
</Link>
);
})}
{/* Settings dropdown */}
<div className="relative ml-2" ref={settingsRef}>
<button
onClick={() => setSettingsOpen(v => !v)}
className={[
"px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 flex items-center gap-2",
isSettingsActive
? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
: "text-zinc-400 hover:text-white hover:bg-white/5 border border-transparent",
].join(" ")}
>
Settings
<svg className={`w-3.5 h-3.5 transition-transform ${settingsOpen ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{settingsOpen && (
<div className="absolute top-full left-0 mt-2 w-80 rounded-2xl glass-strong shadow-2xl z-50 overflow-hidden border border-white/10">
<div className="p-2">
{SETTINGS_ITEMS.map((item) => {
const active = pathname === item.href || (item.href !== "/admin/settings" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={[
"flex items-start gap-3 rounded-xl px-4 py-3 transition-all duration-200",
active
? "bg-emerald-500/10 border border-emerald-500/20"
: "hover:bg-white/5 border border-transparent",
].join(" ")}
>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${active ? "text-emerald-400" : "text-white"}`}>
{item.label}
</p>
<p className="text-xs text-zinc-500 mt-0.5 leading-snug">{item.description}</p>
</div>
{active && (
<span className="mt-0.5 flex-shrink-0 w-2 h-2 rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/50" />
)}
</Link>
);
})}
</div>
</div>
)}
</div>
</nav>
</div>
<div className="flex items-center gap-6">
{roleLabel && (
<div className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/5">
<span className="text-xs font-medium text-zinc-400">{roleLabel}</span>
</div>
)}
<button
onClick={handleLogout}
className="text-sm font-medium text-zinc-500 hover:text-white transition-colors px-3 py-1.5 rounded-lg hover:bg-white/5"
>
Sign out
</button>
</div>
</div>
</header>
);
}
+373
View File
@@ -0,0 +1,373 @@
"use client";
import { useState } from "react";
import { markPickupComplete } from "@/actions/pickup";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
products: { name: string } | null;
};
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
stop_id: string | null;
status: string;
subtotal: number;
pickup_complete: boolean;
pickup_completed_at: string | null;
pickup_completed_by: string | null;
created_at: string;
payment_processor: string | null;
stops: {
id?: string;
city: string;
state: string;
date: string;
brand_id?: string;
} | null;
order_items?: OrderItem[];
};
type Stop = {
id: string;
city: string;
state: string;
date: string;
brand_id?: string;
};
type AdminOrdersPanelProps = {
initialOrders: Order[];
initialStops: Stop[];
brandId: string | null;
};
function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
function formatDate(iso: string | undefined) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export { formatDate };
export default function AdminOrdersPanel({
initialOrders,
initialStops,
brandId,
}: AdminOrdersPanelProps) {
const [orders, setOrders] = useState<Order[]>(initialOrders);
const [stops] = useState<Stop[]>(initialStops);
const [search, setSearch] = useState("");
const [stopFilter, setStopFilter] = useState("");
const [pickingUp, setPickingUp] = useState<string | null>(null);
const [squareFilter, setSquareFilter] = useState<"all" | "square" | "other">("all");
const [statusFilter, setStatusFilter] = useState<"all" | "pending" | "picked_up">("all");
const [page, setPage] = useState(0);
const [pickupToast, setPickupToast] = useState<string | null>(null);
const PAGE_SIZE = 50;
async function handleMarkPickup(orderId: string) {
setPickingUp(orderId);
const result = await markPickupComplete(orderId, brandId);
if (result.success) {
setPickupToast(orderId);
setTimeout(() => setPickupToast(null), 3000);
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? {
...o,
pickup_complete: true,
pickup_completed_at: result.pickup_completed_at,
pickup_completed_by: result.pickup_completed_by,
}
: o
)
);
}
setPickingUp(null);
}
const filtered = orders.filter((o) => {
const matchesSearch =
!search ||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(o.customer_phone ?? "").includes(search) ||
o.id.includes(search.toUpperCase().slice(0, 8));
const matchesStop = !stopFilter || o.stop_id === stopFilter;
const matchesSquare =
squareFilter === "all" ||
(squareFilter === "square" && o.payment_processor === "square") ||
(squareFilter === "other" && o.payment_processor !== "square");
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "pending" && !o.pickup_complete) ||
(statusFilter === "picked_up" && o.pickup_complete);
return matchesSearch && matchesStop && matchesSquare && matchesStatus;
});
const totalFiltered = filtered.length;
const paginated = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(totalFiltered / PAGE_SIZE);
const pendingCount = filtered.filter((o) => !o.pickup_complete).length;
const pickedUpCount = filtered.filter((o) => o.pickup_complete).length;
return (
<div className="bg-transparent">
{/* Header */}
<div className="border-b border-stone-200 bg-white px-6 py-5">
<div className="mx-auto max-w-7xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Orders</h1>
<p className="mt-0.5 text-sm text-stone-500">
{pendingCount} pending
{pickedUpCount > 0 && ` · ${pickedUpCount} picked up`}
{brandId && (
<span className="ml-2 text-xs text-stone-400">(brand-scoped)</span>
)}
</p>
</div>
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-6 py-4">
{/* Status filter + pagination */}
<div className="mb-4 flex items-center justify-between gap-3 flex-wrap">
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "pending", "picked_up"] as const).map((f) => (
<button
key={f}
onClick={() => { setStatusFilter(f); setPage(0); }}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "pending" ? "Pending" : "Picked Up"}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<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-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Previous page"
>
<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="px-2 text-xs text-stone-500">{page + 1} / {totalPages}</span>
<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-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
aria-label="Next page"
>
<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>
{/* Search + Stop + Square filter */}
<div className="mb-4 flex gap-3 flex-wrap">
<input
type="search"
placeholder="Search name, phone, order #..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 min-w-48 rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 font-mono transition-colors"
/>
<select
value={stopFilter}
onChange={(e) => setStopFilter(e.target.value)}
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors"
>
<option value="">All Stops</option>
{stops.map((s) => (
<option key={s.id} value={s.id}>
{s.city}, {s.state} · {formatDate(s.date)}
</option>
))}
</select>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "square", "other"] as const).map((f) => (
<button
key={f}
onClick={() => setSquareFilter(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
squareFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "square" ? "Square" : "Other"}
</button>
))}
</div>
</div>
{/* Table */}
{paginated.length === 0 ? (
<div className="rounded-lg bg-white border border-stone-200 py-12 text-center text-sm text-stone-500 shadow-sm">
No orders found
</div>
) : (
<div className="overflow-x-auto rounded-lg bg-white border border-stone-200 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-200 bg-stone-50 text-left">
<th className="px-4 py-3 font-semibold text-stone-500">Order</th>
<th className="px-4 py-3 font-semibold text-stone-500">Customer</th>
<th className="px-4 py-3 font-semibold text-stone-500 hidden md:table-cell">Stop</th>
<th className="px-4 py-3 font-semibold text-stone-500 hidden lg:table-cell">Date</th>
<th className="px-4 py-3 font-semibold text-stone-500 text-center">Items</th>
<th className="px-4 py-3 font-semibold text-stone-500 text-right">Total</th>
<th className="px-4 py-3 font-semibold text-stone-500 text-center">Status</th>
<th className="px-4 py-3 font-semibold text-stone-500 text-right">Actions</th>
</tr>
</thead>
<tbody>
{paginated.map((order) => (
<OrderRow
key={order.id}
order={order}
onMarkPickup={handleMarkPickup}
pickingUp={pickingUp}
shortId={shortId(order.id)}
showToast={pickupToast === order.id}
/>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
type OrderRowProps = {
order: Order;
onMarkPickup: (orderId: string) => void;
pickingUp: string | null;
shortId: string;
showToast?: boolean;
};
function OrderRow({ order, onMarkPickup, pickingUp, shortId, showToast }: OrderRowProps) {
const itemCount = order.order_items?.length ?? 0;
return (
<tr className="border-b border-stone-100 last:border-0 hover:bg-stone-50 transition-colors">
{/* Order ID */}
<td className="px-4 py-3">
<span className="font-mono text-xs text-stone-500">{shortId}</span>
</td>
{/* Customer */}
<td className="px-4 py-3">
<div className="font-medium text-stone-900">{order.customer_name}</div>
{order.customer_phone && (
<div className="font-mono text-xs text-stone-400">{order.customer_phone}</div>
)}
</td>
{/* Stop */}
<td className="px-4 py-3 hidden md:table-cell">
{order.stops ? (
<span className="text-stone-600">{order.stops.city}, {order.stops.state}</span>
) : (
<span className="text-stone-300"></span>
)}
</td>
{/* Date */}
<td className="px-4 py-3 hidden lg:table-cell">
<span className="font-mono text-stone-500">{formatDate(order.created_at)}</span>
</td>
{/* Items */}
<td className="px-4 py-3 text-center">
<span className="font-mono text-stone-600">{itemCount}</span>
</td>
{/* Total */}
<td className="px-4 py-3 text-right">
<span className="font-mono font-semibold text-stone-900">
${Number(order.subtotal).toFixed(2)}
</span>
</td>
{/* Status */}
<td className="px-4 py-3 text-center">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
order.pickup_complete
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-amber-100 text-amber-700 border border-amber-200"
}`}
>
{order.pickup_complete ? "Picked Up" : "Pending"}
</span>
{order.payment_processor === "square" && (
<span className="ml-1.5 inline-block rounded-full bg-purple-100 border border-purple-200 px-1.5 py-0.5 text-xs font-semibold text-purple-700">
Square
</span>
)}
</td>
{/* Actions */}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<a
href={`/admin/orders/${order.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
View / Edit
</a>
{!order.pickup_complete && (
<button
onClick={() => onMarkPickup(order.id)}
disabled={pickingUp === order.id}
className="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 transition-colors shadow-sm"
>
{pickingUp === order.id ? "..." : "Pick Up"}
</button>
)}
{showToast && (
<div className="fixed bottom-6 right-6 z-50 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-2.5 text-xs font-semibold text-emerald-700 shadow-lg">
Done
</div>
)}
</div>
</td>
</tr>
);
}
+300
View File
@@ -0,0 +1,300 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
// Warm earth-tone dark sidebar design
// Colors: stone-900 bg, stone-300 text, emerald accent
const TOP_LINKS = [
{ href: "/admin", label: "Dashboard", icon: "grid" },
{ href: "/admin/orders", label: "Orders", icon: "shopping-cart" },
{ href: "/admin/stops", label: "Stops & Routes", icon: "map-pin" },
{ href: "/admin/products", label: "Products", icon: "package" },
{ href: "/admin/route-trace", label: "Route Trace", icon: "clipboard" },
{ href: "/admin/time-tracking", label: "Time Tracking", icon: "clock" },
{ href: "/admin/communications", label: "Communications", icon: "mail" },
];
const SETTINGS_SUB_LINKS = [
{ href: "/admin/settings", label: "General" },
{ href: "/admin/settings#workers", label: "Workers & PINs" },
{ href: "/admin/settings#tasks", label: "Tasks" },
{ href: "/admin/settings#users", label: "Users & Permissions" },
{ href: "/admin/settings#integrations", label: "Integrations" },
];
function GridIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25v-2.25z" />
</svg>
);
}
function CartIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
</svg>
);
}
function MapPinIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
);
}
function PackageIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
);
}
function ClipboardIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} 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>
);
}
function ClockIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
function MailIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
);
}
function SettingsCogIcon({ open }: { open: boolean }) {
return (
<svg className={`w-4 h-4 transition-transform ${open ? "rotate-45" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09.542.56.94 1.11.94h2.64c.55 0 1.02-.398 1.11-.94l.213-1.999c.018-.158.04-.315.062-.472a.563.563 0 00-.122-.519l-.79-2.758A.562.562 0 0014.56 0H9.44a.563.563 0 00-.424.264l-.79 2.758a.563.563 0 00-.122.519c.022.157.044.314.062.472l.213 1.999zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
}
function HamburgerIcon() {
return (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
);
}
const ICON_MAP: Record<string, React.ReactNode> = {
grid: <GridIcon />,
"shopping-cart": <CartIcon />,
"map-pin": <MapPinIcon />,
package: <PackageIcon />,
clipboard: <ClipboardIcon />,
clock: <ClockIcon />,
mail: <MailIcon />,
};
type SidebarProps = {
userRole?: string | null;
};
export default function AdminSidebar({ userRole }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
: userRole === "brand_admin" ? "Brand Admin"
: userRole === "store_employee" ? "Store Employee"
: null;
const isActive = (href: string) => {
if (href === "/admin") return pathname === "/admin";
if (href === "/admin/settings") return pathname === "/admin/settings" || pathname.startsWith("/admin/settings");
return pathname.startsWith(href);
};
const isSettingsPage = pathname.startsWith("/admin/settings");
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
setMobileOpen(false);
router.push("/login");
router.refresh();
}
return (
<>
{/* Mobile hamburger button */}
<button
onClick={() => setMobileOpen(true)}
className="fixed top-4 left-4 z-50 lg:hidden"
aria-label="Open sidebar"
>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white shadow-md border border-stone-200">
<HamburgerIcon />
</div>
</button>
{/* Mobile overlay backdrop */}
{mobileOpen && (
<div
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Sidebar panel - Warm earth-tone dark */}
<aside className={[
"fixed top-0 left-0 h-full w-60 z-50",
"bg-stone-900 border-r border-stone-800",
"flex flex-col transition-transform duration-300 lg:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full"
].join(" ")}>
{/* Logo row */}
<div className="flex items-center h-16 px-5 border-b border-stone-800 flex-shrink-0">
<Link
href="/admin"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-3 text-stone-100 hover:text-emerald-400 transition-colors"
>
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-600 shadow-sm">
<svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold tracking-tight">Admin</span>
<a
href="/"
onClick={(e) => e.stopPropagation()}
className="text-xs text-stone-500 hover:text-stone-300 transition-colors ml-2"
>
Back to Site
</a>
</div>
</Link>
</div>
{/* Nav links */}
<nav className="flex-1 overflow-y-auto px-3 py-5 space-y-1">
{TOP_LINKS.map((item) => {
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={[
"group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
active
? "bg-emerald-900/20 text-emerald-400 border-l-2 border-emerald-500"
: "text-stone-300 hover:text-stone-100 hover:bg-stone-800/50 border-l-2 border-transparent",
].join(" ")}
>
<span className={[
"flex-shrink-0 transition-colors",
active ? "text-emerald-400" : "text-stone-500 group-hover:text-stone-300"
].join(" ")}>
{ICON_MAP[item.icon]}
</span>
{item.label}
{active && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-emerald-500" />
)}
</Link>
);
})}
{/* Settings collapsible section */}
<div className="pt-4 pb-1">
<div className="h-px bg-stone-800 mb-4" />
<button
onClick={() => setSettingsOpen(v => !v)}
className={[
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
isSettingsPage
? "bg-emerald-900/20 text-emerald-400 border-l-2 border-emerald-500"
: "text-stone-300 hover:text-stone-100 hover:bg-stone-800/50 border-l-2 border-transparent",
].join(" ")}
>
<span className="flex items-center gap-3">
<span className={isSettingsPage ? "text-emerald-400" : "text-stone-500"}>
<SettingsCogIcon open={settingsOpen} />
</span>
Settings
</span>
<ChevronIcon open={settingsOpen} />
</button>
{settingsOpen && (
<div className="mt-2 ml-2 space-y-0.5 border-l border-stone-700 pl-3">
{SETTINGS_SUB_LINKS.map((item) => {
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={[
"flex items-center px-3 py-2 rounded-lg text-sm transition-all duration-200",
active
? "bg-emerald-900/20 text-emerald-400"
: "text-stone-400 hover:text-stone-200 hover:bg-stone-800/50",
].join(" ")}
>
{item.label}
</Link>
);
})}
</div>
)}
</div>
</nav>
{/* Bottom: role + sign out */}
<div className="px-4 py-5 border-t border-stone-800 flex-shrink-0">
{roleLabel && (
<div className="px-3 py-2 mb-3 rounded-lg bg-stone-800 border border-stone-700">
<p className="text-[10px] text-stone-500 font-medium uppercase tracking-widest mb-0.5">{roleLabel}</p>
<p className="text-xs text-stone-400">Signed in</p>
</div>
)}
<button
onClick={handleLogout}
className="w-full px-3 py-2 rounded-lg text-sm font-medium text-stone-400 hover:text-stone-200 hover:bg-stone-800 transition-all border border-transparent"
>
Sign out
</button>
</div>
</aside>
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ADDON_CATALOG, type BrandFeatureKey } from "@/lib/feature-flags";
import { toggleBrandFeature } from "@/actions/settings/features";
type Props = {
brandId: string;
initialEnabledFeatures: Record<string, boolean>;
};
export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: Props) {
const router = useRouter();
const [enabledFeatures, setEnabledFeatures] = useState<Record<string, boolean>>(initialEnabledFeatures);
const [toggling, setToggling] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const showToast = (msg: string) => {
setToast(msg);
setTimeout(() => setToast(null), 3000);
};
const handleToggle = async (key: string) => {
const newEnabled = !enabledFeatures[key];
setToggling(key);
setEnabledFeatures((prev) => ({ ...prev, [key]: newEnabled }));
const result = await toggleBrandFeature(brandId, key as BrandFeatureKey, newEnabled);
if (result.success) {
showToast(newEnabled ? `${ADDON_CATALOG[key as BrandFeatureKey].name} enabled` : `${ADDON_CATALOG[key as BrandFeatureKey].name} disabled`);
router.refresh();
} else {
setEnabledFeatures((prev) => ({ ...prev, [key]: !newEnabled }));
showToast(`Error: ${result.error}`);
}
setToggling(null);
};
const keys = Object.keys(ADDON_CATALOG) as BrandFeatureKey[];
return (
<>
{toast && (
<div className="fixed bottom-6 right-6 z-50 rounded-xl bg-stone-900 px-4 py-3 text-sm font-medium text-white shadow-lg">
{toast}
</div>
)}
<div className="grid gap-5 md:grid-cols-2">
{keys.map((key) => {
const addon = ADDON_CATALOG[key];
const enabled = !!enabledFeatures[key];
const busy = toggling === key;
return (
<div
key={key}
className={`rounded-2xl p-6 shadow-black/20 ring-1 ${
enabled
? "bg-gradient-to-br from-white to-green-50 ring-green-200"
: "bg-zinc-900 ring-stone-200"
}`}
>
<div className="flex items-start gap-4">
<span className="text-3xl mt-0.5">{addon.icon}</span>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-zinc-100">{addon.name}</h3>
<span
className={`text-xs font-medium rounded-full px-2 py-0.5 ${
enabled
? "bg-green-900/40 text-green-400"
: "bg-stone-100 text-stone-500"
}`}
>
{enabled ? "Enabled" : "Disabled"}
</span>
</div>
<p className="text-sm text-stone-700 leading-relaxed">
{addon.description}
</p>
{addon.addOnPrice && !enabled && (
<p className="mt-2 text-xs font-medium text-amber-400">
Requires: {addon.addOnPrice}
</p>
)}
</div>
</div>
<div className="mt-5 flex items-center gap-3 justify-end">
{enabled && (
<Link
href={addon.adminRoute}
className="rounded-xl border border-zinc-800 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-zinc-950 transition-colors"
>
Open Module
</Link>
)}
<button
onClick={() => handleToggle(key)}
disabled={busy}
className={`rounded-xl px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 ${
enabled
? "border border-red-200 text-red-400 hover:bg-red-900/30"
: "bg-green-600 text-white hover:bg-green-700"
}`}
>
{busy ? "..." : enabled ? "Disable" : "Enable"}
</button>
</div>
</div>
);
})}
</div>
</>
);
}
+989
View File
@@ -0,0 +1,989 @@
"use client";
import NextImage from "next/image";
import { useState, useEffect, useRef } from "react";
import {
getBrandSettings,
saveBrandSettings,
uploadBrandLogo,
uploadOlatheSweetLogo,
uploadOlatheSweetLogoDark,
type BrandSettings,
} from "@/actions/brand-settings";
type Props = {
settings: BrandSettings | null;
brandId: string;
brandName: string;
brands?: { id: string; name: string }[];
isPlatformAdmin?: boolean;
};
export default function BrandSettingsForm({
settings,
brandId: initialBrandId,
brandName: initialBrandName,
brands = [],
isPlatformAdmin = false,
}: Props) {
const [activeBrandId, setActiveBrandId] = useState(initialBrandId);
const [activeBrandName, setActiveBrandName] = useState(initialBrandName);
const [legalBusinessName, setLegalBusinessName] = useState(settings?.legal_business_name ?? "");
const [phone, setPhone] = useState(settings?.phone ?? "");
const [email, setEmail] = useState(settings?.email ?? "");
const [websiteUrl, setWebsiteUrl] = useState(settings?.website_url ?? "");
const [streetAddress, setStreetAddress] = useState(settings?.street_address ?? "");
const [city, setCity] = useState(settings?.city ?? "");
const [state, setState] = useState(settings?.state ?? "");
const [postalCode, setPostalCode] = useState(settings?.postal_code ?? "");
const [country, setCountry] = useState(settings?.country ?? "US");
const [logoUrl, setLogoUrl] = useState(settings?.logo_url ?? "");
const [logoUrlDark, setLogoUrlDark] = useState(settings?.logo_url_dark ?? "");
const [olatheSweetLogoUrl, setOlatheSweetLogoUrl] = useState(settings?.olathe_sweet_logo_url ?? "");
const [olatheSweetLogoUrlDark, setOlatheSweetLogoUrlDark] = useState(settings?.olathe_sweet_logo_url_dark ?? "");
const [defaultEmailSignature, setDefaultEmailSignature] = useState(
settings?.default_email_signature ?? ""
);
const [invoiceFooterNotes, setInvoiceFooterNotes] = useState(
settings?.invoice_footer_notes ?? ""
);
// Storefront customization fields
const [heroTagline, setHeroTagline] = useState(settings?.hero_tagline ?? "");
const [aboutHeadline, setAboutHeadline] = useState(settings?.about_headline ?? "");
const [aboutSubheadline, setAboutSubheadline] = useState(settings?.about_subheadline ?? "");
const [customFooterText, setCustomFooterText] = useState(settings?.custom_footer_text ?? "");
const [showWholesaleLink, setShowWholesaleLink] = useState(settings?.show_wholesale_link ?? true);
const [showZipSearch, setShowZipSearch] = useState(settings?.show_zip_search ?? true);
const [showSchedulePdf, setShowSchedulePdf] = useState(settings?.show_schedule_pdf ?? true);
const [showTextAlerts, setShowTextAlerts] = useState(settings?.show_text_alerts ?? false);
const [schedulePdfNotes, setSchedulePdfNotes] = useState(settings?.schedule_pdf_notes ?? "");
const [heroImageUrl, setHeroImageUrl] = useState(settings?.hero_image_url ?? "");
const [brandPrimaryColor, setBrandPrimaryColor] = useState(settings?.brand_primary_color ?? "#16a34a");
const [brandSecondaryColor, setBrandSecondaryColor] = useState(settings?.brand_secondary_color ?? "#f5f5f4");
const [brandBgColor, setBrandBgColor] = useState(settings?.brand_bg_color ?? "#fafaf9");
const [brandTextColor, setBrandTextColor] = useState(settings?.brand_text_color ?? "#1c1917");
// Tax settings
const [collectSalesTax, setCollectSalesTax] = useState(settings?.collect_sales_tax ?? false);
const [nexusStates, setNexusStates] = useState<string[]>(settings?.nexus_states ?? ["CO"]);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Reload settings when brand changes (platform admin)
useEffect(() => {
if (!isPlatformAdmin) return;
setLoading(true);
getBrandSettings(activeBrandId).then((result) => {
setLoading(false);
if (result.success && result.settings) {
const s = result.settings;
setLegalBusinessName(s.legal_business_name ?? "");
setPhone(s.phone ?? "");
setEmail(s.email ?? "");
setWebsiteUrl(s.website_url ?? "");
setStreetAddress(s.street_address ?? "");
setCity(s.city ?? "");
setState(s.state ?? "");
setPostalCode(s.postal_code ?? "");
setCountry(s.country ?? "US");
setLogoUrl(s.logo_url ?? "");
setLogoUrlDark(s.logo_url_dark ?? "");
setOlatheSweetLogoUrl(s.olathe_sweet_logo_url ?? "");
setOlatheSweetLogoUrlDark(s.olathe_sweet_logo_url_dark ?? "");
setDefaultEmailSignature(s.default_email_signature ?? "");
setInvoiceFooterNotes(s.invoice_footer_notes ?? "");
setHeroTagline(s.hero_tagline ?? "");
setAboutHeadline(s.about_headline ?? "");
setAboutSubheadline(s.about_subheadline ?? "");
setCustomFooterText(s.custom_footer_text ?? "");
setShowWholesaleLink(s.show_wholesale_link ?? true);
setShowZipSearch(s.show_zip_search ?? true);
setShowSchedulePdf(s.show_schedule_pdf ?? true);
setShowTextAlerts(s.show_text_alerts ?? false);
setSchedulePdfNotes(s.schedule_pdf_notes ?? "");
setHeroImageUrl(s.hero_image_url ?? "");
setBrandPrimaryColor(s.brand_primary_color ?? "#16a34a");
setBrandSecondaryColor(s.brand_secondary_color ?? "#f5f5f4");
setBrandBgColor(s.brand_bg_color ?? "#fafaf9");
setBrandTextColor(s.brand_text_color ?? "#1c1917");
setCollectSalesTax(s.collect_sales_tax ?? false);
setNexusStates(s.nexus_states ?? ["CO"]);
setActiveBrandName(s.brand_name ?? "");
} else {
setLegalBusinessName("");
setPhone("");
setEmail("");
setWebsiteUrl("");
setStreetAddress("");
setCity("");
setState("");
setPostalCode("");
setCountry("US");
setLogoUrl("");
setLogoUrlDark("");
setOlatheSweetLogoUrl("");
setOlatheSweetLogoUrlDark("");
setDefaultEmailSignature("");
setInvoiceFooterNotes("");
setHeroTagline("");
setAboutHeadline("");
setAboutSubheadline("");
setCustomFooterText("");
setShowWholesaleLink(true);
setShowZipSearch(true);
setShowSchedulePdf(true);
setShowTextAlerts(false);
setSchedulePdfNotes("");
setHeroImageUrl("");
setBrandPrimaryColor("#16a34a");
setBrandSecondaryColor("#f5f5f4");
setBrandBgColor("#fafaf9");
setBrandTextColor("#1c1917");
setCollectSalesTax(false);
setNexusStates(["CO"]);
}
});
}, [activeBrandId, isPlatformAdmin]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setSaved(false);
const result = await saveBrandSettings({
brandId: activeBrandId,
legalBusinessName: legalBusinessName || undefined,
phone: phone || undefined,
email: email || undefined,
websiteUrl: websiteUrl || undefined,
streetAddress: streetAddress || undefined,
city: city || undefined,
state: state || undefined,
postalCode: postalCode || undefined,
country: country || undefined,
logoUrl: logoUrl || undefined,
logoUrlDark: logoUrlDark || undefined,
olatheSweetLogoUrl: olatheSweetLogoUrl || undefined,
olatheSweetLogoUrlDark: olatheSweetLogoUrlDark || undefined,
defaultEmailSignature: defaultEmailSignature || undefined,
invoiceFooterNotes: invoiceFooterNotes || undefined,
heroTagline: heroTagline || undefined,
aboutHeadline: aboutHeadline || undefined,
aboutSubheadline: aboutSubheadline || undefined,
customFooterText: customFooterText || undefined,
showWholesaleLink: showWholesaleLink,
showZipSearch: showZipSearch,
showSchedulePdf: showSchedulePdf,
showTextAlerts: showTextAlerts,
schedulePdfNotes: schedulePdfNotes || undefined,
heroImageUrl: heroImageUrl || undefined,
brandPrimaryColor: brandPrimaryColor || undefined,
brandSecondaryColor: brandSecondaryColor || undefined,
brandBgColor: brandBgColor || undefined,
brandTextColor: brandTextColor || undefined,
collectSalesTax,
nexusStates,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
} else {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
}
return (
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
<select
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-emerald-500"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
<p className="mt-1 text-xs text-zinc-500">
Viewing <strong className="text-zinc-300">{activeBrandName}</strong>
</p>
</div>
)}
{error && (
<div className="rounded-xl bg-red-950/50 border border-red-800 p-4 text-sm text-red-300">{error}</div>
)}
{saved && (
<div className="rounded-xl bg-green-950/50 border border-green-800 p-4 text-sm text-green-300">
Brand settings saved.
</div>
)}
{/* Company Info */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Company Information</h3>
<p className="mt-1 text-sm text-zinc-400">
Primary contact and business details for this brand.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Legal Business Name</label>
<input
type="text"
value={legalBusinessName}
onChange={(e) => setLegalBusinessName(e.target.value)}
placeholder="Tuxedo Corn LLC"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Phone</label>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="(772) 555-0100"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="orders@tuxedocorn.com"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Website</label>
<input
type="url"
value={websiteUrl}
onChange={(e) => setWebsiteUrl(e.target.value)}
placeholder="https://tuxedocorn.com"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
</div>
</div>
{/* Address */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Business Address</h3>
<p className="mt-1 text-sm text-zinc-400">
Used on invoices and shipping labels.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Street Address</label>
<input
type="text"
value={streetAddress}
onChange={(e) => setStreetAddress(e.target.value)}
placeholder="1234 Farm Road"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-300">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Stuart"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">State</label>
<input
type="text"
value={state}
onChange={(e) => setState(e.target.value)}
placeholder="FL"
maxLength={2}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm uppercase text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">ZIP / Postal Code</label>
<input
type="text"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
placeholder="34994"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Country</label>
<select
value={country}
onChange={(e) => setCountry(e.target.value)}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
</div>
</div>
</div>
{/* Brand Logos */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Brand Logos</h3>
<p className="mt-1 text-sm text-zinc-400">
Upload your brand logos to Supabase Storage. Recommended: 1200×400px max, 5MB max (ideally under 2MB for faster loading).
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Primary Logo */}
<LogoUploadField
label="Primary Brand Logo"
hint="Light backgrounds — invoices, email headers, storefront header"
value={logoUrl}
previewBg="bg-zinc-800"
brandId={activeBrandId}
onUpload={(url) => setLogoUrl(url)}
/>
{/* Dark Logo */}
<LogoUploadField
label="Dark Background Logo"
hint="Dark backgrounds — storefront nav on dark header"
value={logoUrlDark}
previewBg="bg-zinc-900"
brandId={activeBrandId}
onUpload={(url) => setLogoUrlDark(url)}
isDark
/>
</div>
{/* Olathe Sweet Logos — two variants */}
<div className="mt-2">
<p className="text-sm font-semibold text-zinc-200 mb-4">Olathe Sweet Logo</p>
<p className="text-xs text-zinc-400 mb-4">
The mountain &amp; wagon wheel mark use the light version on dark backgrounds, dark/white version on light backgrounds. Shown in hero, About page, and product cards.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<LogoUploadField
label="Olathe Sweet™ — Dark/White"
hint="Works on light backgrounds (stone, white)"
value={olatheSweetLogoUrl}
previewBg="bg-zinc-700"
brandId={activeBrandId}
onUpload={(url) => setOlatheSweetLogoUrl(url)}
isOlatheSweetLight
/>
<LogoUploadField
label="Olathe Sweet™ — Transparent"
hint="Works on dark backgrounds (hero, dark sections)"
value={olatheSweetLogoUrlDark}
previewBg="bg-zinc-900"
brandId={activeBrandId}
onUpload={(url) => setOlatheSweetLogoUrlDark(url)}
isOlatheSweetDark
/>
</div>
</div>
</div>
{/* Email & Invoice branding */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Email & Invoice Branding</h3>
<p className="mt-1 text-sm text-zinc-400">
Used in email signatures, invoice footers, and customer-facing documents.
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">
Default Email Signature
</label>
<textarea
value={defaultEmailSignature}
onChange={(e) => setDefaultEmailSignature(e.target.value)}
rows={3}
placeholder="The Tuxedo Corn Team&#10;orders@tuxedocorn.com | (772) 555-0100&#10;Fresh from Florida since 1987"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">
Appears at the bottom of all outbound emails. Supports line breaks.
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">
Invoice Footer Notes
</label>
<textarea
value={invoiceFooterNotes}
onChange={(e) => setInvoiceFooterNotes(e.target.value)}
rows={2}
placeholder="Thank you for your order! Pickup available at our farm stand.&#10;Questions? Call (772) 555-0100 or email orders@tuxedocorn.com"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">
Printed at the bottom of invoices and order confirmations.
</p>
</div>
</div>
{/* Storefront Customization */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Storefront Customization</h3>
<p className="mt-1 text-sm text-zinc-400">
Controls what appears on your public storefront homepage.
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Hero Tagline</label>
<input
type="text"
value={heroTagline}
onChange={(e) => setHeroTagline(e.target.value)}
placeholder="Fresh citrus delivered to stops near you."
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">Subtitle below the brand name in the hero section.</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Hero Image URL</label>
<input
type="url"
value={heroImageUrl}
onChange={(e) => setHeroImageUrl(e.target.value)}
placeholder="https://cdn.example.com/hero-corn-field.jpg"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">
Full-width hero background image. Recommended: 1600×600px, under 1MB. Paste a public image URL.
For Tuxedo Corn: corn field photo. For IRD: citrus orchard.
</p>
{heroImageUrl && (
<div className="relative mt-2 rounded-lg overflow-hidden border border-zinc-700 h-32">
<NextImage src={heroImageUrl} alt="Hero preview" fill style={{ objectFit: "cover" }} />
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">About Page Headline</label>
<input
type="text"
value={aboutHeadline}
onChange={(e) => setAboutHeadline(e.target.value)}
placeholder="Our Story"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">About Page Subheadline</label>
<input
type="text"
value={aboutSubheadline}
onChange={(e) => setAboutSubheadline(e.target.value)}
placeholder="Growing citrus in the Indian River region since 1985."
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Custom Footer Text</label>
<textarea
value={customFooterText}
onChange={(e) => setCustomFooterText(e.target.value)}
rows={2}
placeholder="Fresh from Florida — Ships Sept through May"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">Appears above the copyright line in the brand footer.</p>
</div>
</div>
{/* Brand Colors */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Brand Colors</h3>
<p className="mt-1 text-sm text-zinc-400">
Customize your storefront accent and background colors. Use hex codes (e.g. #e11d48).
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Primary Accent</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={brandPrimaryColor}
onChange={(e) => setBrandPrimaryColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
type="text"
value={brandPrimaryColor}
onChange={(e) => setBrandPrimaryColor(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
placeholder="#16a34a"
/>
</div>
<p className="mt-1 text-xs text-zinc-500">Buttons, badges, links</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Text Color</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={brandTextColor}
onChange={(e) => setBrandTextColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
type="text"
value={brandTextColor}
onChange={(e) => setBrandTextColor(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
placeholder="#1c1917"
/>
</div>
<p className="mt-1 text-xs text-zinc-500">Headlines, body text</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Background Color</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={brandBgColor}
onChange={(e) => setBrandBgColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
type="text"
value={brandBgColor}
onChange={(e) => setBrandBgColor(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
placeholder="#fafaf9"
/>
</div>
<p className="mt-1 text-xs text-zinc-500">Page background (light mode)</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Secondary Accent</label>
<div className="flex gap-2 items-center">
<input
type="color"
value={brandSecondaryColor}
onChange={(e) => setBrandSecondaryColor(e.target.value)}
className="w-10 h-10 rounded-lg border border-zinc-600 cursor-pointer"
/>
<input
type="text"
value={brandSecondaryColor}
onChange={(e) => setBrandSecondaryColor(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 font-mono"
placeholder="#f5f5f4"
/>
</div>
<p className="mt-1 text-xs text-zinc-500">Hover states, highlights</p>
</div>
</div>
<div className="mt-3 p-4 rounded-xl bg-zinc-800 border border-zinc-700">
<p className="text-xs text-zinc-400 mb-2 font-medium">Preview</p>
<div className="flex gap-3 items-center flex-wrap">
<div className="flex gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold" style={{ backgroundColor: brandPrimaryColor }}>
A
</div>
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold border border-zinc-600" style={{ backgroundColor: brandBgColor, color: brandTextColor }}>
A
</div>
<div className="w-8 h-8 rounded-lg border border-zinc-600" style={{ backgroundColor: brandSecondaryColor }} />
</div>
<div className="h-8 w-px bg-zinc-600" />
<div className="flex gap-1.5">
<div className="px-3 py-1.5 rounded-lg text-white text-xs font-semibold" style={{ backgroundColor: brandPrimaryColor }}>
Button
</div>
<div className="px-3 py-1.5 rounded-lg text-xs font-semibold border" style={{ borderColor: brandPrimaryColor, color: brandPrimaryColor }}>
Outline
</div>
<div className="px-3 py-1.5 rounded-lg text-xs font-semibold" style={{ backgroundColor: brandSecondaryColor, color: brandTextColor }}>
Secondary
</div>
</div>
</div>
</div>
</div>
{/* Tax Settings */}
<div className="space-y-5 rounded-xl border border-amber-700 bg-amber-950/30 p-5">
<div>
<h3 className="font-semibold text-amber-200">Tax Settings</h3>
<p className="mt-1 text-sm text-amber-300/70">
Configure automatic sales tax collection via Stripe Tax. Tax is only calculated for ship orders shipping to your nexus states.
</p>
</div>
<FeatureToggle
label="Collect Sales Tax"
description="Automatically calculate and collect sales tax for ship orders via Stripe Tax."
checked={collectSalesTax}
onChange={setCollectSalesTax}
/>
{collectSalesTax && (
<div>
<label className="block text-sm font-medium text-amber-200 mb-1">
Nexus States
</label>
<p className="mt-1 text-xs text-amber-300/60 mb-2">
States where you have tax nexus (physical presence). Stripe Tax will calculate tax for ship orders to these states.
</p>
<NexusStateInput
value={nexusStates}
onChange={setNexusStates}
/>
</div>
)}
</div>
{/* Feature Toggles */}
<div className="space-y-5 rounded-xl border border-zinc-700 p-5">
<div>
<h3 className="font-semibold text-zinc-100">Feature Toggles</h3>
<p className="mt-1 text-sm text-zinc-400">
Show or hide features on your public storefront.
</p>
</div>
<div className="space-y-4">
<FeatureToggle
label="Wholesale Portal link"
description="Show the green Wholesale Portal banner on the homepage and nav link."
checked={showWholesaleLink}
onChange={setShowWholesaleLink}
/>
<FeatureToggle
label="Zip Code Search"
description="Show the ZIP code search box on the IRD homepage."
checked={showZipSearch}
onChange={setShowZipSearch}
/>
<FeatureToggle
label="Schedule PDF Download"
description="Show the 'Download Schedule PDF' button on the stops section."
checked={showSchedulePdf}
onChange={setShowSchedulePdf}
/>
<FeatureToggle
label="Text Alerts Signup"
description="Show a text alert signup section on the homepage."
checked={showTextAlerts}
onChange={setShowTextAlerts}
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Schedule PDF Footer Notes</label>
<textarea
value={schedulePdfNotes}
onChange={(e) => setSchedulePdfNotes(e.target.value)}
rows={1}
placeholder="All orders prepaid. No refunds on unpicked orders."
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">Printed at the bottom of the generated schedule PDF.</p>
</div>
</div>
{loading ? (
<div className="flex items-center gap-3 text-zinc-400">
<div className="h-5 w-5 rounded-full border-2 border-zinc-500 border-t-transparent animate-spin" />
Loading settings...
</div>
) : (
<button
type="submit"
disabled={saving}
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white hover:bg-blue-900/300 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Brand Settings"}
</button>
)}
</form>
);
}
// ── Feature Toggle Component ─────────────────────────────────────────────────
function FeatureToggle({
label,
description,
checked,
onChange,
}: {
label: string;
description: string;
checked: boolean;
onChange: (val: boolean) => void;
}) {
return (
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors ${
checked ? "bg-green-600" : "bg-zinc-600"
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-zinc-900 transition-transform ${
checked ? "translate-x-4" : "translate-x-1"
}`}
/>
</button>
<div>
<p className="text-sm font-medium text-zinc-200">{label}</p>
<p className="text-xs text-zinc-400">{description}</p>
</div>
</div>
);
}
// ── Nexus States Input Component ──────────────────────────────────────────────
const US_STATES = [
"AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA",
"HI","ID","IL","IN","IA","KS","KY","LA","ME","MD",
"MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ",
"NM","NY","NC","ND","OH","OK","OR","PA","RI","SC",
"SD","TN","TX","UT","VT","VA","WA","WV","WI","WY",
];
function NexusStateInput({
value,
onChange,
}: {
value: string[];
onChange: (v: string[]) => void;
}) {
const [input, setInput] = useState("");
function addState(code: string) {
const upper = code.toUpperCase().trim();
if (US_STATES.includes(upper) && !value.includes(upper)) {
onChange([...value, upper]);
}
setInput("");
}
function removeState(code: string) {
onChange(value.filter((s) => s !== code));
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
if (input.trim()) addState(input);
}
}
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{value.map((code) => (
<span
key={code}
className="inline-flex items-center gap-1 rounded-lg bg-amber-600 text-amber-050 px-2 py-1 text-xs font-semibold"
>
{code}
<button
type="button"
onClick={() => removeState(code)}
className="hover:text-amber-200"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type state code (e.g. CO) and press Enter"
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 uppercase"
maxLength={2}
/>
<div className="relative">
<select
value=""
onChange={(e) => { if (e.target.value) addState(e.target.value); }}
className="h-[42px] rounded-xl border border-zinc-600 bg-zinc-800 px-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
>
<option value="">Add state</option>
{US_STATES.filter((s) => !value.includes(s)).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
</div>
);
}
// ── Logo Upload Component ────────────────────────────────────────────────────
type LogoUploadFieldProps = {
label: string;
hint: string;
value: string;
previewBg: string;
brandId: string;
onUpload: (url: string) => void;
isDark?: boolean;
isOlatheSweetLight?: boolean;
isOlatheSweetDark?: boolean;
};
export function LogoUploadField({
label,
hint,
value,
previewBg,
brandId,
onUpload,
isDark = false,
isOlatheSweetLight = false,
isOlatheSweetDark = false,
}: LogoUploadFieldProps) {
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFile(file: File) {
setError(null);
setUploading(true);
let result;
if (isOlatheSweetLight) {
result = await uploadOlatheSweetLogo(brandId, file);
} else if (isOlatheSweetDark) {
result = await uploadOlatheSweetLogoDark(brandId, file);
} else {
result = await uploadBrandLogo(brandId, file, isDark);
}
setUploading(false);
if (result.success) {
onUpload(result.logoUrl);
} else {
setError(result.error ?? "Upload failed");
}
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) handleFile(file);
}
return (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">{label}</label>
<p
className="text-xs text-zinc-500 mb-2"
dangerouslySetInnerHTML={{ __html: hint }}
/>
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`
relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${dragOver ? "border-emerald-500 bg-emerald-950/30" : "border-zinc-600 hover:border-zinc-500 hover:bg-zinc-800/50"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-zinc-500 border-t-transparent animate-spin" />
<span className="text-sm text-zinc-400">Uploading...</span>
</>
) : value ? (
<>
<span className="relative inline-block h-10 w-auto">
<NextImage src={value} alt={label} fill style={{ objectFit: "contain" }} />
</span>
<span className="text-xs text-zinc-400">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-zinc-500" 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-zinc-400">Drag & drop or click to upload</span>
<span className="text-xs text-zinc-500">PNG, JPEG, WebP · 1200×400px max · max 5MB (ideally under 2MB)</span>
</>
)}
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={handleChange}
/>
</div>
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
{value && (
<div className={`mt-3 rounded-lg p-3 text-center ${previewBg}`}>
<p className="text-xs text-zinc-400 mb-2">Preview</p>
<span className="relative inline-block h-10 w-auto">
<NextImage src={value} alt={label} fill style={{ objectFit: "contain" }} className="mx-auto" />
</span>
</div>
)}
</div>
);
}
+498
View File
@@ -0,0 +1,498 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import type { Campaign, CampaignType, CampaignStatus, AudienceRules } from "@/actions/communications/campaigns";
import { formatDate } from "@/lib/format-date";
import type { Template } from "@/actions/communications/templates";
import type { AudiencePreviewResult } from "@/actions/communications/send";
import { previewCampaignAudience, sendCampaign } from "@/actions/communications/send";
import { upsertCampaign, deleteCampaign } from "@/actions/communications/campaigns";
import { getCommunicationTemplates } from "@/actions/communications/templates";
import { getCommunicationSegments, type Segment } from "@/actions/communications/segments";
const BRAND_ID = process.env.NEXT_PUBLIC_TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
{ value: "operational", label: "Operational" },
{ value: "marketing", label: "Marketing" },
{ value: "transactional", label: "Transactional" },
];
const STATUS_COLORS: Record<CampaignStatus, string> = {
draft: "bg-zinc-950 text-zinc-300",
scheduled: "bg-blue-900/40 text-blue-700",
sending: "bg-yellow-100 text-yellow-700",
sent: "bg-green-900/40 text-green-400",
canceled: "bg-red-900/40 text-red-400",
};
export default function CampaignListPanel({ initialCampaigns }: { initialCampaigns: Campaign[] }) {
const router = useRouter();
const [campaigns, setCampaigns] = useState(initialCampaigns);
const [filterType, setFilterType] = useState<CampaignType | "all">("all");
const [filterStatus, setFilterStatus] = useState<CampaignStatus | "all">("all");
const filtered = campaigns.filter((c) => {
if (filterType !== "all" && c.campaign_type !== filterType) return false;
if (filterStatus !== "all" && c.status !== filterStatus) return false;
return true;
});
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Campaigns</h2>
<p className="text-sm text-zinc-500">{filtered.length} campaign{filtered.length !== 1 ? "s" : ""}</p>
</div>
<a
href="/admin/communications/campaigns/new"
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
+ New Campaign
</a>
</div>
<div className="flex gap-4 mb-4">
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
value={filterType}
onChange={(e) => setFilterType(e.target.value as CampaignType | "all")}
>
<option value="all">All Types</option>
{CAMPAIGN_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as CampaignStatus | "all")}
>
<option value="all">All Statuses</option>
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
<option value="sent">Sent</option>
<option value="canceled">Canceled</option>
</select>
</div>
{filtered.length === 0 ? (
<div className="text-center py-12 text-slate-400">No campaigns found</div>
) : (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Type</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Status</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Created</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Sent</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((c) => (
<tr key={c.id} className="hover:bg-zinc-800 cursor-pointer" onClick={() => router.push(`/admin/communications/campaigns/${c.id}`)}>
<td className="px-4 py-3 font-medium text-zinc-100">{c.name}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
c.campaign_type === "operational" ? "bg-purple-100 text-purple-700" :
c.campaign_type === "marketing" ? "bg-orange-100 text-orange-700" :
"bg-zinc-950 text-zinc-400"
}`}>
{c.campaign_type}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[c.status]}`}>
{c.status}
</span>
</td>
<td className="px-4 py-3 text-zinc-500">{formatDate(new Date(c.created_at))}</td>
<td className="px-4 py-3 text-zinc-500">{c.sent_at ? formatDate(new Date(c.sent_at)) : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ── Campaign Edit Panel ──────────────────────────────────────────────────────
export function CampaignEditPanel({
campaign,
templates,
mode,
brandId,
}: {
campaign?: Campaign;
templates: Template[];
mode: "edit" | "new";
brandId: string;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [sending, setSending] = useState(false);
const [name, setName] = useState(campaign?.name ?? "");
const [subject, setSubject] = useState(campaign?.subject ?? "");
const [bodyText, setBodyText] = useState(campaign?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState(campaign?.body_html ?? "");
const [templateId, setTemplateId] = useState(campaign?.template_id ?? "");
const [campaignType, setCampaignType] = useState<CampaignType>(campaign?.campaign_type ?? "operational");
const [audienceTarget, setAudienceTarget] = useState<string>(campaign?.audience_rules?.target ?? "stop");
const [stopId, setStopId] = useState(campaign?.audience_rules?.stop_id ?? "");
const [dateFrom, setDateFrom] = useState(campaign?.audience_rules?.date_from ?? "");
const [dateTo, setDateTo] = useState(campaign?.audience_rules?.date_to ?? "");
const [scheduleMode, setScheduleMode] = useState<"now" | "later">(
campaign?.scheduled_at ? "later" : "now"
);
const [scheduledAt, setScheduledAt] = useState(
campaign?.scheduled_at
? new Date(campaign.scheduled_at).toISOString().slice(0, 16)
: ""
);
const [segments, setSegments] = useState<Segment[]>([]);
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
const [preview, setPreview] = useState<AudiencePreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [error, setError] = useState("");
// Load saved segments
useEffect(() => {
getCommunicationSegments(brandId).then((result) => {
if (result.success) setSegments(result.segments);
});
}, []);
// When a segment is selected, populate audience fields
function applySegment(segmentId: string) {
setSelectedSegmentId(segmentId);
if (!segmentId) return;
const seg = segments.find((s) => s.id === segmentId);
if (!seg) return;
const rules = seg.rules;
if (rules.target) setAudienceTarget(rules.target);
if (rules.stop_id) setStopId(rules.stop_id);
if (rules.date_from) setDateFrom(rules.date_from);
if (rules.date_to) setDateTo(rules.date_to);
}
const loadPreview = async () => {
setPreviewLoading(true);
const rules: AudienceRules = {
target: audienceTarget as AudienceRules["target"],
stop_id: stopId || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
};
const result = await previewCampaignAudience(brandId, rules);
setPreview(result);
setPreviewLoading(false);
};
const applyTemplate = (t: Template) => {
setSubject(t.subject);
setBodyText(t.body_text);
setBodyHtml(t.body_html ?? "");
setCampaignType((t.campaign_type as CampaignType) ?? "operational");
};
const handleSave = async () => {
setSaving(true);
setError("");
const rules: AudienceRules = {
target: audienceTarget as AudienceRules["target"],
stop_id: stopId || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
};
const effectiveScheduledAt =
scheduleMode === "later" && scheduledAt
? new Date(scheduledAt).toISOString()
: null;
const result = await upsertCampaign({
id: campaign?.id,
brand_id: brandId,
name,
subject,
body_text: bodyText,
body_html: bodyHtml || undefined,
template_id: templateId || undefined,
campaign_type: campaignType,
status: scheduleMode === "later" ? "scheduled" : (campaign?.status ?? "draft"),
audience_rules: rules,
scheduled_at: effectiveScheduledAt ?? undefined,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
router.push("/admin/communications");
router.refresh();
};
const handleSend = async () => {
setSending(true);
setError("");
if (!campaign?.id) {
setError("Save as draft first");
setSending(false);
return;
}
const result = await sendCampaign(campaign.id, brandId);
setSending(false);
if (!result.success) {
setError(result.error ?? "Failed to send");
return;
}
router.push("/admin/communications");
router.refresh();
};
return (
<div className="max-w-3xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-zinc-100">
{mode === "new" ? "New Campaign" : "Edit Campaign"}
</h2>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
)}
<div className="space-y-6">
{/* Basic info */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="e.g. Tuxedo Stop 5/15 Reminder"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Type *</label>
<select
value={campaignType}
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
>
{CAMPAIGN_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Template</label>
<select
value={templateId}
onChange={(e) => {
setTemplateId(e.target.value);
const t = templates.find((t) => t.id === e.target.value);
if (t) applyTemplate(t);
}}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
>
<option value="">No template</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name} ({t.template_type})</option>
))}
</select>
</div>
</div>
</div>
{/* Audience builder */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Audience</h3>
{segments.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Saved segment:</span>
<select
value={selectedSegmentId}
onChange={(e) => applySegment(e.target.value)}
className="text-sm border border-zinc-600 rounded-lg px-3 py-1.5"
>
<option value=""> None </option>
{segments.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Target By</label>
<select
value={audienceTarget}
onChange={(e) => {
setAudienceTarget(e.target.value);
setSelectedSegmentId("");
}}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
>
<option value="stop">Stop / Date Range</option>
<option value="zip_code">ZIP Code</option>
<option value="customer_history">Customer History</option>
<option value="all_customers">All Customers</option>
</select>
</div>
{audienceTarget === "stop" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Stop ID</label>
<input
type="text"
value={stopId}
onChange={(e) => setStopId(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="UUID"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">From</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">To</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
/>
</div>
</div>
)}
<button
type="button"
onClick={loadPreview}
disabled={previewLoading}
className="text-sm text-blue-400 hover:text-blue-700 font-medium"
>
{previewLoading ? "Counting..." : "Preview audience count"}
</button>
{preview && (
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm">
<span className="font-semibold text-blue-700">{preview.count}</span> customers match this audience
{preview.sample_customers.length > 0 && (
<div className="mt-2 text-xs text-blue-400">
Sample: {preview.sample_customers.slice(0, 5).map((c) => c.email).join(", ")}
</div>
)}
</div>
)}
</div>
{/* Message content */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<h3 className="text-sm font-semibold text-slate-800">Message Content</h3>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Subject</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Email subject line"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Body (Plain Text)</label>
<textarea
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={6}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Message content..."
/>
</div>
</div>
{/* Scheduling */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<h3 className="text-sm font-semibold text-slate-800">Scheduling</h3>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={scheduleMode === "now"}
onChange={() => setScheduleMode("now")}
className="text-blue-400"
/>
<span className="text-zinc-300">Send immediately</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
checked={scheduleMode === "later"}
onChange={() => setScheduleMode("later")}
className="text-blue-400"
/>
<span className="text-zinc-300">Schedule for later</span>
</label>
</div>
{scheduleMode === "later" && (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Send date & time</label>
<input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="border border-zinc-600 rounded-lg px-3 py-2 text-sm"
min={new Date().toISOString().slice(0, 16)}
/>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
onClick={handleSave}
disabled={saving || !name || (scheduleMode === "later" && !scheduledAt)}
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : scheduleMode === "later" ? "Schedule Campaign" : "Save Draft"}
</button>
{campaign?.id && campaign.status === "draft" && scheduleMode === "now" && (
<button
type="button"
onClick={handleSend}
disabled={sending}
className="inline-flex items-center rounded-lg bg-green-600 px-5 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{sending ? "Sending..." : "Send Campaign"}
</button>
)}
{campaign?.id && scheduleMode === "later" && (
<span className="text-sm text-blue-400 font-medium">
Scheduled for {scheduledAt ? new Date(scheduledAt).toLocaleString() : "—"}
</span>
)}
<a href="/admin/communications" className="text-sm text-zinc-500 hover:text-zinc-300 ml-4">
Cancel
</a>
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
import { useCart } from "@/context/CartContext";
type Props = { userId: string };
export default function CartHydration({ userId }: Props) {
const { loadServerCart } = useCart();
useEffect(() => {
if (!userId) return;
loadServerCart(userId).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]);
return null;
}
@@ -0,0 +1,621 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
getPlatformMetrics,
getPlatformActivityFeed,
getBrandHealthSnapshot,
getPainLog,
resolvePainLogItem,
createPainLogItem,
type PlatformMetrics,
type ActivityEvent,
type BrandHealth,
type PainLogItem,
} from "@/actions/platform/command-center";
// ── Constants ─────────────────────────────────────────────────────────────────
const REFRESH_INTERVAL = 30000;
const EVENT_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
order_placed: { bg: "bg-violet-500/20", text: "text-violet-300", dot: "bg-violet-400" },
order_completed: { bg: "bg-emerald-500/20", text: "text-emerald-300", dot: "bg-emerald-400" },
pickup_completed: { bg: "bg-blue-900/300/20", text: "text-blue-300", dot: "bg-blue-400" },
payment_failed: { bg: "bg-red-900/300/20", text: "text-red-300", dot: "bg-red-400" },
stop_created: { bg: "bg-amber-900/300/20", text: "text-amber-300", dot: "bg-amber-400" },
route_updated: { bg: "bg-cyan-500/20", text: "text-cyan-300", dot: "bg-cyan-400" },
default: { bg: "bg-zinc-900/10", text: "text-white/60", dot: "bg-zinc-900/40" },
};
const SEVERITY_CONFIG: Record<string, { bg: string; text: string; border: string }> = {
critical: { bg: "bg-red-900/300/15", text: "text-red-300", border: "border-red-500/30" },
high: { bg: "bg-orange-500/15",text: "text-orange-300", border: "border-orange-500/30" },
medium: { bg: "bg-amber-900/300/15",text: "text-amber-300", border: "border-amber-500/30" },
low: { bg: "bg-zinc-900/8", text: "text-white/60", border: "border-white/10" },
};
const HEALTH_CONFIG: Record<string, { label: string; bg: string; text: string; border: string; dot: string }> = {
healthy: { label: "Operational", bg: "bg-emerald-500/15", text: "text-emerald-300", border: "border-emerald-500/25", dot: "bg-emerald-400" },
warning: { label: "Attention", bg: "bg-amber-900/300/15", text: "text-amber-300", border: "border-amber-500/25", dot: "bg-amber-400" },
critical: { label: "Critical", bg: "bg-red-900/300/15", text: "text-red-300", border: "border-red-500/25", dot: "bg-red-400" },
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatCurrency(n: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency", currency: "USD",
minimumFractionDigits: 0, maximumFractionDigits: 0,
}).format(n);
}
function formatCurrencyFull(n: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency", currency: "USD",
minimumFractionDigits: 2, maximumFractionDigits: 2,
}).format(n);
}
function timeAgo(dateStr: string): string {
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400)return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
// ── Metric Card ─────────────────────────────────────────────────────────────────
function MetricCard({
label,
value,
sub,
prefix,
suffix,
status,
delay = 0,
tv = false,
}: {
label: string;
value: string | number;
sub?: string;
prefix?: string;
suffix?: string;
status?: "healthy" | "warning" | "critical";
delay?: number;
tv?: boolean;
}) {
const valueColor = {
healthy: "text-white",
warning: "text-amber-300",
critical: "text-red-300",
}[status ?? "healthy"]!;
return (
<div
className={`
rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-5
${status === "critical" ? "border-red-500/20" : ""}
`}
>
<p className={`text-[10px] font-semibold uppercase tracking-[0.18em] text-white/25`}>{label}</p>
<div className="mt-3 flex items-baseline gap-2">
{prefix && <span className="text-2xl text-white/25">{prefix}</span>}
<span className={`${tv ? "text-5xl" : "text-4xl"} font-black tracking-tighter leading-none ${valueColor}`}>
{typeof value === "number" ? value.toLocaleString() : value}
</span>
{suffix && <span className="text-2xl text-white/25">{suffix}</span>}
</div>
{sub && <p className={`text-xs mt-2 text-white/20`}>{sub}</p>}
</div>
);
}
// ── Brand Health Card ─────────────────────────────────────────────────────────
function BrandCard({ brand, tv = false }: { brand: BrandHealth; tv?: boolean }) {
const c = HEALTH_CONFIG[brand.health_status as keyof typeof HEALTH_CONFIG] ?? HEALTH_CONFIG.warning;
return (
<div className={`rounded-2xl border ${c.border} ${c.bg} p-6`}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className={`font-bold text-white truncate leading-tight ${tv ? "text-2xl" : "text-lg"}`}>{brand.brand_name}</p>
<p className={`text-sm text-white/20 mt-1`}>{brand.brand_slug}</p>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<span className={`rounded-full border px-3 py-1 text-xs font-bold uppercase tracking-wider ${c.bg} ${c.text} ${c.border}`}>
{c.label}
</span>
<div className={`h-2 w-2 rounded-full ${c.dot}`} />
</div>
</div>
<div className={`mt-6 grid grid-cols-3 gap-6`}>
{[
{ label: "Orders", value: brand.orders_today },
{ label: "Revenue", value: formatCurrency(Number(brand.revenue_today)) },
{ label: "Routes", value: brand.active_stops },
].map(({ label, value }) => (
<div key={label} className="text-center">
<p className={`text-xs font-medium text-white/20`}>{label}</p>
<p className={`${tv ? "text-2xl" : "text-xl"} font-black text-white mt-1 leading-none`}>{value}</p>
</div>
))}
</div>
{brand.open_pain_items > 0 && (
<div className="mt-4 flex items-center gap-2 rounded-xl bg-red-900/300/10 border border-red-500/20 px-3 py-2.5">
<span className="h-2 w-2 rounded-full bg-red-400" />
<span className={`text-sm font-semibold text-red-300`}>
{brand.open_pain_items} open issue{brand.open_pain_items !== 1 ? "s" : ""}
</span>
</div>
)}
</div>
);
}
// ── Activity Item ──────────────────────────────────────────────────────────────
function ActivityItem({ event }: { event: ActivityEvent }) {
const c = EVENT_COLORS[event.event_type] ?? EVENT_COLORS.default;
const label = event.event_type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase());
return (
<div className="flex items-start gap-3.5 py-3.5 border-b border-white/[0.03] last:border-0">
<div className="mt-1.5 flex-shrink-0">
<span className={`block h-2 w-2 rounded-full ${c.dot}`} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`rounded-md px-2 py-0.5 text-xs font-semibold ${c.bg} ${c.text}`}>
{label}
</span>
{event.brand_name && (
<span className={`text-xs font-medium text-white/25`}>{event.brand_name}</span>
)}
</div>
<div className="mt-1 flex items-center gap-3">
<p className={`text-sm text-white/40 truncate`}>
{event.entity_type && <span>{event.entity_type}</span>}
{event.entity_id && <span className="font-mono text-white/15 ml-1">#{event.entity_id.slice(0, 6)}</span>}
</p>
<span className={`text-xs text-white/15 shrink-0`}>
{event.created_at ? timeAgo(event.created_at) : ""}
</span>
</div>
</div>
</div>
);
}
// ── Pain Item ─────────────────────────────────────────────────────────────────
function PainItem({
item,
onResolve,
onSnooze,
}: {
item: PainLogItem;
onResolve: (id: string) => void;
onSnooze: (id: string) => void;
}) {
const c = SEVERITY_CONFIG[item.severity] ?? SEVERITY_CONFIG.low;
return (
<div className={`rounded-xl border ${c.border} ${c.bg} p-4`}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 flex-wrap min-w-0">
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-bold uppercase tracking-wider ${c.bg} ${c.text} ${c.border}`}>
{item.severity}
</span>
<span className={`text-sm font-semibold text-white/80 leading-tight`}>{item.title}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => onSnooze(item.id)}
className="rounded-lg bg-zinc-900/5 border border-white/10 px-2.5 py-1 text-xs font-medium text-white/35 hover:bg-zinc-900/10 hover:text-white/60 transition-all"
>
Snooze
</button>
<button
onClick={() => onResolve(item.id)}
className="rounded-lg bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 text-xs font-medium text-emerald-300 hover:bg-emerald-500/30 transition-all"
>
Done
</button>
</div>
</div>
<div className={`mt-2 flex items-center gap-2 text-xs text-white/25`}>
{item.brand_name && <span>{item.brand_name}</span>}
<span>·</span>
<span>{item.category}</span>
<span>·</span>
<span>{item.created_at ? timeAgo(item.created_at) + " ago" : ""}</span>
</div>
</div>
);
}
// ── AI Briefing ────────────────────────────────────────────────────────────────
function AIBriefing({ metrics, brandHealth }: {
metrics: PlatformMetrics | null;
brandHealth: BrandHealth[];
}) {
const lines: string[] = [];
if (!metrics) return <div className="h-16 animate-pulse bg-zinc-900/[0.04] rounded-xl" />;
if (metrics.orders_today > 0) {
lines.push(`${metrics.orders_today} order${metrics.orders_today !== 1 ? "s" : ""} placed today.`);
}
if (Number(metrics.revenue_today) > 0) {
lines.push(`${formatCurrencyFull(Number(metrics.revenue_today))} in revenue processed.`);
}
if (metrics.failed_orders_today > 0) {
lines.push(`${metrics.failed_orders_today} failed order${metrics.failed_orders_today !== 1 ? "s" : ""} need attention.`);
}
if (metrics.pending_orders_today > 0) {
lines.push(`${metrics.pending_orders_today} order${metrics.pending_orders_today !== 1 ? "s" : ""} pending confirmation.`);
}
const crit = brandHealth.filter(b => b.health_status === "critical").length;
if (crit > 0) lines.push(`${crit} brand${crit !== 1 ? "s" : ""} in critical state.`);
const warn = brandHealth.filter(b => b.health_status === "warning").length;
if (warn > 0 && crit === 0) lines.push(`${warn} brand${warn !== 1 ? "s" : ""} need attention.`);
if (lines.length === 0) {
lines.push("Platform is running smoothly — no active issues.");
}
return (
<div className="space-y-2">
{lines.map((line, i) => (
<p key={i} className="text-sm text-white/50 leading-relaxed">
{line}
</p>
))}
</div>
);
}
// ── Main ─────────────────────────────────────────────────────────────────────
export default function CommandCenterDashboard() {
const [metrics, setMetrics] = useState<PlatformMetrics | null>(null);
const [activity, setActivity] = useState<ActivityEvent[]>([]);
const [brandHealth, setBrandHealth] = useState<BrandHealth[]>([]);
const [painLog, setPainLog] = useState<PainLogItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [resolving, setResolving] = useState<Set<string>>(new Set());
const [showAddForm, setShowAddForm] = useState(false);
const [tvMode, setTvMode] = useState(false);
const [addForm, setAddForm] = useState({ severity: "medium", category: "operations", title: "", description: "" });
const [snoozed, setSnoozed] = useState<Set<string>>(new Set());
const tv = tvMode;
const fetchAll = useCallback(async () => {
try {
const [m, a, b, p] = await Promise.all([
getPlatformMetrics(),
getPlatformActivityFeed(),
getBrandHealthSnapshot(),
getPainLog(),
]);
setMetrics(m);
setActivity(a);
setBrandHealth(b);
setPainLog(p);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
fetchAll();
const iv = setInterval(fetchAll, REFRESH_INTERVAL);
return () => clearInterval(iv);
}, [fetchAll]);
const handleResolve = async (id: string) => {
setResolving(prev => new Set([...prev, id]));
try {
const r = await resolvePainLogItem(id);
if (r.success) setPainLog(prev => prev.filter(i => i.id !== id));
} finally {
setResolving(prev => { const n = new Set(prev); n.delete(id); return n; });
}
};
const handleSnooze = (id: string) => {
setSnoozed(prev => new Set([...prev, id]));
setTimeout(() => {
setSnoozed(prev => { const n = new Set(prev); n.delete(id); return n; });
}, 3600000);
};
const handleAdd = async () => {
if (!addForm.title.trim()) return;
const r = await createPainLogItem({
severity: addForm.severity as "low" | "medium" | "high" | "critical",
category: addForm.category,
title: addForm.title,
description: addForm.description || null,
});
if (r.success) {
setAddForm({ severity: "medium", category: "operations", title: "", description: "" });
setShowAddForm(false);
fetchAll();
}
};
const openItems = painLog.filter(p => p.status === "open" && !snoozed.has(p.id));
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-6">
<div className="h-14 w-14 rounded-full border-2 border-white/[0.08] border-t-white/25 animate-spin" />
<p className="text-xs text-white/25 tracking-widest uppercase">Initializing</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center max-w-md">
<div className="mb-5 text-5xl"></div>
<h3 className="text-xl font-bold text-white">Connection Lost</h3>
<p className="text-sm text-white/35 mt-2">{error}</p>
<button
onClick={fetchAll}
className="mt-8 rounded-2xl bg-zinc-900/10 border border-white/20 px-8 py-3 text-sm font-semibold text-white hover:bg-zinc-900/20 transition-all"
>
Reconnect
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen">
{/* ── Header ─────────────────────────────────────────────────────────── */}
<div className="mb-10 flex items-center justify-between px-8">
<div>
<h1 className={`${tv ? "text-4xl" : "text-3xl"} font-black tracking-tight text-white`}>
Command Center
</h1>
<p className={`${tv ? "text-base" : "text-sm"} text-white/25 mt-2 tracking-wide`}>
{new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
{" · "}
<span className="text-emerald-400/50 font-semibold">LIVE</span>
<span className="ml-2 inline-block h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
</p>
</div>
<div className="flex items-center gap-4">
<button
onClick={fetchAll}
className="flex items-center gap-2.5 rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-2 text-sm text-white/35 hover:bg-zinc-900/10 hover:text-white/60 transition-all"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
<button
onClick={() => setTvMode(v => !v)}
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all ${
tv
? "bg-zinc-900/15 border border-white/25 text-white/80"
: "bg-zinc-900/5 border border-white/10 text-white/35 hover:text-white/60"
}`}
>
{tv ? "Exit TV" : "TV Mode"}
</button>
</div>
</div>
{/* ── KPI Row ──────────────────────────────────────────────────────────── */}
<div className={`grid ${tv ? "grid-cols-3" : "grid-cols-2 lg:grid-cols-6"} gap-5 px-8 mb-12`}>
<MetricCard label="Active Brands" value={metrics?.active_brands ?? 0} status="healthy" />
<MetricCard label="Orders Today" value={metrics?.orders_today ?? 0} status={metrics?.failed_orders_today ? "critical" : "healthy"} />
<MetricCard label="Revenue Today" value={formatCurrency(Number(metrics?.revenue_today ?? 0))} status="healthy" />
<MetricCard label="Active Routes" value={metrics?.active_routes ?? 0} status={(metrics?.pending_orders_today ?? 0) > 5 ? "warning" : "healthy"} />
<MetricCard label="Failed Orders" value={metrics?.failed_orders_today ?? 0} status={(metrics?.failed_orders_today ?? 0) > 0 ? "critical" : "healthy"} />
<MetricCard label="Pending Orders" value={metrics?.pending_orders_today ?? 0} status={(metrics?.pending_orders_today ?? 0) > 5 ? "warning" : "healthy"} />
</div>
{/* ── AI Briefing (full width) ──────────────────────────────────────── */}
<div className="px-8 mb-10">
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-8">
<div className="flex items-center gap-4 mb-6 pb-6 border-b border-white/[0.04]">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-violet-500/15 border border-violet-500/20">
<span className="text-lg">🤖</span>
</div>
<div>
<h2 className="text-xs font-bold uppercase tracking-[0.22em] text-white/40">Daily Briefing</h2>
<p className="text-xs text-white/20 mt-0.5">Platform intelligence summary</p>
</div>
</div>
<AIBriefing metrics={metrics} brandHealth={brandHealth} />
</div>
</div>
{/* ── 2-Column Body ───────────────────────────────────────────────────── */}
<div className={`grid gap-10 px-8 ${tv ? "grid-cols-12" : "grid-cols-1 lg:grid-cols-2"}`}>
{/* ── LEFT: Brand Health + Activity ───────────────────────────── */}
<div className="space-y-10">
{/* Brand Health */}
<div>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
Brand Health
</h2>
<span className="rounded-full bg-zinc-900/5 px-2.5 py-0.5 text-xs text-white/15">
{brandHealth.length}
</span>
</div>
<div className="space-y-5">
{brandHealth.length === 0 ? (
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center">
<p className="text-sm text-white/25">No active brands</p>
</div>
) : (
brandHealth.map((b) => (
<BrandCard key={b.brand_id} brand={b} tv={tv} />
))
)}
</div>
</div>
{/* Activity Feed */}
<div>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
Live Activity
</h2>
<span className="rounded-full bg-zinc-900/5 px-2.5 py-0.5 text-xs text-white/15">
{activity.length}
</span>
</div>
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl overflow-hidden">
<div className="max-h-[420px] overflow-y-auto p-5">
{activity.length === 0 ? (
<div className="py-12 text-center">
<p className="text-sm text-white/20">No recent events</p>
</div>
) : (
activity.slice(0, 30).map((e) => (
<ActivityItem key={e.id} event={e} />
))
)}
</div>
</div>
</div>
</div>
{/* ── RIGHT: Founder Queue ─────────────────────────────── */}
<div className="space-y-10">
<div>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25">
Founder Queue
</h2>
{openItems.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-amber-900/300/20 text-xs font-black text-amber-300">
{openItems.length}
</span>
)}
</div>
<button
onClick={() => setShowAddForm(v => !v)}
className="text-xs text-white/25 hover:text-white/50 transition-colors"
>
+ Add
</button>
</div>
{showAddForm && (
<div className="mb-5 space-y-2.5 rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-5">
<select
value={addForm.severity}
onChange={e => setAddForm(f => ({ ...f, severity: e.target.value }))}
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 outline-none focus:border-white/20"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
<input
value={addForm.title}
onChange={e => setAddForm(f => ({ ...f, title: e.target.value }))}
placeholder="Issue title..."
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
/>
<input
value={addForm.category}
onChange={e => setAddForm(f => ({ ...f, category: e.target.value }))}
placeholder="Category (e.g. deployment, payment)"
className="w-full rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
/>
<textarea
value={addForm.description}
onChange={e => setAddForm(f => ({ ...f, description: e.target.value }))}
placeholder="Description (optional)"
rows={2}
className="w-full resize-none rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-3 text-sm text-white/75 placeholder-white/15 outline-none focus:border-white/20"
/>
<div className="flex gap-2.5">
<button onClick={handleAdd} className="flex-1 rounded-xl bg-emerald-500/20 border border-emerald-500/30 px-4 py-2.5 text-sm font-semibold text-emerald-300 hover:bg-emerald-500/30 transition-all">
Log Issue
</button>
<button onClick={() => setShowAddForm(false)} className="flex-1 rounded-xl bg-zinc-900/5 border border-white/10 px-4 py-2.5 text-sm font-medium text-white/35 hover:bg-zinc-900/10 transition-all">
Cancel
</button>
</div>
</div>
)}
<div className="space-y-3">
{openItems.length === 0 ? (
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-10 text-center">
<p className="text-base font-semibold text-white/35">All clear</p>
<p className="text-sm text-white/20 mt-1.5">Add an issue to track it here.</p>
</div>
) : (
openItems.map((item) => (
<PainItem
key={item.id}
item={item}
onResolve={handleResolve}
onSnooze={handleSnooze}
/>
))
)}
</div>
</div>
{/* Quick Access */}
<div>
<h2 className="text-xs font-bold uppercase tracking-[0.28em] text-white/25 mb-5">
Quick Access
</h2>
<div className="rounded-2xl border border-white/[0.06] bg-black/70 backdrop-blur-2xl p-4 space-y-1">
{[
{ label: "Reports", href: "/admin/reports" },
{ label: "Orders", href: "/admin/orders" },
{ label: "Stops", href: "/admin/stops" },
{ label: "Settings", href: "/admin/settings" },
].map(link => (
<a
key={link.href}
href={link.href}
className="flex items-center justify-between rounded-xl px-4 py-3.5 text-sm text-white/30 hover:bg-zinc-900/5 hover:text-white/70 transition-all"
>
<span className="font-medium">{link.label}</span>
<span className="text-white/10"></span>
</a>
))}
</div>
</div>
</div>
</div>
<div className="h-24" />
</div>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { CommunicationSettings } from "@/actions/communications/settings";
import { upsertCommunicationSettings } from "@/actions/communications/settings";
const BRAND_ID = process.env.NEXT_PUBLIC_TUXEDO_BRAND_ID ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
export default function CommunicationSettingsForm({
settings,
brandId,
}: {
settings: CommunicationSettings | null;
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 handleSave = async () => {
setSaving(true);
setError("");
setSuccess(false);
const result = await upsertCommunicationSettings({
brand_id: brandId,
sender_email: senderEmail,
sender_name: senderName,
reply_to_email: replyTo,
provider: "resend",
footer_html: footerHtml,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setSuccess(true);
router.refresh();
};
return (
<div className="max-w-2xl">
<div className="mb-6">
<h2 className="text-xl font-semibold text-zinc-100">Sender Settings</h2>
<p className="text-sm text-zinc-500 mt-1">
Configure the default sender identity for this brand&apos;s email campaigns.
Provider (Resend) is configured via environment variables no credentials stored here.
</p>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
)}
{success && (
<div className="mb-4 rounded-lg bg-green-900/30 border border-green-200 px-4 py-3 text-sm text-green-400">
Settings saved successfully.
</div>
)}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Default Sender Email</label>
<input
type="email"
value={senderEmail}
onChange={(e) => setSenderEmail(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="orders@tuxedocorn.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Default Sender Name</label>
<input
type="text"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Route Commerce"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Reply-To Email</label>
<input
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="support@tuxedocorn.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Email Footer HTML (optional)</label>
<textarea
value={footerHtml}
onChange={(e) => setFooterHtml(e.target.value)}
rows={3}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
placeholder="<p>Unsubscribe: <a href='...'>link</a></p>"
/>
<p className="mt-1 text-xs text-slate-400">Include your unsubscribe link here. Add {`{unsubscribe_url}`} as placeholder.</p>
</div>
</div>
<div className="mt-4">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
const TABS = [
{ id: "campaigns", label: "Campaigns", href: "/admin/communications" },
{ id: "compose", label: "Compose", href: "/admin/communications/compose" },
{ id: "segments", label: "Segments", href: "/admin/communications/segments" },
{ id: "analytics", label: "Analytics", href: "/admin/communications/analytics" },
{ id: "templates", label: "Templates", href: "/admin/communications/templates" },
{ id: "contacts", label: "Contacts", href: "/admin/communications/contacts" },
{ id: "logs", label: "Message Logs", href: "/admin/communications/logs" },
{ id: "settings", label: "Settings", href: "/admin/communications/settings" },
];
export default function CommunicationsNav({
activeTab,
}: {
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "settings" | "segments" | "analytics" | "compose";
}) {
return (
<div className="border-b border-zinc-800 mb-6">
<nav className="flex gap-1 -mb-px">
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<a
key={tab.id}
href={tab.href}
className={`px-1 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
isActive
? "border-blue-600 text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-300 hover:border-zinc-600"
}`}
>
{tab.label}
</a>
);
})}
</nav>
</div>
);
}
export { TABS };
+115
View File
@@ -0,0 +1,115 @@
"use client";
import CommunicationsNav from "./CommunicationsNav";
import CampaignListPanel, { CampaignEditPanel } from "./CampaignListPanel";
import { TemplateListPanel, TemplateEditForm } from "./TemplateEditForm";
import MessageLogPanel from "./MessageLogPanel";
import CommunicationSettingsForm from "./CommunicationSettingsForm";
import ContactListPanel from "./ContactListPanel";
import ContactImportForm from "./ContactImportForm";
import SegmentBuilderPage from "@/components/admin/HarvestReach/SegmentBuilderPage";
import AnalyticsDashboard from "@/components/admin/HarvestReach/AnalyticsDashboard";
import CampaignComposerPage from "@/components/admin/HarvestReach/CampaignComposerPage";
import type { Campaign } from "@/actions/communications/campaigns";
import type { Template } from "@/actions/communications/templates";
import type { MessageLogEntry } from "@/actions/communications/send";
import type { CommunicationSettings } from "@/actions/communications/settings";
import type { Contact } from "@/actions/communications/contacts";
import type { Segment } from "@/actions/harvest-reach/segments";
import type { CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
export default function CommunicationsPage({
campaigns,
templates,
activeTab,
brandId,
editCampaign,
editMode,
editTemplate,
initialLogs = [],
initialSettings = null,
initialContacts = [],
initialContactTotal = 0,
initialSegments = [],
initialAnalytics = [],
editCampaignId,
}: {
campaigns: Campaign[];
templates: Template[];
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "settings" | "segments" | "analytics" | "compose";
brandId: string;
editCampaign?: Campaign | null;
editMode?: "edit" | "new";
editTemplate?: Template | null;
initialLogs?: MessageLogEntry[];
initialSettings?: CommunicationSettings | null;
initialContacts?: Contact[];
initialContactTotal?: number;
initialSegments?: Segment[];
initialAnalytics?: CampaignAnalytics[];
editCampaignId?: string;
}) {
return (
<div>
<CommunicationsNav activeTab={activeTab} />
{activeTab === "campaigns" && (
editCampaign !== undefined || editMode === "new" ? (
<CampaignEditPanel
campaign={editCampaign ?? undefined}
templates={templates}
mode={editMode ?? "edit"}
brandId={brandId}
/>
) : (
<CampaignListPanel initialCampaigns={campaigns} />
)
)}
{activeTab === "templates" && (
editTemplate !== undefined || editMode === "new" ? (
<TemplateEditForm
template={editTemplate ?? undefined}
mode={editMode ?? "edit"}
brandId={brandId}
/>
) : (
<TemplateListPanel templates={templates} />
)
)}
{activeTab === "contacts" && (
<div className="space-y-6">
<ContactListPanel initialContacts={initialContacts} initialTotal={initialContactTotal} brandId={brandId} />
<ContactImportForm brandId={brandId} />
</div>
)}
{activeTab === "logs" && (
<MessageLogPanel initialLogs={initialLogs} />
)}
{activeTab === "settings" && (
<CommunicationSettingsForm settings={initialSettings} brandId={brandId} />
)}
{activeTab === "segments" && (
<SegmentBuilderPage brandId={brandId} initialSegments={initialSegments} />
)}
{activeTab === "analytics" && (
<AnalyticsDashboard analytics={initialAnalytics} />
)}
{(activeTab === "compose" || (activeTab === "campaigns" && editCampaignId)) && (
<CampaignComposerPage
brandId={brandId}
campaigns={campaigns}
templates={templates}
segments={initialSegments}
editCampaignId={editCampaignId}
/>
)}
</div>
);
}
+621
View File
@@ -0,0 +1,621 @@
"use client";
import { useState, useRef, useCallback } from "react";
import {
importContactsBatch,
previewContactImport,
type ContactImportEntry,
type ImportPreviewResult,
type ColumnMapping,
type ImportField,
} from "@/actions/communications/contacts";
type Step = "idle" | "preview" | "confirming" | "importing" | "result";
type ImportResult = {
created: number;
updated: number;
skipped: number;
errors: { row: ContactImportEntry; error: string }[];
};
const LARGE_FILE_ROWS = 10_000;
const FIELD_LABELS: Record<string, string> = {
email: "Email",
phone: "Phone",
first_name: "First Name",
last_name: "Last Name",
full_name: "Full Name",
tags: "Tags",
email_opt_in: "Email Opt-In",
sms_opt_in: "SMS Opt-In",
external_id: "External ID",
};
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 [rawHeaders, setRawHeaders] = useState<string[]>([]);
const [rawRows, setRawRows] = useState<string[][]>([]);
const [importRows, setImportRows] = useState<ContactImportEntry[]>([]);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const [globalError, setGlobalError] = useState("");
const [overridingMappings, setOverridingMappings] = useState<
Record<string, ImportField>
>({});
const fileRef = useRef<HTMLInputElement>(null);
// ── CSV parsing + preview ──────────────────────────────────────────────────
const handleFile = useCallback(
async (f: File) => {
setFile(f);
setResult(null);
setGlobalError("");
setOverridingMappings({});
const text = await f.text();
const previewResult = await previewContactImport(text);
if (!previewResult.success) {
setGlobalError(previewResult.error);
setStep("idle");
return;
}
const { csv, totalRows } = await import("@/lib/csv-parser").then(
(m) => m.parseCSVWithLimits(text)
);
setRawHeaders(csv.headers);
setRawRows(csv.rows);
setPreview(previewResult.preview);
setStep("preview");
},
[]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith(".csv")) handleFile(f);
else setGlobalError("Please upload a .csv file");
},
[handleFile]
);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
},
[handleFile]
);
// ── Mapping override ───────────────────────────────────────────────────────
function applyMappings(
mappings: ColumnMapping[],
overrides: Record<string, ImportField>,
headers: string[],
rows: string[][]
): ContactImportEntry[] {
const effective = mappings.map((m) => ({
...m,
field: overrides[m.csvColumn] ?? m.field,
}));
const emailColIdx = effective.findIndex((m) => m.field === "email");
const phoneColIdx = effective.findIndex((m) => m.field === "phone");
const seenEmails = new Set<string>();
const seenPhones = new Set<string>();
const entries: ContactImportEntry[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const entry: ContactImportEntry = {};
const ignored: Record<string, string> = {};
for (const mapping of effective) {
const colIdx = headers.indexOf(mapping.csvColumn);
if (colIdx === -1) continue;
const raw = row[colIdx] ?? "";
if (mapping.field === null) {
if (raw) ignored[mapping.csvColumn] = raw;
continue;
}
switch (mapping.field) {
case "email": {
const cleaned = raw.trim().toLowerCase();
entry.email = cleaned || undefined;
break;
}
case "phone": {
const stripped = raw.replace(/[\s\-().[\]]/g, "");
entry.phone = raw.trim() || undefined;
break;
}
case "first_name":
entry.first_name = raw || undefined;
break;
case "last_name":
entry.last_name = raw || undefined;
break;
case "full_name":
entry.full_name = raw || undefined;
break;
case "tags":
entry.tags = raw
? raw.split(/[;:,]/).map((t) => t.trim()).filter(Boolean)
: undefined;
break;
case "email_opt_in":
entry.email_opt_in =
raw === "true" ||
raw === "1" ||
raw === "yes" ||
raw === "y" ||
raw === "on";
break;
case "sms_opt_in":
entry.sms_opt_in =
raw === "true" ||
raw === "1" ||
raw === "yes" ||
raw === "y" ||
raw === "on";
break;
case "external_id":
entry.external_id = raw || undefined;
break;
}
}
const hasEmail = !!entry.email;
const hasPhone = !!entry.phone;
if (!hasEmail && !hasPhone) continue;
// Dedupe within file
let isDup = false;
if (entry.email) {
if (seenEmails.has(entry.email!)) isDup = true;
seenEmails.add(entry.email!);
}
if (entry.phone && !isDup) {
if (seenPhones.has(entry.phone)) isDup = true;
seenPhones.add(entry.phone);
}
if (isDup) continue;
if (Object.keys(ignored).length > 0) {
entry._metadata = ignored;
}
entries.push(entry);
}
return entries;
}
// ── Confirm and import ─────────────────────────────────────────────────────
const handleConfirm = useCallback(
async () => {
if (!preview || rawRows.length === 0) return;
setStep("importing");
const rowsToImport = applyMappings(
preview.mappings,
overridingMappings,
rawHeaders,
rawRows
);
setImportRows(rowsToImport);
setImporting(true);
const importResult = await importContactsBatch({
brandId,
contacts: rowsToImport,
});
setImporting(false);
if (importResult.success) {
setResult(importResult.result);
setStep("result");
} else {
setGlobalError(importResult.error ?? "Import failed");
setStep("result");
}
},
[brandId, preview, overridingMappings, rawHeaders, rawRows]
);
const handleReset = () => {
setStep("idle");
setFile(null);
setPreview(null);
setImportRows([]);
setResult(null);
setGlobalError("");
setOverridingMappings({});
if (fileRef.current) fileRef.current.value = "";
};
// ── Render helpers ────────────────────────────────────────────────────────
function renderMappings() {
if (!preview) return null;
return (
<div className="space-y-3">
<div className="overflow-x-auto">
<table className="w-full text-xs border border-zinc-800 rounded-lg">
<thead className="bg-zinc-900">
<tr>
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
CSV Column
</th>
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
Maps To
</th>
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
Sample Values
</th>
</tr>
</thead>
<tbody>
{preview.mappings.map((m) => (
<tr key={m.csvColumn} className="border-t border-slate-100">
<td className="px-3 py-2 text-slate-800 font-mono text-xs">
{m.csvColumn}
</td>
<td className="px-3 py-2">
<select
value={
overridingMappings[m.csvColumn] ?? m.field ?? "ignore"
}
onChange={(e) => {
const val = e.target.value;
setOverridingMappings((prev) => ({
...prev,
[m.csvColumn]:
val === "ignore"
? null
: (val as ImportField),
}));
}}
className="text-xs border border-zinc-600 rounded px-2 py-1"
>
<option value="ignore"> ignore </option>
{Object.entries(FIELD_LABELS).map(([k, label]) => (
<option key={k} value={k}>
{label}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-zinc-500 text-xs">
{m.sampleValues.length > 0
? m.sampleValues.join(", ")
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{preview.ignoredColumns.length > 0 && (
<p className="text-xs text-zinc-500">
Extra columns (stored in metadata):
<span className="font-mono">
{" "}
{preview.ignoredColumns.join(", ")}
</span>
</p>
)}
</div>
);
}
function renderStats() {
if (!preview) return null;
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Stat label="Total rows" value={preview.totalRows} />
<Stat
label="Importable"
value={preview.validRows}
highlight
/>
<Stat label="Skipped" value={preview.skippedRows} warn={preview.skippedRows > 0} />
<Stat
label="Duplicates"
value={preview.duplicateRows}
warn={preview.duplicateRows > 0}
/>
</div>
);
}
function renderSampleRows() {
if (!preview) return null;
return (
<div className="overflow-x-auto">
<table className="w-full text-xs border border-zinc-800 rounded-lg">
<thead className="bg-zinc-900">
<tr>
<th className="px-2 py-1 text-left text-zinc-400">#</th>
<th className="px-2 py-1 text-left text-zinc-400">email</th>
<th className="px-2 py-1 text-left text-zinc-400">phone</th>
<th className="px-2 py-1 text-left text-zinc-400">first_name</th>
<th className="px-2 py-1 text-left text-zinc-400">last_name</th>
<th className="px-2 py-1 text-left text-zinc-400">full_name</th>
<th className="px-2 py-1 text-left text-zinc-400">email_opt_in</th>
<th className="px-2 py-1 text-left text-zinc-400">sms_opt_in</th>
<th className="px-2 py-1 text-left text-zinc-400">tags</th>
</tr>
</thead>
<tbody>
{preview.sampleRows.map((row, i) => (
<tr key={i} className="border-t border-slate-100">
<td className="px-2 py-1 text-slate-400">{i + 1}</td>
<td className="px-2 py-1">{row.email ?? ""}</td>
<td className="px-2 py-1">{row.phone ?? ""}</td>
<td className="px-2 py-1">{row.first_name ?? ""}</td>
<td className="px-2 py-1">{row.last_name ?? ""}</td>
<td className="px-2 py-1">{row.full_name ?? ""}</td>
<td className="px-2 py-1">
{row.email_opt_in === undefined
? ""
: row.email_opt_in
? "true"
: "false"}
</td>
<td className="px-2 py-1">
{row.sms_opt_in === undefined
? ""
: row.sms_opt_in
? "true"
: "false"}
</td>
<td className="px-2 py-1">{row.tags?.join("; ") ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── Main render ────────────────────────────────────────────────────────────
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Import Contacts</h3>
{step !== "idle" && (
<button
onClick={handleReset}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
Start over
</button>
)}
</div>
{globalError && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{globalError}
</div>
)}
{/* ── STEP: idle ─────────────────────────────────────────── */}
{step === "idle" && (
<>
<div
className="border-2 border-dashed border-zinc-600 rounded-xl p-8 text-center"
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={handleDrop}
>
<input
ref={fileRef}
type="file"
accept=".csv"
onChange={handleFileChange}
className="hidden"
id="csv-upload"
/>
<label htmlFor="csv-upload" className="cursor-pointer">
<div className="text-zinc-400 text-sm">
<span className="font-medium text-blue-400 hover:text-blue-700">
Click to upload
</span>
{" "}or drag and drop a CSV file
</div>
<p className="text-xs text-slate-400 mt-1">
Works with Mailchimp, Square, Shopify, WooCommerce, QuickBooks, and
generic spreadsheets
</p>
</label>
</div>
</>
)}
{/* ── STEP: preview ──────────────────────────────────────── */}
{step === "preview" && preview && (
<>
{preview.warnings.map((w, i) => (
<div
key={i}
className="rounded-lg bg-amber-900/30 border border-amber-200 px-4 py-3 text-sm text-amber-700"
>
{w}
</div>
))}
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm text-blue-700">
<p className="font-medium mb-1">{file?.name}</p>
<p>
Column mappings detected review below, adjust if needed, then
confirm import.
</p>
</div>
<div className="space-y-3">
{renderMappings()}
</div>
<div>
<p className="text-xs text-zinc-500 mb-2 font-medium">
Import Summary
</p>
{renderStats()}
</div>
<div>
<p className="text-xs text-zinc-500 mb-2 font-medium">
Sample rows (first {preview.sampleRows.length})
</p>
{renderSampleRows()}
</div>
{preview.skippedReasons.length > 0 && (
<div>
<p className="text-xs text-zinc-500 mb-1 font-medium">
Skipped rows ({preview.skippedReasons.length})
</p>
<div className="max-h-32 overflow-y-auto text-xs text-zinc-500 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-slate-400">
...and{" "}
{preview.skippedReasons.length - 10} more
</p>
)}
</div>
</div>
)}
{preview.totalRows > LARGE_FILE_ROWS && (
<div className="rounded-lg bg-amber-900/30 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
onClick={() => handleReset()}
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm text-zinc-400 hover:bg-zinc-900"
>
Cancel
</button>
<button
onClick={() => handleConfirm()}
disabled={preview.validRows === 0}
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
Import {preview.validRows} contacts
</button>
</div>
</>
)}
{/* ── STEP: importing ────────────────────────────────────── */}
{step === "importing" && (
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-6 text-center text-sm text-blue-700">
Importing {importRows.length} contacts...
</div>
)}
{/* ── STEP: result ────────────────────────────────────────── */}
{step === "result" && result && (
<div className="space-y-3">
<div className="rounded-lg bg-green-900/30 border border-green-200 px-4 py-3 text-sm space-y-1">
<p className="font-semibold text-green-400">Import complete</p>
<p className="text-green-600">Created: {result.created}</p>
<p className="text-green-600">Updated: {result.updated}</p>
<p className="text-green-600">Skipped: {result.skipped}</p>
</div>
{result.errors.length > 0 && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm">
<p className="font-medium text-red-400 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={i} className="text-red-400 text-xs">
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
</p>
))}
{result.errors.length > 10 && (
<p className="text-red-400 text-xs">
...and {result.errors.length - 10} more
</p>
)}
</div>
</div>
)}
<button
onClick={handleReset}
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm text-zinc-400 hover:bg-zinc-900"
>
Import more
</button>
</div>
)}
</div>
);
}
function Stat({
label,
value,
highlight,
warn,
}: {
label: string;
value: number;
highlight?: boolean;
warn?: boolean;
}) {
return (
<div
className={`rounded-lg border px-4 py-3 text-center ${
highlight
? "bg-blue-900/30 border-blue-200"
: warn
? "bg-amber-900/30 border-amber-200"
: "bg-zinc-900 border-zinc-800"
}`}
>
<p
className={`text-xl font-semibold ${
highlight
? "text-blue-700"
: warn
? "text-amber-700"
: "text-zinc-300"
}`}
>
{value.toLocaleString()}
</p>
<p className="text-xs text-zinc-500 mt-0.5">{label}</p>
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { useState, 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";
const SOURCE_COLORS: Record<ContactSource, string> = {
order: "bg-blue-900/40 text-blue-700",
import: "bg-purple-100 text-purple-700",
manual: "bg-green-900/40 text-green-400",
admin: "bg-zinc-950 text-zinc-300",
};
export default function ContactListPanel({
initialContacts,
initialTotal,
brandId,
}: {
initialContacts: Contact[];
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 loadPage = useCallback(async (searchVal: string, sourceVal: string, pageNum: number) => {
setLoading(true);
const result = await getContacts({
brandId,
search: searchVal || undefined,
source: sourceVal || undefined,
limit,
offset: pageNum * limit,
});
setLoading(false);
if (result.success) {
setContacts(result.contacts);
setTotal(result.total);
}
}, [brandId]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(0);
loadPage(search, sourceFilter, 0);
};
const handleSourceFilter = (val: ContactSource | "") => {
setSourceFilter(val);
setPage(0);
loadPage(search, val, 0);
};
const handlePage = (dir: number) => {
const next = page + dir;
setPage(next);
loadPage(search, sourceFilter, next);
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this contact?")) return;
setDeleting(id);
const result = await deleteContact(id);
setDeleting(null);
if (result.success) {
setContacts((prev) => prev.filter((c) => c.id !== id));
setTotal((t) => t - 1);
}
};
const handleExport = async () => {
setExporting(true);
const result = await exportContacts({
brandId,
brandSlug: "contacts",
search: search || undefined,
source: sourceFilter || undefined,
});
setExporting(false);
if (!result.success) {
alert("Export failed: " + result.error);
return;
}
const blob = new Blob([result.csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = result.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Contacts</h2>
<p className="text-sm text-zinc-500">{total} contact{total !== 1 ? "s" : ""}</p>
</div>
<button
onClick={handleExport}
disabled={exporting || total === 0}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{exporting ? "Exporting..." : "Export Contacts"}
</button>
</div>
{/* Search + filters */}
<form onSubmit={handleSearch} className="flex gap-3 mb-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search email, name, phone..."
className="flex-1 text-sm border border-zinc-600 rounded-lg px-3 py-2"
/>
<select
value={sourceFilter}
onChange={(e) => handleSourceFilter(e.target.value as ContactSource | "")}
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
>
<option value="">All Sources</option>
<option value="order">Order</option>
<option value="import">Import</option>
<option value="manual">Manual</option>
<option value="admin">Admin</option>
</select>
<button
type="submit"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Search
</button>
</form>
{/* Table */}
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
) : contacts.length === 0 ? (
<div className="text-center py-12 text-slate-400">No contacts found</div>
) : (
<>
<div className="hidden sm:block bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Email</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Phone</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Source</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Email Opt-in</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Unsubscribed</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{contacts.map((c) => (
<tr key={c.id} className="hover:bg-zinc-800">
<td className="px-4 py-3 font-medium text-zinc-100">
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
</td>
<td className="px-4 py-3 text-zinc-400">{c.email || "—"}</td>
<td className="px-4 py-3 text-zinc-400">{c.phone || "—"}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
{c.source}
</span>
</td>
<td className="px-4 py-3">
{c.email_opt_in ? (
<span className="text-green-600 text-xs font-medium">Opted in</span>
) : (
<span className="text-red-500 text-xs font-medium">Opted out</span>
)}
</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
{c.unsubscribed_at
? formatDate(new Date(c.unsubscribed_at))
: "—"}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDelete(c.id)}
disabled={deleting === c.id}
className="text-red-500 hover:text-red-400 text-xs disabled:opacity-50"
>
{deleting === c.id ? "..." : "Delete"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{contacts.map((c) => (
<div key={c.id} className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 space-y-2">
<div className="flex items-start justify-between">
<div className="font-medium text-zinc-100 text-sm">
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
</div>
<button
onClick={() => handleDelete(c.id)}
disabled={deleting === c.id}
className="text-red-500 hover:text-red-400 text-xs disabled:opacity-50"
>
{deleting === c.id ? "..." : "Delete"}
</button>
</div>
<div className="text-xs text-zinc-400">{c.email || "—"}</div>
<div className="flex items-center gap-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
{c.source}
</span>
{c.phone && <span className="text-xs text-zinc-500">{c.phone}</span>}
{c.email_opt_in ? (
<span className="text-green-600 text-xs font-medium">Opted in</span>
) : (
<span className="text-red-500 text-xs font-medium">Opted out</span>
)}
</div>
</div>
))}
</div>
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-zinc-500">
Page {page + 1} {contacts.length} of {total}
</span>
<div className="flex gap-2">
<button
onClick={() => handlePage(-1)}
disabled={page === 0}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-zinc-800"
>
Previous
</button>
<button
onClick={() => handlePage(1)}
disabled={(page + 1) * limit >= total}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-sm disabled:opacity-50 hover:bg-zinc-800"
>
Next
</button>
</div>
</div>
</>
)}
</div>
);
}
+419
View File
@@ -0,0 +1,419 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { markPickupComplete } from "@/actions/pickup";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
fulfillment?: string;
products: { name: string } | null;
};
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
stop_id: string | null;
status: string;
subtotal: number;
pickup_complete: boolean;
pickup_completed_at: string | null;
pickup_completed_by: string | null;
created_at: string;
stops: { id?: string; city: string; state: string; date: string; brand_id?: string } | null;
order_items?: OrderItem[];
};
type Stop = {
id: string;
city: string;
state: string;
date: string;
brand_id?: string;
};
type DriverPickupPanelProps = {
initialPendingOrders: Order[];
initialPickedUpOrders: Order[];
initialStops: Stop[];
brandId: string | null;
canManagePickup: boolean;
};
function formatPickupTime(iso: string | null) {
if (!iso) return "";
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "Just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 1440) return `${Math.floor(diffMin / 60)}h ago`;
// Same year — show mon/day
if (d.getFullYear() === now.getFullYear()) {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
export default function DriverPickupPanel({
initialPendingOrders,
initialPickedUpOrders,
initialStops,
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 activeStopFilter = stopFilter;
const filteredPending = pendingOrders.filter((o) => {
const matchesSearch =
!search ||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(o.customer_phone ?? "").includes(search) ||
o.id.includes(search.toUpperCase().slice(0, 8));
const matchesStop = !activeStopFilter || o.stop_id === activeStopFilter;
return matchesSearch && matchesStop;
});
const filteredPickedUp = pickedUpOrders.filter((o) => {
const matchesSearch =
!search ||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(o.customer_phone ?? "").includes(search) ||
o.id.includes(search.toUpperCase().slice(0, 8));
const matchesStop = !activeStopFilter || o.stop_id === activeStopFilter;
return matchesSearch && matchesStop;
});
const hasAnyPickedUp = pickedUpOrders.length > 0;
async function handleMarkPickup(orderId: string) {
if (!canManagePickup) return;
setPickingUp(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);
}
}
setPickingUp(null);
}
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<div className="bg-slate-900 px-4 py-5">
<div className="mx-auto max-w-6xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Driver Pickup</h1>
<p className="mt-1 text-sm text-slate-400">
{filteredPending.length} pending
{hasAnyPickedUp && ` · ${pickedUpOrders.length} picked up`}
</p>
</div>
<Link
href="/admin/orders"
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
>
All Orders
</Link>
</div>
</div>
</div>
<div className="mx-auto max-w-6xl px-4 py-4 space-y-4">
{/* Stop Filter */}
<select
value={stopFilter}
onChange={(e) => setStopFilter(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base font-medium outline-none focus:border-slate-900"
>
<option value="">All Stops</option>
{stops.map((s) => (
<option key={s.id} value={s.id}>
{s.city}, {s.state} · {s.date}
</option>
))}
</select>
{/* Search */}
<input
type="search"
placeholder="Search name, phone, or order #..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base outline-none focus:border-slate-900"
/>
{/* Pending Orders */}
<div className="space-y-3">
{filteredPending.length === 0 ? (
<div className="rounded-xl bg-zinc-900 py-12 text-center text-slate-400">
No pending orders
</div>
) : (
filteredPending.map((order) => (
<OrderCard
key={order.id}
order={order}
canManagePickup={canManagePickup}
onMarkPickup={handleMarkPickup}
pickingUp={pickingUp}
shortId={shortId(order.id)}
/>
))
)}
</div>
{/* Picked Up Toggle */}
{hasAnyPickedUp && (
<button
onClick={() => setShowPickedUp((v) => !v)}
className="w-full rounded-xl border border-green-200 bg-green-900/30 px-4 py-3 text-sm font-medium text-green-400 hover:bg-green-900/40"
>
{showPickedUp ? "▲ Hide" : "▼"} {pickedUpOrders.length} picked up
</button>
)}
{showPickedUp && (
<div className="space-y-3">
{filteredPickedUp.length === 0 && activeStopFilter ? (
<div className="rounded-xl border border-green-200 bg-green-900/30 py-6 text-center text-sm text-green-600">
No picked-up orders for this stop. {pickedUpOrders.length} picked up at other stops.
</div>
) : (
filteredPickedUp.map((order) => (
<PickedUpCard
key={order.id}
order={order}
shortId={shortId(order.id)}
/>
))
)}
</div>
)}
{/* Success toast */}
{pickupToast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl bg-green-600 px-6 py-3 text-sm font-semibold text-white shadow-lg animate-in fade-in slide-in-from-bottom-2">
Order picked up
</div>
)}
</div>
</div>
);
}
type OrderCardProps = {
order: Order;
canManagePickup: boolean;
onMarkPickup: (orderId: string) => void;
pickingUp: string | null;
shortId: string;
};
function OrderCard({ order, canManagePickup, onMarkPickup, pickingUp, shortId }: OrderCardProps) {
const allItems = order.order_items ?? [];
const pickupItems = allItems.filter((i) => i.fulfillment !== "shipping");
const shippingItems = allItems.filter((i) => i.fulfillment === "shipping");
const isMixed = pickupItems.length > 0 && shippingItems.length > 0;
const pickupTotal = pickupItems.reduce((sum, i) => sum + Number(i.price) * i.quantity, 0);
return (
<div className="rounded-2xl bg-zinc-900 p-5 shadow-black/20 ring-1 ring-zinc-700">
{/* Top row: name + order # */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xl font-bold text-zinc-100">{order.customer_name}</p>
{order.customer_phone && (
<p className="mt-1 text-base text-zinc-400">{order.customer_phone}</p>
)}
<p className="mt-1 font-mono text-xs text-slate-400 uppercase">{shortId}</p>
</div>
<div className="text-right shrink-0 flex flex-col items-end gap-1">
<span className="rounded-full bg-yellow-100 px-3 py-1 text-xs font-bold text-yellow-800">
Pending
</span>
{isMixed && (
<span className="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-semibold text-purple-700">
Mixed order
</span>
)}
</div>
</div>
{/* Products list — pickup items only */}
{pickupItems.length > 0 && (
<div className="mt-4 rounded-lg bg-slate-50 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
Pickup items ({pickupItems.reduce((s, i) => s + i.quantity, 0)} total)
</p>
<ul className="space-y-1">
{pickupItems.map((item) => (
<li key={item.id} className="flex items-center justify-between text-sm">
<span className="text-zinc-300">
{item.quantity > 1 && (
<span className="font-semibold text-zinc-100">{item.quantity}× </span>
)}
{item.products?.name ?? "Unknown Product"}
</span>
<span className="text-zinc-500">
${(Number(item.price) * item.quantity).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-2 border-t border-zinc-800 pt-2 flex justify-between">
<span className="text-sm font-medium text-zinc-400">Pickup subtotal</span>
<span className="text-sm font-bold text-zinc-100">${pickupTotal.toFixed(2)}</span>
</div>
</div>
)}
{/* Shipping items note */}
{isMixed && (
<div className="mt-3 rounded-lg bg-purple-50 border border-purple-100 px-3 py-2 text-sm text-purple-700">
{shippingItems.length} shipping item{shippingItems.length > 1 ? "s" : ""} will also be marked picked up
</div>
)}
{/* Middle: stop + items count */}
<div className="mt-4 flex items-center justify-between">
<div>
{order.stops ? (
<p className="font-medium text-zinc-300">
{order.stops.city}, {order.stops.state}
</p>
) : (
<p className="text-slate-400">No stop</p>
)}
<p className="text-sm text-slate-400">
{pickupItems.length} pickup {pickupItems.length === 1 ? "item" : "items"}
{isMixed && ` · ${shippingItems.length} shipping`}
</p>
</div>
<p className="text-3xl font-bold text-zinc-100">
${pickupTotal.toFixed(2)}
</p>
</div>
{/* Pickup Button */}
{canManagePickup ? (
<div className="mt-4 flex gap-2">
<a
href={`/admin/orders/${order.id}`}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-center text-base font-semibold text-zinc-300 active:bg-slate-50"
style={{ minHeight: "56px", display: "flex", alignItems: "center", justifyContent: "center" }}
>
View Details
</a>
<button
onClick={() => onMarkPickup(order.id)}
disabled={pickingUp === order.id}
className="flex-1 rounded-xl bg-green-600 px-4 py-5 text-xl font-bold text-white active:bg-green-700 disabled:opacity-50"
style={{ minHeight: "56px" }}
>
{pickingUp === order.id ? "..." : "✓ Pick Up"}
</button>
</div>
) : (
<div className="mt-4 rounded-xl bg-zinc-950 px-4 py-3 text-center text-sm text-zinc-500">
No pickup permission
</div>
)}
</div>
);
}
type PickedUpCardProps = {
order: Order;
shortId: string;
};
function PickedUpCard({ order, shortId }: PickedUpCardProps) {
const allItems = order.order_items ?? [];
const pickupItems = allItems.filter((i) => i.fulfillment === "pickup");
const pickupTotal = pickupItems.reduce((s, i) => s + Number(i.price) * i.quantity, 0);
const isMixed = allItems.some((i) => i.fulfillment === "shipping");
return (
<div className="rounded-xl border border-green-200 bg-green-900/30 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-bold text-green-900">{order.customer_name}</p>
{order.customer_phone && (
<p className="text-sm text-green-400">{order.customer_phone}</p>
)}
<p className="mt-1 text-xs text-green-600">
{shortId} · {order.stops?.city}, {order.stops?.state}
</p>
{pickupItems.length > 0 && (
<ul className="mt-2 space-y-0.5">
{pickupItems.map((item) => (
<li key={item.id} className="text-xs text-green-400">
{item.quantity > 1 && <span className="font-semibold">{item.quantity}× </span>}
{item.products?.name ?? "Unknown"}
<span className="ml-1 text-green-500">${(Number(item.price) * item.quantity).toFixed(2)}</span>
</li>
))}
</ul>
)}
<p className="mt-2 text-xs font-semibold text-green-400">
{pickupItems.reduce((s, i) => s + i.quantity, 0)} pickup items · ${pickupTotal.toFixed(2)}
{isMixed && <span className="text-purple-600 ml-1">· mixed</span>}
</p>
</div>
<div className="text-right shrink-0">
<span className="rounded-full bg-green-200 px-3 py-1 text-xs font-bold text-green-800">
Picked Up
</span>
{order.pickup_completed_at && (
<p className="mt-1 text-xs text-green-600">
{formatPickupTime(order.pickup_completed_at)}
</p>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { type CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
type Props = {
analytics: CampaignAnalytics[];
};
type Period = "30" | "90" | "all";
function RateBar({ value }: { value: number }) {
return (
<div className="w-full bg-zinc-950 rounded-full h-1.5">
<div
className="bg-stone-900 h-1.5 rounded-full"
style={{ width: `${Math.min(100, value)}%` }}
/>
</div>
);
}
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-1.5">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold text-zinc-100">{value}</p>
{sub && <p className="text-xs text-slate-400">{sub}</p>}
</div>
);
}
export default function AnalyticsDashboard({ analytics }: Props) {
const [period, setPeriod] = useState<Period>("30");
const totalSent = analytics.reduce((s, a) => s + a.total_sent, 0);
const totalDelivered = analytics.reduce((s, a) => s + a.total_delivered, 0);
const totalOpened = analytics.reduce((s, a) => s + a.total_opened, 0);
const totalClicked = analytics.reduce((s, a) => s + a.total_clicked, 0);
const avgOpenRate = totalDelivered > 0 ? (totalOpened / totalDelivered) * 100 : 0;
const avgClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0;
return (
<div className="flex flex-col gap-5">
{/* Summary stat cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="Total Sent" value={totalSent.toLocaleString()} sub={`${analytics.length} campaign${analytics.length !== 1 ? "s" : ""}`} />
<StatCard
label="Delivered"
value={totalSent > 0 ? `${Math.round((totalDelivered / totalSent) * 100)}%` : "—"}
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} emails` : undefined}
/>
<StatCard label="Avg. Open Rate" value={totalSent > 0 ? `${avgOpenRate.toFixed(1)}%` : "—"} sub="of delivered" />
<StatCard label="Avg. Click Rate" value={totalSent > 0 ? `${avgClickRate.toFixed(1)}%` : "—"} sub="of delivered" />
</div>
{/* Campaign performance table */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Campaign Performance</h3>
<div className="flex rounded-lg border border-zinc-800 bg-slate-50 p-0.5">
{(["30", "90", "all"] as const).map((val) => (
<button
key={val}
onClick={() => setPeriod(val as Period)}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
period === val ? "bg-stone-900 text-white" : "text-zinc-400 hover:bg-zinc-900"
}`}
>
{val === "30" ? "30 days" : val === "90" ? "90 days" : "All time"}
</button>
))}
</div>
</div>
{analytics.length === 0 ? (
<div className="px-6 py-12 text-center">
<p className="text-sm text-slate-400">No campaign analytics yet.</p>
<p className="text-xs text-slate-400 mt-1">Send a campaign to start tracking engagement.</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
{["Campaign", "Sent", "Delivered", "Opened", "Clicked", "Bounced", "Engagement"].map((h) => (
<th key={h} className={`text-left px-6 py-3 font-medium text-zinc-400 ${h !== "Campaign" ? "text-right" : ""}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{analytics.map((a) => (
<tr key={a.campaign_id} className="hover:bg-zinc-800 transition-colors">
<td className="px-6 py-3.5">
<div className="flex flex-col">
<span className="font-medium text-slate-800">{a.campaign_name}</span>
<span className="text-xs text-slate-400">
{a.sent_at ? new Date(a.sent_at).toLocaleDateString() : "—"}
</span>
</div>
</td>
<td className="px-6 py-3.5 text-right text-zinc-300">{a.total_sent.toLocaleString()}</td>
<td className="px-6 py-3.5 text-right">
<span className="text-zinc-300">{a.total_delivered.toLocaleString()}</span>
<span className="text-xs text-slate-400 ml-1">({a.delivered_rate}%)</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-zinc-300">{a.total_opened.toLocaleString()}</span>
<span className="text-xs text-slate-400 ml-1">({a.open_rate}%)</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-zinc-300">{a.total_clicked.toLocaleString()}</span>
<span className="text-xs text-slate-400 ml-1">({a.click_rate}%)</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className={a.total_bounced > 0 ? "text-red-400" : "text-slate-400"}>
{a.total_bounced.toLocaleString()}
</span>
<span className="text-xs text-slate-400 ml-1">({a.bounce_rate}%)</span>
</td>
<td className="px-6 py-3.5 w-36">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between text-xs">
<span className="text-zinc-500">Open</span>
<span className="font-medium text-zinc-300">{a.open_rate}%</span>
</div>
<RateBar value={Number(a.open_rate)} />
<div className="flex items-center justify-between text-xs">
<span className="text-zinc-500">Click</span>
<span className="font-medium text-zinc-300">{a.click_rate}%</span>
</div>
<RateBar value={Number(a.click_rate)} />
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,505 @@
"use client";
import { useState } from "react";
import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns";
import { type Template } from "@/actions/communications/templates";
import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
campaigns: Campaign[];
templates: Template[];
segments: Segment[];
editCampaignId?: string;
};
type Step = 1 | 2 | 3 | 4;
const STEPS = ["Template", "Audience", "Preview", "Schedule"] as const;
const STATUS_COLORS: Record<string, string> = {
draft: "bg-zinc-950 text-zinc-400",
scheduled: "bg-blue-900/40 text-blue-700",
sending: "bg-amber-100 text-amber-700",
sent: "bg-green-900/40 text-green-400",
canceled: "bg-red-900/40 text-red-400",
};
const CAMPAIGN_TYPES: { value: CampaignType; label: string; desc: string }[] = [
{ value: "marketing", label: "Marketing", desc: "Promotions, newsletters" },
{ value: "operational", label: "Operational", desc: "Stop updates, reminders" },
{ value: "transactional", label: "Transactional", desc: "Receipts, confirmations" },
];
function statusBadge(status: string) {
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[status] ?? "bg-zinc-950 text-zinc-400"}`}>
{status}
</span>
);
}
export default function CampaignComposerPage({ brandId, campaigns, templates, segments, editCampaignId }: Props) {
const editing = campaigns.find((c) => c.id === editCampaignId);
const [step, setStep] = useState<Step>(editing ? 4 : 1);
const [name, setName] = useState(editing?.name ?? "");
const [campaignType, setCampaignType] = useState<CampaignType>(editing?.campaign_type ?? "marketing");
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(editing?.template_id ?? "");
const [subject, setSubject] = useState(editing?.subject ?? "");
const [bodyText, setBodyText] = useState(editing?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState(editing?.body_html ?? "");
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
const [sendNow, setSendNow] = useState(!editing?.scheduled_at);
const [scheduledAt, setScheduledAt] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
const selectedSegment = segments.find((s) => s.id === selectedSegmentId);
function handleTemplateSelect(template: Template) {
setSelectedTemplateId(template.id);
setSubject(template.subject ?? "");
setBodyText(template.body_text ?? "");
setBodyHtml(template.body_html ?? "");
setStep(2);
}
async function handleCreateOrSchedule() {
setError("");
setSaving(true);
const rules = selectedSegment?.rules ?? { combinator: "AND", filters: [] };
const { upsertCampaign } = await import("@/actions/communications/campaigns");
const { sendCampaign } = await import("@/actions/communications/send");
const result = await upsertCampaign({
id: editing?.id,
brand_id: brandId,
name: name || "Untitled Campaign",
subject,
body_text: bodyText,
body_html: bodyHtml,
template_id: selectedTemplateId || undefined,
campaign_type: campaignType,
status: sendNow ? "draft" : "scheduled",
audience_rules: rules as any,
scheduled_at: sendNow ? undefined : scheduledAt || undefined,
});
if (!result.success) {
setError(result.error ?? "Failed to save campaign");
setSaving(false);
return;
}
if (sendNow) {
const sendResult = await sendCampaign(result.campaign.id, brandId);
if (!sendResult.success) {
setError(sendResult.error ?? "Failed to send campaign");
setSaving(false);
return;
}
}
setSaved(true);
setSaving(false);
setTimeout(() => setSaved(false), 3000);
}
if (saved) {
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-10 text-center">
<div className="w-12 h-12 rounded-full bg-green-900/40 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold text-zinc-100 mb-2">
{sendNow ? "Campaign Sent!" : "Campaign Scheduled!"}
</h2>
<p className="text-sm text-zinc-500 mb-8">
{sendNow
? "Your campaign has been sent to all matching customers."
: `It will be sent on ${new Date(scheduledAt).toLocaleString()}.`}
</p>
<button
onClick={() => window.location.href = "/admin/communications"}
className="px-6 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
Back to Campaigns
</button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
{/* Step indicator */}
<div className="flex items-center">
{STEPS.map((label, i) => {
const n = (i + 1) as Step;
const done = step > n;
return (
<div key={label} className="flex items-center">
<div className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
step === n
? "bg-stone-900 text-white"
: done
? "bg-stone-200 text-stone-700"
: "bg-zinc-950 text-slate-400"
}`}>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs border ${
step === n ? "border-white" : done ? "border-stone-400" : "border-zinc-600"
}`}>
{done ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : n}
</span>
<span className="hidden sm:inline">{label}</span>
</div>
{i < STEPS.length - 1 && (
<div className={`h-0.5 w-6 ${done ? "bg-stone-300" : "bg-slate-200"} mx-1`} />
)}
</div>
);
})}
</div>
{/* Step content card */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6">
{/* Step 1: Template */}
{step === 1 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Choose a Template</h2>
<p className="text-sm text-zinc-500 mt-0.5">Select a template or start from scratch.</p>
</div>
{templates.length === 0 ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-zinc-500">No templates yet. Create your subject and body below.</p>
<div className="flex flex-col gap-3">
<input
type="text"
placeholder="Subject line"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
/>
<textarea
placeholder="Email body text…"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={5}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm resize-none outline-none focus:border-slate-900"
/>
</div>
<div className="flex justify-end">
<button
onClick={() => setStep(2)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
Continue
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Start from scratch */}
<button
onClick={() => setStep(2)}
className="rounded-xl border-2 border-dashed border-zinc-600 p-5 text-left hover:border-stone-400 hover:bg-zinc-950 transition-all group"
>
<span className="text-2xl block mb-2">+</span>
<p className="text-sm font-medium text-zinc-300 group-hover:text-zinc-100">Start from scratch</p>
<p className="text-xs text-slate-400 mt-0.5">Write your own subject and body</p>
</button>
{/* Template cards */}
{templates.map((t) => (
<button
key={t.id}
onClick={() => handleTemplateSelect(t)}
className={`rounded-xl border p-4 text-left transition-all ${
selectedTemplateId === t.id
? "border-stone-900 bg-zinc-950"
: "border-zinc-800 hover:border-slate-400 hover:bg-zinc-800"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{t.name}</p>
<p className="text-xs text-slate-400 mt-0.5 truncate">{t.subject}</p>
</div>
<span className="inline-flex items-center rounded-full bg-zinc-950 px-2 py-0.5 text-xs text-zinc-400 flex-shrink-0">
{t.template_type}
</span>
</div>
</button>
))}
</div>
)}
</div>
)}
{/* Step 2: Audience */}
{step === 2 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Audience</h2>
<p className="text-sm text-zinc-500 mt-0.5">Name your campaign and pick a segment.</p>
</div>
<div className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Campaign Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. May Sweet Corn Promotion"
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Saved Segment</label>
<div className="flex gap-2">
<select
value={selectedSegmentId}
onChange={(e) => {
setSelectedSegmentId(e.target.value);
}}
className="flex-1 border border-zinc-600 rounded-lg px-3 py-2.5 text-sm bg-zinc-900 outline-none focus:border-slate-900"
>
<option value=""> Choose a saved segment </option>
{segments.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{selectedSegmentId && (
<button
onClick={() => setSelectedSegmentId("")}
className="px-3 text-xs text-slate-400 hover:text-red-500 border border-zinc-800 rounded-lg hover:border-red-200"
>
Clear
</button>
)}
</div>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(1)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={() => setStep(3)}
disabled={!name}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 disabled:opacity-40"
>
Continue to Preview
</button>
</div>
</div>
)}
{/* Step 3: Preview */}
{step === 3 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Preview & Edit</h2>
<p className="text-sm text-zinc-500 mt-0.5">Review how your message will look.</p>
</div>
{/* Mock email frame */}
<div className="rounded-xl border border-zinc-800 bg-slate-50 overflow-hidden">
<div className="px-4 py-3 bg-zinc-900 border-b border-zinc-800 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-stone-300" />
<div>
<p className="text-xs font-medium text-zinc-300">Your Brand</p>
<p className="text-xs text-slate-400">To: {"{{customer_name}}"}</p>
</div>
</div>
<div className="p-5">
<p className="text-base font-semibold text-zinc-100 mb-3">{subject || "(no subject)"}</p>
<div className="text-sm text-zinc-300 whitespace-pre-wrap">{bodyText || "(no body)"}</div>
</div>
</div>
{/* Editable subject */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-1.5">Subject Line</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
/>
</div>
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(2)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={() => setStep(4)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
Continue to Schedule
</button>
</div>
</div>
)}
{/* Step 4: Schedule */}
{step === 4 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Schedule & Send</h2>
<p className="text-sm text-zinc-500 mt-0.5">Set campaign type and timing.</p>
</div>
{/* Campaign type */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-2">Campaign Type</label>
<div className="grid grid-cols-3 gap-3">
{CAMPAIGN_TYPES.map((ct) => (
<button
key={ct.value}
onClick={() => setCampaignType(ct.value)}
className={`rounded-xl border py-3 px-4 text-left transition-all ${
campaignType === ct.value
? "border-stone-900 bg-stone-900 text-white"
: "border-zinc-800 hover:border-slate-400 hover:bg-zinc-800"
}`}
>
<p className={`text-sm font-medium ${campaignType === ct.value ? "text-white" : "text-zinc-300"}`}>{ct.label}</p>
<p className={`text-xs mt-0.5 ${campaignType === ct.value ? "text-stone-300" : "text-slate-400"}`}>{ct.desc}</p>
</button>
))}
</div>
</div>
{/* Send timing */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-2">Send</label>
<div className="flex gap-6">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
checked={sendNow}
onChange={() => setSendNow(true)}
className="accent-stone-900 w-4 h-4"
/>
<span className="text-sm font-medium text-zinc-300">Send now</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
checked={!sendNow}
onChange={() => setSendNow(false)}
className="accent-stone-900 w-4 h-4"
/>
<span className="text-sm font-medium text-zinc-300">Schedule for later</span>
</label>
</div>
</div>
{!sendNow && (
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-1.5">Send Date & Time</label>
<input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
min={new Date().toISOString().slice(0, 16)}
/>
</div>
)}
{/* Summary */}
<div className="rounded-xl bg-slate-50 border border-zinc-800 p-4">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide mb-3">Summary</p>
<div className="grid grid-cols-2 gap-y-2 text-sm">
<span className="text-zinc-500">Campaign</span>
<span className="text-zinc-100 font-medium">{name || "—"}</span>
<span className="text-zinc-500">Template</span>
<span className="text-zinc-100">{selectedTemplate?.name ?? "Custom"}</span>
<span className="text-zinc-500">Audience</span>
<span className="text-zinc-100">{selectedSegment?.name ?? "—"}</span>
<span className="text-zinc-500">Type</span>
<span className="text-zinc-100 capitalize">{campaignType}</span>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(3)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={handleCreateOrSchedule}
disabled={saving || (!sendNow && !scheduledAt)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-50"
>
{saving ? "Processing…" : sendNow ? "Send Campaign" : "Schedule Campaign"}
</button>
</div>
</div>
)}
</div>
{/* Recent campaigns */}
{campaigns.length > 0 && (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-800">
<h3 className="text-sm font-semibold text-slate-800">Recent Campaigns</h3>
</div>
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
{["Name", "Type", "Status", "Sent"].map((h) => (
<th key={h} className={`text-left px-6 py-3 font-medium text-zinc-400 ${h !== "Name" ? "hidden sm:table-cell" : ""}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{campaigns.slice(0, 10).map((c) => (
<tr key={c.id} className="hover:bg-zinc-800 transition-colors">
<td className="px-6 py-3.5 font-medium text-slate-800">{c.name}</td>
<td className="px-6 py-3.5 hidden sm:table-cell text-zinc-500 capitalize">{c.campaign_type}</td>
<td className="px-6 py-3.5 hidden sm:table-cell">{statusBadge(c.status)}</td>
<td className="px-6 py-3.5 hidden sm:table-cell text-xs text-slate-400">
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,35 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const TABS = [
{ href: "/admin/harvest-reach/segments", label: "Segments" },
{ href: "/admin/harvest-reach/campaigns", label: "Campaigns" },
{ href: "/admin/harvest-reach/analytics", label: "Analytics" },
];
export default function HarvestReachNav() {
const pathname = usePathname();
return (
<nav className="flex gap-1 border-b border-zinc-800 mb-6">
{TABS.map((tab) => {
const active = pathname.startsWith(tab.href);
return (
<Link
key={tab.href}
href={tab.href}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
active
? "border-stone-900 text-stone-900"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{tab.label}
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,146 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
rules: SegmentRuleV2;
};
const DEBOUNCE_MS = 300;
export default function MatchingCustomersPanel({ brandId, rules }: Props) {
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (rules.filters.length === 0) {
setPreview(null);
return;
}
setLoading(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
const { previewSegmentWithCustomers } = await import("@/actions/harvest-reach/segments");
const result = await previewSegmentWithCustomers(brandId, rules);
setPreview(result);
setLoading(false);
setPage(0);
}, DEBOUNCE_MS);
return () => clearTimeout(timerRef.current);
}, [brandId, rules]);
const PAGE_SIZE = 50;
const filtered = preview?.sample_customers ?? [];
const searched = search
? filtered.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.email.toLowerCase().includes(search.toLowerCase())
)
: filtered;
const total = preview?.count ?? 0;
const paged = searched.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Matching Customers</h3>
{preview && (
<span className="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-700">
{total.toLocaleString()} total
</span>
)}
</div>
{rules.filters.length === 0 ? (
<div className="flex-1 flex items-center justify-center py-12">
<p className="text-sm text-slate-400 text-center">
Add filters to see matching customers
</p>
</div>
) : loading ? (
<div className="flex-1 flex items-center justify-center py-12">
<div className="flex items-center gap-2.5 text-slate-400">
<svg className="w-4 h-4 animate-spin" 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">Loading</span>
</div>
</div>
) : (
<>
<input
type="search"
placeholder="Search by name or email…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
{paged.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-6">No customers match these filters.</p>
) : (
<div className="flex-1 overflow-y-auto max-h-[460px] flex flex-col gap-1">
{paged.map((c) => (
<div
key={c.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-stone-200 flex items-center justify-center text-xs font-medium text-stone-600 flex-shrink-0">
{c.name ? c.name.slice(0, 2).toUpperCase() : "??"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{c.name || "(no name)"}</p>
<p className="text-xs text-slate-400 truncate">{c.email}</p>
</div>
{c.tags.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{c.tags.slice(0, 2).map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-blue-900/30 text-blue-400 px-2 py-0.5 text-xs"
>
{tag}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
{searched.length > PAGE_SIZE && (
<div className="flex items-center justify-between pt-3 border-t border-slate-100">
<span className="text-xs text-slate-400">
Showing {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, searched.length)} of {searched.length}
</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
>
Prev
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * PAGE_SIZE >= searched.length}
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { type Segment, type SegmentRuleV2 } from "@/actions/harvest-reach/segments";
import SegmentBuilderPanel from "./SegmentBuilderPanel";
import MatchingCustomersPanel from "./MatchingCustomersPanel";
import SegmentListSidebar from "./SegmentListSidebar";
import SegmentEditModal from "./SegmentEditModal";
type Props = {
brandId: string;
initialSegments: Segment[];
};
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);
function handleSegmentSelect(segment: Segment) {
setActiveSegment(segment);
setCurrentRules(segment.rules as SegmentRuleV2);
}
function handleNewSegment() {
setActiveSegment(null);
setCurrentRules({ combinator: "AND", filters: [] });
}
function handleRulesChange(rules: SegmentRuleV2) {
setCurrentRules(rules);
setActiveSegment(null);
}
async function handleSaveSegment(name: string, description: string) {
const { upsertHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
const result = await upsertHarvestReachSegment({
id: activeSegment?.id,
brand_id: brandId,
name,
description,
rules: 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);
}
}
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) {
handleNewSegment();
}
}
return (
<div className="flex flex-col gap-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Segment Builder</h2>
<p className="text-sm text-zinc-500 mt-0.5">
Build filters to define your audience, then save and reuse the segment.
</p>
</div>
</div>
{/* Main layout */}
<div className="flex gap-5">
{/* Left sidebar */}
<div className="w-72 flex-shrink-0">
<SegmentListSidebar
segments={segments}
activeSegmentId={activeSegment?.id}
onSelect={handleSegmentSelect}
onNew={handleNewSegment}
onDelete={handleDeleteSegment}
/>
</div>
{/* Main content: builder + preview */}
<div className="flex-1 grid grid-cols-2 gap-5 min-h-[580px]">
<SegmentBuilderPanel
brandId={brandId}
rules={currentRules}
onChange={handleRulesChange}
onSave={() => setShowEditModal(true)}
hasActiveSegment={!!activeSegment}
/>
<MatchingCustomersPanel brandId={brandId} rules={currentRules} />
</div>
</div>
{showEditModal && (
<SegmentEditModal
initialName={activeSegment?.name ?? ""}
initialDescription={activeSegment?.description ?? ""}
onSave={handleSaveSegment}
onClose={() => setShowEditModal(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,343 @@
"use client";
import { useState, useCallback } from "react";
import {
type SegmentRuleV2,
type SegmentFilter,
type SegmentFilterType,
type SegmentFilterParams,
} from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
rules: SegmentRuleV2;
onChange: (rules: SegmentRuleV2) => void;
onSave: () => void;
hasActiveSegment: boolean;
};
const FILTER_TYPES: { value: SegmentFilterType; label: string }[] = [
{ value: "all_customers", label: "All Customers" },
{ value: "stop", label: "Past Stop" },
{ value: "upcoming_stop", label: "Upcoming Stop" },
{ value: "product", label: "Product Purchased" },
{ value: "zip_code", label: "ZIP / City" },
{ value: "customer_history", label: "Order History" },
{ value: "tags", label: "Tags" },
];
const ORDER_HISTORY_OPTIONS = [
{ value: "all", label: "All customers" },
{ value: "first_order", label: "First-time buyers" },
{ value: "repeat", label: "Repeat buyers" },
];
function emptyFilter(type: SegmentFilterType): SegmentFilter {
const params: SegmentFilterParams = {};
if (type === "product") params.days_back = 90;
if (type === "customer_history") { params.order_history = "all"; params.days_back = 90; }
if (type === "stop" || type === "upcoming_stop") { params.date_from = ""; params.date_to = ""; }
if (type === "zip_code") { params.zip_codes = []; params.city = ""; }
if (type === "tags") params.tags = [];
return { type, params };
}
export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave, hasActiveSegment }: Props) {
const setCombinator = useCallback((combinator: "AND" | "OR") => {
onChange({ ...rules, combinator });
}, [rules, onChange]);
function updateFilter(index: number, updates: Partial<SegmentFilter>) {
const newFilters = rules.filters.map((f, i) =>
i === index ? { ...f, ...updates, params: { ...f.params, ...(updates.params ?? {}) } } : f
);
onChange({ ...rules, filters: newFilters });
}
function removeFilter(index: number) {
onChange({ ...rules, filters: rules.filters.filter((_, i) => i !== index) });
}
function addFilter(type: SegmentFilterType) {
onChange({ ...rules, filters: [...rules.filters, emptyFilter(type)] });
}
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-5">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Filter Rules</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Match</span>
<div className="flex rounded-lg border border-zinc-800 bg-slate-50 p-0.5">
{(["AND", "OR"] as const).map((c) => (
<button
key={c}
onClick={() => setCombinator(c)}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
rules.combinator === c
? "bg-stone-900 text-white"
: "text-zinc-400 hover:bg-zinc-900"
}`}
>
{c}
</button>
))}
</div>
<span className="text-xs text-zinc-500">of the following</span>
</div>
</div>
{/* Filter blocks */}
<div className="flex flex-col gap-3">
{rules.filters.length === 0 && (
<div className="py-8 text-center">
<p className="text-sm text-slate-400">
No filters yet. Add a filter below to start building your segment.
</p>
</div>
)}
{rules.filters.map((filter, index) => (
<FilterBlock
key={index}
brandId={brandId}
filter={filter}
onChange={(updates) => updateFilter(index, updates)}
onRemove={() => removeFilter(index)}
/>
))}
</div>
{/* Add filter chips */}
<div className="flex gap-2 flex-wrap">
{FILTER_TYPES.map((ft) => (
<button
key={ft.value}
onClick={() => addFilter(ft.value)}
className="px-3 py-1.5 text-xs font-medium rounded-full border border-zinc-600 text-zinc-400 hover:bg-zinc-800 hover:border-slate-400 transition-colors"
>
+ {ft.label}
</button>
))}
</div>
{/* Save button */}
<div className="pt-1 border-t border-slate-100">
<button
onClick={onSave}
disabled={rules.filters.length === 0}
className="w-full py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{hasActiveSegment ? "Update Segment" : "Save Segment"}
</button>
</div>
</div>
);
}
// ─── Filter Block ─────────────────────────────────────────────
type FilterBlockProps = {
brandId: string;
filter: SegmentFilter;
onChange: (updates: Partial<SegmentFilter>) => void;
onRemove: () => void;
};
function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps) {
const [products, setProducts] = useState<{ id: string; name: string }[]>([]);
const [stops, setStops] = useState<{ id: string; city: string; date: string }[]>([]);
const [productsLoaded, setProductsLoaded] = useState(false);
const [stopsLoaded, setStopsLoaded] = useState(false);
function loadProducts() {
if (productsLoaded) return;
import("@/actions/harvest-reach/products").then((m) => {
m.getProductsForSegmentPicker(brandId).then((data) => {
setProducts(data);
setProductsLoaded(true);
});
});
}
function loadStops(past: boolean) {
if (stopsLoaded) return;
import("@/actions/harvest-reach/stops").then((m) => {
m.getStopsForSegmentPicker(brandId).then((data) => {
setStops(data.filter((s) => past ? s.is_past : s.is_upcoming));
setStopsLoaded(true);
});
});
}
return (
<div className="rounded-xl border border-zinc-800 p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<select
value={filter.type}
onChange={(e) =>
onChange({ type: e.target.value as SegmentFilterType, params: emptyFilter(e.target.value as SegmentFilterType).params })
}
className="text-sm font-medium border border-zinc-800 rounded-lg px-2.5 py-1.5 bg-zinc-900 outline-none focus:border-slate-900"
>
{FILTER_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>{ft.label}</option>
))}
</select>
<button
onClick={onRemove}
className="text-slate-400 hover:text-red-500 transition-colors"
aria-label="Remove filter"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex flex-col gap-2.5">
{/* Stop / Upcoming Stop */}
{(filter.type === "stop" || filter.type === "upcoming_stop") && (
<>
<select
value={filter.params.stop_id ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, stop_id: e.target.value } })}
onMouseEnter={() => loadStops(filter.type === "stop")}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 w-full outline-none focus:border-slate-900"
>
<option value="">Select {filter.type === "stop" ? "past" : "upcoming"} stop</option>
{stops.map((s) => (
<option key={s.id} value={s.id}>{s.city} {s.date}</option>
))}
</select>
<div className="grid grid-cols-2 gap-2">
<input
type="date"
value={filter.params.date_from ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_from: e.target.value } })}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<input
type="date"
value={filter.params.date_to ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_to: e.target.value } })}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</div>
</>
)}
{/* Product */}
{filter.type === "product" && (
<>
<select
value={filter.params.product_id ?? ""}
onChange={(e) => {
onChange({ params: { ...filter.params, product_id: e.target.value } });
if (e.target.value) loadProducts();
}}
onMouseEnter={loadProducts}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 w-full outline-none focus:border-slate-900"
>
<option value="">Select a product</option>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-zinc-500">In the last</label>
<input
type="number"
min={1}
max={365}
value={filter.params.days_back ?? 90}
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
className="border border-zinc-800 rounded-lg px-2.5 py-1.5 text-sm w-20 outline-none focus:border-slate-900"
/>
<span className="text-xs text-zinc-500">days</span>
</div>
</>
)}
{/* ZIP / City */}
{filter.type === "zip_code" && (
<>
<input
type="text"
placeholder="ZIP codes (comma-separated)"
value={(filter.params.zip_codes ?? []).join(", ")}
onChange={(e) =>
onChange({
params: {
...filter.params,
zip_codes: e.target.value.split(",").map((z) => z.trim()).filter(Boolean),
},
})
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<input
type="text"
placeholder="City (optional)"
value={filter.params.city ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, city: e.target.value } })}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</>
)}
{/* Customer History */}
{filter.type === "customer_history" && (
<div className="flex flex-col gap-2">
<select
value={filter.params.order_history ?? "all"}
onChange={(e) =>
onChange({ params: { ...filter.params, order_history: e.target.value as "all" | "first_order" | "repeat" } })
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 outline-none focus:border-slate-900"
>
{ORDER_HISTORY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-zinc-500">In the last</label>
<input
type="number"
min={1}
max={365}
value={filter.params.days_back ?? 90}
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
className="border border-zinc-800 rounded-lg px-2.5 py-1.5 text-sm w-20 outline-none focus:border-slate-900"
/>
<span className="text-xs text-zinc-500">days</span>
</div>
</div>
)}
{/* Tags */}
{filter.type === "tags" && (
<input
type="text"
placeholder="Tags (comma-separated)"
value={(filter.params.tags ?? []).join(", ")}
onChange={(e) =>
onChange({
params: {
...filter.params,
tags: e.target.value.split(",").map((t) => t.trim()).filter(Boolean),
},
})
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
)}
{/* All customers */}
{filter.type === "all_customers" && (
<p className="text-xs text-slate-400">Matches all customers in your brand.</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
type Props = {
initialName?: string;
initialDescription?: string;
onSave: (name: string, description: string) => Promise<void>;
onClose: () => void;
};
export default function SegmentEditModal({ initialName = "", initialDescription = "", onSave, onClose }: Props) {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [saving, setSaving] = useState(false);
async function handleSave() {
if (!name.trim()) return;
setSaving(true);
await onSave(name.trim(), description.trim());
setSaving(false);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-zinc-900 rounded-xl border border-zinc-800 w-full max-w-md p-6 flex flex-col gap-4 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-zinc-100">
{initialName ? "Update Segment" : "Save Segment"}
</h2>
<button onClick={onClose} className="text-slate-400 hover:text-zinc-400 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex flex-col gap-3">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Segment Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
placeholder="e.g. Fort Pierce Regulars"
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Description <span className="text-slate-400 font-normal">(optional)</span></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. Customers who pick up at the Fort Pierce stop regularly"
rows={3}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm resize-none"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={onClose}
className="flex-1 py-2.5 rounded-lg border border-zinc-600 text-sm font-medium text-zinc-400 hover:bg-zinc-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!name.trim() || saving}
className="flex-1 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "Saving…" : "Save Segment"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import type { Segment } from "@/actions/harvest-reach/segments";
type Props = {
segments: Segment[];
activeSegmentId?: string;
onSelect: (segment: Segment) => void;
onNew: () => void;
onDelete: (segmentId: string) => void;
};
export default function SegmentListSidebar({ segments, activeSegmentId, onSelect, onNew, onDelete }: Props) {
const [search, setSearch] = useState("");
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const filtered = segments.filter((s) =>
s.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Saved Segments</h3>
<button
onClick={onNew}
className="w-7 h-7 rounded-lg bg-stone-900 text-white flex items-center justify-center text-base leading-none hover:bg-stone-800 transition-colors"
aria-label="New segment"
>
+
</button>
</div>
<input
type="search"
placeholder="Search segments…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<div className="flex flex-col gap-1.5">
{filtered.length === 0 && (
<p className="text-xs text-slate-400 text-center py-4">
{search ? "No segments match." : "No saved segments yet."}
</p>
)}
{filtered.map((segment) => (
<div key={segment.id} className="group relative">
{confirmDelete === segment.id ? (
<div className="rounded-xl border border-red-200 bg-red-900/30 p-3 flex flex-col gap-2">
<p className="text-xs text-red-400 font-medium">Delete &ldquo;{segment.name}&rdquo;?</p>
<div className="flex gap-2">
<button
onClick={() => { onDelete(segment.id); setConfirmDelete(null); }}
className="flex-1 py-1.5 text-xs rounded-lg bg-red-600 text-white hover:bg-red-700 font-medium"
>
Delete
</button>
<button
onClick={() => setConfirmDelete(null)}
className="flex-1 py-1.5 text-xs rounded-lg border border-zinc-600 bg-zinc-900 hover:bg-zinc-800 font-medium"
>
Cancel
</button>
</div>
</div>
) : (
<div
onClick={() => onSelect(segment)}
className={`px-3 py-2.5 rounded-xl cursor-pointer flex items-center justify-between gap-2 transition-all ${
activeSegmentId === segment.id
? "bg-stone-100 border border-stone-300"
: "hover:bg-zinc-800 border border-transparent"
}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{segment.name}</p>
{segment.description && (
<p className="text-xs text-slate-400 truncate mt-0.5">{segment.description}</p>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); setConfirmDelete(segment.id); }}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all p-1"
aria-label="Delete segment"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
)}
</div>
))}
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateWaterHeadgate, deleteWaterHeadgate } from "@/actions/water-log/admin";
type Headgate = {
id: string;
name: string;
active: boolean;
unit: string;
created_at: string;
};
const UNIT_OPTIONS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"];
type Props = {
headgate: Headgate;
backHref?: string;
};
export default function HeadgateEditForm({ headgate, backHref = "/admin/water-log" }: Props) {
const router = useRouter();
const [name, setName] = useState(headgate.name);
const [active, setActive] = useState(headgate.active);
const [unit, setUnit] = useState(headgate.unit);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const result = await updateWaterHeadgate(headgate.id, name.trim(), active, unit);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to save");
setSaving(false);
}
}
async function handleDelete() {
if (!window.confirm(`Delete headgate "${headgate.name}"? Existing log entries will be preserved.`)) return;
setDeleting(true);
const result = await deleteWaterHeadgate(headgate.id);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to delete");
setDeleting(false);
}
}
return (
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
{error}
</div>
)}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-400 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Default Unit</label>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
{UNIT_OPTIONS.map((u) => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Status</label>
<select
value={active ? "1" : "0"}
onChange={(e) => setActive(e.target.value === "1")}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div className="flex items-center justify-between pt-2">
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{deleting ? "..." : "Delete Headgate"}
</button>
<button
type="submit"
disabled={saving}
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{saving ? "..." : "Save Changes"}
</button>
</div>
</form>
);
}
+329
View File
@@ -0,0 +1,329 @@
"use client";
import { useState, useEffect } from "react";
import AIProviderPanel from "@/components/admin/AIProviderPanel";
import { savePaymentSettings } from "@/actions/payments";
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
type CredentialField = {
key: string;
label: string;
placeholder: string;
isSecret?: boolean;
};
type SyncOption = {
key: string;
label: string;
description?: string;
};
type Integration = {
id: string;
name: string;
icon: string;
description: string;
accentColor: string;
credentials: CredentialField[];
syncOptions: SyncOption[];
};
const INTEGRATIONS: Integration[] = [
{
id: "openai",
name: "OpenAI",
icon: "AI",
description: "Power AI tools like Campaign Writer, Report Explainer, and Pricing Advisor.",
accentColor: "border-violet-200",
credentials: [
{ key: "OPENAI_API_KEY", label: "API Key", placeholder: "sk-...", isSecret: true },
{ key: "OPENAI_ORG_ID", label: "Organization ID", placeholder: "org-... (optional)" },
],
syncOptions: [
{ key: "campaign_writer", label: "Campaign Idea Generator" },
{ key: "product_writer", label: "Product Description Writer" },
{ key: "report_explainer", label: "Report Explainer" },
{ key: "pricing_advisor", label: "Pricing Advisor" },
],
},
{
id: "resend",
name: "Resend",
icon: "Email",
description: "Send transactional and marketing emails via Harvest Reach.",
accentColor: "border-amber-200",
credentials: [
{ key: "RESEND_API_KEY", label: "API Key", placeholder: "re_...", isSecret: true },
{ key: "RESEND_FROM_EMAIL", label: "From Email Address", placeholder: "orders@yourbrand.com" },
{ key: "RESEND_FROM_NAME", label: "From Name", placeholder: "Your Brand Name" },
],
syncOptions: [
{ key: "transactional", label: "Transactional Email" },
{ key: "marketing", label: "Marketing Campaigns" },
],
},
{
id: "stripe",
name: "Stripe",
icon: "Stripe",
description: "Process online payments for orders.",
accentColor: "border-stone-300",
credentials: [
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", placeholder: "pk_live_..." },
{ key: "STRIPE_SECRET_KEY", label: "Secret Key", placeholder: "sk_live_...", isSecret: true },
{ key: "STRIPE_WEBHOOK_SECRET", label: "Webhook Secret", placeholder: "whsec_...", isSecret: true },
],
syncOptions: [
{ key: "checkout", label: "Online Checkout" },
{ key: "refunds", label: "Refund Processing" },
],
},
{
id: "twilio",
name: "Twilio",
icon: "SMS",
description: "Send SMS campaigns and alerts via Harvest Reach.",
accentColor: "border-blue-200",
credentials: [
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", placeholder: "AC..." },
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", placeholder: "Your Twilio auth token", isSecret: true },
{ key: "TWILIO_PHONE_NUMBER", label: "Phone Number", placeholder: "+1234567890" },
],
syncOptions: [
{ key: "sms_campaigns", label: "SMS Campaigns" },
{ key: "alerts", label: "Order Alerts" },
],
},
];
const iconColors: Record<string, string> = {
AI: "bg-violet-100 text-violet-700",
Email: "bg-amber-100 text-amber-700",
Stripe: "bg-stone-100 text-stone-700",
SMS: "bg-blue-100 text-blue-700",
};
function IntegrationCard({
integration,
initialCredentials,
brandId,
}: {
integration: Integration;
initialCredentials?: Record<string, string>;
brandId: string;
}) {
const [credentials, setCredentials] = useState<Record<string, string>>(
initialCredentials ?? {}
);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleTest() {
setTestResult(null);
setError(null);
if (integration.id === "resend") {
const apiKey = credentials["RESEND_API_KEY"];
if (!apiKey?.trim()) {
setTestResult({ ok: false, message: "API key is required" });
return;
}
const result = await testResendConnection(apiKey);
setTestResult(result);
} else if (integration.id === "twilio") {
const accountSid = credentials["TWILIO_ACCOUNT_SID"];
const authToken = credentials["TWILIO_AUTH_TOKEN"];
if (!accountSid?.trim() || !authToken?.trim()) {
setTestResult({ ok: false, message: "Account SID and Auth Token are required" });
return;
}
const result = await testTwilioConnection(accountSid, authToken);
setTestResult(result);
} else {
// Default test (placeholder)
await new Promise((r) => setTimeout(r, 500));
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
setTestResult(
hasKey
? { ok: true, message: `Successfully connected to ${integration.name}` }
: { ok: false, message: `No API key configured for ${integration.name}` }
);
}
}
async function handleSave() {
setSaving(true);
setError(null);
setTestResult(null);
try {
if (integration.id === "resend") {
const result = await saveResendCredentials(brandId, {
api_key: credentials["RESEND_API_KEY"]?.trim() || null,
from_email: credentials["RESEND_FROM_EMAIL"]?.trim() || null,
from_name: credentials["RESEND_FROM_NAME"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Resend credentials saved successfully" });
} else if (integration.id === "twilio") {
const result = await saveTwilioCredentials(brandId, {
account_sid: credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
auth_token: credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
phone_number: credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Twilio credentials saved successfully" });
} else if (integration.id === "stripe") {
const result = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Stripe credentials saved successfully" });
} else {
// Other integrations - just show success for now
setTestResult({ ok: true, message: `${integration.name} settings saved` });
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
} finally {
setSaving(false);
}
}
function toggleSecret(key: string) {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}
return (
<div className={`rounded-xl border p-5 bg-white shadow-sm ${integration.accentColor}`}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<span className={`w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[integration.icon] ?? "bg-stone-100 text-stone-700"}`}>{integration.icon}</span>
<div>
<h3 className="text-base font-semibold text-stone-900">{integration.name}</h3>
<p className="text-xs text-stone-500 mt-0.5">{integration.description}</p>
</div>
</div>
</div>
<div className="space-y-3 mb-4">
{integration.credentials.map((field) => (
<div key={field.key}>
<label className="block text-xs font-medium text-stone-600 mb-1">{field.label}</label>
<div className="relative">
<input
type={showSecrets[field.key] ? "text" : "password"}
value={credentials[field.key] ?? ""}
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm pr-16 text-stone-900 placeholder:text-stone-400 outline-none focus:border-emerald-500"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
{field.isSecret && (
<button
type="button"
onClick={() => toggleSecret(field.key)}
className="text-xs text-stone-400 hover:text-stone-600 px-1"
>
{showSecrets[field.key] ? "Hide" : "Show"}
</button>
)}
</div>
</div>
</div>
))}
</div>
{testResult && (
<div className={`mb-4 rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
testResult.ok ? "bg-emerald-50 text-emerald-700 border border-emerald-200" : "bg-red-50 text-red-700 border border-red-200"
}`}>
<span>{testResult.ok ? "✓" : "✗"}</span>
<span>{testResult.message}</span>
</div>
)}
{error && (
<div className="mb-4 rounded-lg px-4 py-2.5 text-sm bg-red-50 text-red-700 border border-red-200">{error}</div>
)}
<div className="flex gap-2">
<button onClick={handleTest} className="rounded-lg border border-stone-200 px-4 py-2 text-xs font-medium text-stone-600 hover:bg-stone-50">Test Connection</button>
<button onClick={handleSave} disabled={saving} className="flex-1 rounded-lg bg-stone-900 px-4 py-2 text-xs font-bold text-white hover:bg-stone-800 disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
);
}
type Props = {
brandId: string;
brands: { id: string; name: string }[];
};
export default function IntegrationsInner({ brandId, brands }: Props) {
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
// Fetch initial credentials for each integration
useEffect(() => {
async function fetchCredentials() {
const [resendCreds, twilioCreds] = await Promise.all([
getResendCredentials(selectedBrandId),
getTwilioCredentials(selectedBrandId),
]);
setInitialCredentials({
resend: {
RESEND_API_KEY: resendCreds.api_key ?? "",
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
RESEND_FROM_NAME: resendCreds.from_name ?? "",
},
twilio: {
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
},
});
}
fetchCredentials();
}, [selectedBrandId]);
return (
<div>
{/* AI Provider — separate panel */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold text-stone-800">AI Provider</h3>
<a href="/admin/settings/ai" className="text-xs text-emerald-600 hover:text-emerald-700 underline underline-offset-2">Configure AI </a>
</div>
<AIProviderPanel brandId={selectedBrandId} />
</div>
{/* Payment & email integrations grid */}
<div className="space-y-3 mb-4">
{INTEGRATIONS.filter(i => i.id !== "openai").map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
initialCredentials={initialCredentials[integration.id]}
brandId={selectedBrandId}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect } from "react";
import { sendStopBlast } from "@/actions/communications/stop-blast";
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
pickup_complete: boolean;
};
type BlastMessage = {
id: string;
type: string;
subject: string | null;
body: string;
created_at: string;
message_recipients: { id: string }[];
};
type MessageCustomersSectionProps = {
stopId: string;
brandId?: string;
};
export default function MessageCustomersSection({
stopId,
brandId,
}: MessageCustomersSectionProps) {
const [orders, setOrders] = useState<Order[]>([]);
const [messages, setMessages] = useState<BlastMessage[]>([]);
const [loadingOrders, setLoadingOrders] = useState(true);
const [loadingMessages, setLoadingMessages] = useState(true);
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 [error, setError] = useState<string | null>(null);
const [confirm, setConfirm] = useState<string | null>(null);
useEffect(() => {
fetchOrders();
fetchMessages();
}, []);
async function fetchOrders() {
setLoadingOrders(true);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?stop_id=eq.${stopId}&select=customer_name,customer_email,customer_phone,pickup_complete`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
}
);
const data = await res.json();
if (res.ok) setOrders(data);
setLoadingOrders(false);
}
async function fetchMessages() {
// Legacy messages
const [msgRes, campaignRes] = await Promise.all([
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/messages?stop_id=eq.${stopId}&select=*,message_recipients(id)&order=created_at.desc&limit=10`,
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
),
// Campaign-based blast logs for this stop
brandId
? fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/communication_campaigns?brand_id=eq.${brandId}&audience_rules->>'stop_id'=eq.${stopId}&order=created_at.desc&limit=5`,
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
)
: Promise.resolve({ ok: false, json: async () => [] } as Response),
]);
const legacyData = msgRes.ok ? await msgRes.json() : [];
const campaignData = campaignRes.ok ? await campaignRes.json() : [];
// Normalize campaign logs into BlastMessage shape
const campaignMessages: BlastMessage[] = (campaignData as Array<{
id: string; subject: string | null; body_text: string | null;
sent_at: string; created_at: string; status: string;
}>).map((c) => ({
id: c.id,
type: "campaign",
subject: c.subject,
body: c.body_text ?? "",
created_at: c.sent_at,
message_recipients: [],
}));
// Interleave: legacy first, then campaign-based
const merged = [...legacyData, ...campaignMessages].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
setMessages(merged.slice(0, 10));
setLoadingMessages(false);
}
const allOrders = orders;
const pendingOrders = orders.filter((o) => !o.pickup_complete);
const pickedUpOrders = orders.filter((o) => o.pickup_complete);
const filteredOrders = (() => {
if (audience === "pending") return pendingOrders;
if (audience === "picked_up") return pickedUpOrders;
return allOrders;
})();
const recipients = filteredOrders.filter((o) => {
if (channel === "sms") return o.customer_phone;
if (channel === "email") return o.customer_email;
return o.customer_phone || o.customer_email;
});
const showSubject = channel === "email" || channel === "both";
async function handleSend(e: React.FormEvent) {
e.preventDefault();
if (!body.trim()) return;
if (showSubject && !subject.trim()) {
setError("Subject is required for email or both.");
return;
}
if (!brandId) {
setError("Brand ID not available.");
return;
}
setSending(true);
setError(null);
setConfirm(null);
const result = await sendStopBlast({
stopId,
brandId,
channel,
subject: showSubject ? subject : undefined,
body,
audience,
});
setSending(false);
if (!result.success) {
setError(result.error ?? "Failed to send blast");
return;
}
setConfirm(`Blast sent — ${result.messages_logged} message${result.messages_logged !== 1 ? "s" : ""} logged via campaign.`);
setBody("");
setSubject("");
fetchMessages();
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{confirm && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
{confirm}
</div>
)}
{loadingOrders ? (
<p className="text-sm text-zinc-500">Loading orders...</p>
) : 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">
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
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">
Channel
</label>
<div className="flex gap-2">
{(["sms", "email", "both"] as const).map((ch) => (
<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 className="mb-1 block text-sm font-medium text-zinc-300">
Subject
</label>
<input
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 className="mb-1 block text-sm font-medium text-zinc-300">
Message
</label>
<textarea
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>
</>
)}
{/* 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>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useState } from "react";
import type { MessageLogEntry } from "@/actions/communications/send";
import { formatDate } from "@/lib/format-date";
const STATUS_COLORS: Record<string, string> = {
queued: "bg-zinc-950 text-zinc-300",
sent: "bg-blue-900/40 text-blue-700",
delivered: "bg-emerald-100 text-emerald-700",
opened: "bg-violet-100 text-violet-700",
clicked: "bg-amber-100 text-amber-700",
bounced: "bg-red-900/40 text-red-400",
failed: "bg-red-900/40 text-red-400",
unsubscribed: "bg-orange-100 text-orange-700",
};
const METHOD_COLORS: Record<string, string> = {
email: "bg-blue-900/40 text-blue-700",
sms: "bg-green-900/40 text-green-400",
push: "bg-purple-100 text-purple-700",
internal: "bg-zinc-950 text-zinc-400",
};
function EngagementBadges({ log }: { log: MessageLogEntry }) {
const badges = [];
if (log.delivered_at) {
badges.push(
<span key="delivered" className="rounded-full bg-emerald-100 text-emerald-700 px-2 py-0.5 text-xs font-medium">
Delivered
</span>
);
}
if (log.opened_at) {
badges.push(
<span key="opened" className="rounded-full bg-violet-100 text-violet-700 px-2 py-0.5 text-xs font-medium">
Opened {formatDate(new Date(log.opened_at))}
</span>
);
}
if (log.clicked_at) {
badges.push(
<span key="clicked" className="rounded-full bg-amber-100 text-amber-700 px-2 py-0.5 text-xs font-medium">
Clicked {formatDate(new Date(log.clicked_at))}
</span>
);
}
if (log.bounced_at) {
badges.push(
<span key="bounced" className="rounded-full bg-red-900/40 text-red-400 px-2 py-0.5 text-xs font-medium">
Bounced
{log.bounce_reason && <span className="ml-1">{log.bounce_reason}</span>}
</span>
);
}
return badges.length > 0 ? <div className="flex flex-wrap gap-1 mt-1">{badges}</div> : null;
}
export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageLogEntry[] }) {
const [logs] = useState(initialLogs);
const [filterStatus, setFilterStatus] = useState("all");
const [filterMethod, setFilterMethod] = useState("all");
const filtered = logs.filter((l) => {
if (filterStatus !== "all" && l.status !== filterStatus) return false;
if (filterMethod !== "all" && l.delivery_method !== filterMethod) return false;
return true;
});
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Message Logs</h2>
<p className="text-sm text-zinc-500">{filtered.length} message{filtered.length !== 1 ? "s" : ""}</p>
</div>
</div>
<div className="flex gap-4 mb-4 flex-wrap">
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
>
<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>
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
value={filterMethod}
onChange={(e) => setFilterMethod(e.target.value)}
>
<option value="all">All Methods</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
<option value="push">Push</option>
<option value="internal">Internal</option>
</select>
</div>
{filtered.length === 0 ? (
<div className="text-center py-12 text-slate-400">No messages logged yet</div>
) : (
<>
{/* Desktop table */}
<div className="hidden sm:block bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Sent At</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Recipient</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Method</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Subject</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Status</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Engagement</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((l) => (
<tr key={l.id} className="hover:bg-zinc-800">
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">
{l.sent_at ? new Date(l.sent_at).toLocaleString() : "—"}
</td>
<td className="px-4 py-3 text-zinc-300 text-xs">{l.customer_email ?? "—"}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[l.delivery_method] ?? "bg-zinc-950"}`}>
{l.delivery_method}
</span>
</td>
<td className="px-4 py-3 text-zinc-300 text-xs truncate max-w-[180px]">{l.subject ?? "—"}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[l.status] ?? "bg-zinc-950"}`}>
{l.status}
</span>
{l.error_message && (
<p className="text-xs text-red-500 mt-0.5 truncate max-w-[120px]">{l.error_message}</p>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
{l.delivered_at && (
<span className="text-xs text-emerald-600"> {formatDate(new Date(l.delivered_at))}</span>
)}
{l.opened_at && (
<span className="text-xs text-violet-600">Opened {formatDate(new Date(l.opened_at))}</span>
)}
{l.clicked_at && (
<span className="text-xs text-amber-400">Clicked {formatDate(new Date(l.clicked_at))}</span>
)}
{l.bounced_at && (
<span className="text-xs text-red-500"> Bounced{l.bounce_reason ? `: ${l.bounce_reason}` : ""}</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{filtered.map((l) => (
<div key={l.id} className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 space-y-2">
<div className="flex items-start justify-between">
<div className="text-sm font-medium text-zinc-100">{l.customer_email ?? "—"}</div>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[l.status] ?? "bg-zinc-950"}`}>
{l.status}
</span>
</div>
<div className="text-xs text-zinc-500">{l.subject ?? "—"}</div>
<div className="flex items-center gap-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[l.delivery_method] ?? "bg-zinc-950"}`}>
{l.delivery_method}
</span>
<span className="text-xs text-slate-400">
{l.sent_at ? formatDate(new Date(l.sent_at)) : "—"}
</span>
</div>
<EngagementBadges log={l} />
</div>
))}
</div>
</>
)}
</div>
);
}
+314
View File
@@ -0,0 +1,314 @@
"use client";
import NextImage from "next/image";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { uploadProductImage } from "@/actions/products/upload-image";
import { createProduct } from "@/actions/products/create-product";
export default function NewProductForm() {
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 fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImageUrl, setPendingImageUrl] = useState<string>("");
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.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("Image must be under 5MB.");
return;
}
// Client-side resize to max 1200px width
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
setUploadError(null);
setUploading(true);
const result = await uploadProductImage("__NEW__", resizedFile);
setUploading(false);
if (result.success) {
setPendingImageUrl(result.imageUrl);
setImagePreview(result.imageUrl);
} else {
setUploadError(result.error ?? "Upload failed.");
}
}
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to resize image"));
return;
}
blob.arrayBuffer().then((buf) => resolve(buf));
}, "image/jpeg", 0.85);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const price = parseFloat(formData.get("price") as string);
const type = formData.get("type") as string;
const brandId = formData.get("brand_id") as string;
const active = formData.get("active") === "true";
// Use pendingImageUrl if image was uploaded, otherwise null
const imageUrl = pendingImageUrl || null;
const isTaxable = formData.get("is_taxable") === "true";
const pickup_type = formData.get("pickup_type") as string;
const result = await createProduct(brandId, {
name,
description,
price,
type,
active,
image_url: imageUrl,
is_taxable: isTaxable,
pickup_type,
});
if (!result.success) {
setError(result.error ?? "Failed to create product");
setLoading(false);
return;
}
router.push("/admin/products");
router.refresh();
}
return (
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-300">
Product Name
</label>
<input
name="name"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. Dozen Sweet Corn"
autoComplete="off"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
Description
</label>
<textarea
name="description"
rows={3}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. Fresh-picked Olathe sweet corn."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">
Price ($)
</label>
<input
name="price"
type="number"
step="0.01"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="12.00"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
Type
</label>
<select
name="type"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="Pickup">Pickup</option>
<option value="Shipping">Shipping</option>
<option value="Pickup & Shipping">Pickup & Shipping</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
Brand
</label>
<select
name="brand_id"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<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>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Taxable</label>
<p className="text-xs text-zinc-500 mb-2">Tax applied at checkout for shipping orders in nexus states. Disable for non-taxable items like apparel or cooler boxes.</p>
<select
name="is_taxable"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="true">Yes taxable (default)</option>
<option value="false">No non-taxable</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Pickup Type</label>
<p className="text-xs text-zinc-500 mb-2">Shed pickup uses the product description as the location no stop required at checkout.</p>
<select
name="pickup_type"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="scheduled_stop">Scheduled Stop requires stop selection at checkout</option>
<option value="shed">Shed Pickup uses description as location</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
Active
</label>
<select
name="active"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="true">Yes show on storefront</option>
<option value="false">No hide from storefront</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Product Image</label>
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}}
onClick={() => fileInputRef.current?.click()}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${uploadError ? "border-red-400" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" />
<span className="text-sm text-zinc-500">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-zinc-500">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-slate-400" 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-zinc-500">Drag & drop or click to upload</span>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
</div>
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>}
{imagePreview && (
<button
type="button"
onClick={() => { setImagePreview(null); setPendingImageUrl(""); }}
className="mt-2 text-xs text-red-500 hover:underline"
>
Remove image
</button>
)}
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={loading}
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white disabled:opacity-50"
>
{loading ? "Creating..." : "Create Product"}
</button>
<a
href="/admin/products"
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300"
>
Cancel
</a>
</div>
</form>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createStop } from "@/actions/stops/create-stop";
type Stop = {
city: string;
state: string;
location: string;
date: string;
time: string;
brand_id: string;
active: boolean;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
};
type Props = {
duplicateFrom?: Stop | null;
};
export default function NewStopForm({ duplicateFrom }: Props) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const defaultBrand = duplicateFrom?.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const city = formData.get("city") as string;
const state = formData.get("state") as string;
const location = formData.get("location") as string;
const date = formData.get("date") as string;
const time = formData.get("time") as string;
const brandId = formData.get("brand_id") as string;
const active = formData.get("active") === "true";
const result = await createStop(brandId, {
city,
state,
location,
date,
time,
active,
address: (formData.get("address") as string) || null,
zip: (formData.get("zip") as string) || null,
cutoff_time: (formData.get("cutoff_time") as string) || null,
});
if (!result.success) {
setError(result.error ?? "Failed to create stop");
setLoading(false);
return;
}
if (result.id) {
router.push(`/admin/stops/${result.id}`);
} else {
router.push("/admin/stops");
}
router.refresh();
}
return (
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">City</label>
<input
name="city"
required
defaultValue={duplicateFrom?.city}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. Denver"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">State</label>
<input
name="state"
required
maxLength={2}
defaultValue={duplicateFrom?.state}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. CO"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Location Name</label>
<input
name="location"
required
defaultValue={duplicateFrom?.location}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. Southwest Plaza Parking Lot"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Date</label>
<input
name="date"
required
type="date"
defaultValue={duplicateFrom?.date}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Time</label>
<input
name="time"
required
type="text"
defaultValue={duplicateFrom?.time}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. 8:00 AM 2:00 PM"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Brand</label>
<select
name="brand_id"
required
defaultValue={defaultBrand}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<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>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Active</label>
<select
name="active"
defaultValue="true"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="true">Yes show on storefront</option>
<option value="false">No hide from storefront</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Street Address</label>
<input
name="address"
defaultValue={duplicateFrom?.address ?? ""}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="123 Main St"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">ZIP Code</label>
<input
name="zip"
maxLength={10}
defaultValue={duplicateFrom?.zip ?? ""}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="80102"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Order Cutoff</label>
<input
name="cutoff_time"
type="datetime-local"
defaultValue={duplicateFrom?.cutoff_time ?? ""}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
/>
<p className="mt-1 text-xs text-slate-400">
Customers must order before this time to be included at this stop.
</p>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={loading}
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white disabled:opacity-50"
>
{loading ? "Creating..." : "Create Stop"}
</button>
<a
href="/admin/stops"
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300"
>
Cancel
</a>
</div>
</form>
);
}
+440
View File
@@ -0,0 +1,440 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateOrder, updateOrderItem, deleteOrderItem } from "@/actions/orders/update-order";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
products: { name: string } | null;
};
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
stop_id: string | null;
status: string;
subtotal: number;
discount_amount: number;
tax_amount: number | null;
tax_rate: number | null;
tax_source: string | null;
tax_location: string | null;
internal_notes: string | null;
discount_reason: string | null;
pickup_complete: boolean;
pickup_completed_at: string | null;
pickup_completed_by: string | null;
created_at: string;
stops: { city: string; state: string; date: string } | null;
order_items: OrderItem[];
};
type OrderEditFormProps = {
order: Order;
brandId: string | null;
};
type EditableItem = {
id: string;
product_id: string;
quantity: number;
price: number;
productName: string;
removed: boolean;
};
export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [customer_name, setCustomer_name] = useState(order.customer_name);
const [customer_email, setCustomer_email] = useState(order.customer_email ?? "");
const [customer_phone, setCustomer_phone] = useState(order.customer_phone ?? "");
const [discount_amount, setDiscount_amount] = useState(order.discount_amount ?? 0);
const [discount_reason, setDiscount_reason] = useState(order.discount_reason ?? "");
const [internal_notes, setInternal_notes] = useState(order.internal_notes ?? "");
const [status, setStatus] = useState(order.status);
const [pickup_complete, setPickup_complete] = useState(order.pickup_complete);
const [items, setItems] = useState<EditableItem[]>(
order.order_items.map((item) => ({
id: item.id,
product_id: item.product_id,
quantity: item.quantity,
price: Number(item.price),
productName: item.products?.name ?? "Unknown",
removed: false,
}))
);
const visibleItems = items.filter((i) => !i.removed);
const subtotal = visibleItems.reduce(
(sum, i) => sum + Number(i.price) * i.quantity,
0
);
const total = Math.max(0, subtotal - discount_amount);
function updateItem(id: string, field: "quantity" | "price", value: number) {
setItems((prev) =>
prev.map((i) => (i.id === id ? { ...i, [field]: value } : i))
);
}
function removeItem(id: string) {
setItems((prev) =>
prev.map((i) => (i.id === id ? { ...i, removed: true } : i))
);
}
async function handleSave() {
setSaving(true);
setError(null);
setSaved(false);
const toSave = items.filter((i) => !i.removed);
const toRemove = items.filter((i) => i.removed);
for (const item of toRemove) {
const result = await deleteOrderItem(item.id);
if (!result.success) {
setError(result.error ?? "Failed to remove item");
setSaving(false);
return;
}
}
for (const item of toSave) {
const orig = order.order_items.find((o) => o.id === item.id);
if (!orig) continue;
if (
Number(orig.quantity) !== item.quantity ||
Number(orig.price) !== item.price
) {
const result = await updateOrderItem(item.id, {
quantity: item.quantity,
price: Number(item.price),
});
if (!result.success) {
setError(result.error ?? "Failed to update item");
setSaving(false);
return;
}
}
}
const result = await updateOrder(order.id, brandId, {
customer_name,
customer_email: customer_email || null,
customer_phone: customer_phone || null,
discount_amount,
discount_reason: discount_reason || null,
internal_notes: internal_notes || null,
status,
pickup_complete,
pickup_completed_at: pickup_complete ? new Date().toISOString() : null,
subtotal,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
setSaving(false);
return;
}
setSaved(true);
setSaving(false);
router.refresh();
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
Order updated successfully.
</div>
)}
{/* Order items */}
<div>
<p className="mb-2 text-sm font-medium text-zinc-300">
Order Items ({visibleItems.length})
</p>
{visibleItems.length === 0 ? (
<p className="rounded-xl border border-dashed border-zinc-600 p-4 text-center text-sm text-zinc-500">
No items.
</p>
) : (
<div className="space-y-3">
{visibleItems.map((item) => (
<div
key={item.id}
className="rounded-xl border border-zinc-800 bg-zinc-900 p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium text-zinc-100">{item.productName}</p>
<button
onClick={() => removeItem(item.id)}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-red-400 hover:bg-red-900/30"
>
Remove
</button>
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-500">
Qty
</label>
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
updateItem(item.id, "quantity", Number(e.target.value))
}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-500">
Price
</label>
<input
type="number"
step="0.01"
min="0"
value={item.price}
onChange={(e) =>
updateItem(item.id, "price", Number(e.target.value))
}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
</div>
</div>
<p className="mt-2 text-right text-sm font-semibold text-zinc-100">
${(Number(item.price) * item.quantity).toFixed(2)}
</p>
</div>
))}
</div>
)}
</div>
{/* Pricing — subtotal derived from items */}
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Subtotal (auto-calculated)
</label>
<input
type="number"
step="0.01"
value={subtotal}
readOnly
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Discount Amount
</label>
<input
type="number"
step="0.01"
min="0"
value={discount_amount}
onChange={(e) => setDiscount_amount(Number(e.target.value))}
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 className="mb-1 block text-sm font-medium text-zinc-300">
Discount Reason
</label>
<input
type="text"
value={discount_reason}
onChange={(e) => setDiscount_reason(e.target.value)}
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 className="mb-1 block text-sm font-medium text-zinc-300">
Tax Amount
</label>
<input
type="number"
step="0.01"
min="0"
value={order.tax_amount ?? 0}
onChange={(e) => {}}
readOnly
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
/>
<p className="mt-1 text-xs text-slate-400 italic">Taxes calculated at payment integration.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Tax Rate
</label>
<input
type="text"
value={order.tax_rate ?? ""}
readOnly
placeholder="e.g. 0.08"
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Tax Location
</label>
<input
type="text"
value={order.tax_location ?? ""}
readOnly
placeholder="e.g. NC"
className="w-full rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3 text-base text-zinc-500"
/>
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
<div className="flex justify-between">
<span className="text-lg font-medium text-zinc-300">Total</span>
<span className="text-2xl font-bold text-zinc-100">
${(subtotal + Number(order.tax_amount ?? 0) - discount_amount).toFixed(2)}
</span>
</div>
</div>
</div>
{/* Customer fields */}
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Name
</label>
<input
type="text"
value={customer_name}
onChange={(e) => setCustomer_name(e.target.value)}
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 className="mb-1 block text-sm font-medium text-zinc-300">
Email
</label>
<input
type="email"
value={customer_email}
onChange={(e) => setCustomer_email(e.target.value)}
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 className="mb-1 block text-sm font-medium text-zinc-300">
Phone
</label>
<input
type="tel"
value={customer_phone}
onChange={(e) => setCustomer_phone(e.target.value)}
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>
{/* Status & pickup */}
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Status
</label>
<div className="flex gap-3">
{["pending", "confirmed", "cancelled"].map((s) => (
<button
key={s}
onClick={() => setStatus(s)}
className={`flex-1 rounded-xl px-4 py-3 text-sm font-medium capitalize transition-colors ${
status === s
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{s}
</button>
))}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Pickup
</label>
<button
onClick={() => setPickup_complete((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
pickup_complete
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{pickup_complete ? "Picked Up" : "Not Picked Up"}
</button>
{order.pickup_complete && order.pickup_completed_at && (
<p className="mt-1 text-xs text-slate-400">
Completed{" "}
{new Date(order.pickup_completed_at).toLocaleString()}
</p>
)}
</div>
</div>
{/* Internal notes */}
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Internal Notes
</label>
<textarea
value={internal_notes}
onChange={(e) => setInternal_notes(e.target.value)}
rows={3}
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="Private notes for staff..."
/>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
);
}
@@ -0,0 +1,268 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/format-date";
import { updateOrder } from "@/actions/orders/update-order";
import { createRefund } from "@/actions/orders/create-refund";
type Refund = {
id: string;
order_id: string;
amount: number;
reason: string | null;
processor: string | null;
processor_refund_id: string | null;
status: string;
created_at: string;
};
type OrderPaymentSectionProps = {
orderId: string;
brandId: string | null;
orderTotal: number;
payment_processor: string | null;
payment_status: string | null;
payment_transaction_id: string | null;
refunded_amount: number;
refund_reason: string | null;
existingRefunds: Refund[];
};
export default function OrderPaymentSection({
orderId,
brandId,
orderTotal,
payment_processor,
payment_status,
payment_transaction_id,
refunded_amount,
refund_reason,
existingRefunds,
}: OrderPaymentSectionProps) {
const router = useRouter();
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);
async function handleSavePayment(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setSaved(false);
const result = await updateOrder(orderId, brandId, {
payment_processor: processor || null,
payment_status: status,
payment_transaction_id: transactionId || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
} else {
setSaved(true);
}
setSaving(false);
}
async function handleRefund(e: React.FormEvent) {
e.preventDefault();
const amount = Number(refundAmount);
if (!amount || amount <= 0) return;
setRefunding(true);
setError(null);
setRefundSaved(false);
const result = await createRefund(orderId, brandId, {
amount,
reason: refundReason || null,
});
if (!result.success) {
setError(result.error ?? "Failed to record refund");
} else {
setRefundAmount("");
setRefundReason("");
setRefundSaved(true);
}
setRefunding(false);
}
const totalRefunded = existingRefunds
.filter((r) => r.status === "completed")
.reduce((sum, r) => sum + Number(r.amount), 0);
const remainingBalance = Math.max(0, orderTotal - totalRefunded);
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">{error}</div>
)}
{/* Payment details */}
<form onSubmit={handleSavePayment} className="space-y-4">
{saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
Payment details saved.
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Processor
</label>
<select
value={processor}
onChange={(e) => setProcessor(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
>
<option value=""></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 className="mb-1 block text-sm font-medium text-zinc-300">
Payment Status
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
>
{["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 className="mb-1 block text-sm font-medium text-zinc-300">
Transaction ID
</label>
<input
type="text"
value={transactionId}
onChange={(e) => setTransactionId(e.target.value)}
placeholder="External payment reference"
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>
<button
type="submit"
disabled={saving}
className="w-full rounded-xl bg-slate-900 px-5 py-3 text-sm font-medium text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Payment Details"}
</button>
</form>
{/* Refund history */}
{existingRefunds.length > 0 && (
<div>
<p className="mb-2 text-sm font-medium text-zinc-300">
Refunds ({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-zinc-800 bg-slate-50 px-4 py-3"
>
<div>
<p className="font-medium text-zinc-100">
${Number(r.amount).toFixed(2)}
</p>
{r.reason && (
<p className="text-xs text-zinc-500">{r.reason}</p>
)}
<p className="text-xs text-slate-400">
{formatDate(new Date(r.created_at))}
</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
r.status === "completed"
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-500"
}`}
>
{r.status}
</span>
</div>
))}
</div>
<div className="mt-2 flex justify-between rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm">
<span className="text-zinc-500">Total refunded</span>
<span className="font-medium text-zinc-100">
${totalRefunded.toFixed(2)}
</span>
</div>
</div>
)}
{/* Record a refund */}
<div className="rounded-xl border border-dashed border-zinc-600 p-4">
<p className="mb-3 text-sm font-medium text-zinc-300">Record a Refund</p>
{refundSaved && (
<div className="mb-3 rounded-xl bg-green-900/30 p-3 text-sm text-green-400">
Refund recorded.
</div>
)}
<form onSubmit={handleRefund} className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-500">
Amount
</label>
<input
type="number"
step="0.01"
min="0.01"
max={remainingBalance}
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
placeholder={`Max $${remainingBalance.toFixed(2)}`}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-500">
Reason
</label>
<input
type="text"
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
placeholder="Customer request, defective product, etc."
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm outline-none focus:border-slate-900"
/>
</div>
<button
type="submit"
disabled={!refundAmount || Number(refundAmount) <= 0 || refunding}
className="w-full rounded-xl border border-red-300 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{refunding ? "Processing..." : "Record Refund"}
</button>
</form>
</div>
</div>
);
}
@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import { markPickupComplete } from "@/actions/pickup";
type Props = {
orderId: string;
brandId: string | null;
currentlyPickedUp: boolean;
};
export default function OrderPickupAction({
orderId,
brandId,
currentlyPickedUp,
}: Props) {
const [pickingUp, setPickingUp] = useState(false);
const [toast, setToast] = useState<{ type: "success" | "error"; msg: string } | null>(null);
async function handlePickup() {
setPickingUp(true);
const result = await markPickupComplete(orderId, brandId);
setPickingUp(false);
if (result.success) {
setToast({ type: "success", msg: "Order marked as picked up." });
setTimeout(() => window.location.reload(), 1200);
} else {
setToast({ type: "error", msg: result.error ?? "Failed to mark picked up." });
}
}
if (currentlyPickedUp) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Picked Up
</span>
);
}
return (
<div className="flex flex-col items-start gap-2">
<button
onClick={handlePickup}
disabled={pickingUp}
className="rounded-xl bg-green-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-50"
>
{pickingUp ? "Marking..." : "Mark Picked Up"}
</button>
{toast && (
<div
className={`rounded-lg px-3 py-2 text-sm font-medium ${
toast.type === "success" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"
}`}
>
{toast.msg}
</div>
)}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { formatDate } from "@/lib/format-date";
type Order = {
id: string;
customer_name: string;
customer_email: string | null;
stop_id: string;
status: string;
subtotal: number;
pickup_complete: boolean;
created_at: string;
};
export default function OrderTableBody({ orders }: { orders: Order[] }) {
const [pickupToggles, setPickupToggles] = useState<Record<string, boolean>>(
() => Object.fromEntries(orders.map((o) => [o.id, o.pickup_complete]))
);
async function togglePickup(orderId: string, current: boolean) {
setPickupToggles((prev) => ({ ...prev, [orderId]: !current }));
await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?id=eq.${orderId}`,
{
method: "PATCH",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({ pickup_complete: !current }),
}
);
}
const statusColors: Record<string, string> = {
pending: "bg-yellow-100 text-yellow-700",
confirmed: "bg-blue-900/40 text-blue-700",
paid: "bg-green-900/40 text-green-400",
cancelled: "bg-red-900/40 text-red-400",
completed: "bg-zinc-950 text-zinc-400",
};
return (
<tbody className="divide-y divide-slate-200">
{orders.map((order) => (
<tr key={order.id} className="hover:bg-zinc-800">
<td className="px-5 py-4">
<span className="font-mono text-sm text-zinc-500">
{order.id.slice(0, 8)}
</span>
</td>
<td className="px-5 py-4">
<div className="font-medium text-zinc-100">
{order.customer_name}
</div>
<div className="text-sm text-zinc-500">
{order.customer_email}
</div>
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
statusColors[order.status] ?? "bg-zinc-950 text-zinc-400"
}`}
>
{order.status ?? "pending"}
</span>
</td>
<td className="px-5 py-4 font-semibold text-zinc-100">
${Number(order.subtotal).toFixed(2)}
</td>
<td className="px-5 py-4">
<button
onClick={() => togglePickup(order.id, pickupToggles[order.id])}
className={`rounded-full px-3 py-1 text-xs font-medium ${
pickupToggles[order.id]
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-500"
}`}
>
{pickupToggles[order.id] ? "Picked up" : "Pending"}
</button>
</td>
<td className="px-5 py-4 text-sm text-zinc-500">
{formatDate(new Date(order.created_at))}
</td>
</tr>
))}
</tbody>
);
}
@@ -0,0 +1,449 @@
"use client";
import { useState, useEffect } from "react";
import { savePaymentSettings, type PaymentProvider, type PaymentSettings } from "@/actions/payments";
import { syncSquareNow, getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import WebhookLogsSection from "@/components/admin/WebhookLogsSection";
type InventoryMode = "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
type Props = {
settings: {
provider?: PaymentProvider | null;
stripe_publishable_key?: string | null;
stripe_secret_key?: string | null;
square_access_token?: string | null;
square_location_id?: string | null;
square_sync_enabled?: boolean;
square_inventory_mode?: InventoryMode;
square_last_sync_at?: string | null;
square_last_sync_error?: string | null;
} | null;
brandId: string;
brands?: { id: string; name: string }[];
isPlatformAdmin?: boolean;
};
export default function PaymentSettingsForm({ settings, brandId, brands = [], isPlatformAdmin = false }: Props) {
const [activeBrandId, setActiveBrandId] = useState(brandId);
const [provider, setProvider] = useState<PaymentProvider | "">(
(settings?.provider ?? "") as PaymentProvider | ""
);
const [stripePublishableKey, setStripePublishableKey] = useState(
settings?.stripe_publishable_key ?? ""
);
const [stripeSecretKey, setStripeSecretKey] = useState(
settings?.stripe_secret_key ?? ""
);
const [squareAccessToken, setSquareAccessToken] = useState(
settings?.square_access_token ?? ""
);
const [squareLocationId, setSquareLocationId] = useState(
settings?.square_location_id ?? ""
);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showStripe, setShowStripe] = useState(settings?.provider === "stripe" || settings?.provider === "square" ? settings.provider === "stripe" : false);
const [showSquare, setShowSquare] = useState(settings?.provider === "square");
// Square Sync state
const [squareSyncEnabled, setSquareSyncEnabled] = useState(
settings?.square_sync_enabled ?? false
);
const [squareInventoryMode, setSquareInventoryMode] = useState<InventoryMode>(
settings?.square_inventory_mode ?? "none"
);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<{ success: boolean; message: string } | null>(null);
const [syncLog, setSyncLog] = useState<SyncLogEntry[]>([]);
const hasSquareToken = !!settings?.square_access_token;
// Read URL params to show connection success/error
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("square_connected") === "true") {
setSyncResult({ success: true, message: "Square connected successfully!" });
window.history.replaceState({}, "", window.location.pathname);
}
const err = params.get("error");
if (err) {
setSyncResult({ success: false, message: `Square connection failed: ${err}` });
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
// Load sync log on mount
useEffect(() => {
if (hasSquareToken) {
getSyncLog(activeBrandId).then((result) => {
if (result.success) setSyncLog(result.logs);
});
}
}, [hasSquareToken, activeBrandId]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const result = await savePaymentSettings({
brandId: activeBrandId,
provider: provider || null,
stripePublishableKey: stripePublishableKey || undefined,
stripeSecretKey: stripeSecretKey || undefined,
squareAccessToken: squareAccessToken || undefined,
squareLocationId: squareLocationId || undefined,
squareSyncEnabled,
squareInventoryMode,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
} else {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
}
async function handleSyncNow(type: string) {
setSyncing(true);
setSyncResult(null);
const result = await syncSquareNow(activeBrandId, type as "products" | "orders" | "all");
setSyncResult({
success: result.success,
message: result.success
? `Synced ${result.synced} item(s).`
: `Sync failed: ${result.errors?.[0] ?? "Unknown error"}`,
});
setSyncing(false);
// Refresh sync log
const logResult = await getSyncLog(brandId);
if (logResult.success) setSyncLog(logResult.logs);
}
async function handleDisconnectSquare() {
if (!confirm("Disconnect Square? This will clear the access token.")) return;
setSaving(true);
setError(null);
const result = await savePaymentSettings({
brandId: activeBrandId,
provider: provider || null,
squareAccessToken: "",
squareLocationId: "",
squareSyncEnabled: false,
squareInventoryMode: "none",
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to disconnect");
} else {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
}
const lastSyncAt = settings?.square_last_sync_at
? new Date(settings.square_last_sync_at).toLocaleString()
: "Never";
return (
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
<select
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-zinc-400"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
)}
{error && (
<div className="rounded-xl bg-red-900/50 border border-red-700 p-4 text-sm text-red-200">{error}</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/50 border border-green-700 p-4 text-sm text-green-200">
Payment settings saved.
</div>
)}
{syncResult && (
<div className={`rounded-xl p-4 text-sm border ${
syncResult.success ? "bg-green-900/50 border-green-700 text-green-200" : "bg-red-900/50 border-red-700 text-red-200"
}`}>
{syncResult.message}
</div>
)}
{/* Connected status banner */}
{settings?.stripe_publishable_key && (
<div className="flex items-center gap-3 rounded-xl border border-green-200 bg-green-900/30 px-4 py-3 text-sm">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-green-900/300 text-white text-xs font-bold"></span>
<div>
<p className="font-semibold text-green-300">Stripe Connected</p>
<p className="text-xs text-green-400/70">Payments are configured for this brand</p>
</div>
</div>
)}
{/* Provider selection */}
<div>
<label className="block text-sm font-semibold text-zinc-300">Payment Provider</label>
<p className="mt-1 text-sm text-zinc-500">
Choose how to process payments for this brand.
</p>
<div className="mt-3 flex gap-3">
{[
{ value: "", label: "None / Manual" },
{ value: "stripe", label: "Stripe" },
{ value: "square", label: "Square" },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
setProvider(opt.value as PaymentProvider | "");
setShowStripe(opt.value === "stripe");
setShowSquare(opt.value === "square");
}}
className={`rounded-xl border px-4 py-3 text-sm font-medium ${
provider === opt.value
? "border-zinc-400 bg-zinc-700 text-zinc-50"
: "border-zinc-600 text-zinc-400 hover:bg-zinc-800"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Stripe credentials */}
{showStripe && (
<div className="space-y-4 rounded-xl border border-blue-200 bg-blue-900/30 p-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-blue-900">Stripe</h3>
{settings?.stripe_publishable_key && (
<span className="flex items-center gap-1.5 rounded-full bg-green-900/40 px-2.5 py-1 text-xs font-semibold text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-green-900/300"></span>
Connected
</span>
)}
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Publishable Key</label>
<input
type="text"
value={stripePublishableKey}
onChange={(e) => setStripePublishableKey(e.target.value)}
placeholder="pk_live_..."
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Secret Key</label>
<input
type="password"
value={stripeSecretKey}
onChange={(e) => setStripeSecretKey(e.target.value)}
placeholder="sk_live_..."
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
/>
</div>
<button
type="submit"
disabled={saving}
className="rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Stripe Settings"}
</button>
</div>
)}
{/* Square credentials */}
{showSquare && (
<div className="space-y-4 rounded-xl border border-green-200 bg-green-900/30 p-4">
<h3 className="font-semibold text-green-900">Square</h3>
{!hasSquareToken ? (
<div className="space-y-3">
<p className="text-sm text-green-400">
Connect your Square account via OAuth to enable sync.
</p>
<a
href="/api/square/oauth"
className="inline-block rounded-xl bg-green-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-green-700"
>
Connect Square
</a>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-zinc-300">Location ID</label>
<input
type="text"
value={squareLocationId}
onChange={(e) => setSquareLocationId(e.target.value)}
placeholder="L..."
className="mt-1 w-full rounded-xl border border-zinc-600 px-4 py-3 text-sm font-mono outline-none focus:border-slate-900"
/>
</div>
<button
type="button"
onClick={handleDisconnectSquare}
className="text-sm text-red-400 hover:underline"
>
Disconnect Square
</button>
</>
)}
</div>
)}
{/* Square Sync section */}
{hasSquareToken && provider === "square" && (
<div className="space-y-5 rounded-xl border border-purple-200 bg-purple-50 p-5">
<div>
<h3 className="font-semibold text-purple-900">Square Sync</h3>
<p className="mt-1 text-sm text-purple-700">
Keep products, orders, and inventory in sync between Route Commerce and Square.
</p>
</div>
{/* Enable toggle */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setSquareSyncEnabled(!squareSyncEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
squareSyncEnabled ? "bg-purple-600" : "bg-slate-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${
squareSyncEnabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span className="text-sm font-medium text-purple-900">
{squareSyncEnabled ? "Enabled" : "Disabled"}
</span>
</div>
{squareSyncEnabled && (
<>
{/* Inventory mode */}
<div>
<p className="mb-2 text-sm font-medium text-zinc-300">Inventory sync direction</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
{ value: "none", label: "None" },
{ value: "rc_to_square", label: "RC → Square" },
{ value: "square_to_rc", label: "Square → RC" },
{ value: "bidirectional", label: "Bidirectional" },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSquareInventoryMode(opt.value as InventoryMode)}
className={`rounded-lg border px-3 py-2 text-xs font-medium ${
squareInventoryMode === opt.value
? "border-purple-600 bg-purple-600 text-white"
: "border-zinc-600 text-zinc-400 hover:bg-purple-100"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Sync Now buttons */}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleSyncNow("products")}
disabled={syncing}
className="rounded-lg border border-purple-300 bg-zinc-900 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50"
>
{syncing ? "Syncing..." : "Sync Products Now"}
</button>
<button
type="button"
onClick={() => handleSyncNow("orders")}
disabled={syncing}
className="rounded-lg border border-purple-300 bg-zinc-900 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50"
>
{syncing ? "Syncing..." : "Sync Orders Now"}
</button>
<button
type="button"
onClick={() => handleSyncNow("all")}
disabled={syncing}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-semibold text-white hover:bg-purple-700 disabled:opacity-50"
>
{syncing ? "Syncing..." : "Sync All Now"}
</button>
</div>
{/* Last sync info */}
<div className="text-xs text-zinc-500">
Last sync: {lastSyncAt}
{settings?.square_last_sync_error && (
<span className="ml-2 text-red-500"> {settings.square_last_sync_error}</span>
)}
</div>
{/* Sync log preview */}
{syncLog.length > 0 && (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">
Recent sync activity
</p>
<div className="space-y-1">
{syncLog.map((entry) => (
<div
key={entry.id}
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-xs ${
entry.status === "success"
? "border-green-200 bg-green-900/30 text-green-400"
: "border-red-200 bg-red-900/30 text-red-400"
}`}
>
<span>
[{entry.direction ?? "—"}] {entry.event_type} {entry.status}
</span>
<span className="text-slate-400">
{new Date(entry.created_at).toLocaleTimeString()}
</span>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
)}
{/* Wholesale webhook log */}
<WebhookLogsSection brandId={activeBrandId} />
<button
type="submit"
disabled={saving}
className="rounded-xl bg-zinc-100 px-6 py-3 text-sm font-bold text-zinc-900 hover:bg-zinc-200 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Payment Settings"}
</button>
</form>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
type Product = {
id: string;
name: string;
type: string;
price: number;
};
export default function ProductAssignmentForm({
stopId,
allProducts,
assignedProductIds,
}: {
stopId: string;
allProducts: Product[];
assignedProductIds: string[];
}) {
const [selected, setSelected] = useState("");
const [loading, setLoading] = useState(false);
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set(assignedProductIds));
const [error, setError] = useState<string | null>(null);
const availableProducts = allProducts.filter(
(p) => !assignedIds.has(p.id)
);
async function handleAssign(e: React.FormEvent) {
e.preventDefault();
if (!selected) return;
setLoading(true);
setError(null);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: selected,
}),
}
);
const data = await res.json();
if (!res.ok || data.success === false) {
setError(data.error ?? "Failed to assign product");
setLoading(false);
return;
}
setAssignedIds((prev) => new Set(prev).add(selected));
setSelected("");
setLoading(false);
}
async function handleRemove(productId: string) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: productId,
}),
}
);
const data = await res.json();
if (!res.ok || data.success === false) {
setError(data.error ?? "Failed to remove");
return;
}
setAssignedIds((prev) => {
const next = new Set(prev);
next.delete(productId);
return next;
});
}
const assignedProducts = allProducts.filter((p) => assignedIds.has(p.id));
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{/* Assigned products list */}
{assignedProducts.length > 0 && (
<div>
<p className="text-sm font-medium text-zinc-300 mb-3">
Currently assigned
</p>
<div className="space-y-2">
{assignedProducts.map((product) => (
<div
key={product.id}
className="flex items-center justify-between rounded-xl border border-zinc-800 p-4"
>
<div>
<p className="font-medium text-zinc-100">{product.name}</p>
<p className="text-sm text-zinc-500">
{product.type} · ${product.price}
</p>
</div>
<button
onClick={() => handleRemove(product.id)}
className="text-sm text-red-400 hover:text-red-800"
>
Remove
</button>
</div>
))}
</div>
</div>
)}
{/* Assign form */}
<form onSubmit={handleAssign} className="flex gap-3">
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
required
className="flex-1 rounded-xl border border-zinc-600 px-4 py-3 outline-none focus:border-slate-900"
>
<option value="">Select a product...</option>
{availableProducts.map((product) => (
<option key={product.id} value={product.id}>
{product.name} {product.type} (${product.price})
</option>
))}
</select>
<button
type="submit"
disabled={!selected || loading}
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
>
{loading ? "Assigning..." : "Assign Product"}
</button>
</form>
{availableProducts.length === 0 && assignedProducts.length > 0 && (
<p className="text-sm text-zinc-500">All products already assigned.</p>
)}
{allProducts.length === 0 && (
<p className="text-sm text-zinc-500">
No active products for this brand yet.
</p>
)}
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
"use client";
import NextImage from "next/image";
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";
type Brand = {
id: string;
name: string;
slug: string;
};
type ProductEditFormProps = {
product: {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
brand_id: string;
image_url?: string | null;
is_taxable?: boolean;
pickup_type?: string;
};
brands: Brand[];
};
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 [name, setName] = useState(product.name);
const [description, setDescription] = useState(product.description);
const [price, setPrice] = useState(product.price);
const [type, setType] = useState(product.type);
const [active, setActive] = useState(product.active);
const [brand_id, setBrand_id] = useState(product.brand_id);
const [image_url, setImage_url] = useState(product.image_url ?? "");
const [is_taxable, setIs_taxable] = useState(product.is_taxable ?? true);
const [pickup_type, setPickup_type] = useState(product.pickup_type ?? "scheduled_stop");
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);
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.");
return;
}
if (file.size > 5 * 1024 * 1024) {
setUploadError("Image must be under 5MB.");
return;
}
// Client-side resize to max 1200px width
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
setUploadError(null);
setUploading(true);
const result = await uploadProductImage(product.id, resizedFile);
setUploading(false);
if (result.success) {
setImage_url(result.imageUrl);
setImagePreview(result.imageUrl);
} else {
setUploadError(result.error ?? "Upload failed.");
}
}
async function handleRemoveImage() {
const result = await deleteProductImage(product.id);
if (result.success) {
setImage_url("");
setImagePreview(null);
}
}
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("Failed to resize image"));
return;
}
blob.arrayBuffer().then((buf) => resolve(buf));
}, "image/jpeg", 0.85);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
async function handleSave() {
if (!name.trim()) {
setError("Product name is required.");
return;
}
setSaving(true);
setError(null);
setSaved(false);
const result = await updateProduct(product.id, brand_id, {
name,
description,
price: Number(price),
type,
active,
image_url: image_url || null,
is_taxable,
pickup_type,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
setSaving(false);
return;
}
setSaved(true);
setSaving(false);
router.refresh();
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
Product updated successfully.
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Price</label>
<input
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Type</label>
<input
type="text"
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="e.g. Sweet Corn"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Brand</label>
<select
value={brand_id}
onChange={(e) => setBrand_id(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
<button
onClick={() => setActive((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{active ? "Active" : "Inactive"}
</button>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Taxable</label>
<button
onClick={() => setIs_taxable((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center gap-3 ${
is_taxable
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200"
: "bg-amber-900/30 text-amber-700 ring-1 ring-amber-200"
}`}
>
<span className="text-lg">{is_taxable ? "✓" : "✗"}</span>
<span>{is_taxable ? "Taxable — tax applied at checkout for nexus shipments" : "Non-taxable — always exempt from sales tax"}</span>
</button>
<p className="mt-1.5 text-xs text-zinc-500">Tax is calculated at checkout for shipping orders in your brand&apos;s nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Pickup Type</label>
<select
value={pickup_type}
onChange={(e) => setPickup_type(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
>
<option value="scheduled_stop">Scheduled Stop requires stop selection at checkout</option>
<option value="shed">Shed Pickup uses description as location</option>
</select>
<p className="mt-1.5 text-xs text-zinc-500">Shed pickup uses the product description as the pickup location no stop required at checkout.</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Product Image</label>
<p className="text-xs text-zinc-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB (ideally under 2MB)</p>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}}
onClick={() => fileInputRef.current?.click()}
className={`
flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6 cursor-pointer transition-colors
${dragOver ? "border-green-500 bg-green-900/30" : "border-zinc-600 hover:border-slate-400 hover:bg-zinc-900"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-slate-400 border-t-transparent animate-spin" />
<span className="text-sm text-zinc-500">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-zinc-500">Click or drop to replace</span>
</>
) : (
<>
<svg className="h-8 w-8 text-slate-400" 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-zinc-500">Drag & drop or click to upload</span>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
</div>
{uploadError && <p className="mt-1 text-xs text-red-400">{uploadError}</p>}
{imagePreview && (
<button
type="button"
onClick={handleRemoveImage}
className="mt-2 text-xs text-red-500 hover:underline"
>
Remove image
</button>
)}
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { Product } from "@/types";
type Props = {
products: Product[];
onSearchChange: (s: string) => void;
onStatusChange: (f: "all" | "active" | "inactive") => void;
search: string;
statusFilter: "all" | "active" | "inactive";
count: number;
};
export default function ProductFilterBar({
products,
onSearchChange,
onStatusChange,
search,
statusFilter,
count,
}: Props) {
return (
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
<input
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-slate-900 text-white"
: "text-zinc-400 hover:bg-zinc-950"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-slate-400">{count}</span>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { syncSquareNow, getSyncLog } from "@/actions/square-sync-ui";
import Link from "next/link";
type Props = {
brandId: string;
hasSquareToken: boolean;
};
export default function ProductSyncBanner({ brandId, hasSquareToken }: Props) {
const [syncing, setSyncing] = useState(false);
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [logs, setLogs] = useState<{ event_type: string; status: string; created_at: string }[]>([]);
async function handleSyncProducts() {
setSyncing(true);
setMsg(null);
const result = await syncSquareNow(brandId, "products");
setMsg({
kind: result.success ? "success" : "error",
text: result.success
? `Products synced — ${result.synced} item(s).`
: `Failed: ${result.errors[0] ?? "Unknown error"}`,
});
setSyncing(false);
const logResult = await getSyncLog(brandId);
if (logResult.success) setLogs(logResult.logs.filter(l => l.entity_type === "product").slice(0, 5));
}
if (!hasSquareToken) {
return (
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
<span className="text-amber-700">Square not connected.</span>
<Link href="/admin/settings/payments" className="ml-2 font-medium text-emerald-600 hover:text-emerald-700 underline transition-colors">
Connect Square
</Link>
</div>
);
}
return (
<div className="mb-4">
<div className="flex items-center gap-3">
<button
onClick={handleSyncProducts}
disabled={syncing}
className="rounded-lg border border-emerald-200 bg-white px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 transition-colors shadow-sm"
>
{syncing ? (
<span className="flex items-center gap-2">
<svg className="h-4 w-4 animate-spin" 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>
Syncing...
</span>
) : (
<span className="flex items-center gap-2">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Sync Products to Square
</span>
)}
</button>
<Link
href="/admin/settings/square-sync"
className="text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Square Sync Settings
</Link>
</div>
{msg && (
<div className={`mt-2 rounded-lg border px-3 py-2 text-sm ${
msg.kind === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-red-200 bg-red-50 text-red-700"
}`}>
{msg.text}
</div>
)}
{logs.length > 0 && (
<div className="mt-2 space-y-1">
{logs.map((log, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-stone-500">
<span className={`rounded px-1.5 py-0.5 font-medium ${
log.status === "success" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" : "bg-red-100 text-red-700 border border-red-200"
}`}>{log.status}</span>
<span className="text-stone-600">{log.event_type}</span>
<span className="text-stone-400">{new Date(log.created_at).toLocaleTimeString()}</span>
</div>
))}
</div>
)}
</div>
);
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { deleteProduct } from "@/actions/products";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
deleted_at?: string | null;
brands: { name: string } | { name: string }[];
is_taxable?: boolean;
};
type ProductTableBodyProps = {
products: Product[];
search: string;
statusFilter: "all" | "active" | "inactive";
onSearchChange: (v: string) => void;
onStatusChange: (v: "all" | "active" | "inactive") => void;
onDeleted: (productId: string) => void;
};
export default function ProductTableBody({
products,
search,
statusFilter,
onSearchChange,
onStatusChange,
onDeleted,
}: ProductTableBodyProps) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
async function handleDelete(productId: string) {
setDeletingId(productId);
setDeleteError(null);
const result = await deleteProduct(productId, null);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
onDeleted(productId);
} else {
setDeleteError(result.error ?? "Delete failed");
}
}
return (
<>
{/* Filter bar — sits outside <table> in the parent */}
<div className="border-b border-slate-100 px-5 py-3 flex gap-3 flex-wrap items-center">
<input
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="flex-1 min-w-40 rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-slate-900"
/>
<div className="flex gap-1 rounded-lg border border-zinc-600 bg-zinc-900 p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
key={f}
onClick={() => onStatusChange(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-slate-900 text-white"
: "text-zinc-400 hover:bg-zinc-950"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-xs text-slate-400">{filtered.length}</span>
</div>
{/* Delete error banner */}
{deleteError && (
<div className="mx-5 mt-3 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{deleteError}
<button
onClick={() => setDeleteError(null)}
className="ml-2 underline hover:no-underline"
>
Dismiss
</button>
</div>
)}
<tbody className="divide-y divide-slate-200">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-zinc-500">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
</td>
</tr>
) : (
filtered.map((product) => (
<tr
key={product.id}
className="hover:bg-zinc-800 transition-colors relative"
>
<td className="px-5 py-4">
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-zinc-100 hover:text-zinc-400"
>
{product.name}
</Link>
<div className="text-zinc-500 line-clamp-1 text-sm">
{product.description || <span className="italic text-slate-400">No description</span>}
</div>
</td>
<td className="px-5 py-4 text-zinc-300">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-5 py-4 text-zinc-300">{product.type}</td>
<td className="px-5 py-4 font-semibold text-zinc-100">
${Number(product.price).toFixed(2)}
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
product.active
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400"
}`}
>
{product.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4">
{product.is_taxable === false ? (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700">Non-taxable</span>
) : (
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-700">Taxable</span>
)}
</td>
{/* Actions */}
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-950"
>
Edit
</Link>
<button
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-950"
>
</button>
{openMenu === product.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-zinc-900 shadow-lg ring-1 ring-zinc-700 overflow-hidden">
<button
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-400 hover:bg-red-900/30"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === product.id && (
<>
<div
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-40 w-80 rounded-xl bg-zinc-900 shadow-xl ring-1 ring-zinc-700 p-6">
<p className="text-sm font-semibold text-zinc-100">
Delete "{product.name}"?
</p>
<p className="mt-2 text-xs text-zinc-500">
This will remove the product. If it is attached to any
orders, it will be hidden instead of deleted.
</p>
<div className="mt-5 flex gap-3">
<button
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
>
Cancel
</button>
<button
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</>
);
}
+285
View File
@@ -0,0 +1,285 @@
"use client";
import React, { useState, useTransition } from "react";
import Image from "next/image";
import { deleteProduct } from "@/actions/products";
import Link from "next/link";
import { useRouter } from "next/navigation";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
deleted_at?: string | null;
image_url?: string | null;
brands: { name: string } | { name: string }[];
};
type Props = {
products: Product[];
};
export default function ProductTableClient({ products }: Props) {
const router = useRouter();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
const [deleteError, setDeleteError] = useState<string | null>(null);
const filtered = products.filter((p) => {
const matchesSearch =
!search ||
p.name.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && p.active) ||
(statusFilter === "inactive" && !p.active);
return matchesSearch && matchesStatus;
});
function handleDeleted() {
setDeleteError(null);
startTransition(() => {
router.refresh();
});
}
return (
<>
{/* Filter bar */}
<div className="px-5 py-4 flex gap-3 flex-wrap items-center border-b border-stone-200">
<input
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 min-w-48 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
<span className="text-sm text-stone-500 px-2">{filtered.length} products</span>
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200 bg-stone-50">
<tr>
<th className="px-5 py-4 font-semibold text-stone-500">Product</th>
<th className="px-5 py-4 font-semibold text-stone-500">Brand</th>
<th className="px-5 py-4 font-semibold text-stone-500">Type</th>
<th className="px-5 py-4 font-semibold text-stone-500">Price</th>
<th className="px-5 py-4 font-semibold text-stone-500">Status</th>
<th className="px-5 py-4 font-semibold text-stone-500" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-16 text-center text-sm text-stone-500">
{search || statusFilter !== "all"
? "No products match your search."
: "No products found."}
</td>
</tr>
) : (
filtered.map((product) => (
<ProductRow
key={product.id}
product={product}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
/>
))
)}
</tbody>
</table>
</>
);
}
function ProductRowBase({
product,
onDeleted,
onDeleteError,
}: {
product: Product;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
}) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
async function handleDelete(productId: string) {
setDeletingId(productId);
const result = await deleteProduct(productId, null);
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (result.success) {
onDeleted();
} else {
onDeleteError(result.error ?? "Delete failed");
}
}
return (
<tr className="hover:bg-stone-50 transition-colors relative">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
{product.image_url ? (
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-stone-200">
<Image
src={product.image_url}
alt={product.name}
fill
style={{ objectFit: "cover" }}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
) : (
<div className="h-12 w-12 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-stone-200">
<span className="text-stone-400 text-lg"></span>
</div>
)}
<div>
<Link
href={`/admin/products/${product.id}`}
className="block font-medium text-stone-900 hover:text-emerald-600 transition-colors"
>
{product.name}
</Link>
<div className="text-stone-500 line-clamp-1 text-sm">
{product.description || (
<span className="italic text-stone-400">No description</span>
)}
</div>
</div>
</div>
</td>
<td className="px-5 py-4 text-stone-600">
{Array.isArray(product.brands)
? product.brands[0]?.name
: product.brands?.name}
</td>
<td className="px-5 py-4 text-stone-600 font-mono text-xs uppercase tracking-wide">{product.type}</td>
<td className="px-5 py-4 font-mono font-semibold text-stone-900">
${Number(product.price).toFixed(2)}
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
product.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-600 border border-stone-200"
}`}
>
{product.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/products/${product.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
Edit
</Link>
<button
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === product.id ? null : product.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
</button>
{openMenu === product.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
<button
onClick={() => { setOpenMenu(null); setConfirmDelete(product.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === product.id && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-2 text-xs text-stone-500">
This will remove the product. If it is attached to any orders,
it will be hidden instead of deleted.
</p>
<div className="mt-4 flex gap-2">
<button
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-stone-200 bg-white px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 hover:bg-red-500 px-3 py-2 text-xs font-medium text-white disabled:opacity-50 transition-colors shadow-sm"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
);
}
const ProductRow = React.memo(ProductRowBase);
+729
View File
@@ -0,0 +1,729 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import {
getReportsSummary,
getOrdersByStopReport,
getSalesByProductReport,
getFulfillmentReport,
getPickupStatusByStop,
getContactGrowthReport,
getCampaignActivityReport,
} from "@/actions/reports";
import {
exportOrdersByStop,
exportSalesByProduct,
exportPickupStatus,
exportContactGrowth,
exportFulfillment,
exportCampaignActivity,
type ReportsSummary,
type OrderByStop,
type SalesByProduct,
type FulfillmentRow,
type PickupStatusByStop,
type ContactGrowthRow,
type CampaignActivityRow,
} from "@/lib/reports-export";
import { formatDate, formatDateRange } from "@/lib/date-utils";
type DatePreset = "week" | "month" | "quarter" | "this_year" | "custom";
type DateRange = { start: string; end: string };
function buildRange(preset: DatePreset, customStart?: string, customEnd?: string): DateRange {
const now = new Date();
const toDateStr = (d: Date) => d.toISOString().slice(0, 10);
const today = toDateStr(now);
switch (preset) {
case "week": {
const s = new Date(now);
s.setDate(s.getDate() - 6);
return { start: toDateStr(s), end: today };
}
case "month": {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
return { start: toDateStr(start), end: today };
}
case "quarter": {
const q = Math.floor(now.getMonth() / 3);
const start = new Date(now.getFullYear(), q * 3, 1);
return { start: toDateStr(start), end: today };
}
case "this_year": {
const start = new Date(now.getFullYear(), 0, 1);
return { start: toDateStr(start), end: today };
}
case "custom":
return { start: customStart ?? today, end: customEnd ?? today };
}
}
// ── Quarter label helper ──────────────────────────────────────────────────────
function quarterLabel(startISO: string): string {
const s = new Date(startISO + "T00:00:00");
const q = Math.floor(s.getMonth() / 3) + 1;
return `Q${q} ${s.getFullYear()}`;
}
function monthLabel(startISO: string): string {
const s = new Date(startISO + "T00:00:00");
return s.toLocaleDateString("en-US", { month: "long", year: "numeric" });
}
// ── Summary card ─────────────────────────────────────────────────────────────
function SummaryCard({
label,
value,
prefix,
suffix,
}: {
label: string;
value: number;
prefix?: string;
suffix?: string;
}) {
return (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-4">
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500">{label}</p>
<p className="mt-1 text-2xl font-semibold text-zinc-100">
{prefix ?? ""}{typeof value === "number" ? value.toLocaleString() : value}{suffix ?? ""}
</p>
</div>
);
}
// ── CSV export button ───────────────────────────────────────────────────────
function ExportBtn({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
>
Export CSV
</button>
);
}
function ExplainBtn({
onClick,
loading,
}: {
onClick: () => void;
loading: boolean;
}) {
return (
<button
onClick={onClick}
disabled={loading}
className="rounded-lg border border-violet-300 bg-violet-50 px-3 py-1.5 text-xs font-medium text-violet-700 hover:bg-violet-100 disabled:opacity-50"
>
{loading ? "Analyzing..." : "🤖 Explain this Report"}
</button>
);
}
// ── Generic table ───────────────────────────────────────────────────────────
function ReportTable({ headers, rows, renderRow }: {
headers: string[];
rows: unknown[];
renderRow: (row: unknown, i: number) => React.ReactNode;
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-slate-50">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-zinc-500">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={headers.length} className="px-3 py-6 text-center text-slate-400">
No data
</td>
</tr>
) : (
rows.map((_, i) => renderRow(_, i))
)}
</tbody>
</table>
</div>
);
}
// ── Tab definitions ─────────────────────────────────────────────────────────
type Tab = {
id: string;
label: string;
};
const TABS: Tab[] = [
{ id: "overview", label: "Overview" },
{ id: "orders-by-stop", label: "Orders by Stop" },
{ id: "sales-by-product", label: "Sales by Product" },
{ id: "fulfillment", label: "Fulfillment" },
{ id: "pickup-status", label: "Pickup Status" },
{ id: "contact-growth", label: "Contact Growth" },
{ id: "campaigns", label: "Campaigns" },
];
// ── Main component ──────────────────────────────────────────────────────────
export default function ReportsDashboard({
brands,
initialBrandId,
isPlatformAdmin,
brandId,
}: {
brands: { id: string; name: string }[];
initialBrandId: string | null;
isPlatformAdmin: boolean;
brandId?: string | null;
}) {
const [preset, setPreset] = useState<DatePreset>("quarter");
const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState("");
const [selectedBrandId, setSelectedBrandId] = useState<string>(initialBrandId ?? "");
const [activeTab, setActiveTab] = useState("overview");
const [summary, setSummary] = useState<ReportsSummary | null>(null);
const [ordersByStop, setOrdersByStop] = useState<OrderByStop[]>([]);
const [salesByProduct, setSalesByProduct] = useState<SalesByProduct[]>([]);
const [fulfillment, setFulfillment] = useState<FulfillmentRow[]>([]);
const [pickupStatus, setPickupStatus] = useState<PickupStatusByStop[]>([]);
const [contactGrowth, setContactGrowth] = useState<ContactGrowthRow[]>([]);
const [campaigns, setCampaigns] = useState<CampaignActivityRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [explainingTab, setExplainingTab] = useState<string | null>(null);
const [explanation, setExplanation] = useState<{ summary: string; keyInsights: string[]; suggestedActions: string[] } | null>(null);
const [explainError, setExplainError] = useState<string | null>(null);
const range: DateRange = buildRange(preset, customStart, customEnd);
// Stable memoized fetchAll — re-fetches when range or brand changes
const fetchAll = useCallback(async () => {
setLoading(true);
setError(null);
const brandId = selectedBrandId || null;
try {
const [sum, obs, sbp, ful, ps, cg, cam] = await Promise.all([
getReportsSummary(range, selectedBrandId || null),
getOrdersByStopReport(range, selectedBrandId || null),
getSalesByProductReport(range, selectedBrandId || null),
getFulfillmentReport(range, selectedBrandId || null),
getPickupStatusByStop(range, selectedBrandId || null),
getContactGrowthReport(range, selectedBrandId || null),
getCampaignActivityReport(range, selectedBrandId || null),
]);
setSummary(sum);
setOrdersByStop(obs);
setSalesByProduct(sbp);
setFulfillment(ful);
setPickupStatus(ps);
setContactGrowth(cg);
setCampaigns(cam);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load reports");
} finally {
setLoading(false);
}
}, [range, selectedBrandId]);
const handleFetch = () => {
fetchAll();
};
// ── AI Report Explainer ────────────────────────────────────────────────────────
async function handleExplain() {
setExplainingTab(activeTab);
setExplanation(null);
setExplainError(null);
const tabDataMap: Record<string, unknown[]> = {
"orders-by-stop": ordersByStop,
"sales-by-product": salesByProduct,
"fulfillment": fulfillment,
"pickup-status": pickupStatus,
"contact-growth": contactGrowth,
"campaigns": campaigns,
};
const data = tabDataMap[activeTab] ?? [];
try {
const res = await fetch("/api/ai/report-explainer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
reportType: activeTab,
dateRange: range,
brandId: brandId ?? selectedBrandId,
reportData: data,
}),
});
const result = await res.json();
if (!res.ok) throw new Error(result.error ?? "Analysis failed");
setExplanation(result);
} catch (err) {
setExplainError(err instanceof Error ? err.message : "Analysis failed");
} finally {
setExplainingTab(null);
}
}
// Close explanation panel
function closeExplanation() {
setExplanation(null);
setExplainError(null);
}
function handleExportCSV(exportFn: () => string, filename: string) {
const csv = exportFn();
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// Auto-fetch when brand changes
useEffect(() => {
fetchAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBrandId]);
return (
<div className="space-y-6">
{/* ── Error banner ─────────────────────────────────────────────────── */}
{error && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{/* ── Filters ────────────────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-zinc-900 border border-zinc-800 px-5 py-4">
{/* Date preset */}
<div className="flex gap-1.5">
{(["week", "month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
<button
key={p}
onClick={() => setPreset(p)}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition-all ${
preset === p
? "bg-blue-600 text-white shadow-black/20"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{p === "week" ? "This Week" : p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
</button>
))}
</div>
{/* Custom date inputs */}
{preset === "custom" && (
<div className="flex gap-2 items-center">
<input
type="date"
value={customStart}
onChange={(e) => setCustomStart(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
type="date"
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
</div>
)}
{/* Brand filter (platform_admin only) */}
{isPlatformAdmin && (
<select
value={selectedBrandId}
onChange={(e) => setSelectedBrandId(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>
)}
<button
onClick={handleFetch}
disabled={loading}
className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
</button>
<span className="ml-auto text-sm font-medium text-zinc-500">
{preset === "quarter" ? quarterLabel(range.start) :
preset === "this_year" ? `${new Date().getFullYear()} YTD` :
preset === "month" ? monthLabel(range.start) :
preset === "custom" ? `${range.start}${range.end}` :
formatDateRange(range.start, range.end)}
</span>
</div>
{/* ── Tab bar ────────────────────────────────────────────────────────── */}
<div className="flex gap-1 border-b border-zinc-800">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === tab.id
? "border-blue-600 text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{tab.label}
</button>
))}
</div>
{/* ── Tab content ──────────────────────────────────────────────────────── */}
{/* OVERVIEW */}
{activeTab === "overview" && summary && (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<SummaryCard label="Gross Sales" value={summary.gross_sales} prefix="$" />
<SummaryCard label="Total Orders" value={summary.total_orders} />
<SummaryCard label="Avg Order Value" value={summary.avg_order_value} prefix="$" />
<SummaryCard label="Contacts Added" value={summary.contacts_added} />
<SummaryCard label="Pickup Orders" value={summary.pickup_orders} />
<SummaryCard label="Shipping Orders" value={summary.shipping_orders} />
<SummaryCard label="Pending Pickups" value={summary.pending_pickups} />
<SummaryCard label="Completed Pickups" value={summary.completed_pickups} />
<SummaryCard label="Campaigns Sent" value={summary.campaigns_sent} />
<SummaryCard label="Messages Logged" value={summary.messages_logged} />
</div>
<p className="text-xs text-slate-400">
{formatDateRange(range.start, range.end)}
{selectedBrandId ? ` · Brand: ${brands.find(b => b.id === selectedBrandId)?.name ?? selectedBrandId}` : " · All Brands"}
</p>
</div>
)}
{activeTab === "overview" && !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 summary data
</div>
)}
{/* ORDERS BY STOP */}
{activeTab === "orders-by-stop" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Orders by Stop</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "orders-by-stop"} />
{ordersByStop.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportOrdersByStop(ordersByStop), `orders-by-stop-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Stop", "City", "State", "Date", "Orders", "Gross Sales", "Pending", "Completed"]}
rows={ordersByStop}
renderRow={(row) => {
const r = row as OrderByStop;
return (
<tr key={r.stop_name + r.date} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.stop_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-zinc-400">{formatDate(r.date)}</td>
<td className="px-3 py-2 text-right font-medium">{r.order_count}</td>
<td className="px-3 py-2 text-right">${r.gross_sales.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-amber-400">{r.pending_count}</td>
<td className="px-3 py-2 text-right text-green-600">{r.completed_count}</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* SALES BY PRODUCT */}
{activeTab === "sales-by-product" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Sales by Product</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "sales-by-product"} />
{salesByProduct.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportSalesByProduct(salesByProduct), `sales-by-product-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Product", "Units Sold", "Gross Revenue", "Avg Price"]}
rows={salesByProduct}
renderRow={(row) => {
const r = row as SalesByProduct;
return (
<tr key={r.product_name} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.product_name}</td>
<td className="px-3 py-2 text-right">{r.units_sold}</td>
<td className="px-3 py-2 text-right font-medium">${r.gross_revenue.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-zinc-500">${r.avg_price}</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* FULFILLMENT */}
{activeTab === "fulfillment" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Fulfillment Breakdown</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "fulfillment"} />
{fulfillment.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportFulfillment(fulfillment), `fulfillment-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Fulfillment Type", "Order Count", "Revenue", "% of Total"]}
rows={fulfillment}
renderRow={(row) => {
const r = row as FulfillmentRow;
return (
<tr key={r.fulfillment_type} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
r.fulfillment_type === "pickup" ? "bg-blue-900/40 text-blue-700" :
r.fulfillment_type === "shipping" ? "bg-purple-100 text-purple-700" :
"bg-zinc-950 text-zinc-300"
}`}>{r.fulfillment_type}</span>
</td>
<td className="px-3 py-2 text-right">{r.order_count}</td>
<td className="px-3 py-2 text-right font-medium">${r.revenue.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-zinc-500">{r.pct_of_total}%</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* PICKUP STATUS */}
{activeTab === "pickup-status" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Pickup Status by Stop</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "pickup-status"} />
{pickupStatus.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportPickupStatus(pickupStatus), `pickup-status-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Stop", "City", "Date", "Total Orders", "Pending", "Completed", "Canceled"]}
rows={pickupStatus}
renderRow={(row) => {
const r = row as PickupStatusByStop;
return (
<tr key={r.stop_name + r.date} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.stop_name}</td>
<td className="px-3 py-2 text-zinc-400">{r.city}</td>
<td className="px-3 py-2 text-zinc-400">{formatDate(r.date)}</td>
<td className="px-3 py-2 text-right font-medium">{r.total_orders}</td>
<td className="px-3 py-2 text-right text-amber-400">{r.pending}</td>
<td className="px-3 py-2 text-right text-green-600">{r.completed}</td>
<td className="px-3 py-2 text-right text-slate-400">{r.canceled}</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* CONTACT GROWTH */}
{activeTab === "contact-growth" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Contact Growth</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "contact-growth"} />
{contactGrowth.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportContactGrowth(contactGrowth), `contact-growth-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Date", "New Contacts", "Imports", "Total"]}
rows={contactGrowth}
renderRow={(row) => {
const r = row as ContactGrowthRow;
return (
<tr key={r.date} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 text-zinc-300">{r.date}</td>
<td className="px-3 py-2 text-right">{r.new_contacts}</td>
<td className="px-3 py-2 text-right text-blue-400">{r.imports}</td>
<td className="px-3 py-2 text-right font-medium">{r.total}</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* CAMPAIGNS */}
{activeTab === "campaigns" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-300">Campaign Activity</h3>
<div className="flex gap-2">
<ExplainBtn onClick={handleExplain} loading={explainingTab === "campaigns"} />
{campaigns.length > 0 && (
<ExportBtn
label="Export CSV"
onClick={() => handleExportCSV(() => exportCampaignActivity(campaigns), `campaigns-${range.start}.csv`)}
/>
)}
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<ReportTable
headers={["Campaign", "Status", "Type", "Sent At", "Messages Logged"]}
rows={campaigns}
renderRow={(row) => {
const r = row as CampaignActivityRow;
return (
<tr key={r.campaign_name} className="border-t border-slate-100 hover:bg-zinc-800">
<td className="px-3 py-2 font-medium text-zinc-100">{r.campaign_name}</td>
<td className="px-3 py-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
r.status === "sent" ? "bg-green-900/40 text-green-400" :
r.status === "draft" ? "bg-zinc-950 text-zinc-400" :
"bg-amber-100 text-amber-700"
}`}>{r.status}</span>
</td>
<td className="px-3 py-2 text-zinc-500 text-xs">{r.campaign_type}</td>
<td className="px-3 py-2 text-zinc-500 text-xs">{r.sent_at ?? "—"}</td>
<td className="px-3 py-2 text-right">{r.messages_logged}</td>
</tr>
);
}}
/>
</div>
</div>
)}
{/* ── AI Explanation Panel ──────────────────────────────────────────────── */}
{explanation && (
<div className="rounded-2xl border border-violet-200 bg-gradient-to-br from-violet-50 to-white shadow-lg overflow-hidden">
<div className="bg-violet-600 px-5 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-lg">🤖</span>
<h3 className="font-semibold text-white">AI Report Analysis</h3>
<span className="text-xs text-violet-200 ml-1">· {activeTab.replace("-", " ")}</span>
</div>
<button onClick={closeExplanation} className="text-violet-200 hover:text-white p-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-5 space-y-4">
<div className="rounded-xl bg-zinc-900 border border-violet-100 p-4">
<p className="text-xs font-semibold text-violet-500 uppercase tracking-wider mb-2">Summary</p>
<p className="text-sm text-zinc-300 leading-relaxed">{explanation.summary}</p>
</div>
<div className="rounded-xl bg-zinc-900 border border-violet-100 p-4">
<p className="text-xs font-semibold text-violet-500 uppercase tracking-wider mb-2">Key Insights</p>
<ul className="space-y-2">
{explanation.keyInsights.map((insight, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-zinc-300">
<span className="text-violet-500 mt-0.5 flex-shrink-0"></span>
<span>{insight}</span>
</li>
))}
</ul>
</div>
<div className="rounded-xl bg-zinc-900 border border-violet-100 p-4">
<p className="text-xs font-semibold text-green-600 uppercase tracking-wider mb-2">Suggested Actions</p>
<ul className="space-y-2">
{explanation.suggestedActions.map((action, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-zinc-300">
<span className="text-green-500 mt-0.5 flex-shrink-0"></span>
<span>{action}</span>
</li>
))}
</ul>
</div>
<div className="rounded-lg bg-amber-900/30 border border-amber-200 p-3 text-xs text-amber-700">
AI-generated suggestions review before use. Not a substitute for business judgment.
</div>
</div>
</div>
)}
{explainError && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{explainError}
</div>
)}
</div>
);
}
@@ -0,0 +1,380 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { createStopsBatch } from "@/actions/stops";
import type { ParsedStopRow } from "@/lib/csv-parsers";
type ParsedStop = Omit<ParsedStopRow, "_rowIndex" | "_warnings">;
type Step = "idle" | "parsing" | "review" | "importing" | "done" | "error";
type Props = {
brandId: string;
onClose: () => void;
onComplete: (count: number) => void;
};
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 fileInputRef = useRef<HTMLInputElement>(null);
const processFile = useCallback(async (file: File) => {
setError(null);
setStep("parsing");
let text: string;
try {
text = await file.text();
} catch {
setError("Could not read file. Try a different format.");
setStep("idle");
return;
}
try {
const res = await fetch("/api/stops/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, brandId, 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");
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");
return;
}
setParsedStops(data.stops);
setWarnings(data.warnings ?? []);
setStep("review");
} catch {
setError("Network error while parsing file.");
setStep("idle");
}
}, [brandId, 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.");
return;
}
processFile(file);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
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);
const result = await createStopsBatch(
brandId,
parsedStops.map(({ city, state, location, date, time, address, zip, notes }) => ({
city, state, location, date, time, address, zip, notes,
}))
);
if (!result.success) {
setError(result.error ?? "Import failed");
setStep("review");
return;
}
setCreated(result.created);
setStep("done");
onComplete(result.created);
}
const hasWarnings = warnings.length > 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="relative w-full max-w-2xl rounded-2xl bg-zinc-900 shadow-xl">
{/* Header */}
<div className="flex items-center justify-between rounded-t-2xl border-b border-zinc-800 px-6 py-4">
<div>
<h2 className="text-xl font-bold text-zinc-100">Import Schedule</h2>
<p className="mt-0.5 text-sm text-zinc-500">
Upload a CSV to bulk-create stops as drafts.
</p>
</div>
<button
onClick={onClose}
className="rounded-lg px-3 py-1.5 text-slate-400 hover:bg-zinc-950 hover:text-zinc-400"
>
</button>
</div>
{/* Body */}
<div className="p-6">
{step === "idle" && (
<div className="space-y-4">
{error && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{/* AI toggle */}
<div className="flex items-center gap-3">
<button
onClick={() => setUseAI((v) => !v)}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
useAI
? "border-purple-300 bg-purple-50 text-purple-700"
: "border-zinc-800 text-zinc-400 hover:bg-zinc-800"
}`}
>
<span className="text-base">{useAI ? "◉" : "○"}</span>
Use AI for text/PDF parsing
</button>
<span className="text-xs text-slate-400">
{useAI
? "AI will parse unstructured text. Requires OPENAI_API_KEY."
: "Best results with CSV. Text/PDF uses AI if enabled."}
</span>
</div>
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`cursor-pointer rounded-xl border-2 border-dashed p-10 text-center transition-colors ${
dragOver
? "border-slate-900 bg-zinc-900"
: "border-zinc-600 hover:border-slate-400 hover:bg-zinc-800"
}`}
>
<p className="text-2xl text-slate-400">📄</p>
<p className="mt-2 font-medium text-zinc-300">
Drop your schedule file here
</p>
<p className="mt-1 text-sm text-zinc-500">
or click to browse CSV, TXT, JSON
</p>
<p className="mt-3 text-xs text-slate-400">
CSV columns: city, state, location, date, time, address, zip, notes
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv,.txt,.json"
className="hidden"
onChange={handleChange}
/>
</div>
)}
{step === "parsing" && (
<div className="flex flex-col items-center gap-4 py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-slate-900" />
<p className="text-zinc-400">Parsing file</p>
</div>
)}
{step === "review" && (
<div className="space-y-4">
{error && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{hasWarnings && (
<div className="rounded-xl bg-amber-900/30 border border-yellow-200 px-4 py-3 text-sm text-yellow-700">
<p className="font-medium">Parsing warnings:</p>
<ul className="mt-1 list-disc list-inside space-y-0.5">
{warnings.slice(0, 5).map((w, i) => (
<li key={i}>{w}</li>
))}
{warnings.length > 5 && <li>and {warnings.length - 5} more</li>}
</ul>
</div>
)}
<p className="text-sm text-zinc-400">
{parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found review and edit below before importing.
</p>
{/* Review table */}
<div className="max-h-80 overflow-y-auto rounded-xl border border-zinc-800">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-zinc-900 text-zinc-400">
<tr>
<th className="px-2 py-2 text-left font-semibold">City</th>
<th className="px-2 py-2 text-left font-semibold">State</th>
<th className="px-2 py-2 text-left font-semibold">Location</th>
<th className="px-2 py-2 text-left font-semibold">Date</th>
<th className="px-2 py-2 text-left font-semibold">Time</th>
<th className="px-2 py-2 text-left font-semibold">Address</th>
<th className="w-8 px-2 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{parsedStops.map((stop, idx) => (
<tr key={idx} className="hover:bg-zinc-800">
<td className="px-1 py-1">
<input
value={stop.city}
onChange={(e) => updateStop(idx, "city", e.target.value)}
className="w-full rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
/>
</td>
<td className="px-1 py-1">
<input
value={stop.state}
onChange={(e) => updateStop(idx, "state", e.target.value)}
className="w-12 rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
/>
</td>
<td className="px-1 py-1">
<input
value={stop.location}
onChange={(e) => updateStop(idx, "location", e.target.value)}
className="w-full rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
/>
</td>
<td className="px-1 py-1">
<input
value={stop.date}
onChange={(e) => updateStop(idx, "date", e.target.value)}
className="w-24 rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
/>
</td>
<td className="px-1 py-1">
<input
value={stop.time}
onChange={(e) => updateStop(idx, "time", e.target.value)}
className="w-20 rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
/>
</td>
<td className="px-1 py-1">
<input
value={stop.address ?? ""}
onChange={(e) => updateStop(idx, "address", e.target.value)}
placeholder="—"
className="w-full rounded border border-transparent bg-transparent px-2 py-1 text-xs outline-none focus:border-slate-400"
/>
</td>
<td className="px-1 py-1">
<button
onClick={() => removeStop(idx)}
className="rounded px-1 py-0.5 text-red-400 hover:bg-red-900/30 hover:text-red-400"
>
×
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-500">
{parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created
</p>
<div className="flex gap-3">
<button
onClick={() => { setStep("idle"); setParsedStops([]); setWarnings([]); }}
className="rounded-xl border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={parsedStops.length === 0}
className="rounded-xl bg-slate-900 px-5 py-2 text-sm font-medium text-white disabled:opacity-50 hover:bg-slate-800"
>
Create {parsedStops.length} Draft Stop{parsedStops.length !== 1 ? "s" : ""}
</button>
</div>
</div>
</div>
)}
{step === "importing" && (
<div className="flex flex-col items-center gap-4 py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-zinc-600 border-t-slate-900" />
<p className="text-zinc-400">Creating draft stops</p>
</div>
)}
{step === "done" && (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<p className="text-4xl"></p>
<p className="text-lg font-bold text-zinc-100">
{created} draft stop{created !== 1 ? "s" : ""} created
</p>
<p className="text-zinc-400">
Review them in the stops list and publish when ready.
</p>
<button
onClick={onClose}
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white hover:bg-slate-800"
>
Back to Stops
</button>
</div>
)}
{step === "error" && (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<p className="text-4xl"></p>
<p className="text-red-400">{error}</p>
<button
onClick={() => setStep("idle")}
className="rounded-xl border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
>
Try Again
</button>
</div>
)}
</div>
</div>
</div>
);
}
+678
View File
@@ -0,0 +1,678 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
getTimeTrackingWorkers,
getTimeTrackingTasks,
createTimeWorker,
resetTimeWorkerPin,
updateTimeWorker,
deleteTimeWorker,
createTimeTask,
updateTimeTask,
deleteTimeTask,
getTimeTrackingSettings,
updateTimeTrackingSettings,
getTimeTrackingNotificationLog,
type TimeWorker,
type TimeTask,
type TimeTrackingSettings,
type NotificationLogEntry,
} from "@/actions/time-tracking";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
type AccordionProps = {
title: string;
id?: string;
description?: string;
defaultOpen?: boolean;
accentColor?: "emerald" | "violet" | "amber" | "stone";
children: React.ReactNode;
};
function Accordion({ title, id, description, defaultOpen = false, accentColor = "stone", children }: AccordionProps) {
const [open, setOpen] = useState(defaultOpen);
const accent = {
emerald: "bg-emerald-50 text-emerald-700 border-emerald-200",
violet: "bg-violet-50 text-violet-700 border-violet-200",
amber: "bg-amber-50 text-amber-700 border-amber-200",
stone: "bg-stone-50 text-stone-700 border-stone-200",
}[accentColor];
const borderColor = open ? "border-stone-300" : "border-stone-200";
return (
<div id={id} className={`rounded-xl border ${borderColor} bg-white shadow-sm overflow-hidden transition-all duration-200`}>
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-stone-50 transition-colors"
aria-expanded={open}
>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-stone-900">{title}</span>
{description && (
<span className="text-xs text-stone-500 hidden sm:block">{description}</span>
)}
</div>
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-1 rounded-md ${accent}`}>
{open ? "Open" : "Closed"}
</span>
<svg
className={`w-4 h-4 text-stone-400 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div>
</button>
<div className={`transition-all duration-200 ${open ? "block" : "hidden"}`}>
<div className="px-5 pb-5 pt-2">{children}</div>
</div>
</div>
);
}
type Props = {
brandId: string;
};
export default function SettingsSections({ brandId }: Props) {
const [workers, setWorkers] = useState<TimeWorker[]>([]);
const [tasks, setTasks] = useState<TimeTask[]>([]);
const [settings, setSettings] = useState<TimeTrackingSettings | null>(null);
const [notificationLog, setNotificationLog] = useState<NotificationLogEntry[]>([]);
const [loading, setLoading] = useState(true);
// Settings form state
const [payPeriodStartDay, setPayPeriodStartDay] = useState(0);
const [payPeriodLength, setPayPeriodLength] = useState(7);
const [dailyOvertimeThreshold, setDailyOvertimeThreshold] = useState(12);
const [weeklyOvertimeThreshold, setWeeklyOvertimeThreshold] = useState(56);
const [overtimeMultiplier, setOvertimeMultiplier] = useState(1.5);
const [overtimeNotifications, setOvertimeNotifications] = useState(true);
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsSaved, setSettingsSaved] = useState(false);
const [settingsError, setSettingsError] = useState<string | null>(null);
// Notification form state
const [notificationEmails, setNotificationEmails] = useState<string[]>([]);
const [notificationSmsNumbers, setNotificationSmsNumbers] = useState<string[]>([]);
const [enableDailyAlerts, setEnableDailyAlerts] = useState(true);
const [enableWeeklyAlerts, setEnableWeeklyAlerts] = useState(true);
const [dailyAlertThreshold, setDailyAlertThreshold] = useState(80);
const [weeklyAlertThreshold, setWeeklyAlertThreshold] = useState(80);
const [sendEndOfPeriodSummary, setSendEndOfPeriodSummary] = useState(true);
const [brandName, setBrandName] = useState("Farm");
const [newEmail, setNewEmail] = useState("");
const [newSms, setNewSms] = useState("");
// Worker modal state
const [showWorkerModal, setShowWorkerModal] = useState(false);
const [editingWorker, setEditingWorker] = useState<TimeWorker | null>(null);
const [workerName, setWorkerName] = useState("");
const [workerRole, setWorkerRole] = useState("worker");
const [workerLang, setWorkerLang] = useState("en");
const [workerActive, setWorkerActive] = useState(true);
const [resetPinResult, setResetPinResult] = useState<string | null>(null);
const [workerError, setWorkerError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Task modal state
const [showTaskModal, setShowTaskModal] = useState(false);
const [editingTask, setEditingTask] = useState<TimeTask | null>(null);
const [taskName, setTaskName] = useState("");
const [taskNameEs, setTaskNameEs] = useState("");
const [taskUnit, setTaskUnit] = useState("hours");
const [taskActive, setTaskActive] = useState(true);
const [taskSortOrder, setTaskSortOrder] = useState(0);
const [taskError, setTaskError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
const [w, t, s] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingTasks(brandId, false),
getTimeTrackingSettings(brandId),
]);
setWorkers(w);
setTasks(t);
if (s) {
setSettings(s);
setPayPeriodStartDay(s.pay_period_start_day);
setPayPeriodLength(s.pay_period_length_days);
setDailyOvertimeThreshold(s.daily_overtime_threshold);
setWeeklyOvertimeThreshold(s.weekly_overtime_threshold);
setOvertimeMultiplier(s.overtime_multiplier);
setOvertimeNotifications(s.overtime_notifications);
setNotificationEmails(s.notification_emails ?? []);
setNotificationSmsNumbers(s.notification_sms_numbers ?? []);
setEnableDailyAlerts(s.enable_daily_alerts ?? true);
setEnableWeeklyAlerts(s.enable_weekly_alerts ?? true);
setDailyAlertThreshold(Math.round((s.daily_alert_threshold ?? 0.80) * 100));
setWeeklyAlertThreshold(Math.round((s.weekly_alert_threshold ?? 0.80) * 100));
setSendEndOfPeriodSummary(s.send_end_of_period_summary ?? true);
setBrandName(s.brand_name ?? "Farm");
}
const log = await getTimeTrackingNotificationLog(brandId, 50);
setNotificationLog(log);
setLoading(false);
}, [brandId]);
useEffect(() => { load(); }, [load]);
const handleSaveNotifications = async () => {
setSettingsSaving(true);
setSettingsError(null);
setSettingsSaved(false);
const result = await updateTimeTrackingSettings(brandId, {
pay_period_start_day: payPeriodStartDay,
pay_period_length_days: payPeriodLength,
daily_overtime_threshold: dailyOvertimeThreshold,
weekly_overtime_threshold: weeklyOvertimeThreshold,
overtime_multiplier: overtimeMultiplier,
overtime_notifications: overtimeNotifications,
notification_emails: notificationEmails,
notification_sms_numbers: notificationSmsNumbers,
enable_daily_alerts: enableDailyAlerts,
enable_weekly_alerts: enableWeeklyAlerts,
daily_alert_threshold: dailyAlertThreshold / 100,
weekly_alert_threshold: weeklyAlertThreshold / 100,
send_end_of_period_summary: sendEndOfPeriodSummary,
brand_name: brandName,
});
setSettingsSaving(false);
if (!result.success) { setSettingsError(result.error ?? "Failed to save"); return; }
setSettingsSaved(true);
setTimeout(() => setSettingsSaved(false), 3000);
load();
};
const addEmail = () => {
const email = newEmail.trim();
if (email && !notificationEmails.includes(email)) {
setNotificationEmails([...notificationEmails, email]);
}
setNewEmail("");
};
const removeEmail = (email: string) => setNotificationEmails(notificationEmails.filter(e => e !== email));
const addSms = () => {
const num = newSms.replace(/[^0-9+]/g, "");
if (num && !notificationSmsNumbers.includes(num)) {
setNotificationSmsNumbers([...notificationSmsNumbers, num]);
}
setNewSms("");
};
const removeSms = (num: string) => setNotificationSmsNumbers(notificationSmsNumbers.filter(n => n !== num));
const openAddWorker = () => {
setEditingWorker(null);
setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en");
setWorkerActive(true); setWorkerError(null); setResetPinResult(null);
setShowWorkerModal(true);
};
const openEditWorker = (w: TimeWorker) => {
setEditingWorker(w); setWorkerName(w.name); setWorkerRole(w.role);
setWorkerLang(w.lang); setWorkerActive(w.active);
setWorkerError(null); setResetPinResult(null);
setShowWorkerModal(true);
};
const handleSaveWorker = async () => {
if (!workerName.trim()) { setWorkerError("Name is required"); return; }
setSubmitting(true); setWorkerError(null);
if (editingWorker) {
const result = await updateTimeWorker(editingWorker.id, workerName.trim(), workerRole, workerLang, workerActive);
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
} else {
const result = await createTimeWorker(brandId, workerName.trim(), workerRole, workerLang);
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
}
setSubmitting(false); setShowWorkerModal(false); load();
};
const handleResetPin = async (workerId: string) => {
const result = await resetTimeWorkerPin(workerId);
if (result.success && result.pin) setResetPinResult(result.pin);
else setWorkerError(result.error ?? "Failed to reset PIN");
};
const handleDeleteWorker = async (workerId: string) => {
if (!confirm("Delete this worker? This cannot be undone.")) return;
const result = await deleteTimeWorker(workerId);
if (!result.success) { setWorkerError(result.error ?? "Failed"); return; }
load();
};
const openAddTask = () => {
setEditingTask(null); setTaskName(""); setTaskNameEs("");
setTaskUnit("hours"); setTaskActive(true); setTaskSortOrder(0);
setTaskError(null); setShowTaskModal(true);
};
const openEditTask = (t: TimeTask) => {
setEditingTask(t); setTaskName(t.name); setTaskNameEs(t.name_es ?? "");
setTaskUnit(t.unit); setTaskActive(t.active); setTaskSortOrder(t.sort_order);
setTaskError(null); setShowTaskModal(true);
};
const handleSaveTask = async () => {
if (!taskName.trim()) { setTaskError("Name is required"); return; }
setSubmitting(true); setTaskError(null);
if (editingTask) {
const result = await updateTimeTask(editingTask.id, taskName.trim(), taskNameEs, taskUnit, taskActive, taskSortOrder);
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
} else {
const result = await createTimeTask(brandId, taskName.trim(), taskNameEs || null, taskUnit, taskSortOrder);
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
}
setSubmitting(false); setShowTaskModal(false); load();
};
const handleDeleteTask = async (taskId: string) => {
if (!confirm("Delete this task?")) return;
const result = await deleteTimeTask(taskId);
if (!result.success) { setTaskError(result.error ?? "Failed"); return; }
load();
};
if (loading) return (
<div className="flex items-center justify-center py-20">
<div className="flex items-center gap-3 text-stone-500 text-sm">
<svg className="animate-spin w-4 h-4" 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>
Loading settings...
</div>
</div>
);
return (
<div className="space-y-4">
{/* ACCORDION 1: General Settings */}
<Accordion
id="general"
title="General Settings"
description="Pay period, overtime, alerts"
defaultOpen={true}
accentColor="stone"
>
<div className="space-y-5">
{/* Pay Period & Overtime */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
<h3 className="text-sm font-semibold text-stone-800 mb-4">Pay Period & Overtime</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
<select value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
{DAYS.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Pay period length</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={31} value={payPeriodLength}
onChange={e => setPayPeriodLength(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">days</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={24} value={dailyOvertimeThreshold}
onChange={e => setDailyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={80} value={weeklyOvertimeThreshold}
onChange={e => setWeeklyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
</div>
</div>
</div>
</div>
{/* Colorado notice */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<span className="text-amber-600 text-base mt-0.5"></span>
<div>
<p className="text-sm font-semibold text-amber-800">Colorado Overtime Law</p>
<p className="text-xs text-amber-700 mt-1">Colorado requires daily overtime (1.5×) after 12 hours in a workday, or weekly overtime after 40 hours in a workweek.</p>
</div>
</div>
</div>
{/* Alert Settings */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
<h3 className="text-sm font-semibold text-stone-800 mb-4">Alert Settings</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Daily overtime alerts</p>
<p className="text-xs text-stone-500">Notify when worker hits daily limit</p>
</div>
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Weekly overtime alerts</p>
<p className="text-xs text-stone-500">Notify when worker hits weekly limit</p>
</div>
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
</div>
</div>
{/* Notification Recipients */}
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
<h3 className="text-sm font-semibold text-stone-800 mb-4">Notification Recipients</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
<div className="flex gap-2">
<input value={newEmail}
onChange={e => setNewEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
placeholder="manager@farm.com" />
<button onClick={addEmail} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationEmails.map(e => (
<span key={e} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
{e}
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">SMS numbers</label>
<div className="flex gap-2">
<input value={newSms}
onChange={e => setNewSms(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500"
placeholder="+1234567890" />
<button onClick={addSms} className="px-5 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationSmsNumbers.map(n => (
<span key={n} className="inline-flex items-center gap-1.5 bg-stone-100 text-stone-700 text-xs px-2.5 py-1 rounded-lg">
{n}
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
</div>
</div>
</div>
{settingsError && (
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
)}
{settingsSaved && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4 text-emerald-700 text-sm">Settings saved.</div>
)}
<button onClick={handleSaveNotifications} disabled={settingsSaving}
className="px-6 py-3 rounded-xl bg-stone-900 hover:bg-stone-800 disabled:opacity-40 font-semibold text-sm text-white transition-all">
{settingsSaving ? "Saving..." : "Save Settings"}
</button>
</div>
</Accordion>
{/* ACCORDION 2: Workers & PINs */}
<Accordion
id="workers"
title="Workers & PINs"
description={`${workers.length} worker${workers.length !== 1 ? "s" : ""}`}
defaultOpen={true}
accentColor="emerald"
>
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-stone-500">Manage time tracking workers and PIN codes.</p>
<button onClick={openAddWorker}
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Worker</button>
</div>
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Role</th>
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Lang</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Last Used</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{workers.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No workers yet add one to get started.</td></tr>
) : workers.map(w => (
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 text-stone-900 font-medium">{w.name}</td>
<td className="px-4 py-3.5 text-stone-500 capitalize text-xs">{w.role}</td>
<td className="px-4 py-3.5 text-stone-400 uppercase text-xs font-mono hidden sm:table-cell">{w.lang}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{w.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
<button onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Accordion>
{/* ACCORDION 3: Tasks */}
<Accordion
id="tasks"
title="Tasks"
description={`${tasks.length} task${tasks.length !== 1 ? "s" : ""}`}
defaultOpen={false}
accentColor="amber"
>
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-stone-500">Define tasks workers can clock into.</p>
<button onClick={openAddTask}
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Task</button>
</div>
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-4 py-3 font-medium">Name (EN)</th>
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Name (ES)</th>
<th className="text-left px-4 py-3 font-medium">Unit</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Sort</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{tasks.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-10 text-center text-stone-400 text-sm">No tasks yet add one to get started.</td></tr>
) : tasks.map(t => (
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 text-stone-900 font-medium">{t.name}</td>
<td className="px-4 py-3.5 text-stone-500 text-xs hidden sm:table-cell">{t.name_es ?? "—"}</td>
<td className="px-4 py-3.5 text-stone-400 text-xs font-mono">{t.unit}</td>
<td className="px-4 py-3.5 text-stone-400 text-xs hidden md:table-cell">{t.sort_order}</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{t.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3.5 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-900 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Accordion>
{/* Worker Modal */}
{showWorkerModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{workerError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{workerError}</div>}
{resetPinResult && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4">
<p className="text-emerald-700 text-sm font-semibold mb-1">New PIN:</p>
<p className="text-2xl font-mono font-bold text-stone-900">{resetPinResult}</p>
<p className="text-stone-500 text-xs mt-1">Show this to the worker it will not be shown again.</p>
</div>
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name *</label>
<input value={workerName} onChange={e => setWorkerName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="Worker name" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Role</label>
<select value={workerRole} onChange={e => setWorkerRole(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="worker">Worker</option>
<option value="time_admin">Time Admin</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Lang</label>
<select value={workerLang} onChange={e => setWorkerLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="workerActive" className="text-sm text-stone-700">Active</label>
</div>
{editingWorker && (
<div className="pt-2 border-t border-stone-100">
<button onClick={() => handleResetPin(editingWorker.id)} className="text-xs text-amber-600 hover:text-amber-800 font-semibold underline underline-offset-2">
Reset PIN for this worker
</button>
</div>
)}
<div className="flex gap-3 pt-2">
<button onClick={() => setShowWorkerModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
Cancel
</button>
<button onClick={handleSaveWorker} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
</button>
</div>
</div>
</div>
</div>
)}
{/* Task Modal */}
{showTaskModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (EN) *</label>
<input value={taskName} onChange={e => setTaskName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Harvesting" />
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (ES)</label>
<input value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Cosecha" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Unit</label>
<select value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
<option value="units">Units</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Sort Order</label>
<input type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="taskActive" className="text-sm text-stone-700">Active</label>
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setShowTaskModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
Cancel
</button>
<button onClick={handleSaveTask} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,517 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { updateShippingStatus } from "@/actions/shipping";
import { getFedExRates, type FedExRate, type FedExServiceType } from "@/actions/shipping/fedex-rates";
import { createFedExShipment } from "@/actions/shipping/fedex-labels";
type OrderItem = {
id: string;
product_id: string;
quantity: number;
price: number;
fulfillment: string;
products: { name: string; is_perishable: boolean } | null;
};
type ShippingOrder = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
status: string;
subtotal: number;
shipping_status: string;
tracking_number: string | null;
created_at: string;
brand_id: string | null;
order_items: OrderItem[];
};
type ShippingFulfillmentPanelProps = {
initialOrders: ShippingOrder[];
canManageOrders: boolean;
};
const SHIPPING_STATUSES = [
{ value: "pending", label: "Pending" },
{ value: "label_created", label: "Label Created" },
{ value: "shipped", label: "Shipped" },
{ value: "delivered", label: "Delivered" },
{ value: "returned", label: "Returned" },
];
const SERVICE_LABELS: Record<FedExServiceType, string> = {
FEDEX_OVERNIGHT: "FedEx Overnight",
FEDEX_2_DAY_AIR: "FedEx 2-Day Air",
FEDEX_EXPRESS_SAVER: "FedEx Express Saver",
FEDEX_GROUND: "FedEx Ground",
};
function shortId(id: string) {
return id.slice(0, 8).toUpperCase();
}
function statusBadge(status: string) {
const map: Record<string, string> = {
pending: "bg-yellow-100 text-yellow-800",
label_created: "bg-blue-900/40 text-blue-800",
shipped: "bg-indigo-100 text-indigo-800",
delivered: "bg-green-900/40 text-green-800",
returned: "bg-red-900/40 text-red-800",
};
const cls = map[status] ?? "bg-zinc-950 text-zinc-300";
const label = SHIPPING_STATUSES.find((s) => s.value === status)?.label ?? status;
return { cls, label };
}
// ── Rate Modal ────────────────────────────────────────────────────────────────
function RateModal({
order,
onClose,
}: {
order: ShippingOrder;
onClose: () => void;
}) {
const [rates, setRates] = useState<FedExRate[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState<FedExServiceType | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [result, setResult] = useState<{ labelUrl: string; trackingNumber: string } | null>(null);
const loadRates = async () => {
setLoading(true);
setError(null);
const res = await getFedExRates(order.id);
setLoading(false);
if (res.success) {
setRates(res.rates);
} else {
setError(res.error ?? "Failed to load rates");
}
};
const handleSelectRate = async (rate: FedExRate) => {
setCreating(rate.serviceType);
setCreateError(null);
const res = await createFedExShipment(
order.id,
rate.serviceType,
rate.totalCharge,
rate.deliveryDate
);
setCreating(null);
if (res.success) {
setResult({ labelUrl: res.labelUrl, trackingNumber: res.trackingNumber });
} else {
setCreateError(res.error ?? "Failed to create shipment");
}
};
// Auto-load on mount
if (rates === null && !loading && !error) {
loadRates();
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-lg rounded-2xl bg-zinc-900 shadow-2xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between border-b border-zinc-800 px-6 py-4 sticky top-0 bg-zinc-900 rounded-t-2xl">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Shipping Rates</h2>
<p className="text-sm text-zinc-500">
{order.customer_name} · {shortId(order.id)}
</p>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-zinc-400 text-2xl leading-none"
>
×
</button>
</div>
<div className="p-6 space-y-4">
{/* Perishable warning */}
{rates !== null && rates[0]?.isPerishableOnly && (
<div className="rounded-lg bg-blue-900/30 border border-blue-200 px-4 py-3 text-sm text-blue-700">
<strong>Perishable shipment.</strong> Only Overnight and 2-Day Air are available
for fresh produce (sweet corn, onions, etc.). Ground and Express Saver are not
offered for temperature-sensitive items.
</div>
)}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 rounded-full border-2 border-blue-600 border-t-transparent animate-spin" />
<span className="ml-3 text-zinc-500">Fetching FedEx rates...</span>
</div>
)}
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
<button
onClick={loadRates}
className="ml-2 underline hover:no-underline"
>
Retry
</button>
</div>
)}
{rates !== null && rates.length === 0 && !loading && (
<div className="text-center py-8 text-zinc-500">
No FedEx rates available for this address. Please verify the shipping address.
</div>
)}
{rates !== null && rates.length > 0 && (
<div className="space-y-2">
{rates.map((rate) => (
<button
key={rate.serviceType}
onClick={() => handleSelectRate(rate)}
disabled={creating !== null}
className="w-full flex items-center justify-between rounded-xl border border-zinc-800 px-4 py-3 text-left hover:border-blue-400 hover:bg-blue-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div>
<div className="font-semibold text-zinc-100">
{SERVICE_LABELS[rate.serviceType] ?? rate.serviceType}
</div>
<div className="text-sm text-zinc-500">
{rate.deliveryDateLabel
? `Arrives ${rate.deliveryDateLabel}`
: rate.deliveryDayOfWeek}
</div>
{rate.isPerishableOnly && (
<span className="mt-1 inline-block rounded-full bg-blue-900/40 text-blue-700 px-2 py-0.5 text-xs font-medium">
Required for perishable
</span>
)}
</div>
<div className="text-right">
<div className="font-bold text-zinc-100">
${(rate.totalCharge / 100).toFixed(2)}
</div>
{creating === rate.serviceType && (
<div className="h-4 w-4 rounded-full border-2 border-blue-600 border-t-transparent animate-spin mt-1 mx-auto" />
)}
</div>
</button>
))}
</div>
)}
{createError && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{createError}
</div>
)}
{result && (
<div className="rounded-xl bg-emerald-50 border border-emerald-200 px-4 py-4 space-y-3">
<div className="flex items-center gap-2 text-emerald-700 font-semibold">
<span className="text-lg"></span> Label Created
</div>
<div className="text-sm text-emerald-600">
<div>Tracking: <span className="font-mono font-medium">{result.trackingNumber}</span></div>
</div>
<div className="flex gap-2">
{result.labelUrl && (
<a
href={result.labelUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700"
>
Download Label
</a>
)}
<button
onClick={onClose}
className="inline-flex items-center rounded-lg border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
>
Done
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ── Main Panel ────────────────────────────────────────────────────────────────
export default function ShippingFulfillmentPanel({
initialOrders,
canManageOrders,
}: ShippingFulfillmentPanelProps) {
const [orders, setOrders] = useState<ShippingOrder[]>(initialOrders);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [updating, setUpdating] = useState<string | null>(null);
const [trackingInputs, setTrackingInputs] = useState<Record<string, string>>({});
const [rateModalOrder, setRateModalOrder] = useState<ShippingOrder | null>(null);
const filtered = orders.filter((o) => {
const matchesSearch =
!search ||
o.customer_name.toLowerCase().includes(search.toLowerCase()) ||
(o.customer_phone ?? "").includes(search) ||
o.id.includes(search.toUpperCase().slice(0, 8));
const matchesStatus = !statusFilter || o.shipping_status === statusFilter;
return matchesSearch && matchesStatus;
});
const counts = SHIPPING_STATUSES.reduce(
(acc, s) => {
acc[s.value] = orders.filter((o) => o.shipping_status === s.value).length;
return acc;
},
{} as Record<string, number>
);
async function handleStatusChange(orderId: string, newStatus: string) {
if (!canManageOrders) return;
setUpdating(orderId);
const trackingNumber =
newStatus === "shipped" ? trackingInputs[orderId] ?? null : null;
const result = await updateShippingStatus(orderId, newStatus, trackingNumber ?? undefined);
if (result.success) {
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, shipping_status: newStatus, tracking_number: trackingNumber ?? o.tracking_number }
: o
)
);
}
setUpdating(null);
}
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<div className="bg-slate-900 px-4 py-5">
<div className="mx-auto max-w-6xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Shipping Fulfillment</h1>
<p className="mt-1 text-sm text-slate-400">
{orders.length} shipping order{orders.length !== 1 ? "s" : ""}
</p>
</div>
<div className="flex gap-3">
<Link
href="/admin/orders"
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
>
All Orders
</Link>
<Link
href="/admin/pickup"
className="rounded-lg bg-slate-700 px-4 py-2 text-sm font-medium text-white hover:bg-slate-600"
>
Pickup
</Link>
</div>
</div>
</div>
</div>
<div className="mx-auto max-w-6xl px-4 py-4 space-y-4">
{/* Status filter pills */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setStatusFilter("")}
className={`rounded-full px-3 py-1.5 text-sm font-medium ${
!statusFilter ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400"
}`}
>
All ({orders.length})
</button>
{SHIPPING_STATUSES.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`rounded-full px-3 py-1.5 text-sm font-medium ${
statusFilter === s.value ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400"
}`}
>
{s.label} ({counts[s.value] ?? 0})
</button>
))}
</div>
{/* Search */}
<input
type="search"
placeholder="Search name, phone, or order #..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-4 text-base outline-none focus:border-slate-900"
/>
{/* Orders */}
<div className="space-y-3">
{filtered.length === 0 ? (
<div className="rounded-xl bg-zinc-900 py-12 text-center text-slate-400">
No shipping orders
</div>
) : (
filtered.map((order) => {
const { cls, label } = statusBadge(order.shipping_status);
const hasShipItems = order.order_items.some((i) => i.fulfillment === "ship");
const hasPerishableItems = order.order_items.some(
(i) => i.fulfillment === "ship" && i.products?.is_perishable
);
const isPending = order.shipping_status === "pending" || order.shipping_status === "label_created";
return (
<div key={order.id} className="rounded-2xl bg-zinc-900 p-5 shadow-black/20 ring-1 ring-zinc-700">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xl font-bold text-zinc-100">{order.customer_name}</p>
{order.customer_phone && (
<p className="mt-1 text-base text-zinc-400">{order.customer_phone}</p>
)}
<p className="mt-1 font-mono text-xs text-slate-400 uppercase">{shortId(order.id)}</p>
</div>
<span className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold ${cls}`}>
{label}
</span>
</div>
{/* Perishable indicator */}
{hasPerishableItems && isPending && (
<div className="mt-3 rounded-lg bg-blue-900/30 border border-blue-200 px-3 py-2 text-xs text-blue-700 font-medium">
🌽 Perishable Overnight or 2-Day Air only
</div>
)}
{/* Tracking number */}
{order.tracking_number && (
<div className="mt-3 rounded-lg bg-slate-50 px-3 py-2 text-sm flex items-center justify-between">
<div>
<span className="text-zinc-500">Tracking: </span>
<span className="font-mono font-medium text-zinc-300">{order.tracking_number}</span>
</div>
{order.shipping_status === "label_created" && (
<button
onClick={() => handleStatusChange(order.id, "shipped")}
disabled={updating === order.id}
className="text-xs rounded-lg bg-indigo-600 px-3 py-1.5 font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
{updating === order.id ? "..." : "Mark Shipped"}
</button>
)}
</div>
)}
{/* Items */}
{order.order_items.filter((i) => i.fulfillment === "ship").length > 0 && (
<div className="mt-4 rounded-lg bg-slate-50 p-3">
<ul className="space-y-1">
{order.order_items
.filter((i) => i.fulfillment === "ship")
.map((item) => (
<li key={item.id} className="flex items-center justify-between text-sm">
<span className="text-zinc-300">
{item.quantity > 1 && (
<span className="font-semibold text-zinc-100">{item.quantity}× </span>
)}
{item.products?.name ?? "Unknown Product"}
{item.products?.is_perishable && (
<span className="ml-1.5 text-xs text-blue-400">🌽 perishable</span>
)}
</span>
<span className="text-zinc-500">
${(Number(item.price) * item.quantity).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-2 border-t border-zinc-800 pt-2 flex justify-between">
<span className="text-sm font-medium text-zinc-400">Subtotal</span>
<span className="text-sm font-bold text-zinc-100">${Number(order.subtotal).toFixed(2)}</span>
</div>
</div>
)}
{/* Actions */}
{canManageOrders && hasShipItems && isPending && (
<div className="mt-4 flex flex-wrap gap-2">
{/* Get Rates / Create Label */}
{order.shipping_status === "pending" && (
<button
onClick={() => setRateModalOrder(order)}
className="rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Get FedEx Rates
</button>
)}
{/* Manual tracking input (fallback) */}
{(order.shipping_status === "pending" ||
order.shipping_status === "label_created") && (
<input
type="text"
placeholder="Enter tracking manually"
value={trackingInputs[order.id] ?? ""}
onChange={(e) =>
setTrackingInputs((prev) => ({ ...prev, [order.id]: e.target.value }))
}
className="flex-1 min-w-[160px] rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
)}
{/* Status transitions */}
{SHIPPING_STATUSES.filter(
(s) =>
s.value !== order.shipping_status &&
s.value !== "label_created" // handled by create label flow
).map((s) => (
<button
key={s.value}
onClick={() => handleStatusChange(order.id, s.value)}
disabled={updating === order.id}
className="rounded-xl border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{updating === order.id ? "..." : `Mark ${s.label}`}
</button>
))}
</div>
)}
{!canManageOrders && (
<div className="mt-4 rounded-xl bg-zinc-950 px-4 py-3 text-center text-sm text-zinc-500">
No order management permission
</div>
)}
</div>
);
})
)}
</div>
</div>
{/* Rate Modal */}
{rateModalOrder && (
<RateModal order={rateModalOrder} onClose={() => setRateModalOrder(null)} />
)}
</div>
);
}
@@ -0,0 +1,361 @@
"use client";
import { useState, useEffect } from "react";
import {
getShippingSettings,
saveShippingSettings,
testFedExConnection,
type ShippingSettings,
} from "@/actions/shipping/settings";
const SERVICE_OPTIONS = [
{ value: "FEDEX_OVERNIGHT", label: "FedEx Overnight" },
{ value: "FEDEX_2_DAY_AIR", label: "FedEx 2-Day Air" },
{ value: "FEDEX_EXPRESS_SAVER", label: "FedEx Express Saver" },
{ value: "FEDEX_GROUND", label: "FedEx Ground" },
];
type Props = {
settings: ShippingSettings | null;
brandId: string;
brands?: { id: string; name: string }[];
isPlatformAdmin?: boolean;
};
export default function ShippingSettingsForm({
settings,
brandId: initialBrandId,
brands = [],
isPlatformAdmin = false,
}: Props) {
const [activeBrandId, setActiveBrandId] = useState(initialBrandId);
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 [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [showSecret, setShowSecret] = useState(false);
const isConfigured = !!settings?.fedex_api_key && !!settings?.fedex_api_secret && !!settings?.fedex_account_number;
// Reload settings when brand changes
useEffect(() => {
if (!isPlatformAdmin) return;
setLoading(true);
setError(null);
getShippingSettings(activeBrandId).then((result) => {
setLoading(false);
if (result.success && result.settings) {
const s = result.settings;
setFedexAccountNumber(s.fedex_account_number ?? "");
setFedexApiKey(s.fedex_api_key ?? "");
setFedexApiSecret(s.fedex_api_secret ?? "");
setFedexUseProduction(s.fedex_use_production);
setDefaultServiceType(s.default_service_type);
setRefrigeratedHandlingNotes(s.refrigerated_handling_notes ?? "");
setFragileHandlingNotes(s.fragile_handling_notes ?? "");
} else {
// Reset form for new brand
setFedexAccountNumber("");
setFedexApiKey("");
setFedexApiSecret("");
setFedexUseProduction(false);
setDefaultServiceType("FEDEX_GROUND");
setRefrigeratedHandlingNotes("Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions.");
setFragileHandlingNotes("");
}
});
}, [activeBrandId, isPlatformAdmin]);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setSaved(false);
setTestResult(null);
const result = await saveShippingSettings({
brandId: activeBrandId,
fedexAccountNumber: fedexAccountNumber || undefined,
fedexApiKey: fedexApiKey || undefined,
fedexApiSecret: fedexApiSecret || undefined,
fedexUseProduction,
defaultServiceType,
refrigeratedHandlingNotes: refrigeratedHandlingNotes || undefined,
fragileHandlingNotes: fragileHandlingNotes || undefined,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
} else {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
}
async function handleTestConnection() {
if (!fedexApiKey || !fedexApiSecret) {
setTestResult({ success: false, message: "Enter 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" });
}
return (
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
<select
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-zinc-400"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
)}
{/* Connection status banner */}
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium ${
isConfigured
? "bg-emerald-900/30 border border-emerald-700 text-emerald-300"
: "bg-amber-900/30 border border-amber-700 text-amber-300"
}`}>
<span className={`h-2.5 w-2.5 rounded-full ${isConfigured ? "bg-emerald-500" : "bg-amber-900/300"}`} />
{isConfigured
? `FedEx Connected — ${settings?.fedex_use_production ? "Production" : "Sandbox"} mode`
: "FedEx Not Configured — enter credentials below to enable shipping rates and label generation"}
</div>
{error && (
<div className="rounded-xl bg-red-900/50 border border-red-700 p-4 text-sm text-red-200">{error}</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/50 border border-green-700 p-4 text-sm text-green-200">
Shipping settings saved.
</div>
)}
{testResult && (
<div className={`rounded-xl p-4 text-sm border ${testResult.success ? "bg-green-900/50 border-green-700 text-green-200" : "bg-red-900/50 border-red-700 text-red-200"}`}>
{testResult.message}
</div>
)}
{/* FedEx credentials */}
<div className="space-y-5 rounded-xl border border-blue-400 bg-blue-900/20 p-5">
<div>
<h3 className="font-semibold text-blue-300">FedEx API Credentials</h3>
<p className="mt-1 text-sm text-blue-400">
Used for shipping fresh sweet corn and onions. Get your credentials from{" "}
<a
href="https://developer.fedex.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
>
developer.fedex.com
</a>
.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">
FedEx Account Number <span className="text-red-500">*</span>
</label>
<input
type="text"
value={fedexAccountNumber}
onChange={(e) => setFedexAccountNumber(e.target.value)}
placeholder="000000000000 (12 digits)"
maxLength={12}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm font-mono text-zinc-100 outline-none focus:border-zinc-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
API Key <span className="text-red-500">*</span>
</label>
<input
type="text"
value={fedexApiKey}
onChange={(e) => setFedexApiKey(e.target.value)}
placeholder="Your FedEx API key"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
API Secret <span className="text-red-500">*</span>
<span className="ml-2 text-slate-400 font-normal">(password field)</span>
</label>
<div className="relative mt-1">
<input
type={showSecret ? "text" : "password"}
value={fedexApiSecret}
onChange={(e) => setFedexApiSecret(e.target.value)}
placeholder="Your FedEx API secret"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 pr-12 text-sm text-zinc-100 outline-none focus:border-zinc-400"
/>
<button
type="button"
onClick={() => setShowSecret(!showSecret)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-zinc-400 text-xs"
>
{showSecret ? "Hide" : "Show"}
</button>
</div>
</div>
{/* Production toggle */}
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-3">
<button
type="button"
onClick={() => setFedexUseProduction(!fedexUseProduction)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
fedexUseProduction ? "bg-blue-600" : "bg-zinc-600"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${
fedexUseProduction ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<div>
<span className="text-sm font-medium text-zinc-200">Use Production Mode</span>
<p className="text-xs text-zinc-400">
{fedexUseProduction
? "Live rates, real labels. Sandbox is currently active."
: "Sandbox/test mode. Use for testing before going live."}
</p>
</div>
</div>
{/* Test connection */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleTestConnection}
disabled={testing || !fedexApiKey || !fedexApiSecret}
className="rounded-xl border border-blue-400 bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50"
>
{testing ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 rounded-full border-2 border-blue-600 border-t-transparent animate-spin" />
Testing...
</span>
) : (
"Test Connection"
)}
</button>
{testResult?.success && (
<span className="text-sm text-green-400 font-medium"> Connection verified</span>
)}
</div>
</div>
{/* Default shipping options */}
<div className="space-y-5 rounded-xl border border-zinc-700 bg-zinc-900 p-5">
<div>
<h3 className="font-semibold text-zinc-200">Default Shipping Service</h3>
<p className="mt-1 text-sm text-zinc-400">
Applied automatically when creating shipments. Perishable orders always require Overnight or 2-Day Air regardless of this setting.
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Default for non-perishable orders
</label>
<select
value={defaultServiceType}
onChange={(e) => setDefaultServiceType(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
>
{SERVICE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* Handling notes */}
<div className="space-y-5 rounded-xl border border-zinc-700 bg-zinc-900 p-5">
<div>
<h3 className="font-semibold text-zinc-200">Handling Instructions</h3>
<p className="mt-1 text-sm text-zinc-400">
These notes are attached to shipments containing perishable or fragile items (sweet corn, onions, etc.). Appear on the carrier label and in the warehouse.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
🌽 Refrigerated / Perishable Notes
</label>
<textarea
value={refrigeratedHandlingNotes}
onChange={(e) => setRefrigeratedHandlingNotes(e.target.value)}
rows={3}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
placeholder="e.g. Keep refrigerated. Do not freeze. Contains fresh sweet corn and/or onions."
/>
<p className="mt-1 text-xs text-zinc-500">
Applied to all shipments with perishable items (is_perishable = true).
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
📦 Fragile Items Notes
</label>
<textarea
value={fragileHandlingNotes}
onChange={(e) => setFragileHandlingNotes(e.target.value)}
rows={2}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
placeholder="e.g. Fragile — handle with care. Do not stack heavy items on top."
/>
</div>
</div>
{loading ? (
<div className="flex items-center gap-3 text-zinc-500">
<div className="h-5 w-5 rounded-full border-2 border-zinc-500 border-t-transparent animate-spin" />
Loading settings...
</div>
) : (
<button
type="submit"
disabled={saving}
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Shipping Settings"}
</button>
)}
</form>
);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { getSyncLog, syncSquareNow, type SyncLogEntry } from "@/actions/square-sync-ui";
import { getPaymentSettings } from "@/actions/payments";
type Props = {
brandId: string;
};
export default function SquareSyncWidget({ brandId }: Props) {
const [settings, setSettings] = useState<{
provider: string | null;
square_access_token: string | null;
square_sync_enabled: boolean;
square_inventory_mode: string;
square_last_sync_at: string | null;
square_last_sync_error: string | null;
} | null>(null);
const [logs, setLogs] = useState<SyncLogEntry[]>([]);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [queueCount, setQueueCount] = useState(0);
useEffect(() => {
getPaymentSettings(brandId).then((r) => {
if (r.success && r.settings) setSettings(r.settings as any);
});
getSyncLog(brandId).then((r) => {
if (r.success) setLogs(r.logs.slice(0, 5));
});
// Poll pending queue count every 30s
const interval = setInterval(() => checkQueueCount(), 30000);
checkQueueCount();
return () => clearInterval(interval);
}, [brandId]);
async function checkQueueCount() {
const { supabase } = await import("@/lib/supabase");
const { count } = await supabase
.from("square_sync_queue")
.select("*", { count: "exact", head: true })
.eq("brand_id", brandId)
.in("status", ["pending"]);
setQueueCount(count ?? 0);
}
useEffect(() => {
if (!syncMsg) return;
const timer = setTimeout(() => setSyncMsg(null), 4000);
return () => clearTimeout(timer);
}, [syncMsg]);
async function handleSyncNow(type: "products" | "orders" | "all") {
setSyncing(true);
setSyncMsg(null);
const result = await syncSquareNow(brandId, type);
setSyncMsg({
kind: result.success ? "success" : "error",
text: result.success
? `Sync complete — ${result.synced} item(s) synced.`
: `Sync failed: ${result.errors[0] ?? "Unknown error"}`,
});
setSyncing(false);
getSyncLog(brandId).then((r) => {
if (r.success) setLogs(r.logs.slice(0, 5));
});
getPaymentSettings(brandId).then((r) => {
if (r.success && r.settings) setSettings(r.settings as any);
});
}
const lastSyncAt = settings?.square_last_sync_at
? timeAgo(settings.square_last_sync_at)
: "Never";
const lastError = settings?.square_last_sync_error;
const hasToken = !!(settings?.square_access_token);
const autoSyncEnabled = settings?.square_sync_enabled && hasToken;
const subheading = hasToken
? autoSyncEnabled
? `Wholesale products · ${lastSyncAt}`
: "Connected — auto-sync disabled"
: "Not connected";
return (
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-green-900/40">
<svg className="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-zinc-100">Square Inventory</h3>
{autoSyncEnabled && (
<span className="rounded-full bg-green-900/40 px-2 py-0.5 text-xs font-medium text-green-400">
Auto-sync
</span>
)}
{!autoSyncEnabled && hasToken && (
<span className="rounded-full bg-zinc-950 px-2 py-0.5 text-xs font-medium text-zinc-500">
Sync off
</span>
)}
</div>
<p className="text-sm text-zinc-500">
{subheading}
</p>
</div>
</div>
{lastError && (
<span className="rounded-full bg-red-900/40 px-2.5 py-0.5 text-xs font-medium text-red-400">
Sync Error
</span>
)}
</div>
{lastError && (
<div className="mb-3 rounded-lg border border-red-200 bg-red-900/30 px-3 py-2 text-xs text-red-400">
{lastError}
</div>
)}
{syncMsg && (
<div className={`mb-3 rounded-lg border px-3 py-2 text-sm ${
syncMsg.kind === "success"
? "border-green-200 bg-green-900/30 text-green-400"
: "border-red-200 bg-red-900/30 text-red-400"
}`}>
{syncMsg.text}
</div>
)}
{hasToken ? (
<>
<div className="mb-3 flex flex-wrap gap-2">
<button
onClick={() => handleSyncNow("products")}
disabled={syncing}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{syncing ? "..." : "Sync Products"}
</button>
<button
onClick={() => handleSyncNow("orders")}
disabled={syncing}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{syncing ? "..." : "Sync Orders"}
</button>
<button
onClick={() => handleSyncNow("all")}
disabled={syncing}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50"
>
{syncing ? "..." : "Sync All"}
</button>
</div>
{!autoSyncEnabled && hasToken && (
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-900/30 px-3 py-2 text-xs text-amber-700">
Square sync is turned off. Wholesale products will not be pushed to Square automatically.
</div>
)}
{logs.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">
Recent activity
</p>
{logs.slice(0, 4).map((log) => (
<div
key={log.id}
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-xs ${
log.status === "success"
? "border-green-200 bg-green-900/30 text-green-400"
: "border-red-200 bg-red-900/30 text-red-400"
}`}
>
<span className="truncate">
[{log.direction ?? "—"}] {log.event_type}
</span>
<span className="ml-2 text-slate-400">
{timeAgo(log.created_at)}
</span>
</div>
))}
</div>
)}
<div className="mt-3 flex gap-2">
<Link
href="/admin/settings/square-sync"
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
>
View Settings
</Link>
<Link
href="/admin/orders?square=1"
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
>
Square Orders
</Link>
</div>
</>
) : (
<Link
href="/admin/settings/payments"
className="inline-block rounded-lg bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700"
>
Connect Square
</Link>
)}
</div>
);
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
@@ -0,0 +1,9 @@
"use client";
import SquareSyncWidget from "@/components/admin/SquareSyncWidget";
type Props = { brandId: string };
export default function SquareSyncWidgetWrapper({ brandId }: Props) {
return <SquareSyncWidget brandId={brandId} />;
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateStop } from "@/actions/stops/update-stop";
type Brand = {
id: string;
name: string;
slug: string;
};
type StopEditFormProps = {
stop: {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
slug: string;
active: boolean;
brand_id: string;
address?: string | null;
zip?: string | null;
cutoff_time?: string | null;
};
brands: Brand[];
};
export default function StopEditForm({ stop, brands }: StopEditFormProps) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [city, setCity] = useState(stop.city);
const [state, setState] = useState(stop.state);
const [date, setDate] = useState(stop.date);
const [time, setTime] = useState(stop.time);
const [location, setLocation] = useState(stop.location);
const [active, setActive] = useState(stop.active);
const [brand_id, setBrand_id] = useState(stop.brand_id);
const [address, setAddress] = useState(stop.address ?? "");
const [zip, setZip] = useState(stop.zip ?? "");
const [cutoff_time, setCutoff_time] = useState(stop.cutoff_time ?? "");
async function handleSave() {
if (!city.trim() || !state.trim() || !date.trim()) {
setError("City, state, and date are required.");
return;
}
setSaving(true);
setError(null);
setSaved(false);
const slug = city.toLowerCase().replace(/\s+/g, "-") + "-" + date;
const result = await updateStop(stop.id, brand_id, {
city,
state,
location,
date,
time,
active,
address: address || null,
zip: zip || null,
cutoff_time: cutoff_time || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
setSaving(false);
return;
}
setSaved(true);
setSaving(false);
router.refresh();
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
Stop updated successfully.
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
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 className="mb-1 block text-sm font-medium text-zinc-300">State</label>
<input
type="text"
value={state}
onChange={(e) => setState(e.target.value)}
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>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
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 className="mb-1 block text-sm font-medium text-zinc-300">Time</label>
<input
type="text"
value={time}
onChange={(e) => setTime(e.target.value)}
placeholder="e.g. 8:00 AM 2:00 PM"
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>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Location</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Street address or intersection"
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 className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Street Address</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
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 className="mb-1 block text-sm font-medium text-zinc-300">ZIP Code</label>
<input
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80102"
maxLength={10}
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>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Order Cutoff</label>
<input
type="datetime-local"
value={cutoff_time}
onChange={(e) => setCutoff_time(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
<p className="mt-1 text-xs text-slate-400">
Customers must order before this time to be included at this stop.
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Brand</label>
<select
value={brand_id}
onChange={(e) => setBrand_id(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
<button
onClick={() => setActive((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
active
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{active ? "Active" : "Inactive"}
</button>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { useState } from "react";
type Customer = {
id: string;
customer_name: string;
customer_email: string | null;
customer_phone: string | null;
pickup_complete: boolean;
};
type StopMessagingFormProps = {
stopId: string;
};
const quickMessages = [
{ label: "Truck running late", value: "Heads up — the truck is running about 15 minutes behind schedule. Thanks for your patience!" },
{ label: "Stop moved", value: "Attention — today's stop location has changed to a new address. Please check the updated details." },
{ label: "Weather delay", value: "Due to weather conditions, today's stop may be delayed. We'll send updates as the day progresses." },
{ label: "Sold out", value: "This stop has sold out of several items. Check our website or next stop for availability." },
{ label: "Preorder cutoff reminder", value: "Friendly reminder — the preorder cutoff for our next stop is tonight at midnight. Order online to guarantee your pickup!" },
{ label: "Pickup reminder", value: "Reminder — you have an order ready for pickup today. See you soon!" },
];
export default function StopMessagingForm({ stopId }: StopMessagingFormProps) {
const [customers, setCustomers] = useState<Customer[]>([]);
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [channel, setChannel] = useState<"sms" | "email" | "both">("sms");
const [message, setMessage] = useState("");
const [customMessage, setCustomMessage] = useState("");
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(0);
const [error, setError] = useState<string | null>(null);
async function loadCustomers() {
setLoading(true);
setError(null);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/orders?stop_id=eq.${stopId}&pickup_complete=eq.false&pickup_complete=eq.false&select=customer_name,customer_email,customer_phone,pickup_complete`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
}
);
const data = await res.json();
if (!res.ok) {
setError(data.message);
} else {
setCustomers(data);
setLoaded(true);
}
setLoading(false);
}
function useQuickMessage(msg: string) {
setMessage(msg);
setCustomMessage(msg);
}
async function handleSend() {
if (!message.trim()) return;
setSending(true);
setError(null);
const recipients = customers.filter((c) => {
if (channel === "sms") return c.customer_phone;
if (channel === "email") return c.customer_email;
return c.customer_phone || c.customer_email;
});
// Call Supabase Edge Function to send messages
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/send-messages`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel,
message,
recipients: recipients.map((r) => ({
phone: r.customer_phone,
email: r.customer_email,
name: r.customer_name,
})),
}),
}
);
if (!res.ok) {
const err = await res.json();
setError(err.message ?? "Failed to send messages");
setSending(false);
return;
}
setSent(recipients.length);
setSending(false);
}
const recipients = customers.filter((c) => {
if (channel === "sms") return c.customer_phone;
if (channel === "email") return c.customer_email;
return c.customer_phone || c.customer_email;
});
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-zinc-100">
Send Message
</h2>
<p className="mt-1 text-zinc-400">
Notify all customers with pending pickups at this stop.
</p>
</div>
{!loaded ? (
<button
onClick={loadCustomers}
disabled={loading}
className="w-full rounded-xl border-2 border-dashed border-zinc-600 px-6 py-4 text-lg font-medium text-zinc-500 disabled:opacity-50"
>
{loading ? "Loading customers..." : "Load Pending Customers"}
</button>
) : (
<>
{customers.length === 0 ? (
<div className="rounded-xl bg-slate-50 p-6 text-center text-zinc-500">
No pending orders for this stop yet.
</div>
) : (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
{customers.length} pending order{customers.length !== 1 ? "s" : ""} found
{recipients.length !== customers.length && (
<span> {recipients.length} with contact info</span>
)}
</div>
)}
{/* Channel */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Send via
</label>
<div className="flex gap-3">
{(["sms", "email", "both"] as const).map((ch) => (
<button
key={ch}
onClick={() => setChannel(ch)}
className={`flex-1 rounded-xl px-4 py-3 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>
{/* Quick messages */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Quick messages
</label>
<div className="flex flex-wrap gap-2">
{quickMessages.map((qm) => (
<button
key={qm.label}
onClick={() => useQuickMessage(qm.value)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
message === qm.value
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{qm.label}
</button>
))}
</div>
</div>
{/* Message preview */}
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Message
</label>
<textarea
value={message}
onChange={(e) => {
setMessage(e.target.value);
setCustomMessage(e.target.value);
}}
rows={4}
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-slate-900"
placeholder="Type your message..."
/>
<p className="mt-1 text-xs text-slate-400">
{message.length} characters
</p>
</div>
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
{error}
</div>
)}
{sent > 0 && (
<div className="rounded-xl bg-green-900/30 p-4 text-green-400 text-sm">
Message sent to {sent} customer{sent !== 1 ? "s" : ""}!
</div>
)}
{/* Recipients */}
{recipients.length > 0 && message && (
<div className="rounded-xl border border-zinc-800 p-4">
<p className="mb-3 text-sm font-medium text-zinc-300">
Recipients ({recipients.length}):
</p>
<div className="space-y-2">
{recipients.map((c) => (
<div key={c.id} className="flex justify-between text-sm">
<span className="text-zinc-300">{c.customer_name}</span>
<span className="text-slate-400">
{c.customer_phone ?? c.customer_email ?? "no contact"}
</span>
</div>
))}
</div>
</div>
)}
<button
onClick={handleSend}
disabled={!message || 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>
</>
)}
</div>
);
}
@@ -0,0 +1,238 @@
"use client";
import { useState } from "react";
import { logAuditEvent } from "@/actions/audit";
type Product = {
id: string;
name: string;
type: string;
price: number;
};
type AssignedProduct = {
id: string;
product_id: string;
products: { name: string; type: string; price: number } | null;
};
type StopProductAssignmentProps = {
stopId: string;
allProducts: Product[];
assignedProducts: AssignedProduct[];
callerUid: string;
};
export default function StopProductAssignment({
stopId,
allProducts,
assignedProducts,
callerUid,
}: StopProductAssignmentProps) {
const [products, setProducts] = useState(assignedProducts);
const [selected, setSelected] = useState("");
const [assigning, setAssigning] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const assignedIds = new Set(products.map((p) => p.product_id));
const availableProducts = allProducts.filter((p) => !assignedIds.has(p.id));
async function handleAssign(e: React.FormEvent) {
e.preventDefault();
if (!selected) return;
setAssigning(true);
setError(null);
// First run diagnostic to understand the actual state
const diagRes = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/debug_stop_product_assignment`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: selected,
p_caller_uid: callerUid,
}),
}
);
const diag = await diagRes.json();
// Now attempt the actual assignment
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/assign_product_to_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: selected,
p_caller_uid: callerUid,
}),
}
);
const data = await res.json();
if (!res.ok || data.success === false) {
const diagInfo = diag && typeof diag === 'object' && 'reason' in diag
? `[${(diag as any).reason}] (admin_found=${(diag as any).admin_found}, role=${(diag as any).admin_role}, admin_brand=${(diag as any).admin_brand_id}, stop_brand=${(diag as any).stop_brand_id}, product_brand=${(diag as any).product_brand_id})`
: '';
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
const fullError = diagInfo
? `Failed to assign: ${errMsg} ${diagInfo}${errDetail ? ' | ' + errDetail : ''}`
: `Failed to assign: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`;
setError(fullError);
setAssigning(false);
return;
}
// Refresh product list from get_stop_products RPC
const refreshRes = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_stop_products`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stopId }),
}
);
const refreshData = await refreshRes.json();
setProducts(refreshData.products ?? []);
setSelected("");
setAssigning(false);
logAuditEvent({
table_name: "product_stops",
record_id: data.id,
action: "INSERT",
old_data: null,
new_data: { stop_id: stopId, product_id: selected },
brand_id: null,
});
}
async function handleRemove(productId: string) {
const entry = products.find((p) => p.product_id === productId);
if (!entry) return;
setRemoving(productId);
setError(null);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/unassign_product_from_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
p_stop_id: stopId,
p_product_id: productId,
p_caller_uid: callerUid,
}),
}
);
const data = await res.json();
if (!res.ok || data.success === false) {
const errMsg = data?.error ?? data?.message ?? `HTTP ${res.status}`;
const errDetail = data?.details ?? data?.hint ?? data?.code ?? '';
setError(`Failed to remove: ${errMsg}${errDetail ? ' — ' + errDetail : ''}`);
setRemoving(null);
return;
}
setProducts(products.filter((p) => p.product_id !== productId));
setRemoving(null);
logAuditEvent({
table_name: "product_stops",
record_id: entry.id,
action: "DELETE",
old_data: { stop_id: stopId, product_id: productId },
new_data: null,
brand_id: null,
});
}
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
{error}
</div>
)}
{products.length > 0 ? (
<div className="space-y-2">
<p className="text-sm font-medium text-zinc-300">
Assigned Products ({products.length})
</p>
{products.map((ap) => (
<div
key={ap.id}
className="flex items-center justify-between rounded-xl border border-zinc-800 bg-slate-50 px-4 py-3"
>
<div>
<p className="font-medium text-zinc-100">
{ap.products?.name ?? "Unknown"}
</p>
<p className="text-xs text-zinc-500">
{ap.products?.type} ·{" "}
${Number(ap.products?.price ?? 0).toFixed(2)}
</p>
</div>
<button
onClick={() => handleRemove(ap.product_id)}
disabled={removing === ap.product_id}
className="shrink-0 rounded-lg px-3 py-1 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{removing === ap.product_id ? "..." : "Remove"}
</button>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">No products assigned yet.</p>
)}
{availableProducts.length > 0 && (
<form onSubmit={handleAssign} className="flex gap-3">
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
>
<option value="">Select a product...</option>
{availableProducts.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.type})
</option>
))}
</select>
<button
type="submit"
disabled={!selected || assigning}
className="rounded-xl bg-slate-900 px-5 py-3 font-medium text-white disabled:opacity-50"
>
{assigning ? "..." : "Assign"}
</button>
</form>
)}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
brands: { name: string } | { name: string }[];
};
export default function StopTableBody({ stops }: { stops: Stop[] }) {
return (
<tbody className="divide-y divide-slate-200">
{stops?.map((stop) => (
<tr
key={stop.id}
onClick={() =>
(window.location.href = `/admin/stops/${stop.id}`)
}
className="cursor-pointer hover:bg-zinc-800"
>
<td className="px-5 py-4">
<span className="font-medium text-zinc-100">
{stop.city}, {stop.state}
</span>
</td>
<td className="px-5 py-4 text-zinc-300">
{stop.location}
</td>
<td className="px-5 py-4 text-zinc-300">
{stop.date}
</td>
<td className="px-5 py-4 text-zinc-300">
{stop.time}
</td>
<td className="px-5 py-4 text-zinc-300">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
</td>
<td className="px-5 py-4">
<span className="rounded-full bg-zinc-950 px-3 py-1 text-xs font-medium text-zinc-300">
{stop.active ? "Active" : "Inactive"}
</span>
</td>
</tr>
))}
</tbody>
);
}
+322
View File
@@ -0,0 +1,322 @@
"use client";
import React, { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
active: boolean;
deleted_at?: string | null;
brand_id: string;
status?: string;
brands: { name: string } | { name: string }[];
};
type Props = {
stops: Stop[];
};
export default function StopTableClient({ stops }: Props) {
const router = useRouter();
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 PAGE_SIZE = 50;
const filtered = stops.filter((s) => {
const matchesSearch =
!search ||
s.city.toLowerCase().includes(search.toLowerCase()) ||
s.location.toLowerCase().includes(search.toLowerCase());
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "active" && s.active && s.status !== "draft") ||
(statusFilter === "inactive" && !s.active && s.status !== "draft") ||
(statusFilter === "draft" && s.status === "draft");
return matchesSearch && matchesStatus;
});
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
function handleDeleted() {
setDeleteError(null);
startTransition(() => {
router.refresh();
});
}
return (
<>
{/* Filter bar */}
<div className="border-b border-stone-200 px-5 py-3 flex gap-3 flex-wrap items-center">
<input
type="search"
placeholder="Search stops..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="flex-1 min-w-40 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "active", "inactive", "draft"] as const).map((f) => (
<button
key={f}
onClick={() => { setStatusFilter(f); setPage(0); }}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : f === "inactive" ? "Inactive" : "Draft"}
</button>
))}
</div>
<span className="text-xs text-stone-500">{filtered.length} stops</span>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-auto">
<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-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<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-stone-500 px-1">{page + 1}/{totalPages}</span>
<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-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<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>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
</button>
</div>
)}
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-stone-50 text-stone-500">
<tr>
<th className="px-5 py-4 font-semibold">City</th>
<th className="px-5 py-4 font-semibold">Location</th>
<th className="px-5 py-4 font-semibold">Date</th>
<th className="px-5 py-4 font-semibold">Time</th>
<th className="px-5 py-4 font-semibold">Brand</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-stone-500">
{search || statusFilter !== "all"
? "No stops match your search."
: "No stops found. Create one to get started."}
</td>
</tr>
) : (
paginatedStops.map((stop) => (
<StopRow
key={stop.id}
stop={stop}
onDeleted={handleDeleted}
onDeleteError={setDeleteError}
/>
))
)}
</tbody>
</table>
</>
);
}
function StopRowBase({
stop,
onDeleted,
onDeleteError,
}: {
stop: Stop;
onDeleted: () => void;
onDeleteError: (msg: string) => void;
}) {
const router = useRouter();
const [, startTransition] = useTransition();
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);
async function handlePublish(stopId: string) {
setPublishingId(stopId);
setOpenMenu(null);
const result = await publishStop(stopId, stop.brand_id);
setPublishingId(null);
if (result.success) {
startTransition(() => router.refresh());
}
}
async function handleDelete(stopId: string) {
setDeletingId(stopId);
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/delete_stop`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stopId, p_brand_id: stop.brand_id }),
}
);
const data = await res.json();
setDeletingId(null);
setConfirmDelete(null);
setOpenMenu(null);
if (data.success) {
onDeleted();
} else {
onDeleteError(data.error ?? "Delete failed");
}
}
return (
<tr className="hover:bg-stone-50 transition-colors relative">
<td className="px-5 py-4">
<Link
href={`/admin/stops/${stop.id}`}
className="font-medium text-stone-900 hover:text-emerald-600 transition-colors"
>
{stop.city}, {stop.state}
</Link>
</td>
<td className="px-5 py-4 text-stone-600">{stop.location}</td>
<td className="px-5 py-4 font-mono text-stone-500 text-xs">{stop.date}</td>
<td className="px-5 py-4 font-mono text-stone-500 text-xs">{stop.time}</td>
<td className="px-5 py-4 text-stone-600">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
stop.status === "draft"
? "bg-amber-100 text-amber-700 border border-amber-200"
: stop.active
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-stone-100 text-stone-600 border border-stone-200"
}`}
>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-right">
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/stops/${stop.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
>
Edit
</Link>
<button
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === stop.id ? null : stop.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
</button>
{openMenu === stop.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
{stop.status === "draft" && (
<button
onClick={() => handlePublish(stop.id)}
disabled={publishingId === stop.id}
className="w-full text-left px-4 py-2.5 text-sm text-emerald-600 hover:bg-emerald-50 disabled:opacity-50 transition-colors"
>
{publishingId === stop.id ? "Publishing..." : "Publish"}
</button>
)}
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50 transition-colors"
>
Duplicate
</a>
<button
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === stop.id && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{stop.city}, {stop.state}&quot;?
</p>
<p className="mt-1.5 text-xs text-stone-500">
This will remove the stop. If it has active orders, you must resolve those first.
</p>
<div className="mt-4 flex gap-2">
<button
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-stone-200 px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button
onClick={() => handleDelete(stop.id)}
disabled={deletingId === stop.id}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors shadow-sm"
>
{deletingId === stop.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
);
}
const StopRow = React.memo(StopRowBase);
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import { useRouter } from "next/navigation";
type Props = {
brandId: string;
};
export default function StopsHeaderActions({ brandId }: Props) {
const [open, setOpen] = useState(false);
const router = useRouter();
function handleComplete(count: number) {
router.refresh();
}
return (
<>
<div className="flex gap-3">
<button
onClick={() => setOpen(true)}
className="rounded-xl border border-zinc-700 bg-zinc-900 px-4 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Upload Schedule
</button>
<a
href="/admin/stops/new"
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-3 text-sm font-semibold text-white transition-colors shadow-lg shadow-emerald-900/30"
>
+ Add Stop
</a>
</div>
{open && (
<ScheduleImportModal
brandId={brandId}
onClose={() => setOpen(false)}
onComplete={handleComplete}
/>
)}
</>
);
}
+461
View File
@@ -0,0 +1,461 @@
"use client";
import { useState, useCallback } from "react";
import { formatDate } from "@/lib/date-utils";
import {
exportTaxReport,
exportTaxByState,
type TaxOrderRow,
type TaxByStateRow,
} from "@/lib/reports-export";
type DatePreset = "month" | "quarter" | "this_year" | "custom";
type DateRange = { start: string; end: string };
function buildRange(preset: DatePreset, customStart?: string, customEnd?: string): DateRange {
const now = new Date();
const toDateStr = (d: Date) => d.toISOString().slice(0, 10);
const today = toDateStr(now);
switch (preset) {
case "month": {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
return { start: toDateStr(start), end: today };
}
case "quarter": {
const q = Math.floor(now.getMonth() / 3);
const start = new Date(now.getFullYear(), q * 3, 1);
return { start: toDateStr(start), end: today };
}
case "this_year": {
const start = new Date(now.getFullYear(), 0, 1);
return { start: toDateStr(start), end: today };
}
case "custom":
return { start: customStart ?? today, end: customEnd ?? today };
}
}
function quarterLabel(start: string, end: string): string {
const s = new Date(start + "T00:00:00");
const q = Math.floor(s.getMonth() / 3) + 1;
return `Q${q} ${s.getFullYear()}`;
}
type TaxSummaryData = {
total_tax_collected: number;
total_gross_sales: number;
order_count: number;
tax_by_state: Array<{
state: string;
total_tax: number;
gross_sales: number;
order_count: number;
}>;
};
function SummaryCard({
label,
value,
prefix,
suffix,
highlight,
}: {
label: string;
value: number;
prefix?: string;
suffix?: string;
highlight?: boolean;
}) {
return (
<div className={`rounded-xl border px-4 py-4 ${highlight ? "border-amber-300 bg-amber-900/30" : "border-zinc-800 bg-zinc-900"}`}>
<p className="text-xs font-medium uppercase tracking-wide text-zinc-500">{label}</p>
<p className={`mt-1 text-2xl font-semibold ${highlight ? "text-amber-700" : "text-zinc-100"}`}>
{prefix ?? ""}{typeof value === "number" ? value.toLocaleString(undefined, { maximumFractionDigits: 2 }) : value}{suffix ?? ""}
</p>
</div>
);
}
function ExportBtn({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
onClick={onClick}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
>
Export CSV
</button>
);
}
function ReportTable({ headers, rows, renderRow }: {
headers: string[];
rows: unknown[];
renderRow: (row: unknown, i: number) => React.ReactNode;
}) {
return (
<div className="overflow-x-auto rounded-xl border border-zinc-800 bg-zinc-900">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-slate-50">
{headers.map((h) => (
<th key={h} className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-zinc-500">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={headers.length} className="px-3 py-6 text-center text-slate-400">
No data
</td>
</tr>
) : (
rows.map((_, i) => renderRow(_, i))
)}
</tbody>
</table>
</div>
);
}
const TABS = [
{ id: "summary", label: "Tax Summary" },
{ id: "orders", label: "Taxable Orders" },
];
export default function TaxDashboard({
brands,
initialBrandId,
isPlatformAdmin,
}: {
brands: { id: string; name: string }[];
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<TaxSummaryData | null>(null);
const [orders, setOrders] = useState<TaxOrderRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const range: DateRange = buildRange(preset, customStart, customEnd);
const fetchTaxSummary = useCallback(async () => {
if (!selectedBrandId) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_tax_summary`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: selectedBrandId,
p_start_date: range.start,
p_end_date: range.end,
}),
}
);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setSummary({
total_tax_collected: Number(data?.total_tax_collected ?? 0),
total_gross_sales: Number(data?.total_gross_sales ?? 0),
order_count: Number(data?.order_count ?? 0),
tax_by_state: data?.tax_by_state ?? [],
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load tax summary");
} finally {
setLoading(false);
}
}, [selectedBrandId, range]);
const fetchTaxableOrders = useCallback(async () => {
if (!selectedBrandId) return;
setLoading(true);
setError(null);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_taxable_orders`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: selectedBrandId,
p_start_date: range.start,
p_end_date: range.end,
}),
}
);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setOrders(
(data ?? []).map((row: Record<string, unknown>) => ({
order_id: String(row.order_id ?? ""),
date: String(row.date ?? ""),
customer_name: String(row.customer_name ?? ""),
city: String(row.city ?? ""),
state: String(row.state ?? ""),
taxable_amount: Number(row.taxable_amount ?? 0),
tax_amount: Number(row.tax_amount ?? 0),
tax_rate: Number(row.tax_rate ?? 0),
tax_location: String(row.tax_location ?? ""),
}))
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load orders");
} finally {
setLoading(false);
}
}, [selectedBrandId, range]);
function handleFetch() {
if (activeTab === "summary") fetchTaxSummary();
else fetchTaxableOrders();
}
// Auto-fetch when tab/brand/range changes
if (summary === null && activeTab === "summary" && selectedBrandId && !loading) {
fetchTaxSummary();
}
if (orders.length === 0 && activeTab === "orders" && selectedBrandId && !loading) {
fetchTaxableOrders();
}
function handleExportCSV() {
if (activeTab === "summary" && summary) {
const rows: TaxByStateRow[] = summary.tax_by_state.map((s) => ({
state: s.state,
total_tax: s.total_tax,
gross_sales: s.gross_sales,
order_count: s.order_count,
}));
const csv = exportTaxByState(rows);
downloadCSV(csv, `tax-by-state-${range.start}.csv`);
} else if (activeTab === "orders" && orders.length > 0) {
const csv = exportTaxReport(orders);
downloadCSV(csv, `taxable-orders-${range.start}.csv`);
}
}
function downloadCSV(csv: string, filename: string) {
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
const effectiveRate = summary && summary.total_gross_sales > 0
? (summary.total_tax_collected / summary.total_gross_sales) * 100
: 0;
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{/* ── Filters ── */}
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-zinc-900 border border-zinc-800 px-5 py-4">
<div className="flex gap-1">
{(["month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
<button
key={p}
onClick={() => setPreset(p)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium ${
preset === p
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
>
{p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
</button>
))}
</div>
{preset === "custom" && (
<div className="flex gap-2 items-center">
<input
type="date"
value={customStart}
onChange={(e) => setCustomStart(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
type="date"
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs"
/>
</div>
)}
{isPlatformAdmin && (
<select
value={selectedBrandId}
onChange={(e) => {
setSelectedBrandId(e.target.value);
setSummary(null);
setOrders([]);
}}
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>
)}
<button
onClick={handleFetch}
disabled={loading || !selectedBrandId}
className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
</button>
<span className="ml-auto text-xs text-zinc-500">
{preset === "quarter" ? quarterLabel(range.start, range.end) :
preset === "this_year" ? `${new Date().getFullYear()} YTD` :
`${range.start}${range.end}`}
</span>
</div>
{/* ── Tab bar ── */}
<div className="flex gap-1 border-b border-zinc-800">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === tab.id
? "border-blue-600 text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{tab.label}
</button>
))}
</div>
{/* ── 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>
<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>
);
}}
/>
</>
)}
{!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>
)}
{/* ── TAXABLE ORDERS TAB ── */}
{activeTab === "orders" && (
<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={handleExportCSV} />
)}
</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>
)}
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
type TaxSummaryData = {
total_tax_collected: number;
total_gross_sales: number;
order_count: number;
};
function quarterLabel(): string {
const now = new Date();
const q = Math.floor(now.getMonth() / 3) + 1;
return `Q${q} ${now.getFullYear()}`;
}
function quarterDateRange(): { start: string; end: string } {
const now = new Date();
const q = Math.floor(now.getMonth() / 3);
const startDate = new Date(now.getFullYear(), q * 3, 1);
const end = now.toISOString().slice(0, 10);
return { start: startDate.toISOString().slice(0, 10), end };
}
export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
const [data, setData] = useState<TaxSummaryData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const range = quarterDateRange();
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/get_tax_summary`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
body: JSON.stringify({
p_brand_id: brandId,
p_start_date: range.start,
p_end_date: range.end,
}),
}
)
.then((r) => r.json())
.then((d) => {
setData({
total_tax_collected: Number(d?.total_tax_collected ?? 0),
total_gross_sales: Number(d?.total_gross_sales ?? 0),
order_count: Number(d?.order_count ?? 0),
});
setLoading(false);
})
.catch(() => setLoading(false));
}, [brandId]);
if (loading) {
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-amber-400 border-t-transparent animate-spin" />
<span className="text-xs text-amber-700">Loading tax summary...</span>
</div>
);
}
if (!data || data.order_count === 0) return null;
const effectiveRate = data.total_gross_sales > 0
? (data.total_tax_collected / data.total_gross_sales) * 100
: 0;
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-amber-700">
{quarterLabel()} Tax Collected
</span>
</div>
<Link
href="/admin/taxes"
className="text-xs text-amber-600 hover:text-amber-800 font-medium"
>
View Details
</Link>
</div>
<div className="mt-2 flex items-baseline gap-4">
<span className="text-xl font-bold text-amber-800">
${data.total_tax_collected.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
<span className="text-xs text-amber-600">
on ${data.total_gross_sales.toLocaleString(undefined, { minimumFractionDigits: 2 })} gross sales · {effectiveRate.toFixed(3)}% rate · {data.order_count} orders
</span>
</div>
</div>
);
}
+446
View File
@@ -0,0 +1,446 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import type { Template, TemplateType, CampaignType } from "@/actions/communications/templates";
import { formatDate } from "@/lib/format-date";
import { upsertTemplate } from "@/actions/communications/templates";
import {
BUILT_IN_TEMPLATES,
renderTemplate,
TEMPLATE_VARIABLES,
type EmailTemplate,
} from "@/lib/email-templates";
const TEMPLATE_TYPES: { value: TemplateType; label: string }[] = [
{ value: "email_template", label: "Email" },
{ value: "sms_template", label: "SMS" },
{ value: "internal_note", label: "Internal Note" },
];
const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
{ value: "operational", label: "Operational" },
{ value: "marketing", label: "Marketing" },
{ value: "transactional", label: "Transactional" },
];
// Sample variables for preview rendering
const SAMPLE_VARS: Record<string, string> = {
first_name: "Jane",
company_name: "Route Commerce",
stop_name: "Tuxedo Warehouse",
pickup_date: "May 15, 2026",
order_total: "$450.00",
balance_due: "$225.00",
due_date: "May 10, 2026",
item_summary: "10× Tuxedo Set, 5× Dress Shirt",
custom_message: "We look forward to seeing you!",
cta_text: "Shop Now",
cta_url: "https://routecommerce.com",
tag: "New Arrivals",
headline: "Spring Collection is Here!",
unsubscribe_url: "#",
};
export function TemplateListPanel({ templates }: { templates: Template[] }) {
const router = useRouter();
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Templates</h2>
<p className="text-sm text-zinc-500">{templates.length} template{templates.length !== 1 ? "s" : ""}</p>
</div>
<a
href="/admin/communications/templates/new"
className="inline-flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
+ New Template
</a>
</div>
{templates.length === 0 ? (
<div className="text-center py-12 text-slate-400">No templates yet</div>
) : (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-zinc-900 border-b border-zinc-800">
<tr>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Name</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Type</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Subject</th>
<th className="text-left px-4 py-3 font-medium text-zinc-400">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{templates.map((t) => (
<tr key={t.id} className="hover:bg-zinc-900 cursor-pointer" onClick={() => router.push(`/admin/communications/templates/${t.id}`)}>
<td className="px-4 py-3 font-medium text-zinc-100">{t.name}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
t.template_type === "email_template" ? "bg-blue-900/40 text-blue-700" :
t.template_type === "sms_template" ? "bg-green-900/40 text-green-400" :
"bg-zinc-950 text-zinc-400"
}`}>
{t.template_type}
</span>
</td>
<td className="px-4 py-3 text-zinc-400 truncate max-w-xs">{t.subject}</td>
<td className="px-4 py-3 text-zinc-500">{formatDate(new Date(t.updated_at))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
export function TemplateEditForm({
template,
mode = "edit",
brandId,
}: {
template?: Template;
mode?: "edit" | "new";
brandId: string;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [name, setName] = useState(template?.name ?? "");
const [subject, setSubject] = useState(template?.subject ?? "");
const [bodyText, setBodyText] = useState(template?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState(template?.body_html ?? "");
const [templateType, setTemplateType] = useState<TemplateType>(template?.template_type ?? "email_template");
const [campaignType, setCampaignType] = useState<CampaignType | "">(template?.campaign_type ?? "");
const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState<"edit" | "preview">("edit");
const [previewDevice, setPreviewDevice] = useState<"desktop" | "mobile">("desktop");
const [htmlMode, setHtmlMode] = useState(!!template?.body_html);
// Apply a built-in template to the editor fields
function applyBuiltIn(tpl: EmailTemplate) {
const rendered = renderTemplate(tpl, SAMPLE_VARS);
setName(tpl.name);
setSubject(rendered.subject);
setBodyText(rendered.body_text);
setBodyHtml(rendered.body_html);
setHtmlMode(true);
}
function insertVariable(key: string) {
const token = `{{${key}}}`;
const ta = document.getElementById("body-textarea") as HTMLTextAreaElement;
if (ta) {
const start = ta.selectionStart;
const end = ta.selectionEnd;
const next = bodyText.slice(0, start) + token + bodyText.slice(end);
setBodyText(next);
setTimeout(() => {
ta.focus();
ta.setSelectionRange(start + token.length, start + token.length);
}, 0);
} else {
setBodyText((prev) => prev + "\n" + token);
}
}
const preview = {
subject: subject || "Your Email Subject",
body_text: bodyText,
body_html: bodyHtml || `<p style="white-space:pre-wrap">${bodyText}</p>`,
};
const handleSave = async () => {
setSaving(true);
setError("");
const result = await upsertTemplate({
id: template?.id,
brand_id: brandId,
name,
subject,
body_text: bodyText,
body_html: (htmlMode && bodyHtml) ? bodyHtml : undefined,
template_type: templateType,
campaign_type: (campaignType as CampaignType) || undefined,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
router.push("/admin/communications/templates");
router.refresh();
};
return (
<div className="max-w-5xl">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-zinc-100">
{mode === "new" ? "New Template" : "Edit Template"}
</h2>
<div className="flex gap-2">
<button
type="button"
onClick={() => setActiveTab("edit")}
className={`rounded-lg px-4 py-2 text-sm font-medium ${activeTab === "edit" ? "bg-slate-900 text-white" : "bg-zinc-950 text-zinc-400 hover:bg-slate-200"}`}
>
Edit
</button>
<button
type="button"
onClick={() => setActiveTab("preview")}
className={`rounded-lg px-4 py-2 text-sm font-medium ${activeTab === "preview" ? "bg-slate-900 text-white" : "bg-zinc-950 text-zinc-400 hover:bg-slate-200"}`}
>
Preview
</button>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">{error}</div>
)}
{activeTab === "edit" ? (
<div className="space-y-6">
{/* Basic info */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Template Info</h3>
{/* Built-in template picker */}
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Start from:</span>
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-1.5"
onChange={(e) => {
const tpl = BUILT_IN_TEMPLATES.find((t) => t.id === e.target.value);
if (tpl) applyBuiltIn(tpl);
e.target.value = "";
}}
defaultValue=""
>
<option value=""> Pre-built template </option>
{BUILT_IN_TEMPLATES.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Template Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="e.g. Pickup Reminder"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Template Type *</label>
<select
value={templateType}
onChange={(e) => setTemplateType(e.target.value as TemplateType)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
>
{TEMPLATE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Type</label>
<select
value={campaignType}
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
>
<option value="">Any</option>
{CAMPAIGN_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
</div>
{/* Subject */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<h3 className="text-sm font-semibold text-slate-800">Subject Line</h3>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Subject *</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Email subject line"
/>
<p className="mt-1 text-xs text-slate-400">
Variables:{" "}
{TEMPLATE_VARIABLES.slice(0, 6).map((v) => (
<button
key={v.key}
type="button"
onClick={() => {
const tok = `{{${v.key}}}`;
const start = subject.length;
setSubject((s) => s + tok);
}}
className="ml-1 text-blue-400 hover:text-blue-800"
>
{`{{${v.key}}}`}
</button>
))}
</p>
</div>
</div>
{/* Body editor */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Message Body</h3>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={htmlMode}
onChange={(e) => setHtmlMode(e.target.checked)}
className="rounded border-slate-400"
/>
<span className="text-zinc-400">HTML mode</span>
</label>
</div>
{/* Variable toolbar */}
<div className="flex flex-wrap gap-1.5">
<span className="text-xs text-slate-400 self-center mr-1">Insert:</span>
{TEMPLATE_VARIABLES.map((v) => (
<button
key={v.key}
type="button"
title={`${v.label}: ${v.example}`}
onClick={() => insertVariable(v.key)}
className="rounded bg-zinc-950 px-2 py-1 text-xs text-zinc-400 hover:bg-blue-900/40 hover:text-blue-700"
>
{`{{${v.key}}}`}
</button>
))}
</div>
{htmlMode ? (
<>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">HTML Body</label>
<textarea
value={bodyHtml}
onChange={(e) => setBodyHtml(e.target.value)}
rows={12}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm font-mono text-xs"
placeholder="<p>HTML version with variables like {{first_name}}...</p>"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Plain Text Fallback *</label>
<textarea
id="body-textarea"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={6}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Plain text version..."
/>
</div>
</>
) : (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Body (Plain Text) *</label>
<textarea
id="body-textarea"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={10}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
placeholder="Message body with variables like {{first_name}}..."
/>
</div>
)}
<p className="text-xs text-slate-400">
Tip: Click any variable button above to insert it at the cursor position. Variables are replaced when the email is sent.
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={saving || !name || !subject || !bodyText}
className="inline-flex items-center rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Template"}
</button>
<a href="/admin/communications/templates" className="text-sm text-zinc-500 hover:text-zinc-300 ml-4">
Cancel
</a>
</div>
</div>
) : (
/* Preview Tab */
<div className="space-y-4">
<div className="flex items-center gap-3">
<span className="text-sm text-zinc-400">Preview:</span>
<div className="flex rounded-lg border border-zinc-600 overflow-hidden">
<button
onClick={() => setPreviewDevice("desktop")}
className={`px-4 py-2 text-sm font-medium ${previewDevice === "desktop" ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400 hover:bg-zinc-900"}`}
>
Desktop
</button>
<button
onClick={() => setPreviewDevice("mobile")}
className={`px-4 py-2 text-sm font-medium ${previewDevice === "mobile" ? "bg-slate-900 text-white" : "bg-zinc-900 text-zinc-400 hover:bg-zinc-900"}`}
>
Mobile
</button>
</div>
</div>
{/* Email preview */}
<div className={`mx-auto transition-all ${previewDevice === "mobile" ? "max-w-[375px]" : "max-w-[600px]"}`}>
<div className="bg-zinc-900 rounded-xl border border-zinc-800 shadow-black/20 overflow-hidden">
{/* Mock email header */}
<div className="bg-zinc-950 border-b border-zinc-800 px-4 py-2 flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400" />
<div className="w-3 h-3 rounded-full bg-yellow-400" />
<div className="w-3 h-3 rounded-full bg-green-400" />
</div>
<div className="flex-1 text-center text-xs text-zinc-500 truncate">
{preview.subject}
</div>
</div>
{/* Email body */}
<div
className="overflow-hidden"
dangerouslySetInnerHTML={{ __html: preview.body_html }}
/>
{/* Plain text toggle */}
{bodyText && (
<details className="border-t border-zinc-800">
<summary className="px-4 py-2 text-xs text-zinc-500 cursor-pointer hover:text-zinc-300">
View plain text version
</summary>
<pre className="px-4 pb-4 text-xs text-zinc-400 whitespace-pre-wrap">{preview.body_text}</pre>
</details>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,338 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { getTimeTrackingWorkers, getTimeTrackingTasks, getWorkerTimeLogs, getTimeTrackingSummary, type TimeWorker, type TimeTask, type TimeLog, type TimeSummary } from "@/actions/time-tracking";
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
function formatHours(h: number): string {
return h < 1 ? `${(h * 60).toFixed(0)}m` : `${h.toFixed(1)}h`;
}
function formatMinutes(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
if (h === 0) return `${m}m`;
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }) {
const [workers, setWorkers] = useState<TimeWorker[]>([]);
const [tasks, setTasks] = useState<TimeTask[]>([]);
const [summary, setSummary] = useState<TimeSummary | null>(null);
const [logs, setLogs] = useState<TimeLog[]>([]);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState<{ start: string; end: string }>(() => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
const end = now.toISOString().slice(0, 10);
return { start, end };
});
const [selectedWorker, setSelectedWorker] = useState("");
const [selectedTask, setSelectedTask] = useState("");
const [tab, setTab] = useState<"summary" | "logs">("summary");
const [logPage, setLogPage] = useState(0);
const PAGE_SIZE = 50;
const load = useCallback(async () => {
if (!brandId) return;
setLoading(true);
const [w, t, s, l] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingTasks(brandId, false),
getTimeTrackingSummary(brandId, dateRange.start, dateRange.end),
getWorkerTimeLogs(brandId, {
workerId: selectedWorker || undefined,
taskId: selectedTask || undefined,
start: dateRange.start,
end: dateRange.end,
limit: PAGE_SIZE,
offset: logPage * PAGE_SIZE,
}),
]);
setWorkers(w);
setTasks(t);
setSummary(s);
setLogs(l);
setLoading(false);
}, [brandId, dateRange, selectedWorker, selectedTask, logPage]);
// eslint-disable-next-line react-hooks/set-state-in-effect -- Data fetching pattern
useEffect(() => { load(); }, [load]);
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset pagination on filter change
useEffect(() => { setLogPage(0); }, [dateRange, selectedWorker, selectedTask]);
if (!brandId) {
return (
<div className="text-center py-20 text-stone-500">
<p className="text-sm">Select a brand to view time tracking.</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-stone-950 tracking-tight">Time Tracking</h1>
<p className="text-sm text-stone-500 mt-0.5">
{dateRange.start} {dateRange.end}
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/admin/time-tracking/settings?tab=export"
className="flex items-center gap-2 text-sm text-stone-600 hover:text-stone-900 border border-stone-200 hover:border-stone-300 bg-white hover:bg-stone-50 rounded-xl px-4 py-2 transition-all shadow-sm"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export CSV
</Link>
<Link
href="/admin/time-tracking/settings"
className="flex items-center gap-2 text-sm text-stone-600 hover:text-stone-900 border border-stone-200 hover:border-stone-300 bg-white hover:bg-stone-50 rounded-xl px-4 py-2 transition-all shadow-sm"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</Link>
</div>
</div>
{/* Filters */}
<div className="rounded-xl border border-stone-200 bg-white p-4 shadow-sm">
<div className="flex flex-wrap gap-4 items-end">
<div className="space-y-1">
<label className="text-xs text-stone-500 font-medium">From</label>
<input
type="date"
value={dateRange.start}
onChange={e => setDateRange(r => ({ ...r, start: e.target.value }))}
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-stone-500 font-medium">To</label>
<input
type="date"
value={dateRange.end}
onChange={e => setDateRange(r => ({ ...r, end: e.target.value }))}
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-stone-500 font-medium">Worker</label>
<select
value={selectedWorker}
onChange={e => setSelectedWorker(e.target.value)}
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors min-w-[160px]"
>
<option value="">All Workers</option>
{workers.map(w => (
<option key={w.id} value={w.id}>{w.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-stone-500 font-medium">Task</label>
<select
value={selectedTask}
onChange={e => setSelectedTask(e.target.value)}
className="px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-500 transition-colors min-w-[160px]"
>
<option value="">All Tasks</option>
{tasks.map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<button
onClick={load}
className="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-all shadow-sm"
>
Refresh
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-stone-200">
<button
onClick={() => setTab("summary")}
className={`px-4 py-2.5 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "summary" ? "text-emerald-600 border-emerald-600" : "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"}`}
>
Summary
</button>
<button
onClick={() => setTab("logs")}
className={`px-4 py-2.5 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "logs" ? "text-emerald-600 border-emerald-600" : "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"}`}
>
All Logs ({loading ? "…" : logs.length})
</button>
</div>
{loading ? (
<div className="text-center py-16 text-stone-500 text-sm">Loading...</div>
) : tab === "summary" && summary ? (
<div className="space-y-6">
{/* Totals */}
<div className="grid grid-cols-3 gap-4">
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
<p className="text-xs text-stone-500 uppercase tracking-widest mb-2">Total Hours</p>
<p className="text-3xl font-black text-stone-950">{formatHours(summary.totals.total_hours)}</p>
</div>
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
<p className="text-xs text-stone-500 uppercase tracking-widest mb-2">Entries</p>
<p className="text-3xl font-black text-stone-950">{summary.totals.entry_count}</p>
</div>
<div className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
<p className="text-xs text-stone-500 uppercase tracking-widest mb-2">Open Clock-ins</p>
<p className="text-3xl font-black text-amber-600">{summary.totals.open_count}</p>
</div>
</div>
{/* By Worker */}
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100">
<h3 className="text-sm font-bold text-stone-950">By Worker</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest bg-stone-50">
<th className="text-left px-5 py-3 font-medium">Worker</th>
<th className="text-right px-5 py-3 font-medium">Entries</th>
<th className="text-right px-5 py-3 font-medium">Total Hours</th>
</tr>
</thead>
<tbody>
{(summary.by_worker ?? []).length === 0 ? (
<tr><td colSpan={3} className="px-5 py-8 text-center text-stone-400">No data</td></tr>
) : (summary.by_worker ?? []).map(w => (
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-900 font-medium">{w.name}</td>
<td className="px-5 py-3.5 text-stone-600 text-right">{w.entry_count}</td>
<td className="px-5 py-3.5 text-stone-900 text-right font-mono">{formatHours(w.total_hours)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* By Task */}
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100">
<h3 className="text-sm font-bold text-stone-950">By Task</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest bg-stone-50">
<th className="text-left px-5 py-3 font-medium">Task</th>
<th className="text-right px-5 py-3 font-medium">Entries</th>
<th className="text-right px-5 py-3 font-medium">Total Hours</th>
</tr>
</thead>
<tbody>
{(summary.by_task ?? []).length === 0 ? (
<tr><td colSpan={3} className="px-5 py-8 text-center text-stone-400">No data</td></tr>
) : (summary.by_task ?? []).map(t => (
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-900 font-medium">{t.name}</td>
<td className="px-5 py-3.5 text-stone-600 text-right">{t.entry_count}</td>
<td className="px-5 py-3.5 text-stone-900 text-right font-mono">{formatHours(t.total_hours)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
/* Logs tab */
<div className="rounded-2xl border border-stone-200 bg-white overflow-hidden shadow-sm">
{/* Pagination header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-xs text-stone-500">
{logs.length === PAGE_SIZE
? `Showing page ${logPage + 1}`
: `${logs.length} entries`}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setLogPage(p => Math.max(0, p - 1))}
disabled={logPage === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<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-stone-500 px-1">Page {logPage + 1}</span>
<button
onClick={() => setLogPage(p => p + 1)}
disabled={logs.length < PAGE_SIZE}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<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>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-200 bg-stone-50">
<th className="text-left px-5 py-3 font-medium">Worker</th>
<th className="text-left px-5 py-3 font-medium">Task</th>
<th className="text-left px-5 py-3 font-medium">Clock In</th>
<th className="text-left px-5 py-3 font-medium">Clock Out</th>
<th className="text-right px-5 py-3 font-medium">Lunch</th>
<th className="text-right px-5 py-3 font-medium">Total</th>
<th className="text-left px-5 py-3 font-medium">Notes</th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr><td colSpan={7} className="px-5 py-12 text-center text-stone-400">No entries found</td></tr>
) : logs.map(log => (
<tr key={log.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-900 font-medium">{log.worker_name}</td>
<td className="px-5 py-3.5 text-stone-600">{log.task_name}</td>
<td className="px-5 py-3.5 text-stone-600 font-mono text-xs">
<span>{formatDate(log.clock_in)}</span>
<span className="ml-2 text-stone-400">{formatTime(log.clock_in)}</span>
</td>
<td className="px-5 py-3.5 font-mono text-xs">
{log.clock_out ? (
<>
<span className="text-stone-600">{formatDate(log.clock_out)}</span>
<span className="ml-2 text-stone-400">{formatTime(log.clock_out)}</span>
</>
) : (
<span className="text-amber-600 font-semibold">OPEN</span>
)}
</td>
<td className="px-5 py-3.5 text-stone-600 text-right">{log.lunch_break_minutes > 0 ? `${log.lunch_break_minutes}m` : "—"}</td>
<td className="px-5 py-3.5 text-stone-900 text-right font-mono">{formatMinutes(log.total_minutes)}</td>
<td className="px-5 py-3.5 text-stone-500 text-xs max-w-[160px] truncate">{log.notes ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,766 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
getTimeTrackingWorkers,
getTimeTrackingTasks,
createTimeWorker,
resetTimeWorkerPin,
updateTimeWorker,
deleteTimeWorker,
createTimeTask,
updateTimeTask,
deleteTimeTask,
getTimeTrackingSettings,
updateTimeTrackingSettings,
getTimeTrackingNotificationLog,
type TimeWorker,
type TimeTask,
type TimeTrackingSettings,
type NotificationLogEntry,
} from "@/actions/time-tracking";
type Tab = "dashboard" | "settings";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
interface TimeTrackingSettingsClientProps {
brandId: string;
}
export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSettingsClientProps) {
const [tab, setTab] = useState<Tab>("dashboard");
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const t = params.get("tab");
if (t === "settings" || t === "export") setTab(t as Tab);
}, []);
const [workers, setWorkers] = useState<TimeWorker[]>([]);
const [tasks, setTasks] = useState<TimeTask[]>([]);
const [settings, setSettings] = useState<TimeTrackingSettings | null>(null);
const [notificationLog, setNotificationLog] = useState<NotificationLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [payPeriodStartDay, setPayPeriodStartDay] = useState(0);
const [payPeriodLength, setPayPeriodLength] = useState(7);
const [dailyOvertimeThreshold, setDailyOvertimeThreshold] = useState(12);
const [weeklyOvertimeThreshold, setWeeklyOvertimeThreshold] = useState(56);
const [overtimeMultiplier, setOvertimeMultiplier] = useState(1.5);
const [overtimeNotifications, setOvertimeNotifications] = useState(true);
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsSaved, setSettingsSaved] = useState(false);
const [settingsError, setSettingsError] = useState<string | null>(null);
const [notificationEmails, setNotificationEmails] = useState<string[]>([]);
const [notificationSmsNumbers, setNotificationSmsNumbers] = useState<string[]>([]);
const [enableDailyAlerts, setEnableDailyAlerts] = useState(true);
const [enableWeeklyAlerts, setEnableWeeklyAlerts] = useState(true);
const [dailyAlertThreshold, setDailyAlertThreshold] = useState(80);
const [weeklyAlertThreshold, setWeeklyAlertThreshold] = useState(80);
const [sendEndOfPeriodSummary, setSendEndOfPeriodSummary] = useState(true);
const [brandName, setBrandName] = useState("Farm");
const [newEmail, setNewEmail] = useState("");
const [newSms, setNewSms] = useState("");
const [showWorkerModal, setShowWorkerModal] = useState(false);
const [editingWorker, setEditingWorker] = useState<TimeWorker | null>(null);
const [workerName, setWorkerName] = useState("");
const [workerRole, setWorkerRole] = useState("worker");
const [workerLang, setWorkerLang] = useState("en");
const [workerActive, setWorkerActive] = useState(true);
const [resetPinResult, setResetPinResult] = useState<string | null>(null);
const [workerError, setWorkerError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [showTaskModal, setShowTaskModal] = useState(false);
const [editingTask, setEditingTask] = useState<TimeTask | null>(null);
const [taskName, setTaskName] = useState("");
const [taskNameEs, setTaskNameEs] = useState("");
const [taskUnit, setTaskUnit] = useState("hours");
const [taskActive, setTaskActive] = useState(true);
const [taskSortOrder, setTaskSortOrder] = useState(0);
const [taskError, setTaskError] = useState<string | null>(null);
const [exportStart, setExportStart] = useState("");
const [exportEnd, setExportEnd] = useState("");
const [exportBrand, setExportBrand] = useState<"all" | "tuxedo" | "ird">("all");
const [includeOvertime, setIncludeOvertime] = useState(true);
const [includeNotes, setIncludeNotes] = useState(true);
const load = useCallback(async () => {
setLoading(true);
const [w, t, s] = await Promise.all([
getTimeTrackingWorkers(brandId),
getTimeTrackingTasks(brandId, false),
getTimeTrackingSettings(brandId),
]);
setWorkers(w);
setTasks(t);
if (s) {
setSettings(s);
setPayPeriodStartDay(s.pay_period_start_day);
setPayPeriodLength(s.pay_period_length_days);
setDailyOvertimeThreshold(s.daily_overtime_threshold);
setWeeklyOvertimeThreshold(s.weekly_overtime_threshold);
setOvertimeMultiplier(s.overtime_multiplier);
setOvertimeNotifications(s.overtime_notifications);
setNotificationEmails(s.notification_emails ?? []);
setNotificationSmsNumbers(s.notification_sms_numbers ?? []);
setEnableDailyAlerts(s.enable_daily_alerts ?? true);
setEnableWeeklyAlerts(s.enable_weekly_alerts ?? true);
setDailyAlertThreshold(Math.round((s.daily_alert_threshold ?? 0.80) * 100));
setWeeklyAlertThreshold(Math.round((s.weekly_alert_threshold ?? 0.80) * 100));
setSendEndOfPeriodSummary(s.send_end_of_period_summary ?? true);
setBrandName(s.brand_name ?? "Farm");
}
const log = await getTimeTrackingNotificationLog(brandId, 50);
setNotificationLog(log);
setLoading(false);
}, [brandId]);
useEffect(() => { load(); }, [load]);
const handleSaveNotifications = async () => {
setSettingsSaving(true);
setSettingsError(null);
setSettingsSaved(false);
const result = await updateTimeTrackingSettings(brandId, {
pay_period_start_day: payPeriodStartDay,
pay_period_length_days: payPeriodLength,
daily_overtime_threshold: dailyOvertimeThreshold,
weekly_overtime_threshold: weeklyOvertimeThreshold,
overtime_multiplier: overtimeMultiplier,
overtime_notifications: overtimeNotifications,
notification_emails: notificationEmails,
notification_sms_numbers: notificationSmsNumbers,
enable_daily_alerts: enableDailyAlerts,
enable_weekly_alerts: enableWeeklyAlerts,
daily_alert_threshold: dailyAlertThreshold / 100,
weekly_alert_threshold: weeklyAlertThreshold / 100,
send_end_of_period_summary: sendEndOfPeriodSummary,
brand_name: brandName,
});
setSettingsSaving(false);
if (!result.success) { setSettingsError(result.error ?? "Failed to save"); return; }
setSettingsSaved(true);
setTimeout(() => setSettingsSaved(false), 3000);
load();
};
const addEmail = () => {
const email = newEmail.trim();
if (email && !notificationEmails.includes(email)) {
setNotificationEmails([...notificationEmails, email]);
}
setNewEmail("");
};
const removeEmail = (email: string) => setNotificationEmails(notificationEmails.filter(e => e !== email));
const addSms = () => {
const num = newSms.replace(/[^0-9+]/g, "");
if (num && !notificationSmsNumbers.includes(num)) {
setNotificationSmsNumbers([...notificationSmsNumbers, num]);
}
setNewSms("");
};
const removeSms = (num: string) => setNotificationSmsNumbers(notificationSmsNumbers.filter(n => n !== num));
function buildExportUrl(type: "quickbooks" | "payroll" | "detailed" | "summary") {
const params = new URLSearchParams({
type,
start: exportStart,
end: exportEnd,
brand: exportBrand,
overtime: String(includeOvertime),
notes: String(includeNotes),
});
return `/api/time-tracking/export?${params}`;
}
function triggerDownload(url: string) {
const a = document.createElement("a");
a.href = url;
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
const openAddWorker = () => {
setEditingWorker(null);
setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en");
setWorkerActive(true); setWorkerError(null); setResetPinResult(null);
setShowWorkerModal(true);
};
const openEditWorker = (w: TimeWorker) => {
setEditingWorker(w); setWorkerName(w.name); setWorkerRole(w.role);
setWorkerLang(w.lang); setWorkerActive(w.active);
setWorkerError(null); setResetPinResult(null);
setShowWorkerModal(true);
};
const handleSaveWorker = async () => {
if (!workerName.trim()) { setWorkerError("Name is required"); return; }
setSubmitting(true); setWorkerError(null);
if (editingWorker) {
const result = await updateTimeWorker(editingWorker.id, workerName.trim(), workerRole, workerLang, workerActive);
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
} else {
const result = await createTimeWorker(brandId, workerName.trim(), workerRole, workerLang);
if (!result.success) { setWorkerError(result.error ?? "Failed"); setSubmitting(false); return; }
}
setSubmitting(false); setShowWorkerModal(false); load();
};
const handleResetPin = async (workerId: string) => {
const result = await resetTimeWorkerPin(workerId);
if (result.success && result.pin) setResetPinResult(result.pin);
else setWorkerError(result.error ?? "Failed to reset PIN");
};
const handleDeleteWorker = async (workerId: string) => {
if (!confirm("Delete this worker? This cannot be undone.")) return;
const result = await deleteTimeWorker(workerId);
if (!result.success) { setWorkerError(result.error ?? "Failed"); return; }
load();
};
const openAddTask = () => {
setEditingTask(null); setTaskName(""); setTaskNameEs("");
setTaskUnit("hours"); setTaskActive(true); setTaskSortOrder(0);
setTaskError(null); setShowTaskModal(true);
};
const openEditTask = (t: TimeTask) => {
setEditingTask(t); setTaskName(t.name); setTaskNameEs(t.name_es ?? "");
setTaskUnit(t.unit); setTaskActive(t.active); setTaskSortOrder(t.sort_order);
setTaskError(null); setShowTaskModal(true);
};
const handleSaveTask = async () => {
if (!taskName.trim()) { setTaskError("Name is required"); return; }
setSubmitting(true); setTaskError(null);
if (editingTask) {
const result = await updateTimeTask(editingTask.id, taskName.trim(), taskNameEs, taskUnit, taskActive, taskSortOrder);
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
} else {
const result = await createTimeTask(brandId, taskName.trim(), taskNameEs || null, taskUnit, taskSortOrder);
if (!result.success) { setTaskError(result.error ?? "Failed"); setSubmitting(false); return; }
}
setSubmitting(false); setShowTaskModal(false); load();
};
const handleDeleteTask = async (taskId: string) => {
if (!confirm("Delete this task?")) return;
const result = await deleteTimeTask(taskId);
if (!result.success) { setTaskError(result.error ?? "Failed"); return; }
load();
};
const triggerLabel: Record<string, { en: string; color: string }> = {
daily_approaching: { en: "Daily OT Approaching", color: "bg-amber-100 text-amber-700" },
daily_reached: { en: "Daily OT Reached", color: "bg-red-100 text-red-700" },
weekly_approaching: { en: "Weekly OT Approaching", color: "bg-amber-100 text-amber-700" },
weekly_reached: { en: "Weekly OT Reached", color: "bg-red-100 text-red-700" },
end_of_period_summary:{ en: "Period Summary", color: "bg-emerald-100 text-emerald-700" },
};
if (loading) return <div className="text-center py-16 text-stone-500 text-sm">Loading...</div>;
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-stone-900">Time Tracking</h2>
<p className="text-sm text-stone-500 mt-0.5">
{tab === "dashboard" ? "Summary, exports & recent activity" : "Workers, tasks & overtime rules"}
</p>
</div>
</div>
{/* Two-tab nav */}
<div className="flex gap-1 border-b border-stone-200">
<button onClick={() => setTab("dashboard")} className={`px-4 py-2 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "dashboard" ? "text-stone-900 border-stone-900" : "text-stone-500 border-transparent hover:text-stone-700"}`}>
Dashboard
</button>
<button onClick={() => setTab("settings")} className={`px-4 py-2 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "settings" ? "text-stone-900 border-stone-900" : "text-stone-500 border-transparent hover:text-stone-700"}`}>
Settings
</button>
</div>
{/* Dashboard tab */}
{tab === "dashboard" && (
<div className="space-y-6">
{/* Stat row */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
<p className="text-3xl font-bold text-stone-900">{workers.length}</p>
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Workers</p>
</div>
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
<p className="text-3xl font-bold text-stone-900">{tasks.length}</p>
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Tasks</p>
</div>
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
<p className="text-3xl font-bold text-amber-600">{notificationLog.filter(n => n.email_sent || n.sms_sent).length}</p>
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Alerts Sent</p>
</div>
</div>
{/* Quick export section */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-4 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Export Data</h3>
<div className="grid grid-cols-2 gap-3">
<button onClick={() => triggerDownload(buildExportUrl("quickbooks"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-emerald-500 transition-all text-left">
<span className="text-lg">📒</span>
<div>
<p className="text-sm font-semibold text-stone-800">QuickBooks</p>
<p className="text-xs text-stone-500">Time import CSV</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("payroll"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-blue-500 transition-all text-left">
<span className="text-lg">💰</span>
<div>
<p className="text-sm font-semibold text-stone-800">Payroll</p>
<p className="text-xs text-stone-500">ADP / Gusto / Generic</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("detailed"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-violet-500 transition-all text-left">
<span className="text-lg">📋</span>
<div>
<p className="text-sm font-semibold text-stone-800">Detailed Log</p>
<p className="text-xs text-stone-500">Full audit report</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("summary"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-amber-500 transition-all text-left">
<span className="text-lg">📊</span>
<div>
<p className="text-sm font-semibold text-stone-800">Period Summary</p>
<p className="text-xs text-stone-500">Per-worker totals</p>
</div>
</button>
</div>
<div className="flex items-center gap-4 text-xs text-stone-500">
<span>From:</span>
<input type="date" value={exportStart} onChange={e => setExportStart(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-stone-200 bg-white text-stone-700 focus:outline-none focus:border-emerald-500" />
<span>To:</span>
<input type="date" value={exportEnd} onChange={e => setExportEnd(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-stone-200 bg-white text-stone-700 focus:outline-none focus:border-emerald-500" />
</div>
</div>
{/* Notification log */}
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Recent Alerts</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-5 py-3 font-medium">Time</th>
<th className="text-left px-5 py-3 font-medium">Worker</th>
<th className="text-left px-5 py-3 font-medium">Alert</th>
<th className="text-left px-5 py-3 font-medium">Email</th>
<th className="text-left px-5 py-3 font-medium">SMS</th>
</tr>
</thead>
<tbody>
{notificationLog.length === 0 ? (
<tr><td colSpan={5} className="px-5 py-8 text-center text-stone-400">No alerts sent yet</td></tr>
) : notificationLog.slice(0, 20).map(entry => {
const meta = triggerLabel[entry.trigger_type] ?? { en: entry.trigger_type, color: "bg-stone-100 text-stone-500" };
return (
<tr key={entry.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-500 text-xs">{new Date(entry.created_at).toLocaleString()}</td>
<td className="px-5 py-3.5 text-stone-800 font-medium">{entry.worker_name ?? "—"}</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-center">
<span className={`text-sm font-bold ${entry.email_sent ? "text-emerald-600" : "text-stone-300"}`}>{entry.email_sent ? "✓" : "—"}</span>
</td>
<td className="px-5 py-3.5 text-center">
<span className={`text-sm font-bold ${entry.sms_sent ? "text-emerald-600" : "text-stone-300"}`}>{entry.sms_sent ? "✓" : "—"}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Settings tab */}
{tab === "settings" && (
<div className="space-y-6">
{/* Workers section */}
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Workers & PINs</h3>
<button onClick={() => { setEditingWorker(null); setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en"); setWorkerActive(true); setShowWorkerModal(true); setWorkerError(null); setResetPinResult(null); }}
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Worker</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-5 py-3 font-medium">Name</th>
<th className="text-left px-5 py-3 font-medium">Role</th>
<th className="text-left px-5 py-3 font-medium">Lang</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-left px-5 py-3 font-medium">Last Used</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{workers.length === 0 ? (
<tr><td colSpan={6} className="px-5 py-12 text-center text-stone-400">No workers yet</td></tr>
) : workers.map(w => (
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-800 font-medium">{w.name}</td>
<td className="px-5 py-3.5 text-stone-500 capitalize">{w.role}</td>
<td className="px-5 py-3.5 text-stone-400 uppercase text-xs font-mono">{w.lang}</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 ${w.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{w.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-3.5 text-stone-400 text-xs">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-800 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
<button onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Tasks section */}
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Tasks</h3>
<button onClick={() => { setEditingTask(null); setTaskName(""); setTaskNameEs(""); setTaskUnit("hours"); setTaskSortOrder(0); setTaskActive(true); setShowTaskModal(true); setTaskError(null); }}
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Task</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<th className="text-left px-5 py-3 font-medium">Name</th>
<th className="text-left px-5 py-3 font-medium">Name (ES)</th>
<th className="text-left px-5 py-3 font-medium">Unit</th>
<th className="text-left px-5 py-3 font-medium">Sort</th>
<th className="text-left px-5 py-3 font-medium">Status</th>
<th className="text-right px-5 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{tasks.length === 0 ? (
<tr><td colSpan={6} className="px-5 py-12 text-center text-stone-400">No tasks yet</td></tr>
) : tasks.map(t => (
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-800 font-medium">{t.name}</td>
<td className="px-5 py-3.5 text-stone-500">{t.name_es ?? "—"}</td>
<td className="px-5 py-3.5 text-stone-400 text-xs font-mono">{t.unit}</td>
<td className="px-5 py-3.5 text-stone-400 text-xs">{t.sort_order}</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 ${t.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{t.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-800 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pay Period & Overtime section */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-5 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Pay Period & Overtime</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
<select value={payPeriodStartDay} onChange={e => setPayPeriodStartDay(Number(e.target.value))}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
{DAYS.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Pay period length</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={31} value={payPeriodLength}
onChange={e => setPayPeriodLength(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">days</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={24} value={dailyOvertimeThreshold}
onChange={e => setDailyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
<div className="flex items-center gap-3">
<input type="number" min={1} max={80} value={weeklyOvertimeThreshold}
onChange={e => setWeeklyOvertimeThreshold(Number(e.target.value))}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
<span className="text-sm text-stone-500">hrs</span>
</div>
</div>
</div>
</div>
{/* Colorado notice */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-5">
<div className="flex items-start gap-3">
<span className="text-amber-600 text-lg mt-0.5"></span>
<div>
<p className="text-sm font-semibold text-amber-800">Colorado Overtime Law</p>
<p className="text-xs text-amber-700 mt-1">Colorado requires daily overtime (1.5×) after 12 hours in a workday, or weekly overtime after 40 hours in a workweek.</p>
</div>
</div>
</div>
{/* Alert Settings */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-5 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Alert Settings</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Daily overtime alerts</p>
<p className="text-xs text-stone-500">Send notification when worker hits daily threshold</p>
</div>
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Weekly overtime alerts</p>
<p className="text-xs text-stone-500">Send notification when worker hits weekly threshold</p>
</div>
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Show overtime warning on employee screen</p>
<p className="text-xs text-stone-500">Display alert when worker approaches thresholds</p>
</div>
<button onClick={() => setOvertimeNotifications(!overtimeNotifications)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${overtimeNotifications ? "bg-emerald-600" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${overtimeNotifications ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
</div>
</div>
{/* Notification Recipients */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-4 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Notification Recipients</h3>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
<div className="flex gap-2">
<input value={newEmail}
onChange={e => setNewEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="manager@farm.com" />
<button onClick={addEmail} className="px-4 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm transition-all">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationEmails.map(e => (
<span key={e} className="inline-flex items-center gap-1 bg-stone-100 text-stone-700 text-xs px-2 py-1 rounded-lg">
{e}
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">SMS numbers</label>
<div className="flex gap-2">
<input value={newSms}
onChange={e => setNewSms(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="+1234567890" />
<button onClick={addSms} className="px-4 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm transition-all">Add</button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationSmsNumbers.map(n => (
<span key={n} className="inline-flex items-center gap-1 bg-stone-100 text-stone-700 text-xs px-2 py-1 rounded-lg">
{n}
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
</div>
</div>
{settingsError && (
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
)}
{settingsSaved && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4 text-emerald-700 text-sm">Settings saved successfully.</div>
)}
<button onClick={handleSaveNotifications} disabled={settingsSaving}
className="px-6 py-3 rounded-xl bg-stone-900 hover:bg-stone-800 disabled:opacity-40 font-semibold text-sm text-white transition-all">
{settingsSaving ? "Saving..." : "Save Settings"}
</button>
</div>
)}
{/* Worker Modal */}
{showWorkerModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{workerError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{workerError}</div>}
{resetPinResult && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4">
<p className="text-emerald-700 text-sm font-semibold mb-1">New PIN:</p>
<p className="text-2xl font-mono font-bold text-stone-900">{resetPinResult}</p>
<p className="text-stone-500 text-xs mt-1">Show this to the worker it will not be shown again.</p>
</div>
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name *</label>
<input value={workerName} onChange={e => setWorkerName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="Worker name" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Role</label>
<select value={workerRole} onChange={e => setWorkerRole(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="worker">Worker</option>
<option value="time_admin">Time Admin</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Lang</label>
<select value={workerLang} onChange={e => setWorkerLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="workerActive" className="text-sm text-stone-700">Active</label>
</div>
{editingWorker && (
<div className="pt-2 border-t border-stone-100">
<button onClick={() => handleResetPin(editingWorker.id)} className="text-xs text-amber-600 hover:text-amber-800 font-semibold underline underline-offset-2">
Reset PIN for this worker
</button>
</div>
)}
<div className="flex gap-3 pt-2">
<button onClick={() => setShowWorkerModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
Cancel
</button>
<button onClick={handleSaveWorker} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
</button>
</div>
</div>
</div>
</div>
)}
{/* Task Modal */}
{showTaskModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (EN) *</label>
<input value={taskName} onChange={e => setTaskName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Harvesting" />
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (ES)</label>
<input value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
placeholder="e.g. Cosecha" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Unit</label>
<select value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
<option value="units">Units</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Sort Order</label>
<input type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="taskActive" className="text-sm text-stone-700">Active</label>
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setShowTaskModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
Cancel
</button>
<button onClick={handleSaveTask} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+656
View File
@@ -0,0 +1,656 @@
"use client";
import { useState } from "react";
import { AdminUserRow, CreateAdminUserInput, UpdateAdminUserInput } from "@/actions/admin/users";
import { formatDate } from "@/lib/format-date";
type UsersPageProps = {
initialUsers: AdminUserRow[];
brands: { id: string; name: string }[];
currentUser: {
id: string;
role: string;
can_manage_users?: boolean;
};
};
type PasswordModal = {
email: string;
tempPassword: string | null;
loading: boolean;
error: string | null;
};
const FLAG_LABELS: Record<string, string> = {
can_manage_products: "Products",
can_manage_stops: "Stops",
can_manage_orders: "Orders",
can_manage_pickup: "Pickup",
can_manage_messages: "Messages",
can_manage_refunds: "Refunds",
can_manage_users: "Users",
can_manage_water_log: "Water Log",
can_manage_reports: "Reports",
};
const ALL_FLAGS = Object.keys(FLAG_LABELS);
function RoleBadge({ role }: { role: string }) {
const colors: Record<string, string> = {
platform_admin: "bg-purple-100 text-purple-700",
brand_admin: "bg-blue-100 text-blue-700",
store_employee: "bg-emerald-100 text-emerald-700",
staff: "bg-stone-100 text-stone-600",
};
return (
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${colors[role] ?? "bg-stone-100 text-stone-600"}`}>
{role.replace("_", " ")}
</span>
);
}
type EditingUser = {
id?: string;
email?: string;
password?: string;
display_name?: string;
phone_number?: string;
role: "platform_admin" | "brand_admin" | "store_employee";
brand_id: string | null;
flags: Record<string, boolean>;
active?: boolean;
isNew: boolean;
};
const defaultFlags: Record<string, boolean> = {
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: false,
can_manage_pickup: false,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
};
function emptyEditing(isNew: boolean): EditingUser {
return {
isNew,
role: "store_employee",
brand_id: null,
display_name: "",
phone_number: "",
password: "",
flags: { ...defaultFlags },
};
}
function editingFromRow(row: AdminUserRow): EditingUser {
return {
id: row.id,
email: row.email,
display_name: row.display_name ?? "",
phone_number: row.phone_number ?? "",
role: row.role as "platform_admin" | "brand_admin" | "store_employee",
brand_id: row.brand_id,
flags: {
can_manage_products: row.can_manage_products,
can_manage_stops: row.can_manage_stops,
can_manage_orders: row.can_manage_orders,
can_manage_pickup: row.can_manage_pickup,
can_manage_messages: row.can_manage_messages,
can_manage_refunds: row.can_manage_refunds,
can_manage_users: row.can_manage_users,
can_manage_water_log: row.can_manage_water_log,
can_manage_reports: row.can_manage_reports,
},
active: row.active,
isNew: false,
};
}
export default function UsersPage({ initialUsers, brands, currentUser }: UsersPageProps) {
const [users, setUsers] = useState(initialUsers);
const [panelOpen, setPanelOpen] = useState(false);
const [editing, setEditing] = useState<EditingUser>(emptyEditing(true));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [actionMenuId, setActionMenuId] = useState<string | null>(null);
const [passwordModal, setPasswordModal] = useState<PasswordModal | null>(null);
const [resetLinkLoading, setResetLinkLoading] = useState<string | null>(null);
const canManageUsers =
currentUser.role === "platform_admin" ||
(currentUser.role === "brand_admin" && currentUser.can_manage_users);
function openCreate() {
setEditing(emptyEditing(true));
setError(null);
setPanelOpen(true);
}
function openEdit(row: AdminUserRow) {
setEditing(editingFromRow(row));
setError(null);
setPanelOpen(true);
}
function closePanel() {
setPanelOpen(false);
setEditing(emptyEditing(true));
setError(null);
}
function setRole(role: EditingUser["role"]) {
setEditing((prev) => ({ ...prev, role }));
}
function setBrand(brand_id: string | null) {
setEditing((prev) => ({ ...prev, brand_id }));
}
function toggleFlag(flag: string) {
setEditing((prev) => ({
...prev,
flags: { ...prev.flags, [flag]: !prev.flags[flag] },
}));
}
function setActive(active: boolean) {
setEditing((prev) => ({ ...prev, active }));
}
async function handleSave() {
setSaving(true);
setError(null);
try {
if (editing.isNew) {
const res = await import("@/actions/admin/users").then((m) =>
m.createAdminUser({
email: editing.email!,
password: editing.password!,
display_name: editing.display_name || undefined,
phone_number: editing.phone_number || undefined,
role: editing.role,
brand_id: editing.brand_id,
flags: editing.flags,
mustChangePassword: true,
})
);
if (res.error) {
if (res.error === "Not authenticated" || res.error.includes("session") || res.error.includes("authenticated")) {
setError("Your session may have expired. Please log in again.");
} else if (res.error.includes("permission") || res.error.includes("Insufficient permissions")) {
setError("You do not have permission to edit this user.");
} else {
setError(res.error);
}
return;
}
if (res.user) setUsers((prev) => [res.user!, ...prev]);
} else {
const res = await import("@/actions/admin/users").then((m) =>
m.updateAdminUser({
id: editing.id!,
role: editing.role,
brand_id: editing.brand_id,
flags: editing.flags,
active: editing.active,
display_name: editing.display_name === "" ? null : editing.display_name || undefined,
phone_number: editing.phone_number === "" ? null : editing.phone_number || undefined,
})
);
if (res.error) {
if (res.error === "Not authenticated" || res.error.includes("session") || res.error.includes("authenticated")) {
setError("Your session may have expired. Please log in again.");
} else if (res.error.includes("permission") || res.error.includes("Insufficient permissions")) {
setError("You do not have permission to edit this user.");
} else {
setError(res.error);
}
return;
}
if (res.user) setUsers((prev) => prev.map((u) => (u.id === res.user!.id ? res.user! : u)));
}
closePanel();
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
const res = await import("@/actions/admin/users").then((m) => m.deleteAdminUser(id));
if (res.error) { setError(res.error); return; }
setUsers((prev) => prev.filter((u) => u.id !== id));
setDeleteConfirmId(null);
}
async function handleResetPassword(email: string) {
setPasswordModal({ email, tempPassword: null, loading: true, error: null });
try {
const { resetAdminPassword } = await import("@/actions/admin/reset-admin");
const result = await resetAdminPassword(email, "Tuxedo2026!");
if (result.success) {
setPasswordModal({ email, tempPassword: result.tempPassword, loading: false, error: null });
} else {
setPasswordModal({ email, tempPassword: null, loading: false, error: result.error ?? "Failed" });
}
} catch (e: any) {
setPasswordModal({ email, tempPassword: null, loading: false, error: e?.message ?? "Error" });
}
}
async function handleForcePasswordChange(id: string) {
try {
const { setMustChangePassword } = await import("@/actions/admin/users");
const res = await setMustChangePassword(id);
if (res.error) throw new Error(res.error);
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, must_change_password: true } : u));
} catch (e: any) {
setError(e?.message ?? "Failed to require password change");
}
}
async function handleSendResetLink(email: string) {
setResetLinkLoading(email);
try {
const { sendPasswordResetEmail } = await import("@/actions/admin/users");
const result = await sendPasswordResetEmail(email);
setResetLinkLoading(null);
if (result.error) throw new Error(result.error);
setError(null);
} catch (e: any) {
setResetLinkLoading(null);
setError(e?.message ?? "Failed to send reset email");
}
}
function copyTempPassword() {
if (passwordModal?.tempPassword) {
navigator.clipboard.writeText(passwordModal.tempPassword).catch(() => {});
}
}
const showBrandSelect = editing.role === "brand_admin" || editing.role === "store_employee";
const availableRoles: EditingUser["role"][] =
currentUser.role === "platform_admin"
? ["platform_admin", "brand_admin", "store_employee"]
: ["brand_admin", "store_employee"];
return (
<>
{/* User list */}
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-stone-900">{users.length} user{users.length !== 1 ? "s" : ""}</h2>
</div>
{canManageUsers && (
<button
onClick={openCreate}
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800"
>
+ Create User
</button>
)}
</div>
{/* Error banner */}
{error && (
<div className="mb-4 flex items-start justify-between rounded-lg bg-red-50 p-4 text-sm text-red-700 gap-3 border border-red-200">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0"></button>
</div>
)}
<div className="overflow-visible rounded-xl bg-white shadow-sm ring-1 ring-stone-200">
<table className="w-full text-left text-sm">
<thead className="bg-stone-50 text-stone-500">
<tr>
<th className="px-5 py-4 font-semibold">Name</th>
<th className="px-5 py-4 font-semibold">Phone</th>
<th className="px-5 py-4 font-semibold">Role</th>
<th className="px-5 py-4 font-semibold">Brand</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold">Created</th>
{canManageUsers && <th className="px-5 py-4 font-semibold" />}
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-stone-50">
<td className="px-5 py-4">
<div className="font-medium text-stone-900">{user.display_name || user.email}</div>
{user.display_name && user.display_name !== "No Name" && (
<div className="text-xs text-stone-500">{user.email}</div>
)}
</td>
<td className="px-5 py-4 text-stone-500 text-sm">{user.phone_number ?? "—"}</td>
<td className="px-5 py-4">
<RoleBadge role={user.role} />
</td>
<td className="px-5 py-4 text-stone-500">{user.brand_name ?? "—"}</td>
<td className="px-5 py-4">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${user.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{user.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-stone-400 text-xs">
{formatDate(new Date(user.created_at))}
</td>
{canManageUsers && (
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<button
onClick={() => openEdit(user)}
className="text-xs font-medium text-stone-500 hover:text-stone-900"
>
Edit
</button>
<div className="relative overflow-visible">
<button
onClick={() => setActionMenuId(actionMenuId === user.id ? null : user.id)}
className="text-base font-medium text-stone-400 hover:text-stone-600 px-2 py-1"
title="More actions"
>
</button>
{actionMenuId === user.id && (
<>
<div className="fixed inset-0 z-40" onClick={() => setActionMenuId(null)} />
<div className="absolute right-0 z-50 mt-1 w-48 rounded-xl bg-white shadow-xl ring-1 ring-stone-200 border border-stone-100 py-1 text-xs overflow-visible">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-stone-400 border-b border-stone-100">
Actions
</div>
<button
onClick={() => { setActionMenuId(null); handleResetPassword(user.email); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
🔑 Reset Password
</button>
<button
onClick={() => { setActionMenuId(null); handleForcePasswordChange(user.id); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
Require Password Change
</button>
<button
onClick={() => { setActionMenuId(null); handleSendResetLink(user.email); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
Send Reset Email
</button>
{user.id !== currentUser.id && (
<button
onClick={() => { setActionMenuId(null); setDeleteConfirmId(user.id); }}
className="w-full text-left px-3 py-2.5 text-red-600 hover:bg-red-50 flex items-center gap-2 border-t border-stone-100 mt-1 pt-1"
>
🗑 Delete
</button>
)}
</div>
</>
)}
</div>
</div>
</td>
)}
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={7} className="px-5 py-12 text-center text-stone-400">
No users found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Delete confirm modal */}
{deleteConfirmId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-2xl bg-white p-6 shadow-xl ring-1 ring-stone-200">
<h3 className="text-lg font-bold text-stone-900">Delete user?</h3>
<p className="mt-2 text-sm text-stone-600">
This will remove their admin access. Their auth account will remain active.
</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setDeleteConfirmId(null)}
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmId)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
{/* Slide-in panel */}
{panelOpen && (
<div className="fixed inset-0 z-50 flex justify-end bg-black/20" onClick={closePanel}>
<div
className="relative flex h-full w-full max-w-md flex-col overflow-y-auto bg-white shadow-xl ring-1 ring-stone-200"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
<h2 className="text-lg font-bold text-stone-900">
{editing.isNew ? "Create User" : "Edit User"}
</h2>
<button onClick={closePanel} className="text-stone-400 hover:text-stone-600">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Saving overlay */}
{saving && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-stone-200 border-t-stone-900" />
<p className="text-sm text-stone-500">Saving</p>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="space-y-6">
{/* Email (create only) */}
{editing.isNew && (
<div>
<label className="block text-sm font-medium text-stone-700">Email</label>
<input
type="email"
value={editing.email ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, email: e.target.value }))}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="user@example.com"
/>
</div>
)}
{/* Password (create only) */}
{editing.isNew && (
<div>
<label className="block text-sm font-medium text-stone-700">Password</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Minimum 6 characters.</p>
<input
type="password"
value={editing.password ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, password: e.target.value }))}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="Choose a password"
minLength={6}
/>
</div>
)}
{/* Display Name */}
<div>
<label className="block text-sm font-medium text-stone-700">Display Name</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Shown in the admin user list.</p>
<input
type="text"
value={editing.display_name ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, display_name: e.target.value }))}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="Kyle Martinez"
/>
</div>
{/* Phone Number */}
<div>
<label className="block text-sm font-medium text-stone-700">Phone Number</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Optional.</p>
<input
type="tel"
value={editing.phone_number ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, phone_number: e.target.value }))}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="+1 (555) 000-0000"
/>
</div>
{/* Role */}
<div>
<label className="block text-sm font-medium text-stone-700">Role</label>
<div className="mt-2 space-y-2">
{availableRoles.map((r) => (
<label key={r} className="flex items-center gap-3 rounded-lg border border-stone-200 px-3 py-2.5 cursor-pointer hover:bg-stone-50">
<input
type="radio"
name="role"
value={r}
checked={editing.role === r}
onChange={() => setRole(r)}
className="accent-stone-900"
/>
<div>
<span className="font-medium text-stone-900 capitalize">{r.replace("_", " ")}</span>
<p className="text-xs text-stone-500">
{r === "platform_admin" ? "Full platform access" :
r === "brand_admin" ? "Brand-level admin" :
"Pickup and order operations only"}
</p>
</div>
</label>
))}
</div>
</div>
{/* Brand */}
{showBrandSelect && (
<div>
<label className="block text-sm font-medium text-stone-700">Brand</label>
<select
value={editing.brand_id ?? ""}
onChange={(e) => setBrand(e.target.value || null)}
className="mt-1 w-full rounded-lg border border-stone-200 px-3 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
>
<option value="">No brand (full platform scope)</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
)}
{/* Active toggle (edit only) */}
{!editing.isNew && (
<div className="flex items-center justify-between rounded-lg border border-stone-200 px-4 py-3">
<div>
<p className="font-medium text-stone-900">Active</p>
<p className="text-xs text-stone-500">Deactivated users cannot log in</p>
</div>
<button
onClick={() => setActive(!editing.active)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${editing.active ? "bg-emerald-600" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${editing.active ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
)}
{/* Permissions */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-3">Permissions</label>
<div className="space-y-2">
{ALL_FLAGS.map((flag) => (
<label key={flag} className="flex items-center justify-between rounded-lg border border-stone-200 px-4 py-2.5 hover:bg-stone-50 cursor-pointer">
<span className="text-sm text-stone-700">{FLAG_LABELS[flag]}</span>
<button
type="button"
onClick={() => toggleFlag(flag)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${editing.flags[flag] ? "bg-emerald-600" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${editing.flags[flag] ? "translate-x-6" : "translate-x-1"}`} />
</button>
</label>
))}
</div>
</div>
</div>
</div>
<div className="border-t px-6 py-4">
<div className="flex justify-end gap-3">
<button
onClick={closePanel}
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving || (editing.isNew && (!editing.email?.includes("@") || !editing.password || editing.password.length < 6))}
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800 disabled:opacity-50"
>
{saving ? "Saving…" : editing.isNew ? "Create User" : "Save Changes"}
</button>
</div>
</div>
</div>
</div>
)}
{/* Password reset modal */}
{passwordModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-2xl bg-white p-6 shadow-xl ring-1 ring-stone-200">
<h3 className="text-lg font-bold text-stone-900">Reset Password</h3>
{passwordModal.loading ? (
<div className="mt-4 flex items-center gap-3 text-sm text-stone-500">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-stone-200 border-t-stone-900" />
Resetting password for {passwordModal.email}...
</div>
) : passwordModal.error ? (
<div>
<p className="mt-3 text-sm text-red-600">{passwordModal.error}</p>
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50">Close</button>
</div>
) : passwordModal.tempPassword ? (
<div>
<p className="mt-3 text-sm text-stone-600">Temporary password for <span className="font-medium text-stone-900">{passwordModal.email}</span>:</p>
<div className="mt-3 flex items-center gap-2 rounded-xl bg-stone-50 p-4 ring-1 ring-stone-200">
<span className="font-mono text-lg font-bold text-stone-900 select-all">{passwordModal.tempPassword}</span>
<button onClick={copyTempPassword} className="shrink-0 rounded-lg bg-stone-200 px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-300">Copy</button>
</div>
<p className="mt-2 text-xs text-stone-500">Share this password securely with the user. They will be required to change it on next login.</p>
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800">Done</button>
</div>
) : null}
</div>
</div>
)}
</>
);
}
+758
View File
@@ -0,0 +1,758 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import {
createWaterHeadgate,
createWaterUser,
getWaterEntries,
} from "@/actions/water-log/admin";
import { downloadWaterLogCSV, filterWaterLogEntries, shapeWaterLogEntry, formatDailyWaterReport, isInIrrigationSeason, type WaterLogFilter, type WaterLogReportRow, type IrrigationSeasonSettings } from "@/lib/water-log-reporting";
type WaterUser = {
id: string;
name: string;
role: "irrigator" | "water_admin";
active: boolean;
language_preference: string;
last_used_at: string | null;
created_at: string;
};
type Headgate = {
id: string;
name: string;
active: boolean;
unit: string;
created_at: string;
};
type WaterEntry = {
id: string;
headgate_id: string;
user_id: string;
headgate_name: string;
user_name: string;
measurement: number;
unit: string;
notes: string | null;
submitted_via: string;
logged_at: string;
};
type WaterLogAdminPanelProps = {
initialUsers: WaterUser[];
initialHeadgates: Headgate[];
initialEntries: WaterEntry[];
brandId: string;
canManage: boolean;
};
function formatDateTime(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export default function WaterLogAdminPanel({
initialUsers,
initialHeadgates,
initialEntries,
brandId,
canManage,
}: WaterLogAdminPanelProps) {
const router = useRouter();
const [users] = useState<WaterUser[]>(initialUsers);
const [headgates, setHeadgates] = useState<Headgate[]>(initialHeadgates);
const [entries] = useState<WaterEntry[]>(initialEntries);
// ── Headgate add state ────────────────────────────
const [showAddHg, setShowAddHg] = useState(false);
const [newHgName, setNewHgName] = useState("");
const [newHgUnit, setNewHgUnit] = useState("CFS");
const [savingHg, setSavingHg] = useState(false);
// ── User add state ─────────────────────────────
const [showAddUser, setShowAddUser] = useState(false);
const [newUserName, setNewUserName] = useState("");
const [newUserRole, setNewUserRole] = useState<"irrigator" | "water_admin">("irrigator");
const [newUserLang, setNewUserLang] = useState("en");
const [savingUser, setSavingUser] = useState(false);
const [newPin, setNewPin] = useState<{ name: string; pin: string } | null>(null);
// ── CSV filter state ─────────────────────────────
const [filterDateFrom, setFilterDateFrom] = useState("");
const [filterDateTo, setFilterDateTo] = useState("");
const [filterHeadgate, setFilterHeadgate] = useState("");
const [filterUser, setFilterUser] = useState("");
const [filterVia, setFilterVia] = useState("");
const [csvLoading, setCsvLoading] = useState(false);
// ── Report / messaging settings (localStorage-persisted) ──
const [showReportSettings, setShowReportSettings] = useState(false);
const [messagingEnabled, setMessagingEnabled] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("wl_messaging_enabled") === "true";
});
const [seasonStart, setSeasonStart] = useState<IrrigationSeasonSettings>(() => {
if (typeof window === "undefined") return { seasonStartMonth: 3, seasonStartDay: 15, seasonEndMonth: 10, seasonEndDay: 15 };
const saved = localStorage.getItem("wl_season_settings");
return saved ? JSON.parse(saved) : { seasonStartMonth: 3, seasonStartDay: 15, seasonEndMonth: 10, seasonEndDay: 15 };
});
const [sendEvenIfEmpty, setSendEvenIfEmpty] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("wl_send_even_if_empty") === "true";
});
const [recipientName, setRecipientName] = useState(() =>
typeof window !== "undefined" ? localStorage.getItem("wl_recipient_name") ?? "" : ""
);
const [recipientPhone, setRecipientPhone] = useState(() =>
typeof window !== "undefined" ? localStorage.getItem("wl_recipient_phone") ?? "" : ""
);
// Persist to localStorage on change
function persistMessaging(val: boolean) {
setMessagingEnabled(val);
localStorage.setItem("wl_messaging_enabled", String(val));
}
function persistSendEvenIfEmpty(val: boolean) {
setSendEvenIfEmpty(val);
localStorage.setItem("wl_send_even_if_empty", String(val));
}
function persistSeason(s: IrrigationSeasonSettings) {
setSeasonStart(s);
localStorage.setItem("wl_season_settings", JSON.stringify(s));
}
function persistRecipientName(v: string) {
setRecipientName(v);
localStorage.setItem("wl_recipient_name", v);
}
function persistRecipientPhone(v: string) {
setRecipientPhone(v);
localStorage.setItem("wl_recipient_phone", v);
}
// ── Preview report ─────────────────────────────
async function handlePreviewReport() {
const allEntries = await getWaterEntries(brandId, 500);
const today = new Date().toISOString().split("T")[0];
const todayRows: WaterLogReportRow[] = allEntries
.filter((e) => e.logged_at.startsWith(today))
.map(shapeWaterLogEntry);
const inSeason = isInIrrigationSeason(new Date(), seasonStart);
const report = formatDailyWaterReport(todayRows, {
recipientName: recipientName || undefined,
sendEvenIfEmpty,
previewMode: true,
});
if (!report) {
alert(`[Preview] No entries today — report would be skipped (outside season: ${inSeason ? "in" : "out"}).`);
} else {
alert(`[Daily Report Preview — ${inSeason ? "in season" : "out of season"}]\n\n${report}`);
}
}
// ── Headgate handlers ────────────────────────────
async function handleAddHg(e: React.FormEvent) {
e.preventDefault();
if (!newHgName.trim()) return;
setSavingHg(true);
const result = await createWaterHeadgate(brandId, newHgName.trim(), newHgUnit);
if (result.success && result.headgate) {
setHeadgates((prev) => [...prev, result.headgate!]);
setNewHgName("");
setNewHgUnit("CFS");
setShowAddHg(false);
}
setSavingHg(false);
}
// ── User handlers ─────────────────────────────
async function handleAddUser(e: React.FormEvent) {
e.preventDefault();
if (!newUserName.trim()) return;
setSavingUser(true);
const result = await createWaterUser(brandId, newUserName.trim(), newUserRole, newUserLang);
if (result.success && result.user) {
setNewPin({ name: result.user!.name, pin: result.pin! });
setNewUserName("");
setNewUserRole("irrigator");
setNewUserLang("en");
setShowAddUser(false);
}
setSavingUser(false);
}
// ── CSV export ──────────────────────────────────
async function handleExportCSV() {
setCsvLoading(true);
try {
const allEntries = await getWaterEntries(brandId, 500);
const shaped: WaterLogReportRow[] = allEntries.map(shapeWaterLogEntry);
const filters: WaterLogFilter = {
dateFrom: filterDateFrom || undefined,
dateTo: filterDateTo || undefined,
headgateId: filterHeadgate || undefined,
userId: filterUser || undefined,
submittedVia: filterVia || undefined,
};
const filtered = filterWaterLogEntries(shaped, filters);
const date = new Date().toISOString().split("T")[0];
downloadWaterLogCSV(`water-log-${date}.csv`, filtered);
} finally {
setCsvLoading(false);
}
}
// Derived filtered entries for display
const filteredEntries = entries.filter((e) => {
if (filterDateFrom && e.logged_at < filterDateFrom) return false;
if (filterDateTo && e.logged_at > filterDateTo + "T23:59:59.999Z") return false;
if (filterHeadgate && e.headgate_id !== filterHeadgate) return false;
if (filterUser && e.user_id !== filterUser) return false;
if (filterVia && e.submitted_via !== filterVia) return false;
return true;
});
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
<div className="mx-auto max-w-4xl flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-zinc-100">Water Log</h1>
<p className="mt-0.5 text-sm text-zinc-500">
PIN-based access · Tuxedo only · separate from site admin
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => router.push("/admin/water-log/headgates")}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
>
📱 QR Codes
</button>
<button
onClick={() => router.push("/admin/water-log/settings")}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
>
Settings
</button>
<a
href="/water/admin"
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
>
Field Admin
</a>
</div>
</div>
</div>
<div className="mx-auto max-w-4xl px-6 py-6 space-y-8">
{/* ── Today's Summary + Report Settings ── */}
<section>
{/* Summary card */}
{(() => {
const today = new Date().toISOString().split("T")[0];
const todayEntries = entries.filter((e) => e.logged_at.startsWith(today));
const inSeason = isInIrrigationSeason(new Date(), seasonStart);
const totalMeasurement = todayEntries.reduce((s, e) => s + e.measurement, 0);
const lastEntry = todayEntries.length > 0
? todayEntries.reduce((a, b) => (a.logged_at > b.logged_at ? a : b))
: null;
return (
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 mb-4">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-bold text-zinc-100">Today&apos;s Summary</h2>
<p className="text-xs text-zinc-500 mt-0.5">
{inSeason
? <span className="text-green-600 font-medium">In irrigation season</span>
: <span className="text-orange-500 font-medium">Outside irrigation season</span>
} &nbsp;·&nbsp; {todayEntries.length} {todayEntries.length === 1 ? "entry" : "entries"}
</p>
</div>
<div className="flex gap-2">
<button
onClick={handlePreviewReport}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
>
Preview Report
</button>
<button
onClick={() => setShowReportSettings(!showReportSettings)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium ${showReportSettings ? "bg-slate-800 text-white" : "border border-zinc-600 bg-zinc-900 text-zinc-300 hover:bg-zinc-800"}`}
>
Report Settings
</button>
</div>
</div>
{todayEntries.length > 0 ? (
<div className="mt-3 grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-zinc-500">Total measurement</p>
<p className="mt-0.5 text-xl font-bold text-zinc-100">{totalMeasurement.toFixed(2)}</p>
</div>
<div>
<p className="text-xs text-zinc-500">Last entry</p>
<p className="mt-0.5 text-sm font-semibold text-zinc-300">{lastEntry?.headgate_name}</p>
<p className="text-xs text-slate-400">{lastEntry ? formatDateTime(lastEntry.logged_at) : "—"}</p>
</div>
<div>
<p className="text-xs text-zinc-500">Messaging</p>
<p className={`mt-0.5 text-sm font-semibold ${messagingEnabled ? "text-green-600" : "text-slate-400"}`}>
{messagingEnabled ? "Enabled" : "Disabled"}
</p>
</div>
</div>
) : (
<p className="mt-2 text-sm text-slate-400">No entries recorded today.</p>
)}
</div>
);
})()}
{/* Report settings panel */}
{showReportSettings && (
<div className="rounded-xl bg-slate-50 border border-zinc-800 p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-semibold text-zinc-300">Report / Messaging Settings</p>
<button onClick={() => setShowReportSettings(false)} className="text-xs text-slate-400 hover:text-zinc-400">Close</button>
</div>
<div className="p-3 mb-3 rounded-lg bg-blue-900/30 border border-blue-100">
<p className="text-xs text-blue-700">
<strong>Preview only.</strong> These settings are local to this browser. Live scheduled WhatsApp/SMS delivery requires a future database settings table and messaging integration.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Messaging enabled</label>
<button
onClick={() => persistMessaging(!messagingEnabled)}
className={`w-full rounded-lg border px-3 py-2 text-sm font-medium ${messagingEnabled ? "bg-green-900/40 border-green-300 text-green-800" : "bg-zinc-900 border-zinc-600 text-zinc-500"}`}
>
{messagingEnabled ? "ON" : "OFF"}
</button>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Send even if empty</label>
<button
onClick={() => persistSendEvenIfEmpty(!sendEvenIfEmpty)}
className={`w-full rounded-lg border px-3 py-2 text-sm font-medium ${sendEvenIfEmpty ? "bg-green-900/40 border-green-300 text-green-800" : "bg-zinc-900 border-zinc-600 text-zinc-500"}`}
>
{sendEvenIfEmpty ? "ON" : "OFF"}
</button>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Season start (month/day)</label>
<div className="flex gap-1">
<select
value={seasonStart.seasonStartMonth}
onChange={(e) => persistSeason({ ...seasonStart, seasonStartMonth: parseInt(e.target.value) })}
className="flex-1 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>{new Date(0, i).toLocaleString("en", { month: "short" })}</option>
))}
</select>
<input
type="number"
min="1"
max="31"
value={seasonStart.seasonStartDay}
onChange={(e) => persistSeason({ ...seasonStart, seasonStartDay: parseInt(e.target.value) || 1 })}
className="w-16 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Season end (month/day)</label>
<div className="flex gap-1">
<select
value={seasonStart.seasonEndMonth}
onChange={(e) => persistSeason({ ...seasonStart, seasonEndMonth: parseInt(e.target.value) })}
className="flex-1 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>{new Date(0, i).toLocaleString("en", { month: "short" })}</option>
))}
</select>
<input
type="number"
min="1"
max="31"
value={seasonStart.seasonEndDay}
onChange={(e) => persistSeason({ ...seasonStart, seasonEndDay: parseInt(e.target.value) || 1 })}
className="w-16 rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Recipient name</label>
<input
type="text"
value={recipientName}
onChange={(e) => persistRecipientName(e.target.value)}
placeholder="David"
className="w-full rounded-lg border border-zinc-600 px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Recipient phone</label>
<input
type="text"
value={recipientPhone}
onChange={(e) => persistRecipientPhone(e.target.value)}
placeholder="+1..."
className="w-full rounded-lg border border-zinc-600 px-3 py-1.5 text-sm"
/>
</div>
</div>
</div>
)}
</section>
{/* ── Headgates ── */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-bold text-zinc-100">Headgates</h2>
{!showAddHg && (
<button
onClick={() => setShowAddHg(true)}
className="rounded-lg bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800"
>
+ Add Headgate
</button>
)}
</div>
{showAddHg && (
<form onSubmit={handleAddHg} className="mb-4 flex gap-2">
<input
type="text"
value={newHgName}
onChange={(e) => setNewHgName(e.target.value)}
placeholder="Headgate name"
className="flex-1 rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
autoFocus
/>
<select
value={newHgUnit}
onChange={(e) => setNewHgUnit(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="CFS">CFS</option>
<option value="GPM">GPM</option>
<option value="gal">gal</option>
<option value="ac-in">ac-in</option>
<option value="ac-ft">ac-ft</option>
</select>
<button type="submit" disabled={savingHg || !newHgName.trim()} className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{savingHg ? "..." : "Add"}
</button>
<button type="button" onClick={() => { setShowAddHg(false); setNewHgName(""); }} className="rounded-lg border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300">
Cancel
</button>
</form>
)}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
{headgates.length === 0 ? (
<p className="p-6 text-center text-sm text-zinc-500">No headgates yet</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50 text-left">
<th className="px-4 py-3 font-semibold text-zinc-400">Name</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Unit</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Status</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Created</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{headgates.map((hg) => (
<tr key={hg.id} className="border-b border-slate-50 last:border-0 hover:bg-zinc-800">
<td className="px-4 py-3 font-medium text-zinc-100">{hg.name}</td>
<td className="px-4 py-3 text-zinc-300">{hg.unit}</td>
<td className="px-4 py-3">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${hg.active ? "bg-green-900/40 text-green-400" : "bg-zinc-950 text-zinc-500"}`}>
{hg.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 text-slate-400 text-xs">{formatDateTime(hg.created_at)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => router.push(`/admin/water-log/headgates/${hg.id}`)}
className="text-xs text-zinc-500 hover:text-zinc-100"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
{/* ── Water Users ── */}
<section>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-lg font-bold text-zinc-100">Water Users</h2>
<p className="text-xs text-zinc-500 mt-0.5">
<span className="font-medium text-blue-400">Admin</span> = manage headgates, users, entries &nbsp;|&nbsp;
<span className="font-medium text-green-600">Irrigator</span> = submit entries only
</p>
</div>
{!showAddUser && (
<button
onClick={() => setShowAddUser(true)}
className="rounded-lg bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800"
>
+ Add User
</button>
)}
</div>
{showAddUser && (
<form onSubmit={handleAddUser} className="mb-4 rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-4 space-y-3">
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="block text-xs font-medium text-zinc-400 mb-1">Name</label>
<input
type="text"
value={newUserName}
onChange={(e) => setNewUserName(e.target.value)}
placeholder="Full name"
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
autoFocus
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
<select
value={newUserRole}
onChange={(e) => setNewUserRole(e.target.value as "irrigator" | "water_admin")}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="irrigator">Irrigator</option>
<option value="water_admin">Admin</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Language</label>
<select
value={newUserLang}
onChange={(e) => setNewUserLang(e.target.value)}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={savingUser || !newUserName.trim()} className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{savingUser ? "..." : "Add User"}
</button>
<button type="button" onClick={() => { setShowAddUser(false); setNewUserName(""); setNewUserRole("irrigator"); setNewUserLang("en"); }}
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm font-medium text-zinc-300">
Cancel
</button>
</div>
</form>
)}
{/* PIN display banner */}
{newPin && (
<div className="mb-4 rounded-xl bg-amber-900/30 border border-yellow-200 p-4">
<p className="font-semibold text-yellow-800">New PIN for {newPin.name}:</p>
<p className="mt-1 text-2xl font-bold tracking-widest text-yellow-900">{newPin.pin}</p>
<p className="mt-1 text-xs text-yellow-600">Write this down it will not be shown again.</p>
<button onClick={() => setNewPin(null)} className="mt-2 text-xs text-yellow-700 underline">Dismiss</button>
</div>
)}
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
{users.length === 0 ? (
<p className="p-6 text-center text-sm text-zinc-500">No water users yet</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50 text-left">
<th className="px-4 py-3 font-semibold text-zinc-400">Name</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Role</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Status</th>
<th className="px-4 py-3 font-semibold text-zinc-400 hidden md:table-cell">Language</th>
<th className="px-4 py-3 font-semibold text-zinc-400 hidden lg:table-cell">Last Used</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-slate-50 last:border-0 hover:bg-zinc-800">
<td className="px-4 py-3 font-medium text-zinc-100">{user.name}</td>
<td className="px-4 py-3">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${
user.role === "water_admin" ? "bg-blue-900/40 text-blue-700" : "bg-green-900/40 text-green-400"
}`}>
{user.role === "water_admin" ? "Admin" : "Irrigator"}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${user.active ? "bg-green-900/40 text-green-400" : "bg-zinc-950 text-zinc-500"}`}>
{user.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 hidden md:table-cell text-zinc-500 text-xs">
{user.language_preference === "es" ? "Español" : "English"}
</td>
<td className="px-4 py-3 hidden lg:table-cell text-slate-400 text-xs">{formatDateTime(user.last_used_at)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => router.push(`/admin/water-log/users/${user.id}`)}
className="text-xs text-zinc-500 hover:text-zinc-100"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
{/* ── Entries + CSV Export ── */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-bold text-zinc-100">Recent Entries</h2>
<button
onClick={handleExportCSV}
disabled={csvLoading}
className="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{csvLoading ? "..." : "Export CSV"}
</button>
</div>
{/* Filters */}
<div className="mb-3 flex flex-wrap gap-2">
<input
type="date"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
placeholder="From"
/>
<input
type="date"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
placeholder="To"
/>
<select
value={filterHeadgate}
onChange={(e) => setFilterHeadgate(e.target.value)}
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
>
<option value="">All headgates</option>
{headgates.map((hg) => (
<option key={hg.id} value={hg.id}>{hg.name}</option>
))}
</select>
<select
value={filterUser}
onChange={(e) => setFilterUser(e.target.value)}
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
>
<option value="">All users</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
<select
value={filterVia}
onChange={(e) => setFilterVia(e.target.value)}
className="rounded-lg border border-zinc-600 px-2 py-1.5 text-sm"
>
<option value="">All via</option>
<option value="field">Field</option>
<option value="admin">Admin</option>
</select>
{(filterDateFrom || filterDateTo || filterHeadgate || filterUser || filterVia) && (
<button
onClick={() => { setFilterDateFrom(""); setFilterDateTo(""); setFilterHeadgate(""); setFilterUser(""); setFilterVia(""); }}
className="text-xs text-zinc-500 hover:text-zinc-300 underline"
>
Clear filters
</button>
)}
</div>
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
{filteredEntries.length === 0 ? (
<p className="p-6 text-center text-sm text-zinc-500">No entries{entries.length > 0 ? " match filters" : ""}</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50 text-left">
<th className="px-4 py-3 font-semibold text-zinc-400">When</th>
<th className="px-4 py-3 font-semibold text-zinc-400">User</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Headgate</th>
<th className="px-4 py-3 font-semibold text-zinc-400 text-right">Measurement</th>
<th className="px-4 py-3 font-semibold text-zinc-400">Via</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{filteredEntries.map((e) => (
<tr key={e.id} className="border-b border-slate-50 last:border-0 hover:bg-zinc-800">
<td className="px-4 py-3 text-zinc-500 text-xs">{formatDateTime(e.logged_at)}</td>
<td className="px-4 py-3 font-medium text-zinc-100">{e.user_name}</td>
<td className="px-4 py-3 text-zinc-300">{e.headgate_name}</td>
<td className="px-4 py-3 text-right font-semibold text-zinc-100">{e.measurement} {e.unit}</td>
<td className="px-4 py-3">
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-semibold ${e.submitted_via === "field" ? "bg-blue-900/40 text-blue-700" : "bg-zinc-950 text-zinc-400"}`}>
{e.submitted_via}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => router.push(`/admin/water-log/entries/${e.id}`)}
className="text-xs text-zinc-500 hover:text-zinc-100"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
</div>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateWaterEntry, deleteWaterEntry } from "@/actions/water-log/admin";
type WaterEntry = {
id: string;
headgate_id: string;
user_id: string;
headgate_name: string;
user_name: string;
measurement: number;
unit: string;
notes: string | null;
submitted_via: string;
logged_at: string;
headgate_unit?: string;
};
const UNIT_OPTIONS = ["CFS", "GPM", "gal", "ac-in", "ac-ft"];
type Props = {
entry: WaterEntry;
brandId: string;
backHref?: string;
};
export default function WaterLogEntryEditForm({ entry, backHref = "/admin/water-log" }: Props) {
const router = useRouter();
const [measurement, setMeasurement] = useState(entry.measurement);
const [unit, setUnit] = useState(entry.unit);
const [notes, setNotes] = useState(entry.notes ?? "");
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const result = await updateWaterEntry(entry.id, measurement, notes || null, unit);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to save");
setSaving(false);
}
}
async function handleDelete() {
if (!window.confirm(`Delete this entry from ${entry.user_name} at ${entry.headgate_name}? This cannot be undone.`)) return;
setDeleting(true);
const result = await deleteWaterEntry(entry.id);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to delete");
setDeleting(false);
}
}
return (
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Headgate</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.headgate_name}</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">User</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.user_name}</p>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Measurement</label>
<input
type="number"
step="any"
value={measurement}
onChange={(e) => setMeasurement(parseFloat(e.target.value) || 0)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Unit</label>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
{UNIT_OPTIONS.map((u) => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Submitted Via</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.submitted_via}</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Logged</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">
{new Date(entry.logged_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900 resize-none"
placeholder="Optional notes..."
/>
</div>
<div className="flex items-center justify-between pt-2">
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{deleting ? "..." : "Delete Entry"}
</button>
<button
type="submit"
disabled={saving}
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{saving ? "..." : "Save Changes"}
</button>
</div>
</form>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateWaterIrrigator, resetWaterIrrigatorPin, deleteWaterUser } from "@/actions/water-log/admin";
type WaterUser = {
id: string;
name: string;
role: "irrigator" | "water_admin";
active: boolean;
language_preference: string;
last_used_at: string | null;
created_at: string;
};
type Props = {
waterUser: WaterUser;
backHref?: string;
};
export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-log" }: Props) {
const router = useRouter();
const [name, setName] = useState(waterUser.name);
const [role, setRole] = useState<"irrigator" | "water_admin">(waterUser.role);
const [active, setActive] = useState(waterUser.active);
const [lang, setLang] = useState(waterUser.language_preference);
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);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
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);
}
}
async function handleResetPin() {
setResetting(true);
const result = await resetWaterIrrigatorPin(waterUser.id);
if (result.success && result.pin) {
setNewPin(result.pin);
} else {
setError(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);
const result = await deleteWaterUser(waterUser.id);
if (result.success) {
router.push(backHref);
} else {
setError(result.error ?? "Failed to delete");
setDeleting(false);
}
}
return (
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
{error}
</div>
)}
{newPin && (
<div className="rounded-xl bg-yellow-50 border border-yellow-200 p-4">
<p className="font-semibold text-yellow-800">New PIN for {name}:</p>
<p className="mt-1 text-2xl font-bold tracking-widest text-yellow-900">{newPin}</p>
<p className="mt-1 text-xs text-yellow-600">Write this down it will not be shown again.</p>
<button onClick={() => setNewPin(null)} className="mt-2 text-xs text-yellow-700 underline">Dismiss</button>
</div>
)}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as "irrigator" | "water_admin")}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="irrigator">Irrigator</option>
<option value="water_admin">Admin</option>
</select>
<p className="text-xs text-slate-400 mt-0.5">
{role === "water_admin"
? "Can manage headgates, users, and entries"
: "Can only submit water log entries"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Language</label>
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
<div className="flex items-center gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Status</label>
<select
value={active ? "1" : "0"}
onChange={(e) => setActive(e.target.value === "1")}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div className="pt-4">
<button
type="button"
onClick={handleResetPin}
disabled={resetting}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-900 disabled:opacity-50"
>
{resetting ? "..." : "Reset PIN"}
</button>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
>
{deleting ? "..." : "Delete User"}
</button>
<button
type="submit"
disabled={saving}
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{saving ? "..." : "Save Changes"}
</button>
</div>
</form>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useState, useEffect } from "react";
import { getRecentWebhookActivity } from "@/actions/wholesale";
type WebhookLogEntry = {
id: string;
event_type: string;
order_id: string | null;
status: string;
attempts: number;
created_at: string;
response: string | null;
};
type Props = {
brandId: string;
};
const EVENT_LABELS: Record<string, string> = {
order_created: "Order Created",
order_fulfilled: "Order Fulfilled",
deposit_recorded: "Deposit Recorded",
order_paid: "Order Paid",
};
const STATUS_STYLES: Record<string, string> = {
sent: "bg-green-900/40 text-green-400",
success: "bg-green-900/40 text-green-400",
failed: "bg-red-900/40 text-red-400",
pending: "bg-yellow-100 text-yellow-700",
retrying: "bg-yellow-100 text-yellow-700",
};
export default function WebhookLogsSection({ brandId }: Props) {
const [logs, setLogs] = useState<WebhookLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
getRecentWebhookActivity(brandId, 25).then((entries) => {
setLogs(entries);
setLoading(false);
});
}, [brandId]);
if (loading) {
return (
<div className="rounded-xl border border-zinc-800 bg-slate-50 p-4 text-sm text-zinc-500">
Loading webhook logs
</div>
);
}
if (logs.length === 0) {
return (
<div className="rounded-xl border border-zinc-800 bg-slate-50 p-4 text-sm text-zinc-500">
No webhook events yet. Deposits, order updates, and fulfillments will appear here.
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-zinc-300">
Wholesale Webhook Events ({logs.length})
</p>
<button
onClick={() => setExpanded((v) => !v)}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
{expanded ? "Collapse" : "Expand"}
</button>
</div>
<div className="space-y-1.5">
{(expanded ? logs : logs.slice(0, 5)).map((entry) => (
<div
key={entry.id}
className="flex items-start justify-between gap-3 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-800">
{EVENT_LABELS[entry.event_type] ?? entry.event_type}
</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
STATUS_STYLES[entry.status] ?? "bg-zinc-950 text-zinc-400"
}`}
>
{entry.status}
</span>
{entry.attempts > 1 && (
<span className="text-xs text-slate-400">
{entry.attempts} attempts
</span>
)}
</div>
{entry.response && (
<p className="mt-1 truncate text-xs text-zinc-500">{entry.response}</p>
)}
<p className="mt-0.5 text-xs text-slate-400">
{new Date(entry.created_at).toLocaleString()}
</p>
</div>
</div>
))}
</div>
{!expanded && logs.length > 5 && (
<button
onClick={() => setExpanded(true)}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
Show {logs.length - 5} more
</button>
)}
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { getWelcomeSequence, resendWelcomeEmail, type WelcomeSequenceEntry } from "@/actions/email-automation/welcome-sequence";
type Props = { brandId: string };
const STATUS_LABELS: Record<string, { en: string; color: string }> = {
active: { en: "Active", color: "bg-blue-900/40 text-blue-400 border border-blue-800" },
completed: { en: "Completed", color: "bg-emerald-900/40 text-emerald-400 border border-emerald-800" },
unsubscribed: { en: "Unsubscribed", color: "bg-red-900/40 text-red-400 border border-red-800" },
bounced: { en: "Bounced", color: "bg-amber-900/40 text-amber-400 border border-amber-800" },
};
const STEP_LABELS: Record<number, string> = {
0: "Enrolled",
1: "Welcome (1h)",
2: "How it works (24h)",
3: "First order (48h)",
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 [resending, setResending] = useState<string | null>(null);
const handleResend = async (entryId: string) => {
setResending(entryId);
await resendWelcomeEmail(entryId, brandId);
await load();
setResending(null);
};
const load = useCallback(async () => {
setLoading(true);
const result = await getWelcomeSequence(brandId);
if (result.success) {
setEntries(result.entries);
setStats(result.stats);
}
setLoading(false);
}, [brandId]);
useEffect(() => { setPage(0); }, [filter]);
const filtered = entries.filter(e => {
if (filter === "active") return e.status === "active";
if (filter === "completed") return e.status === "completed";
return true;
});
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-zinc-100" },
{ label: "Active", value: stats.active, color: "text-blue-400" },
{ label: "Completed", value: stats.completed, color: "text-emerald-400" },
{ label: "Unsubscribed", value: stats.unsubscribed, color: "text-red-400" },
].map(s => (
<div key={s.label} className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 text-center">
<p className={`text-2xl font-black ${s.color}`}>{s.value}</p>
<p className="text-xs text-zinc-500 uppercase tracking-widest mt-1">{s.label}</p>
</div>
))}
</div>
{/* Filter + refresh + pagination */}
<div className="flex items-center gap-3">
<button onClick={load} disabled={loading}
className="text-xs font-semibold text-violet-400 hover:text-violet-300 transition-colors">
{loading ? "Loading..." : "↻ Refresh"}
</button>
<div className="flex gap-2 ml-auto">
{(["all", "active", "completed"] as const).map(f => (
<button key={f} onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${filter === f ? "bg-violet-600 text-white" : "bg-zinc-800 text-zinc-400 hover:text-zinc-200"}`}>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-2">
<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-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<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-zinc-500 px-1">{page + 1}/{totalPages}</span>
<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-zinc-700 text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<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>
{/* Table */}
{filtered.length === 0 ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl py-16 text-center">
<p className="text-zinc-600 text-sm">No entries{filter !== "all" ? ` (${filter})` : ""}</p>
</div>
) : (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-zinc-500 uppercase tracking-widest border-b border-zinc-800">
<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-zinc-800 text-zinc-400" };
return (
<tr key={e.id} className="border-t border-zinc-800/60 hover:bg-zinc-800/30 transition-colors">
<td className="px-5 py-3.5">
<div>
<p className="text-zinc-200 font-medium text-sm">{e.contact_name ?? "—"}</p>
<p className="text-zinc-500 text-xs">{e.contact_email}</p>
</div>
</td>
<td className="px-5 py-3.5">
<span className="text-xs font-mono text-zinc-400 uppercase">{e.locale}</span>
</td>
<td className="px-5 py-3.5 text-xs text-zinc-400">
{STEP_LABELS[e.sequence_step] ?? e.sequence_step}
{e.next_email_at && e.status === "active" && (
<span className="block text-zinc-600 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-zinc-500 text-xs">{new Date(e.created_at).toLocaleDateString()}</td>
<td className="px-5 py-3.5 text-zinc-500 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 onClick={() => handleResend(e.id)} disabled={resending === e.id}
className="text-xs text-violet-400 hover:text-violet-300 px-2 py-1 rounded-lg hover:bg-violet-900/20 transition-all">
{resending === e.id ? "..." : "Resend"}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
type Column<T> = {
key: keyof T | string;
header: string;
render?: (item: T) => React.ReactNode;
className?: string;
align?: "left" | "right" | "center";
};
type DataTableProps<T> = {
data: T[];
columns: Column<T>[];
keyExtractor: (item: T) => string;
emptyMessage?: string;
onRowClick?: (item: T) => void;
rowClassName?: string;
paginate?: boolean;
pageSize?: number;
};
export default function DataTable<T>({
data,
columns,
keyExtractor,
emptyMessage = "No data found",
onRowClick,
rowClassName,
paginate = false,
pageSize = 50,
}: DataTableProps<T>) {
const [page, setPage] = useState(0);
const displayed = paginate ? data.slice(page * pageSize, (page + 1) * pageSize) : data;
const totalPages = Math.ceil(data.length / pageSize);
const alignClass = (align?: "left" | "right" | "center") => {
switch (align) {
case "right": return "text-right";
case "center": return "text-center";
default: return "text-left";
}
};
return (
<div className="overflow-x-auto rounded-lg bg-white border border-stone-200 shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-200 bg-stone-50">
{columns.map((col) => (
<th
key={String(col.key)}
className={`px-4 py-3 font-semibold text-stone-500 ${alignClass(col.align)} ${col.className ?? ""}`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{displayed.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-16 text-center text-sm text-stone-500">
{emptyMessage}
</td>
</tr>
) : (
displayed.map((item) => (
<tr
key={keyExtractor(item)}
className={`border-b border-stone-100 last:border-0 hover:bg-stone-50 transition-colors ${
onRowClick ? "cursor-pointer" : ""
} ${rowClassName ?? ""}`}
onClick={() => onRowClick?.(item)}
>
{columns.map((col) => (
<td
key={String(col.key)}
className={`px-4 py-3 ${alignClass(col.align)} ${col.className ?? ""}`}
>
{col.render
? col.render(item)
: String((item as Record<string, unknown>)[col.key as string] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
{paginate && totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-stone-200">
<span className="text-xs text-stone-500">
Page {page + 1} of {totalPages}
</span>
<div className="flex items-center gap-2">
<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-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<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>
<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-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<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>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
type FilterOption = {
value: string;
label: string;
};
type FilterBarProps = {
searchPlaceholder?: string;
searchValue: string;
onSearchChange: (value: string) => void;
filters?: {
label?: string;
options: FilterOption[];
value: string;
onChange: (value: string) => void;
}[];
tabs?: {
label: string;
value: string;
count?: number;
}[];
activeTab?: string;
onTabChange?: (value: string) => void;
actions?: React.ReactNode;
resultCount?: number;
showCount?: boolean;
};
export default function FilterBar({
searchPlaceholder = "Search...",
searchValue,
onSearchChange,
filters = [],
tabs = [],
activeTab,
onTabChange,
actions,
resultCount,
showCount = true,
}: FilterBarProps) {
return (
<div className="space-y-3">
{/* Search + Filters Row */}
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-48">
<input
type="search"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
/>
</div>
{filters.map((filter, i) => (
<div key={i} className="space-y-1">
{filter.label && (
<label className="text-xs text-stone-500 font-medium pl-1">{filter.label}</label>
)}
<select
value={filter.value}
onChange={(e) => filter.onChange(e.target.value)}
className="rounded-lg border border-stone-200 bg-white px-3 py-2 text-sm text-stone-700 outline-none focus:border-emerald-500 transition-colors min-w-[140px]"
>
{filter.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
{actions && <div className="flex items-center gap-2">{actions}</div>}
{showCount && resultCount !== undefined && (
<span className="text-sm text-stone-500 px-2 py-2">{resultCount} results</span>
)}
</div>
{/* Tabs Row */}
{tabs.length > 0 && (
<div className="flex gap-1 border-b border-stone-200 pb-0">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => onTabChange?.(tab.value)}
className={`
px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px
${activeTab === tab.value
? "text-emerald-600 border-emerald-600"
: "text-stone-500 border-transparent hover:text-stone-700 hover:border-stone-300"
}
`}
>
{tab.label}
{tab.count !== undefined && (
<span className={`ml-2 text-xs ${activeTab === tab.value ? "text-emerald-500" : "text-stone-400"}`}>
{tab.count}
</span>
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import Link from "next/link";
type PageHeaderProps = {
breadcrumb?: { label: string; href?: string }[];
title: string;
description?: string;
action?: React.ReactNode;
};
export default function PageHeader({ breadcrumb, title, description, action }: PageHeaderProps) {
return (
<div className="mb-8">
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
{breadcrumb.map((crumb, i) => (
<span key={i} className="flex items-center gap-2">
{crumb.href ? (
<Link href={crumb.href} className="hover:text-stone-700 transition-colors">
{crumb.label}
</Link>
) : (
<span className="text-stone-600">{crumb.label}</span>
)}
{i < breadcrumb.length - 1 && <span>/</span>}
</span>
))}
</nav>
)}
<div className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">{title}</h1>
{description && (
<p className="mt-1.5 text-sm text-stone-500">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
</div>
);
}
@@ -0,0 +1,51 @@
type StatusBadgeProps = {
status: string;
size?: "sm" | "md";
};
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
// Active/Enabled states
active: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Active" },
enabled: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Enabled" },
published: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Published" },
completed: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Completed" },
picked_up: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Picked Up" },
delivered: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Delivered" },
// Pending/Warning states
pending: { bg: "bg-amber-100", text: "text-amber-700", label: "Pending" },
draft: { bg: "bg-amber-100", text: "text-amber-700", label: "Draft" },
processing: { bg: "bg-amber-100", text: "text-amber-700", label: "Processing" },
open: { bg: "bg-amber-100", text: "text-amber-700", label: "Open" },
in_transit: { bg: "bg-amber-100", text: "text-amber-700", label: "In Transit" },
// Inactive/Disabled states
inactive: { bg: "bg-stone-100", text: "text-stone-600", label: "Inactive" },
disabled: { bg: "bg-stone-100", text: "text-stone-600", label: "Disabled" },
archived: { bg: "bg-stone-100", text: "text-stone-600", label: "Archived" },
// Info states
at_shed: { bg: "bg-blue-100", text: "text-blue-700", label: "At Shed" },
packed: { bg: "bg-purple-100", text: "text-purple-700", label: "Packed" },
square: { bg: "bg-purple-100", text: "text-purple-700", label: "Square" },
// Special states
core: { bg: "bg-emerald-50", text: "text-emerald-600", label: "Core" },
addon: { bg: "bg-amber-50", text: "text-amber-600", label: "Add-on" },
};
export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
const config = STATUS_STYLES[status] ?? {
bg: "bg-stone-100",
text: "text-stone-600",
label: status.replace(/_/g, " "),
};
const padding = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-3 py-1 text-xs";
return (
<span className={`inline-flex items-center rounded-full border font-medium ${config.bg} ${config.text} border-transparent ${padding}`}>
{config.label}
</span>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { default as PageHeader } from "./PageHeader";
export { default as StatusBadge } from "./StatusBadge";
export { default as FilterBar } from "./FilterBar";
export { default as DataTable } from "./DataTable";