Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning

This commit is contained in:
2026-06-02 02:21:11 +00:00
parent 809e0061ca
commit 15e939ad7e
116 changed files with 14991 additions and 5326 deletions
+194 -112
View File
@@ -11,12 +11,83 @@ const PROVIDER_MODELS: Record<Exclude<AIProvider, "custom">, string[]> = {
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: "" },
// Icons
const SparkleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"/>
</svg>
);
const RobotIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="10" rx="2"/>
<circle cx="12" cy="5" r="2"/>
<path d="M12 7v4"/>
<line x1="8" y1="16" x2="8" y2="16"/>
<line x1="16" y1="16" x2="16" y2="16"/>
</svg>
);
const BrainIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.5 2A2.5 2.5 0 0112 4.5v15a2.5 2.5 0 01-2.5 2.5M4.5 8a2.5 2.5 0 015 0v8a2.5 2.5 0 01-5 0M14.5 8a2.5 2.5 0 015 0v8a2.5 2.5 0 01-5 0M9.5 16a2.5 2.5 0 015 0"/>
<path d="M12 4.5v15"/>
</svg>
);
const CircleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="4"/>
</svg>
);
const TornadoIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 4H3M21 4v16M3 8l3 3 3-3 3 3 3-3 3 3M3 16l3 3 3-3 3 3 3-3 3 3"/>
</svg>
);
const WrenchIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>
</svg>
);
const EyeIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
);
const EyeOffIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
);
const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const AlertIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
const PROVIDERS: { id: AIProvider; name: string; icon: React.ReactNode; description: string; website: string; color: string }[] = [
{ id: "openai", name: "OpenAI", icon: <RobotIcon className="h-5 w-5" />, description: "GPT-4o, GPT-4o-mini, GPT-4 Turbo. Industry standard.", website: "openai.com", color: "bg-emerald-500" },
{ id: "anthropic", name: "Anthropic", icon: <BrainIcon className="h-5 w-5" />, description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for reasoning.", website: "anthropic.com", color: "bg-amber-500" },
{ id: "google", name: "Google", icon: <CircleIcon className="h-5 w-5" />, description: "Gemini 2.0 Flash, 1.5 Pro. Fast, cost-effective.", website: "ai.google", color: "bg-blue-500" },
{ id: "xai", name: "xAI", icon: <TornadoIcon className="h-5 w-5" />, description: "Grok-2, Grok-1.5. Real-time data, humor.", website: "x.ai", color: "bg-orange-500" },
{ id: "custom", name: "Custom", icon: <WrenchIcon className="h-5 w-5" />, description: "Any OpenAI-compatible API endpoint.", website: "", color: "bg-stone-500" },
];
type Props = {
@@ -92,37 +163,33 @@ export default function AIProviderPanel({ brandId }: Props) {
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="rounded-xl border border-[var(--admin-border)] bg-white p-6 animate-pulse">
<div className="h-6 bg-stone-200 rounded w-1/3 mb-4" />
<div className="h-4 bg-stone-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" />)}
{[1,2,3,4,5].map(i => <div key={i} className="h-16 bg-stone-100 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 className="space-y-5">
{/* Provider Selection Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500">
<SparkleIcon className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">AI Provider</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Choose your AI model provider for all AI tools</p>
</div>
</div>
</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">
<div className="p-4 sm:p-6">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 sm:gap-3">
{PROVIDERS.map((p) => (
<button
key={p.id}
@@ -132,26 +199,33 @@ export default function AIProviderPanel({ brandId }: Props) {
}}
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"
? "border-violet-500 bg-violet-50"
: "border-[var(--admin-border)] hover:border-violet-200 hover:bg-stone-50"
}`}
>
<span className="text-2xl block mb-1">{p.icon}</span>
<p className="text-xs font-semibold text-slate-800">{p.name}</p>
<div className={`flex h-10 w-10 mx-auto items-center justify-center rounded-xl mb-2 ${p.color}`}>
<span className="text-white">{p.icon}</span>
</div>
<p className="text-xs font-semibold text-[var(--admin-text-primary)]">{p.name}</p>
{p.website && (
<p className="text-[10px] text-slate-400 mt-0.5">{p.website}</p>
<p className="text-[10px] text-[var(--admin-text-muted)] mt-0.5">{p.website}</p>
)}
</button>
))}
</div>
</div>
</div>
{/* Credentials — only for non-custom */}
{provider !== "custom" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Credentials Card */}
{provider !== "custom" && (
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Credentials</h3>
</div>
<div className="p-4 sm:p-6 space-y-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 className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
API Key
</label>
<div className="relative">
<input
@@ -159,77 +233,83 @@ export default function AIProviderPanel({ brandId }: Props) {
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"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-zinc-400"
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
>
{showKey ? "Hide" : "Show"}
{showKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
{provider === "openai" && (
<div>
<label className="block text-xs font-semibold text-zinc-400 mb-1">Organization ID (optional)</label>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
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"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
)}
</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">
{/* Custom Endpoint Card */}
{provider === "custom" && (
<div className="rounded-xl border border-violet-200 bg-violet-50 overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-violet-200">
<div className="flex items-center gap-3">
<WrenchIcon className="h-5 w-5 text-violet-600" />
<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"
/>
<p className="text-sm font-semibold text-violet-800">Custom API Endpoint</p>
<p className="text-xs text-violet-600 mt-0.5">Connect any OpenAI-compatible API</p>
</div>
</div>
</div>
)}
<div className="p-4 sm:p-6 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">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-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
/>
<button type="button" onClick={() => setShowKey(!showKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600">
{showKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">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-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
/>
</div>
</div>
</div>
)}
{/* Model selector */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-zinc-500 mb-2">
Model
</label>
{/* Model Selection Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Model</h3>
</div>
<div className="p-4 sm:p-6">
<div className="flex flex-wrap gap-2">
{models.map((m) => (
<button
@@ -238,7 +318,7 @@ export default function AIProviderPanel({ brandId }: Props) {
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"
: "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"
}`}
>
{m}
@@ -246,34 +326,36 @@ export default function AIProviderPanel({ brandId }: Props) {
))}
</div>
</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>
{/* Test Result */}
{testResult && (
<div className={`rounded-xl px-4 py-3 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"
}`}>
{testResult.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
{testResult.message}
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleTest}
disabled={saving}
className="rounded-lg border border-[var(--admin-border)] px-5 py-2.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-lg bg-emerald-600 px-5 py-2.5 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Provider Settings"}
</button>
</div>
</div>
);
+219 -255
View File
@@ -2,7 +2,7 @@
import { useState, useCallback, useEffect } from "react";
import { createStop } from "@/actions/stops/create-stop";
import { formatDate } from "@/lib/format-date";
import GlassModal from "@/components/admin/GlassModal";
type Props = {
isOpen: boolean;
@@ -25,18 +25,16 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Form state
const [city, setCity] = useState(duplicateFrom?.city ?? "");
const [state, setState] = useState(duplicateFrom?.state ?? "");
const [location, setLocation] = useState(duplicateFrom?.location ?? "");
const [date, setDate] = useState(duplicateFrom?.date ?? "");
const [time, setTime] = useState(duplicateFrom?.time ?? "");
const [address, setAddress] = useState(duplicateFrom?.address ?? "");
const [zip, setZip] = useState(duplicateFrom?.zip ?? "");
const [cutoffTime, setCutoffTime] = useState(duplicateFrom?.cutoff_time ?? "");
const [city, setCity] = useState("");
const [state, setState] = useState("");
const [location, setLocation] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("");
const [address, setAddress] = useState("");
const [zip, setZip] = useState("");
const [cutoffTime, setCutoffTime] = useState("");
const [status, setStatus] = useState<"draft" | "active">("draft");
// Reset form when modal opens/closes or duplicate changes
useEffect(() => {
if (isOpen) {
setCity(duplicateFrom?.city ?? "");
@@ -52,27 +50,6 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
}
}, [isOpen, duplicateFrom]);
// Lock body scroll
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => { document.body.style.overflow = ""; };
}, [isOpen]);
// Escape key handler
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (isOpen) {
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}
}, [isOpen, onClose]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -109,248 +86,235 @@ export default function AddStopModal({ isOpen, onClose, brandId, duplicateFrom,
}
}, [brandId, city, state, location, date, time, address, zip, cutoffTime, status, onSuccess, onClose]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
const isDuplicate = Boolean(duplicateFrom);
const title = isDuplicate ? "Duplicate Stop" : "Add New Stop";
const subtitle = isDuplicate && duplicateFrom
? `From ${duplicateFrom.city}, ${duplicateFrom.state}`
: "Create a new tour stop for your route.";
const inputStyle = {
background: 'rgba(0, 0, 0, 0.02)',
border: '1px solid rgba(0, 0, 0, 0.06)',
outline: 'none',
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
e.target.style.background = 'rgba(16, 185, 129, 0.04)';
e.target.style.border = '1px solid rgba(16, 185, 129, 0.5)';
e.target.style.boxShadow = '0 0 0 4px rgba(16, 185, 129, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04)';
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
e.target.style.background = 'rgba(0, 0, 0, 0.02)';
e.target.style.border = '1px solid rgba(0, 0, 0, 0.06)';
e.target.style.boxShadow = 'none';
};
if (!isOpen) return null;
const isDuplicate = Boolean(duplicateFrom);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={handleBackdropClick}
>
<div className="relative w-full max-w-lg max-h-[90vh] overflow-hidden rounded-2xl bg-white shadow-2xl border border-stone-200">
{/* Header */}
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
<div>
<h2 className="text-xl font-semibold text-stone-950">
{isDuplicate ? "Duplicate Stop" : "Add New Stop"}
</h2>
<p className="mt-0.5 text-sm text-stone-500">
{isDuplicate && duplicateFrom
? `Pre-filled from ${duplicateFrom.city}, ${duplicateFrom.state}`
: "Create a new tour stop for your route."}
</p>
<GlassModal title={title} subtitle={subtitle} onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-xl px-4 py-3 text-sm text-red-600 backdrop-blur-sm"
style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
{error}
</div>
)}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Denver"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">State</label>
<input
type="text"
value={state}
onChange={(e) => setState(e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
<button
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-xl text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="overflow-y-auto max-h-[calc(90vh-140px)]">
<div className="p-6 space-y-5">
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Location</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Whole Foods Market"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
{/* Location row */}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-stone-700 mb-1.5">
City <span className="text-red-500">*</span>
</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Denver"
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
State <span className="text-red-500">*</span>
</label>
<input
type="text"
value={state}
onChange={(e) => setState(e.target.value.toUpperCase())}
placeholder="CO"
maxLength={2}
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
required
/>
</div>
</div>
{/* Location name */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
Location Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Whole Foods Market"
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
required
/>
</div>
{/* Date & Time row */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
Date <span className="text-red-500">*</span>
</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
Pickup Time
</label>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
/>
</div>
</div>
{/* Address & ZIP */}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-stone-700 mb-1.5">
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-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
ZIP Code
</label>
<input
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80202"
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
/>
</div>
</div>
{/* Cutoff time */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">
Order Cutoff Time
</label>
<input
type="time"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
/>
<p className="mt-1 text-xs text-stone-400">
Customers can only order until this time on the day before pickup.
</p>
</div>
{/* Status toggle */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-2">
Status
</label>
<div className="flex gap-3">
<label className={`flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 cursor-pointer transition-all ${
status === "draft"
? "border-amber-300 bg-amber-50 text-amber-800"
: "border-stone-200 bg-white text-stone-500 hover:border-stone-300"
}`}>
<input
type="radio"
name="status"
value="draft"
checked={status === "draft"}
onChange={() => setStatus("draft")}
className="sr-only"
/>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
status === "draft" ? "bg-amber-200 text-amber-900" : "bg-stone-100 text-stone-500"
}`}>
Draft
</span>
<span className="text-sm font-medium">Save as draft</span>
</label>
<label className={`flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 cursor-pointer transition-all ${
status === "active"
? "border-emerald-300 bg-emerald-50 text-emerald-800"
: "border-stone-200 bg-white text-stone-500 hover:border-stone-300"
}`}>
<input
type="radio"
name="status"
value="active"
checked={status === "active"}
onChange={() => setStatus("active")}
className="sr-only"
/>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
status === "active" ? "bg-emerald-200 text-emerald-900" : "bg-stone-100 text-stone-500"
}`}>
Active
</span>
<span className="text-sm font-medium">Publish now</span>
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
required
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Pickup Time</label>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-stone-100 px-6 py-4 bg-stone-50">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Address</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">ZIP</label>
<input
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80202"
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 placeholder:text-stone-400 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Cutoff Time</label>
<input
type="time"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
className="w-full rounded-xl px-4 py-3 text-sm text-stone-900 transition-all"
style={inputStyle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<p className="mt-1 text-xs text-stone-400 ml-1">Orders close this time the day before pickup</p>
</div>
{/* Status toggle */}
<div className="space-y-2">
<label className="block text-xs font-medium text-stone-500 uppercase tracking-wide ml-1">Status</label>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-100 transition-colors"
onClick={() => setStatus("draft")}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
style={{
border: status === "draft" ? '1px solid rgba(245, 158, 11, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
background: status === "draft" ? 'rgba(245, 158, 11, 0.08)' : 'rgba(0, 0, 0, 0.02)',
}}
>
Cancel
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
background: status === "draft" ? 'rgba(245, 158, 11, 0.2)' : 'rgba(0, 0, 0, 0.05)',
color: status === "draft" ? '#b45309' : 'rgba(0, 0, 0, 0.4)',
}}>Draft</span>
<span className="text-sm font-medium" style={{ color: status === "draft" ? '#92400e' : 'rgba(0, 0, 0, 0.4)' }}>Save as draft</span>
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-6 py-2.5 text-sm font-semibold text-white disabled:opacity-50 transition-colors shadow-sm shadow-emerald-200"
type="button"
onClick={() => setStatus("active")}
className="flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 transition-all"
style={{
border: status === "active" ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid rgba(0, 0, 0, 0.08)',
background: status === "active" ? 'rgba(16, 185, 129, 0.08)' : 'rgba(0, 0, 0, 0.02)',
}}
>
{loading ? (
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating...
</span>
) : (
isDuplicate ? "Duplicate Stop" : "Create Stop"
)}
<span className="rounded-full px-2 py-0.5 text-xs font-semibold" style={{
background: status === "active" ? 'rgba(16, 185, 129, 0.2)' : 'rgba(0, 0, 0, 0.05)',
color: status === "active" ? '#047857' : 'rgba(0, 0, 0, 0.4)',
}}>Active</span>
<span className="text-sm font-medium" style={{ color: status === "active" ? '#065f46' : 'rgba(0, 0, 0, 0.4)' }}>Publish now</span>
</button>
</div>
</form>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
<button
type="button"
onClick={onClose}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 transition-all hover:bg-stone-100"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: loading
? 'rgba(16, 185, 129, 0.4)'
: 'linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)',
boxShadow: loading
? 'none'
: '0 4px 12px rgba(16, 185, 129, 0.25), inset 0 1px 0 rgba(255,255,255,0.2)',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? (
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Creating...
</span>
) : (
isDuplicate ? "Duplicate Stop" : "Create Stop"
)}
</button>
</div>
</form>
</GlassModal>
);
}
+297 -387
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useState, useMemo } from "react";
import Link from "next/link";
import { markPickupComplete } from "@/actions/pickup";
import { formatDate } from "@/lib/format-date";
@@ -56,13 +57,58 @@ function shortId(id: string) {
}
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}
export { formatDate };
// Icons
const Icons = {
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
mapPin: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path 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 1 1 15 0z"/>
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
chevronDown: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
),
chevronLeft: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
),
chevronRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
),
package: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.29 7 12 12 20.71 7"/>
<line x1="12" y1="22" x2="12" y2="12"/>
</svg>
),
};
export default function AdminOrdersPanel({
initialOrders,
@@ -80,67 +126,38 @@ export default function AdminOrdersPanel({
const [pickupToast, setPickupToast] = useState<string | null>(null);
const PAGE_SIZE = 20;
// Filter orders based on all criteria
const filteredOrders = useMemo(() => {
return orders.filter((order) => {
// Status filter
if (activeTab === "pending" && order.pickup_complete) return false;
if (activeTab === "picked_up" && !order.pickup_complete) return false;
// Stop filter
if (selectedStops.length > 0 && order.stop_id && !selectedStops.includes(order.stop_id)) {
return false;
}
// Search filter
if (selectedStops.length > 0 && order.stop_id && !selectedStops.includes(order.stop_id)) return false;
if (search.trim()) {
const searchLower = search.toLowerCase();
const matchesName = order.customer_name.toLowerCase().includes(searchLower);
const matchesPhone = (order.customer_phone ?? "").toLowerCase().includes(searchLower);
const matchesId = order.id.toLowerCase().includes(searchLower);
if (!matchesName && !matchesPhone && !matchesId) return false;
const s = search.toLowerCase();
if (!order.customer_name.toLowerCase().includes(s) &&
!(order.customer_phone ?? "").toLowerCase().includes(s) &&
!order.id.toLowerCase().includes(s)) return false;
}
return true;
});
}, [orders, activeTab, selectedStops, search]);
// Pagination
const totalFiltered = filteredOrders.length;
const totalPages = Math.ceil(totalFiltered / PAGE_SIZE);
const totalPages = Math.ceil(filteredOrders.length / PAGE_SIZE);
const paginatedOrders = filteredOrders.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
// Stats
const pendingCount = orders.filter((o) => !o.pickup_complete).length;
const pickedUpCount = orders.filter((o) => o.pickup_complete).length;
// Toggle stop selection
const toggleStop = useCallback((stopId: string) => {
function toggleStop(stopId: string) {
setSelectedStops((prev) =>
prev.includes(stopId) ? prev.filter((id) => id !== stopId) : [...prev, stopId]
);
setPage(0);
}, []);
}
// Clear all stop filters
const clearStopFilters = useCallback(() => {
function clearStops() {
setSelectedStops([]);
setPage(0);
}, []);
}
// Handle tab change
const handleTabChange = useCallback((tab: StatusTab) => {
setActiveTab(tab);
setPage(0);
}, []);
// Handle search
const handleSearch = useCallback((value: string) => {
setSearch(value);
setPage(0);
}, []);
// Handle pickup
async function handleMarkPickup(orderId: string) {
setPickingUp(orderId);
const result = await markPickupComplete(orderId, brandId);
@@ -150,12 +167,7 @@ export default function AdminOrdersPanel({
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, pickup_complete: true, pickup_completed_at: result.pickup_completed_at, pickup_completed_by: result.pickup_completed_by }
: o
)
);
@@ -164,347 +176,245 @@ export default function AdminOrdersPanel({
}
return (
<div className="min-h-screen bg-stone-50 px-6 py-8">
<div className="mx-auto max-w-7xl space-y-6">
{/* Header */}
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-stone-950">Orders</h1>
<div className="mt-2 flex items-center gap-4">
<span className="text-sm text-stone-500">
<span className="font-medium text-stone-700">{pendingCount}</span> pending
</span>
<span className="text-sm text-stone-500">
<span className="font-medium text-stone-700">{pickedUpCount}</span> picked up
</span>
{brandId && (
<span className="rounded-full bg-violet-50 border border-violet-200 px-2.5 py-0.5 text-xs font-medium text-violet-700">
Brand scoped
</span>
)}
</div>
</div>
<div className="p-4 sm:p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
{Icons.package("h-5 w-5 text-white")}
</div>
</div>
{/* Filter Bar */}
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-4">
{/* Status Tabs */}
<div className="flex items-center gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{(["all", "pending", "picked_up"] as const).map((tab) => (
<button
key={tab}
onClick={() => handleTabChange(tab)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
activeTab === tab
? "bg-white text-emerald-700 shadow-sm border border-emerald-200"
: "text-stone-600 hover:text-stone-900 hover:bg-white/50"
}`}
>
{tab === "all" ? "All" : tab === "pending" ? "Pending" : "Picked Up"}
</button>
))}
</div>
{/* Search */}
<div className="relative flex-1 min-w-64">
<svg
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-stone-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="search"
placeholder="Search by name, phone, or order #..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white pl-10 pr-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 transition-all"
/>
</div>
{/* Stop Multi-Select */}
<div className="relative">
<button
onClick={() => setShowStopDropdown(!showStopDropdown)}
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-all ${
selectedStops.length > 0
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-stone-200 bg-white text-stone-600 hover:border-stone-300"
}`}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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>
<span>Stops</span>
{selectedStops.length > 0 && (
<span className="rounded-full bg-emerald-600 text-white px-1.5 py-0.5 text-xs font-semibold">
{selectedStops.length}
</span>
)}
<svg className="h-4 w-4" 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>
{showStopDropdown && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowStopDropdown(false)}
/>
<div className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border border-stone-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
<span className="text-sm font-semibold text-stone-700">Filter by Stop</span>
<button
onClick={clearStopFilters}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium"
>
Clear all
</button>
</div>
<div className="max-h-64 overflow-y-auto p-2">
{stops.map((stop) => (
<label
key={stop.id}
className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-stone-50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedStops.includes(stop.id)}
onChange={() => toggleStop(stop.id)}
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500"
/>
<div className="flex-1">
<span className="text-sm font-medium text-stone-700">
{stop.city}, {stop.state}
</span>
<span className="ml-2 text-xs text-stone-400">
{formatDate(stop.date)}
</span>
</div>
</label>
))}
</div>
</div>
</>
)}
</div>
{/* Results count */}
<div className="ml-auto text-sm text-stone-500">
{totalFiltered} order{totalFiltered !== 1 ? "s" : ""}
</div>
</div>
{/* Selected stop chips */}
{selectedStops.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs text-stone-500">Selected:</span>
{selectedStops.map((stopId) => {
const stop = stops.find((s) => s.id === stopId);
if (!stop) return null;
return (
<span
key={stopId}
className="inline-flex items-center gap-1 rounded-full bg-emerald-100 border border-emerald-200 px-3 py-1 text-xs font-medium text-emerald-700"
>
{stop.city}, {stop.state}
<button
onClick={() => toggleStop(stopId)}
className="ml-1 hover:text-emerald-900"
>
<svg className="h-3 w-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>
{/* Orders Table */}
{paginatedOrders.length === 0 ? (
<div className="rounded-2xl border border-stone-200 bg-white py-16 text-center shadow-sm">
<svg
className="mx-auto h-12 w-12 text-stone-300"
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>
<p className="mt-4 text-sm font-medium text-stone-500">No orders found</p>
<p className="mt-1 text-xs text-stone-400">Try adjusting your filters</p>
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
<table className="w-full">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
<th className="px-5 py-4 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">
Order
</th>
<th className="px-5 py-4 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">
Customer
</th>
<th className="px-5 py-4 text-left text-xs font-semibold uppercase tracking-wide text-stone-500 hidden md:table-cell">
Stop
</th>
<th className="px-5 py-4 text-left text-xs font-semibold uppercase tracking-wide text-stone-500 hidden lg:table-cell">
Date
</th>
<th className="px-5 py-4 text-center text-xs font-semibold uppercase tracking-wide text-stone-500">
Items
</th>
<th className="px-5 py-4 text-right text-xs font-semibold uppercase tracking-wide text-stone-500">
Total
</th>
<th className="px-5 py-4 text-center text-xs font-semibold uppercase tracking-wide text-stone-500">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{paginatedOrders.map((order) => (
<tr
key={order.id}
className="hover:bg-stone-50 transition-colors"
>
{/* Order ID */}
<td className="px-5 py-4">
<span className="font-mono text-sm text-stone-600">{shortId(order.id)}</span>
</td>
{/* Customer */}
<td className="px-5 py-4">
<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-5 py-4 hidden md:table-cell">
{order.stops ? (
<span className="text-sm text-stone-600">
{order.stops.city}, {order.stops.state}
</span>
) : (
<span className="text-stone-300"></span>
)}
</td>
{/* Date */}
<td className="px-5 py-4 hidden lg:table-cell">
<span className="text-sm text-stone-500">{formatDate(order.created_at)}</span>
</td>
{/* Items */}
<td className="px-5 py-4 text-center">
<span className="font-mono text-sm text-stone-600">
{order.order_items?.length ?? 0}
</span>
</td>
{/* Total */}
<td className="px-5 py-4 text-right">
<span className="font-mono font-semibold text-stone-900">
{formatCurrency(order.subtotal)}
</span>
</td>
{/* Status & Actions */}
<td className="px-5 py-4">
<div className="flex items-center justify-center gap-2">
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${
order.pickup_complete
? "bg-emerald-100 text-emerald-800 border border-emerald-200"
: "bg-amber-100 text-amber-800 border border-amber-200"
}`}
>
{order.pickup_complete ? "Picked Up" : "Pending"}
</span>
{order.payment_processor === "square" && (
<span className="rounded-full bg-violet-100 border border-violet-200 px-1.5 py-0.5 text-xs font-semibold text-violet-700">
Square
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-stone-500">
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, totalFiltered)} of {totalFiltered}
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Orders</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
{filteredOrders.length} order{filteredOrders.length !== 1 ? "s" : ""}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-all"
aria-label="Previous page"
>
<svg className="h-4 w-4" 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-3 text-sm font-medium text-stone-700">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-all"
aria-label="Next page"
>
<svg className="h-4 w-4" 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>
)}
{/* Pickup Toast */}
{pickupToast && (
<div className="fixed bottom-6 right-6 z-50 rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 shadow-lg">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
<svg className="h-4 w-4 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="font-medium text-emerald-800">Pickup confirmed!</span>
</div>
</div>
</div>
{brandId && (
<span className="rounded-full bg-violet-50 border border-violet-200 px-2.5 py-1 text-xs font-medium text-violet-700">Brand scoped</span>
)}
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{orders.length}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
<p className="text-xl sm:text-2xl font-bold text-amber-600 mt-1">{pendingCount}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Picked Up</p>
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{pickedUpCount}</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
{/* Status Tabs */}
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
{(["all", "pending", "picked_up"] as StatusTab[]).map((tab) => (
<button
key={tab}
onClick={() => { setActiveTab(tab); setPage(0); }}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab
? "bg-emerald-600 text-white"
: "text-stone-500 hover:bg-stone-50"
}`}
>
{tab === "all" ? "All" : tab === "pending" ? "Pending" : "Picked Up"}
</button>
))}
</div>
{/* Search */}
<div className="relative flex-1">
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
<input
type="text"
placeholder="Search name, phone, or order #..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
</div>
{/* Stop Filter */}
<div className="relative">
<button
onClick={() => setShowStopDropdown(!showStopDropdown)}
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors ${
selectedStops.length > 0
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-[var(--admin-border)] bg-white text-stone-600 hover:bg-stone-50"
}`}
>
{Icons.mapPin("h-4 w-4")}
<span>Stops</span>
{selectedStops.length > 0 && (
<span className="rounded-full bg-emerald-600 text-white px-1.5 py-0.5 text-xs font-semibold">{selectedStops.length}</span>
)}
{Icons.chevronDown("h-4 w-4")}
</button>
{showStopDropdown && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowStopDropdown(false)} />
<div className="absolute right-0 top-full mt-2 z-20 w-72 rounded-xl border border-[var(--admin-border)] bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-stone-100 px-4 py-3">
<span className="text-sm font-semibold text-stone-700">Filter by Stop</span>
<button onClick={clearStops} className="text-xs text-emerald-600 hover:text-emerald-700 font-medium">Clear all</button>
</div>
<div className="max-h-64 overflow-y-auto p-2">
{stops.map((stop) => (
<label key={stop.id} className="flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-stone-50 cursor-pointer">
<input
type="checkbox"
checked={selectedStops.includes(stop.id)}
onChange={() => toggleStop(stop.id)}
className="h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500"
/>
<div className="flex-1">
<span className="text-sm font-medium text-stone-700">{stop.city}, {stop.state}</span>
<span className="ml-2 text-xs text-stone-400">{formatDate(stop.date)}</span>
</div>
</label>
))}
</div>
</div>
</>
)}
</div>
</div>
{/* Selected stop chips */}
{selectedStops.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
{selectedStops.map((stopId) => {
const stop = stops.find((s) => s.id === stopId);
if (!stop) return null;
return (
<span key={stopId} className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 px-3 py-1 text-xs font-medium text-emerald-700">
{stop.city}, {stop.state}
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-emerald-900">
{Icons.x("h-3 w-3")}
</button>
</span>
);
})}
</div>
)}
{/* Orders Table */}
{paginatedOrders.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-[var(--admin-border)] bg-white">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.package("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">No orders found</p>
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-stone-50">
<tr className="border-b border-[var(--admin-border)]">
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Order</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Customer</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden md:table-cell">Stop</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden lg:table-cell">Date</th>
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Items</th>
<th className="text-right px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Total</th>
<th className="text-center px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{paginatedOrders.map((order) => (
<tr key={order.id} className="hover:bg-stone-50 transition-colors">
<td className="px-4 py-3">
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-emerald-600 hover:text-emerald-700">
{shortId(order.id)}
</Link>
</td>
<td className="px-4 py-3">
<div className="font-medium text-[var(--admin-text-primary)]">{order.customer_name}</div>
{order.customer_phone && <div className="font-mono text-xs text-stone-400">{order.customer_phone}</div>}
</td>
<td className="px-4 py-3 hidden md:table-cell">
{order.stops ? (
<span className="text-sm text-[var(--admin-text-primary)]">{order.stops.city}, {order.stops.state}</span>
) : (
<span className="text-stone-300"></span>
)}
</td>
<td className="px-4 py-3 hidden lg:table-cell">
<span className="text-sm text-stone-500">{formatDate(order.created_at)}</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-mono text-sm text-stone-600">{order.order_items?.length ?? 0}</span>
</td>
<td className="px-4 py-3 text-right">
<span className="font-mono font-semibold text-[var(--admin-text-primary)]">{formatCurrency(order.subtotal)}</span>
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
{order.pickup_complete ? (
<span className="rounded-full bg-emerald-100 text-emerald-700 px-2.5 py-0.5 text-[10px] font-semibold">Picked Up</span>
) : (
<span className="rounded-full bg-amber-100 text-amber-700 px-2.5 py-0.5 text-[10px] font-semibold">Pending</span>
)}
{order.payment_processor === "square" && (
<span className="rounded-full bg-violet-100 text-violet-700 px-1.5 py-0.5 text-[10px] font-semibold">Square</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
<p className="text-xs text-[var(--admin-text-muted)]">
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, filteredOrders.length)} of {filteredOrders.length}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--admin-border)] text-stone-500 hover:bg-stone-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
{Icons.chevronLeft("h-4 w-4")}
</button>
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--admin-border)] text-stone-500 hover:bg-stone-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
{Icons.chevronRight("h-4 w-4")}
</button>
</div>
</div>
)}
{/* Toast */}
{pickupToast && (
<div className="fixed bottom-6 right-6 z-50 rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 shadow-lg">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
{Icons.check("h-4 w-4 text-emerald-600")}
</div>
<span className="font-medium text-emerald-800">Pickup confirmed!</span>
</div>
</div>
)}
</div>
);
}
+63 -94
View File
@@ -6,8 +6,8 @@ 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
// Elegant warm sidebar design
// Colors: parchment 100 bg, soft linen text, powder petal accent
const TOP_LINKS = [
{ href: "/admin", label: "Dashboard", icon: "grid" },
@@ -17,15 +17,11 @@ const TOP_LINKS = [
{ 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" },
{ href: "/admin/settings", label: "Settings", icon: "settings" },
{ href: "/admin/advanced", label: "Advanced", icon: "advanced" },
];
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" },
];
const SETTINGS_SUB_LINKS: never[] = [];
function GridIcon({ className }: { className?: string }) {
return (
@@ -84,6 +80,14 @@ function MailIcon({ className }: { className?: string }) {
);
}
function SparkleIcon({ 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.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
</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}>
@@ -116,6 +120,8 @@ const ICON_MAP: Record<string, React.ReactNode> = {
clipboard: <ClipboardIcon />,
clock: <ClockIcon />,
mail: <MailIcon />,
settings: <SettingsCogIcon open={false} />,
advanced: <SparkleIcon />,
};
type SidebarProps = {
@@ -126,7 +132,6 @@ 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"
@@ -139,8 +144,6 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
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();
@@ -157,7 +160,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
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">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white shadow-md border border-[var(--admin-border)]">
<HamburgerIcon />
</div>
</button>
@@ -170,37 +173,37 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
/>
)}
{/* Sidebar panel - Warm earth-tone dark */}
{/* Sidebar panel - Elegant warm dark */}
<aside className={[
"fixed top-0 left-0 h-full w-60 z-50",
"bg-stone-900 border-r border-stone-800",
"border-r border-[var(--admin-sidebar-bg)]",
"flex flex-col transition-transform duration-300 lg:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full"
].join(" ")}>
].join(" ")}
style={{ backgroundColor: "var(--admin-sidebar-bg)" }}>
{/* 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">
<div className="flex items-center h-16 px-5 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
<div className="flex items-center gap-3">
<Link
href="/admin"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-3 text-white hover:opacity-90 transition-colors"
>
<div className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm" style={{ backgroundColor: "var(--admin-accent)" }}>
<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>
<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>
</Link>
<a
href="/"
className="text-xs transition-colors" style={{ color: "var(--admin-sidebar-text)" }}
>
Back to Site
</a>
</div>
</div>
{/* Nav links */}
@@ -215,81 +218,47 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
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",
? "border-l-2"
: "border-l-2 border-transparent hover:border-l-2",
].join(" ")}
style={active ? {
backgroundColor: "rgba(202, 117, 67, 0.15)",
color: "#dea889",
borderColor: "var(--admin-accent)"
} : {
color: "var(--admin-sidebar-text)",
borderColor: "transparent"
}}
>
<span className={[
"flex-shrink-0 transition-colors",
active ? "text-emerald-400" : "text-stone-500 group-hover:text-stone-300"
].join(" ")}>
<span className="flex-shrink-0 transition-colors" style={{
color: active ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)"
}}>
{ICON_MAP[item.icon]}
</span>
{item.label}
{active && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-emerald-500" />
<span className="ml-auto w-1.5 h-1.5 rounded-full" style={{ backgroundColor: "var(--admin-accent)" }} />
)}
</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">
<div className="px-4 py-5 border-t flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
{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 className="px-3 py-2 mb-3 rounded-lg border" style={{
backgroundColor: "rgba(208, 203, 180, 0.1)",
borderColor: "rgba(208, 203, 180, 0.2)"
}}>
<p className="text-[10px] font-medium uppercase tracking-widest mb-0.5" style={{ color: "rgba(195, 195, 193, 0.6)" }}>{roleLabel}</p>
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>Signed in</p>
</div>
)}
<button
onClick={handleLogout}
className="w-full px-3 py-2 rounded-lg text-sm font-medium text-stone-400 hover:text-stone-200 hover:bg-stone-800 transition-all border border-transparent"
className="w-full px-3 py-2 rounded-lg text-sm font-medium transition-all border border-transparent"
style={{ color: "rgba(195, 195, 193, 0.7)" }}
>
Sign out
</button>
+359
View File
@@ -0,0 +1,359 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
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[];
brandId: string;
};
function formatDate(dateStr: string) {
try {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
} catch {
return dateStr;
}
}
function formatTime(timeStr: string) {
if (!timeStr) return "—";
try {
const [h, m] = timeStr.split(":");
const hour = parseInt(h, 10);
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${m} ${ampm}`;
} catch {
return timeStr;
}
}
export default function AdminStopsPanel({ stops, brandId }: Props) {
const router = useRouter();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
const [page, setPage] = useState(0);
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [publishingId, setPublishingId] = useState<string | null>(null);
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);
async function handlePublish(stopId: string) {
setPublishingId(stopId);
setOpenMenu(null);
await publishStop(stopId, brandId);
setPublishingId(null);
router.refresh();
}
async function handleDelete(stopId: string) {
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: brandId }),
}
);
const data = await res.json();
setConfirmDelete(null);
setOpenMenu(null);
if (data.success) {
router.refresh();
}
}
const activeCount = stops.filter((s) => s.active && s.status !== "draft").length;
const draftCount = stops.filter((s) => s.status === "draft").length;
return (
<div className="min-h-screen bg-stone-50">
{/* Header */}
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto max-w-7xl px-6 py-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-stone-950 tracking-tight">Tour Stops</h1>
<div className="mt-2 flex items-center gap-4">
<span className="text-sm text-stone-500">
<span className="font-medium text-stone-700">{activeCount}</span> active
</span>
{draftCount > 0 && (
<span className="text-sm text-stone-500">
<span className="font-medium text-stone-700">{draftCount}</span> drafts
</span>
)}
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowImport(true)}
className="flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Upload Schedule
</button>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add Stop
</button>
</div>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-6 py-6">
{/* Filters */}
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-sm mb-5">
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="relative flex-1 min-w-64">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
placeholder="Search city or location..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full rounded-xl border border-stone-200 bg-white pl-10 pr-4 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20"
/>
</div>
{/* Status tabs */}
<div className="flex items-center gap-1 rounded-xl border border-stone-200 bg-stone-50 p-1">
{(["all", "active", "draft", "inactive"] as const).map((f) => (
<button
key={f}
onClick={() => { setStatusFilter(f); setPage(0); }}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all ${
statusFilter === f
? "bg-white text-emerald-700 shadow-sm border border-emerald-200"
: "text-stone-600 hover:text-stone-900 hover:bg-white/50"
}`}
>
{f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
{/* Count */}
<span className="ml-auto text-sm text-stone-500">{filtered.length} stops</span>
</div>
</div>
{/* Table */}
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
{filtered.length === 0 ? (
<div className="py-16 text-center">
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
<svg className="h-6 w-6 text-stone-400" 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>
</div>
<p className="text-lg font-medium text-stone-600">No stops found</p>
<p className="mt-1 text-sm text-stone-400">Create a stop or adjust your filters</p>
</div>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-stone-100 bg-stone-50">
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">City</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Location</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Date</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Time</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500 hidden md:table-cell">Brand</th>
<th className="px-5 py-4 text-xs font-semibold uppercase tracking-wide text-stone-500">Status</th>
<th className="px-5 py-4" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{paginatedStops.map((stop) => (
<tr key={stop.id} className="hover:bg-stone-50 transition-colors">
<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">
<span className="font-mono text-xs text-stone-500">{formatDate(stop.date)}</span>
</td>
<td className="px-5 py-4">
<span className="font-mono text-xs text-stone-500">{formatTime(stop.time)}</span>
</td>
<td className="px-5 py-4 hidden md:table-cell">
<span className="text-stone-600">
{Array.isArray(stop.brands) ? stop.brands[0]?.name : stop.brands?.name}
</span>
</td>
<td className="px-5 py-4">
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${
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 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={() => setOpenMenu(openMenu === stop.id ? null : stop.id)}
className="rounded-lg px-2 py-1.5 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-lg">
{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"
>
{publishingId === stop.id ? "Publishing..." : "Publish"}
</button>
)}
<a href={`/admin/stops/new?duplicate=${stop.id}`} className="block px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50">
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"
>
Delete
</button>
</div>
</>
)}
{confirmDelete === stop.id && (
<>
<div className="fixed inset-0 z-30" onClick={() => setConfirmDelete(null)} />
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-lg 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 cannot be undone.</p>
<div className="mt-4 flex gap-2">
<button
onClick={() => setConfirmDelete(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"
>
Cancel
</button>
<button
onClick={() => handleDelete(stop.id)}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-500"
>
Delete
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-5 flex items-center justify-between">
<p className="text-sm text-stone-500">
Showing {page * PAGE_SIZE + 1} to {Math.min((page + 1) * PAGE_SIZE, filtered.length)} of {filtered.length}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
>
<svg className="h-4 w-4" 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-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-300 hover:text-stone-700 hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed"
>
<svg className="h-4 w-4" 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>
)}
</main>
{/* Modals */}
<ScheduleImportModal brandId={brandId} onClose={() => setShowImport(false)} onComplete={() => router.refresh()} />
<AddStopModal isOpen={showAdd} onClose={() => setShowAdd(false)} brandId={brandId} onSuccess={() => router.refresh()} />
</div>
);
}
+572
View File
@@ -0,0 +1,572 @@
"use client";
import { useState, useEffect, useCallback } from "react";
// Types
type AIProvider = "openai" | "anthropic" | "google" | "xai" | "custom";
interface AISettings {
provider: AIProvider;
apiKey: string;
orgId: string;
model: string;
customEndpoint: string;
}
interface TestResult {
ok: boolean;
message: string;
}
// Provider configuration
const PROVIDERS = [
{
id: "openai" as AIProvider,
name: "OpenAI",
color: "bg-emerald-500",
placeholder: "sk-...",
website: "openai.com",
description: "GPT-4o, GPT-4 Turbo. Industry standard.",
models: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
hasOrgId: true,
},
{
id: "anthropic" as AIProvider,
name: "Anthropic",
color: "bg-amber-500",
placeholder: "sk-ant-...",
website: "anthropic.com",
description: "Claude 3.5 Sonnet, Claude 3 Opus. Best for reasoning.",
models: ["claude-3-5-sonnet-20241002", "claude-3-5-sonnet-20250611", "claude-3-opus-20240229", "claude-3-haiku-20240307"],
hasOrgId: false,
},
{
id: "google" as AIProvider,
name: "Google",
color: "bg-blue-500",
placeholder: "AIza...",
website: "ai.google",
description: "Gemini 2.0 Flash, 1.5 Pro. Fast, cost-effective.",
models: ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-pro"],
hasOrgId: false,
},
{
id: "xai" as AIProvider,
name: "xAI",
color: "bg-orange-500",
placeholder: "xai-...",
website: "x.ai",
description: "Grok-2, Grok-1.5. Real-time data, humor.",
models: ["grok-2", "grok-2-mini", "grok-1.5"],
hasOrgId: false,
},
{
id: "custom" as AIProvider,
name: "Custom",
color: "bg-stone-500",
placeholder: "sk-...",
website: "",
description: "Any OpenAI-compatible API endpoint.",
models: ["gpt-4o-mini"],
hasOrgId: false,
},
] as const;
// Estimated costs per 1M tokens (for display only)
const MODEL_COSTS: Record<string, { input: number; output: number }> = {
"gpt-4o": { input: 2.50, output: 10.00 },
"gpt-4o-mini": { input: 0.15, output: 0.60 },
"gpt-4-turbo": { input: 10.00, output: 30.00 },
"gpt-3.5-turbo": { input: 0.50, output: 1.50 },
"claude-3-5-sonnet-20241002": { input: 3.00, output: 15.00 },
"claude-3-5-sonnet-20250611": { input: 3.00, output: 15.00 },
"claude-3-opus-20240229": { input: 15.00, output: 75.00 },
"claude-3-haiku-20240307": { input: 0.80, output: 4.00 },
"gemini-2.0-flash": { input: 0.10, output: 0.40 },
"gemini-1.5-pro": { input: 1.25, output: 5.00 },
"gemini-1.5-flash": { input: 0.075, output: 0.30 },
};
// Icons
const SparkleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
</svg>
);
const RobotIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="10" rx="2"/>
<circle cx="12" cy="5" r="2"/>
<path d="M12 7v4"/>
<line x1="8" y1="16" x2="8" y2="16"/>
<line x1="16" y1="16" x2="16" y2="16"/>
</svg>
);
const BrainIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.5 2A2.5 2.5 0 0112 4.5v15a2.5 2.5 0 01-2.5 2.5M4.5 8a2.5 2.5 0 015 0v8a2.5 2.5 0 01-5 0M14.5 8a2.5 2.5 0 015 0v8a2.5 2.5 0 01-5 0M9.5 16a2.5 2.5 0 015 0"/>
<path d="M12 4.5v15"/>
</svg>
);
const CircleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="4"/>
</svg>
);
const TornadoIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 4H3M21 4v16M3 8l3 3 3-3 3 3 3-3 3 3M3 16l3 3 3-3 3 3 3-3 3 3"/>
</svg>
);
const WrenchIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>
</svg>
);
const EyeIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
);
const EyeOffIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
);
const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const AlertIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
const ShieldIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const BotIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 8V4H8"/>
<rect x="4" y="8" width="16" height="12" rx="2"/>
<path d="M2 14h2M20 14h2M15 13v2M9 13v2"/>
</svg>
);
const PROVIDER_ICONS: Record<AIProvider, React.ReactNode> = {
openai: <RobotIcon className="h-5 w-5" />,
anthropic: <BrainIcon className="h-5 w-5" />,
google: <CircleIcon className="h-5 w-5" />,
xai: <TornadoIcon className="h-5 w-5" />,
custom: <WrenchIcon className="h-5 w-5" />,
};
type Props = {
brandId: string;
};
export default function AdvancedAIPanel({ brandId }: Props) {
// Settings state
const [settings, setSettings] = useState<AISettings>({
provider: "openai",
apiKey: "",
orgId: "",
model: "gpt-4o-mini",
customEndpoint: "",
});
// UI state
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [result, setResult] = useState<TestResult | null>(null);
const [hasChanges, setHasChanges] = useState(false);
// Load existing settings
useEffect(() => {
async function loadSettings() {
try {
const res = await fetch(`/api/integrations/ai-provider?brandId=${encodeURIComponent(brandId)}`);
if (res.ok) {
const data = await res.json();
setSettings({
provider: data.provider ?? "openai",
apiKey: data.apiKey ?? "",
orgId: data.orgId ?? "",
model: data.model ?? "gpt-4o-mini",
customEndpoint: data.customEndpoint ?? "",
});
}
} catch (err) {
console.error("Failed to load AI settings:", err);
} finally {
setLoading(false);
}
}
loadSettings();
}, [brandId]);
// Track changes
const handleChange = useCallback((field: keyof AISettings, value: string) => {
setSettings((prev) => ({ ...prev, [field]: value }));
setHasChanges(true);
setResult(null);
}, []);
// Select provider
const handleSelectProvider = useCallback((provider: AIProvider) => {
const providerConfig = PROVIDERS.find((p) => p.id === provider);
const defaultModel = providerConfig?.models[0] ?? "gpt-4o-mini";
setSettings((prev) => ({
...prev,
provider,
model: defaultModel,
}));
setHasChanges(true);
setResult(null);
}, []);
// Save settings
const handleSave = useCallback(async () => {
setSaving(true);
setResult(null);
try {
const res = await fetch("/api/integrations/ai-provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brandId,
...settings,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Failed to save");
setResult({ ok: true, message: "AI provider settings saved successfully" });
setHasChanges(false);
} catch (err) {
setResult({ ok: false, message: err instanceof Error ? err.message : "Save failed" });
} finally {
setSaving(false);
}
}, [brandId, settings]);
// Test connection
const handleTest = useCallback(async () => {
if (!settings.apiKey?.trim()) {
setResult({ ok: false, message: "API key is required to test connection" });
return;
}
setTesting(true);
setResult(null);
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");
setResult({ ok: true, message: data.message ?? "Connection successful" });
} catch (err) {
setResult({ ok: false, message: err instanceof Error ? err.message : "Test failed" });
} finally {
setTesting(false);
}
}, [brandId, settings.apiKey]);
// Get current provider config
const currentProvider = PROVIDERS.find((p) => p.id === settings.provider) ?? PROVIDERS[0];
const modelCosts = MODEL_COSTS[settings.model];
// Loading skeleton
if (loading) {
return (
<div className="space-y-5">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 animate-pulse">
<div className="h-4 bg-amber-100 rounded w-1/3 mb-2" />
<div className="h-3 bg-amber-100 rounded w-2/3" />
</div>
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6 animate-pulse">
<div className="h-6 bg-stone-200 rounded w-1/4 mb-4" />
<div className="grid grid-cols-5 gap-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-20 bg-stone-100 rounded-xl" />
))}
</div>
</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Security Warning Banner */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
<ShieldIcon className="h-4 w-4 text-amber-600" />
</div>
<div>
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
<li> <strong>Never share keys</strong> Don&apos;t paste in Slack, email, or screenshots.</li>
<li> <strong>Usage costs add up fast</strong> AI APIs charge per token. A busy month can hit $50-$200+.</li>
<li> <strong>Set budgets now</strong> Use provider dashboard limits. Start with $10-$25/mo cap.</li>
</ul>
</div>
</div>
</div>
{/* Provider Selection Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500">
<SparkleIcon className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">AI Provider</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Choose your AI model provider for all AI tools</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6">
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 sm:gap-3">
{PROVIDERS.map((provider) => (
<button
key={provider.id}
onClick={() => handleSelectProvider(provider.id)}
className={`rounded-xl p-3 text-center transition-all border-2 ${
settings.provider === provider.id
? "border-violet-500 bg-violet-50"
: "border-[var(--admin-border)] hover:border-violet-200 hover:bg-stone-50"
}`}
>
<div className={`flex h-10 w-10 mx-auto items-center justify-center rounded-xl mb-2 ${provider.color}`}>
<span className="text-white">{PROVIDER_ICONS[provider.id]}</span>
</div>
<p className="text-xs font-semibold text-[var(--admin-text-primary)]">{provider.name}</p>
{provider.website && (
<p className="text-[10px] text-[var(--admin-text-muted)] mt-0.5">{provider.website}</p>
)}
</button>
))}
</div>
</div>
</div>
{/* Credentials Card */}
{settings.provider !== "custom" ? (
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Credentials</h3>
<span className="text-xs text-[var(--admin-text-muted)]">{currentProvider.website}</span>
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
{/* API Key */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
API Key <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
placeholder={currentProvider.placeholder}
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
<p className="mt-1 text-[10px] text-stone-400">
Get your API key from {currentProvider.website}
</p>
</div>
{/* Organization ID (OpenAI only) */}
{currentProvider.hasOrgId && (
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
Organization ID <span className="text-stone-400">(optional)</span>
</label>
<input
type="text"
value={settings.orgId}
onChange={(e) => handleChange("orgId", e.target.value)}
placeholder="org-..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
)}
</div>
</div>
) : (
/* Custom Endpoint Card */
<div className="rounded-xl border border-violet-200 bg-violet-50 overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-violet-200">
<div className="flex items-center gap-3">
<WrenchIcon className="h-5 w-5 text-violet-600" />
<div>
<p className="text-sm font-semibold text-violet-800">Custom API Endpoint</p>
<p className="text-xs text-violet-600 mt-0.5">Connect any OpenAI-compatible API</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
API Key <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
placeholder="sk-..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
/>
<button type="button" onClick={() => setShowApiKey(!showApiKey)} className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600">
{showApiKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">
Base URL <span className="text-red-500">*</span>
</label>
<input
type="text"
value={settings.customEndpoint}
onChange={(e) => handleChange("customEndpoint", e.target.value)}
placeholder="https://api.openai.com/v1 or http://localhost:11434/v1"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
/>
<p className="mt-1 text-[10px] text-stone-500">
Examples: OpenAI proxy, Ollama (localhost:11434), LM Studio
</p>
</div>
</div>
</div>
)}
{/* Model Selection Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Model</h3>
{modelCosts && (
<div className="text-right">
<span className="text-[10px] text-[var(--admin-text-muted)]">Est. cost per 1M tokens:</span>
<div className="flex gap-2 text-[10px]">
<span className="text-stone-500">In: ${modelCosts.input}</span>
<span className="text-stone-500">Out: ${modelCosts.output}</span>
</div>
</div>
)}
</div>
</div>
<div className="p-4 sm:p-6">
<div className="flex flex-wrap gap-2">
{currentProvider.models.map((model) => (
<button
key={model}
onClick={() => handleChange("model", model)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
settings.model === model
? "bg-violet-600 text-white"
: "bg-stone-100 text-[var(--admin-text-secondary)] hover:bg-stone-200"
}`}
>
{model}
</button>
))}
</div>
</div>
</div>
{/* AI Features Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">AI Features</h3>
</div>
<div className="p-4 sm:p-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ name: "Campaign Writer", desc: "Generate email content", icon: <BotIcon className="h-4 w-4" /> },
{ name: "Product Writer", desc: "Product descriptions", icon: <SparkleIcon className="h-4 w-4" /> },
{ name: "Report Explainer", desc: "AI summaries", icon: <BotIcon className="h-4 w-4" /> },
{ name: "Pricing Advisor", desc: "Smart pricing", icon: <SparkleIcon className="h-4 w-4" /> },
].map((feature) => (
<div key={feature.name} className="bg-stone-50 rounded-lg p-3 flex items-center gap-2">
<div className="text-emerald-600">{feature.icon}</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{feature.name}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{feature.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Result Message */}
{result && (
<div className={`rounded-xl px-4 py-3 text-sm flex items-center gap-2 ${
result.ok
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200"
}`}>
{result.ok ? <CheckIcon className="h-4 w-4 flex-shrink-0" /> : <AlertIcon className="h-4 w-4 flex-shrink-0" />}
{result.message}
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<button
onClick={handleTest}
disabled={testing || saving}
className="rounded-lg border border-[var(--admin-border)] px-5 py-2.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
{testing ? "Testing..." : "Test Connection"}
</button>
<button
onClick={handleSave}
disabled={saving || testing}
className="flex-1 rounded-lg bg-emerald-600 px-5 py-2.5 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : hasChanges ? "Save Provider Settings" : "Saved"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,384 @@
"use client";
import { useState, useEffect } from "react";
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
// Icons
const MailIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path 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>
);
const MessageIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z"/>
</svg>
);
const ShieldIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const EyeIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
);
const EyeOffIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
);
const AlertIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
type Props = {
brandId: string;
brands: { id: string; name: string }[];
};
export default function AdvancedIntegrations({ brandId, brands }: Props) {
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
const [credentials, setCredentials] = useState<{
resend: { api_key: string; from_email: string; from_name: string };
twilio: { account_sid: string; auth_token: string; phone_number: string };
}>({
resend: { api_key: "", from_email: "", from_name: "" },
twilio: { account_sid: "", auth_token: "", phone_number: "" },
});
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(false);
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>({});
const [saveStatus, setSaveStatus] = useState<{ type: "success" | "error"; message: string } | null>(null);
useEffect(() => {
async function fetchCredentials() {
const [resendCreds, twilioCreds] = await Promise.all([
getResendCredentials(selectedBrandId),
getTwilioCredentials(selectedBrandId),
]);
setCredentials({
resend: {
api_key: resendCreds.api_key ?? "",
from_email: resendCreds.from_email ?? "",
from_name: resendCreds.from_name ?? "",
},
twilio: {
account_sid: twilioCreds.account_sid ?? "",
auth_token: twilioCreds.auth_token ?? "",
phone_number: twilioCreds.phone_number ?? "",
},
});
}
fetchCredentials();
}, [selectedBrandId]);
function toggleSecret(key: string) {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}
async function handleTest(service: "resend" | "twilio") {
// Clear previous result for this service
const newResults = { ...testResults };
delete newResults[service];
setTestResults(newResults);
if (service === "resend") {
if (!credentials.resend.api_key?.trim()) {
setTestResults((prev) => ({ ...prev, resend: { ok: false, message: "API key is required" } }));
return;
}
const result = await testResendConnection(credentials.resend.api_key);
setTestResults((prev) => ({ ...prev, resend: result }));
} else {
if (!credentials.twilio.account_sid?.trim() || !credentials.twilio.auth_token?.trim()) {
setTestResults((prev) => ({ ...prev, twilio: { ok: false, message: "Account SID and Auth Token are required" } }));
return;
}
const result = await testTwilioConnection(credentials.twilio.account_sid, credentials.twilio.auth_token);
setTestResults((prev) => ({ ...prev, twilio: result }));
}
}
async function handleSave(service: "resend" | "twilio") {
setLoading(true);
setSaveStatus(null);
// Clear test result for this service
const clearedResults = { ...testResults };
delete clearedResults[service];
setTestResults(clearedResults);
try {
if (service === "resend") {
const result = await saveResendCredentials(selectedBrandId, {
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) {
setSaveStatus({ type: "success", message: "Resend credentials saved" });
setTestResults((prev) => ({ ...prev, resend: { ok: true, message: "Saved successfully" } }));
} else {
setSaveStatus({ type: "error", message: result.error ?? "Failed to save" });
}
} else {
const result = await saveTwilioCredentials(selectedBrandId, {
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) {
setSaveStatus({ type: "success", message: "Twilio credentials saved" });
setTestResults((prev) => ({ ...prev, twilio: { ok: true, message: "Saved successfully" } }));
} else {
setSaveStatus({ type: "error", message: result.error ?? "Failed to save" });
}
}
} catch (err) {
setSaveStatus({ type: "error", message: err instanceof Error ? err.message : "Failed to save" });
} finally {
setLoading(false);
}
}
return (
<div className="space-y-5">
{/* Security Warning Banner */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
<ShieldIcon className="h-4 w-4 text-amber-600" />
</div>
<div>
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
<li> <strong>Never share keys</strong> Don&apos;t paste in Slack, email, or commit to GitHub.</li>
<li> <strong>SMS costs add up fast</strong> Twilio charges $0.008-$0.05 per message.</li>
<li> <strong>Set spending caps</strong> Use provider budget controls to avoid surprise bills.</li>
</ul>
</div>
</div>
</div>
{/* Resend Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500">
<MailIcon className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Resend</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Email delivery for Harvest Reach campaigns</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
{/* API Key */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key</label>
<div className="relative">
<input
type={showSecrets.resendKey ? "text" : "password"}
value={credentials.resend.api_key}
onChange={(e) => setCredentials((p) => ({
...p,
resend: { ...p.resend, api_key: e.target.value },
}))}
placeholder="re_..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => toggleSecret("resendKey")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
>
{showSecrets.resendKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
{/* From Email */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">From Email</label>
<input
type="email"
value={credentials.resend.from_email}
onChange={(e) => setCredentials((p) => ({
...p,
resend: { ...p.resend, from_email: e.target.value },
}))}
placeholder="orders@yourbrand.com"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* From Name */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">From Name</label>
<input
type="text"
value={credentials.resend.from_name}
onChange={(e) => setCredentials((p) => ({
...p,
resend: { ...p.resend, from_name: e.target.value },
}))}
placeholder="Your Brand Name"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* Status & Actions */}
{testResults.resend && (
<div className={`rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
testResults.resend.ok
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200"
}`}>
{testResults.resend.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
{testResults.resend.message}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => handleTest("resend")}
disabled={loading}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button
onClick={() => handleSave("resend")}
disabled={loading}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{loading ? "Saving..." : "Save Resend"}
</button>
</div>
</div>
</div>
{/* Twilio Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-500">
<MessageIcon className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Twilio</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">SMS campaigns and alerts via Harvest Reach</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
{/* Account SID */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Account SID</label>
<input
type="text"
value={credentials.twilio.account_sid}
onChange={(e) => setCredentials((p) => ({
...p,
twilio: { ...p.twilio, account_sid: e.target.value },
}))}
placeholder="AC..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* Auth Token */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Auth Token</label>
<div className="relative">
<input
type={showSecrets.twilioToken ? "text" : "password"}
value={credentials.twilio.auth_token}
onChange={(e) => setCredentials((p) => ({
...p,
twilio: { ...p.twilio, auth_token: e.target.value },
}))}
placeholder="Your Twilio auth token"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm pr-10 text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => toggleSecret("twilioToken")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
>
{showSecrets.twilioToken ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
{/* Phone Number */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Phone Number</label>
<input
type="tel"
value={credentials.twilio.phone_number}
onChange={(e) => setCredentials((p) => ({
...p,
twilio: { ...p.twilio, phone_number: e.target.value },
}))}
placeholder="+1234567890"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* Status & Actions */}
{testResults.twilio && (
<div className={`rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
testResults.twilio.ok
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200"
}`}>
{testResults.twilio.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
{testResults.twilio.message}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => handleTest("twilio")}
disabled={loading}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button
onClick={() => handleSave("twilio")}
disabled={loading}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{loading ? "Saving..." : "Save Twilio"}
</button>
</div>
</div>
</div>
{/* Save Status */}
{saveStatus && (
<div className={`rounded-xl px-4 py-3 text-sm font-medium ${
saveStatus.type === "success"
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200"
}`}>
{saveStatus.message}
</div>
)}
</div>
);
}
+471
View File
@@ -0,0 +1,471 @@
"use client";
import { useState } from "react";
import {
createStripeConnectLink,
refreshStripeConnectLink,
disconnectStripeConnect,
createStripeDashboardLink,
getStripeConnectStatus,
} from "@/actions/stripe-connect";
interface AdvancedPaymentsProps {
brandId: string;
initialStatus: {
is_connected: boolean;
account_id?: string;
charges_enabled?: boolean;
payouts_enabled?: boolean;
details_submitted?: boolean;
} | null;
}
// SVG Icons
const CreditCardIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<path d="M2 10h20"/>
</svg>
);
const ExternalLinkIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3"/>
</svg>
);
const AlertTriangleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
</svg>
);
const CheckCircleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M9 12l2 2 4-4"/>
</svg>
);
const XCircleIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M15 9l-6 6M9 9l6 6"/>
</svg>
);
const LoaderIcon = ({ className }: { className?: string }) => (
<svg className={`animate-spin ${className}`} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeOpacity="0.25"/>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
);
const RefreshIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 019-9 9.75 9.75 0 016.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 01-9 9 9.75 9.75 0 01-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
);
const UnlinkIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18.84 12.25l1.72-1.71h-.02a5.004 5.004 0 00-.12-7.07 5.006 5.006 0 00-6.95 0l-1.72 1.71"/>
<path d="M5.17 11.75l-1.71 1.71a5.004 5.004 0 00.12 7.07 5.006 5.006 0 006.95 0l1.71-1.71"/>
<line x1="8" y1="2" x2="8" y2="5"/>
<line x1="2" y1="8" x2="5" y2="8"/>
<line x1="16" y1="19" x2="16" y2="22"/>
<line x1="19" y1="16" x2="22" y2="16"/>
</svg>
);
const ShieldIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const InfoIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
);
const StoreIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
);
const DollarIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="1" x2="12" y2="23"/>
<path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>
</svg>
);
export default function AdvancedPayments({ brandId, initialStatus }: AdvancedPaymentsProps) {
const [status, setStatus] = useState(initialStatus ?? { is_connected: false });
const [loading, setLoading] = useState(false);
const [action, setAction] = useState<"connect" | "refresh" | "dashboard" | "disconnect" | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
async function refreshStatus() {
const result = await getStripeConnectStatus(brandId);
if (!result.error) {
setStatus(result);
}
}
async function handleConnect() {
setLoading(true);
setAction("connect");
setError(null);
setSuccess(null);
const result = await createStripeConnectLink(brandId);
if (!result.success || !result.url) {
setError(result.error ?? "Failed to create Stripe account");
setLoading(false);
setAction(null);
return;
}
// Redirect to Stripe onboarding
window.location.href = result.url;
}
async function handleRefresh() {
setLoading(true);
setAction("refresh");
setError(null);
setSuccess(null);
const result = await refreshStripeConnectLink(brandId);
if (!result.success || !result.url) {
setError(result.error ?? "Failed to refresh onboarding");
setLoading(false);
setAction(null);
return;
}
// Redirect to Stripe to continue onboarding
window.location.href = result.url;
}
async function handleOpenDashboard() {
setLoading(true);
setAction("dashboard");
setError(null);
setSuccess(null);
const result = await createStripeDashboardLink(brandId);
if (!result.success || !result.url) {
setError(result.error ?? "Failed to open Stripe dashboard");
setLoading(false);
setAction(null);
return;
}
// Open Stripe dashboard in new tab
window.open(result.url, "_blank");
setLoading(false);
setAction(null);
}
async function handleDisconnect() {
if (!confirm("Disconnect Stripe? Your brand store will no longer be able to accept payments until reconnected.")) {
return;
}
setLoading(true);
setAction("disconnect");
setError(null);
setSuccess(null);
const result = await disconnectStripeConnect(brandId);
if (!result.success) {
setError(result.error ?? "Failed to disconnect");
} else {
setStatus({ is_connected: false });
setSuccess("Stripe disconnected successfully");
}
setLoading(false);
setAction(null);
}
const isFullyOnboarded = status.is_connected && status.charges_enabled && status.payouts_enabled && status.details_submitted;
return (
<div className="space-y-5">
{/* Info Banner - Two Stripe Systems */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-violet-500 flex-shrink-0">
<StoreIcon className="h-5 w-5 text-white" />
</div>
<div>
<h4 className="text-sm font-semibold text-[var(--admin-text-primary)]">Brand Store Payments</h4>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
This connects your brand&apos;s storefront to <strong>your own Stripe account</strong> so you can accept payments directly from your customers.
This is separate from the Route Commerce SaaS subscription billing.
</p>
</div>
</div>
</div>
{/* Security Warning Banner */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500 flex-shrink-0">
<ShieldIcon className="h-5 w-5 text-white" />
</div>
<div>
<h4 className="text-sm font-semibold text-amber-800">How Stripe Connect Works</h4>
<ul className="mt-2 text-xs text-amber-700 space-y-1.5">
<li> <strong>You sign in to YOUR Stripe account</strong> No API keys to manage or share.</li>
<li> <strong>Express accounts are pre-configured</strong> Card payments and transfers enabled.</li>
<li> <strong>Stripe handles compliance</strong> Identity verification and tax forms handled by Stripe.</li>
<li> <strong>Funds go directly to you</strong> Payments deposit to your connected Stripe account.</li>
<li> <strong>2-7 day payout schedule</strong> Standard Stripe payout timing to your bank.</li>
</ul>
</div>
</div>
</div>
{/* Stripe Connect Status Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
{/* Card Header */}
<div className={`px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] ${
isFullyOnboarded
? "bg-emerald-50/50"
: status.is_connected
? "bg-amber-50/50"
: "bg-stone-50/50"
}`}>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${
isFullyOnboarded
? "bg-emerald-500"
: status.is_connected
? "bg-amber-500"
: "bg-stone-500"
}`}>
<CreditCardIcon className="h-6 w-6 text-white" />
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Stripe Connect</h2>
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">
Accept payments on your brand storefront
</p>
</div>
</div>
{/* Status Badge */}
{status.is_connected ? (
<div className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-semibold border ${
isFullyOnboarded
? "bg-emerald-100 text-emerald-700 border-emerald-200"
: "bg-amber-100 text-amber-700 border-amber-200"
}`}>
{isFullyOnboarded ? (
<CheckCircleIcon className="h-3.5 w-3.5" />
) : (
<AlertTriangleIcon className="h-3.5 w-3.5" />
)}
{isFullyOnboarded ? "Active" : "Incomplete"}
</div>
) : (
<div className="flex items-center gap-1.5 rounded-full bg-stone-100 px-3 py-1.5 text-xs font-semibold text-[var(--admin-text-secondary)] border border-stone-200">
<XCircleIcon className="h-3.5 w-3.5" />
Not Connected
</div>
)}
</div>
</div>
{/* Card Body */}
<div className="p-4 sm:p-6">
{/* Account Info */}
{status.account_id && (
<div className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)]">
<span>Account:</span>
<code className="rounded border border-[var(--admin-border)] bg-stone-50 px-1.5 py-0.5 font-mono text-[var(--admin-text-secondary)]">
{status.account_id}
</code>
</div>
)}
{/* Onboarding Status */}
{status.is_connected && (
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
<StatusIndicator
label="Charges"
enabled={status.charges_enabled}
description="Can accept card payments"
/>
<StatusIndicator
label="Payouts"
enabled={status.payouts_enabled}
description="Can receive transfers"
/>
<StatusIndicator
label="Details"
enabled={status.details_submitted}
description="Business verified"
/>
</div>
)}
{/* Error/Success Messages */}
{error && (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 flex items-center gap-2">
<AlertTriangleIcon className="h-4 w-4 flex-shrink-0" />
{error}
</div>
)}
{success && (
<div className="mt-4 rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700 flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4 flex-shrink-0" />
{success}
</div>
)}
{/* Action Buttons */}
<div className="mt-5 flex flex-wrap gap-3">
{!status.is_connected ? (
<button
onClick={handleConnect}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{loading && action === "connect" ? (
<LoaderIcon className="h-4 w-4" />
) : (
<CreditCardIcon className="h-4 w-4" />
)}
Connect with Stripe
</button>
) : (
<>
{!isFullyOnboarded && (
<button
onClick={handleRefresh}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg bg-amber-500 px-5 py-2.5 text-sm font-bold text-white hover:bg-amber-600 disabled:opacity-50"
>
{loading && action === "refresh" ? (
<LoaderIcon className="h-4 w-4" />
) : (
<RefreshIcon className="h-4 w-4" />
)}
Complete Setup
</button>
)}
<button
onClick={handleOpenDashboard}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-white px-4 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
{loading && action === "dashboard" ? (
<LoaderIcon className="h-4 w-4" />
) : (
<ExternalLinkIcon className="h-4 w-4" />
)}
Stripe Dashboard
</button>
<button
onClick={handleDisconnect}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-white px-4 py-2.5 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{loading && action === "disconnect" ? (
<LoaderIcon className="h-4 w-4" />
) : (
<UnlinkIcon className="h-4 w-4" />
)}
Disconnect
</button>
</>
)}
</div>
</div>
{/* How It Works Section */}
{!status.is_connected && (
<div className="border-t border-[var(--admin-border)] px-4 py-4 sm:px-6 bg-stone-50/50">
<div className="flex items-start gap-3">
<InfoIcon className="h-4 w-4 text-[var(--admin-text-muted)] mt-0.5" />
<div>
<p className="text-xs font-medium text-[var(--admin-text-secondary)] mb-2">Setup steps:</p>
<ol className="text-xs text-[var(--admin-text-muted)] space-y-1.5 list-decimal list-inside">
<li>Click &quot;Connect with Stripe&quot; to start</li>
<li>Sign in to your Stripe account or create one</li>
<li>Complete business verification in the Stripe form</li>
<li>Once approved, start accepting payments on your store</li>
</ol>
</div>
</div>
</div>
)}
{/* Active Banner */}
{isFullyOnboarded && (
<div className="border-t border-emerald-200 px-4 py-4 sm:px-6 bg-emerald-50/50">
<div className="flex items-start gap-3">
<DollarIcon className="h-4 w-4 text-emerald-600 mt-0.5" />
<div>
<p className="text-xs font-medium text-emerald-700 mb-1">Ready to Accept Payments</p>
<p className="text-xs text-emerald-600">
Your Stripe account is fully set up. Payments from your brand storefront will be deposited directly to your Stripe account.
</p>
</div>
</div>
</div>
)}
</div>
</div>
);
}
function StatusIndicator({
label,
enabled,
description,
}: {
label: string;
enabled?: boolean;
description: string;
}) {
return (
<div className={`flex items-center gap-2.5 rounded-lg px-3 py-2 border ${
enabled
? "bg-emerald-50 border-emerald-200"
: "bg-amber-50 border-amber-200"
}`}>
{enabled ? (
<CheckCircleIcon className="h-4 w-4 text-emerald-600 flex-shrink-0" />
) : (
<AlertTriangleIcon className="h-4 w-4 text-amber-600 flex-shrink-0" />
)}
<div>
<p className={`text-xs font-semibold ${enabled ? "text-emerald-700" : "text-amber-700"}`}>
{label} {enabled ? "Enabled" : "Pending"}
</p>
<p className="text-[10px] text-[var(--admin-text-muted)]">{description}</p>
</div>
</div>
);
}
@@ -0,0 +1,253 @@
"use client";
import { useState, useEffect } from "react";
import AdvancedIntegrations from "@/components/admin/AdvancedIntegrations";
import AdvancedAIPanel from "@/components/admin/AdvancedAIPanel";
import AdvancedSquareSync from "@/components/admin/AdvancedSquareSync";
import AdvancedShipping from "@/components/admin/AdvancedShipping";
import AdvancedPayments from "@/components/admin/AdvancedPayments";
type Tab = "integrations" | "ai" | "square" | "shipping" | "webhooks" | "payments";
const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: "integrations", label: "Integrations", icon: "plug" },
{ id: "ai", label: "AI Tools", icon: "sparkles" },
{ id: "square", label: "Square Sync", icon: "square" },
{ id: "shipping", label: "Shipping", icon: "truck" },
{ id: "webhooks", label: "Webhooks", icon: "webhook" },
{ id: "payments", label: "Payments", icon: "credit-card" },
];
// Icon components
const Icons = {
plug: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
</svg>
),
sparkles: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"/>
</svg>
),
square: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M9 12h6M12 9v6"/>
</svg>
),
truck: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>
</svg>
),
webhook: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path d="M8.25 8.25l-.75.75M14.25 14.25l-.75.75M8.25 14.25l.75-.75M14.25 8.25l.75.75"/>
</svg>
),
"credit-card": (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<path d="M2 10h20"/>
</svg>
),
};
type Props = {
brandId: string;
brands: { id: string; name: string }[];
stripeConnect?: {
is_connected: boolean;
account_id?: string;
charges_enabled?: boolean;
payouts_enabled?: boolean;
details_submitted?: boolean;
} | null;
};
export default function AdvancedSettingsClient({ brandId, brands, stripeConnect }: Props) {
const [activeTab, setActiveTab] = useState<Tab>("integrations");
// Handle Stripe Connect callback params
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("stripe_connected") === "true") {
// Successfully connected - could show a toast or refresh
window.history.replaceState({}, "", window.location.pathname);
} else if (params.get("stripe_refresh") === "true") {
// Link expired, need to refresh
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
{/* Header */}
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-violet-600">
{Icons.sparkles("h-5 w-5 sm:h-6 sm:w-6 text-white")}
</div>
<div>
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Advanced</h1>
<p className="text-xs text-[var(--admin-text-muted)]">Developer settings, APIs, and integrations</p>
</div>
</div>
{/* Tab navigation */}
<nav className="grid grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
activeTab === tab.id
? "bg-violet-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.icon === "plug" && Icons.plug("h-4 w-4")}
{tab.icon === "sparkles" && Icons.sparkles("h-4 w-4")}
{tab.icon === "square" && Icons.square("h-4 w-4")}
{tab.icon === "truck" && Icons.truck("h-4 w-4")}
{tab.icon === "webhook" && Icons.webhook("h-4 w-4")}
{tab.icon === "credit-card" && Icons["credit-card"]("h-4 w-4")}
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
{activeTab === tab.id && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-8 bg-white rounded-full" />
)}
</button>
))}
</nav>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
{activeTab === "integrations" && (
<AdvancedIntegrations brandId={brandId} brands={brands} />
)}
{activeTab === "ai" && (
<AdvancedAIPanel brandId={brandId} />
)}
{activeTab === "square" && (
<AdvancedSquareSync brandId={brandId} />
)}
{activeTab === "shipping" && (
<AdvancedShipping brandId={brandId} />
)}
{activeTab === "webhooks" && (
<div className="space-y-6">
{/* Page Header */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-stone-100">
{Icons.webhook("h-6 w-6 text-stone-600")}
</div>
<div className="flex-1">
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Webhooks</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
Configure webhook endpoints to receive real-time notifications for order events, payments, and inventory updates.
</p>
</div>
</div>
</div>
{/* Coming Soon Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="bg-stone-50/50 px-6 py-4 border-b border-[var(--admin-border)]">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Available Webhook Events</h3>
</div>
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-50">
<svg className="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Coming Soon</p>
<p className="text-xs text-[var(--admin-text-muted)]">Webhook configuration will be available shortly</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-6">
{[
{ icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4", label: "Order Status Changes", desc: "Track order lifecycle events" },
{ icon: "M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z", label: "Payment Confirmations", desc: "Get notified on successful payments" },
{ icon: "M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4", label: "Inventory Updates", desc: "Real-time stock changes" },
{ icon: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1", label: "Custom Webhook URLs", desc: "Send events to your endpoint" },
].map((event, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-stone-50/50 border border-stone-100">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white border border-stone-200">
<svg className="h-4 w-4 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={event.icon}/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{event.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{event.desc}</p>
</div>
</div>
))}
</div>
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-stone-50 to-stone-100/50 border border-stone-200">
<div className="flex items-center gap-2">
<svg className="h-4 w-4 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span className="text-sm text-stone-600">Webhook configuration will allow you to receive events via HTTP POST to your custom endpoint.</span>
</div>
</div>
<div className="mt-6 flex items-center justify-between pt-4 border-t border-stone-100">
<p className="text-xs text-[var(--admin-text-muted)]">
Need webhooks now?{" "}
<button className="text-emerald-600 hover:text-emerald-700 font-medium">
Contact support
</button>
</p>
<button className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 transition-colors">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
Notify Me
</button>
</div>
</div>
</div>
{/* Technical Info Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
<h4 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-3">Webhook Payload Format</h4>
<div className="bg-stone-900 rounded-lg p-4 overflow-x-auto">
<pre className="text-xs text-stone-300 font-mono leading-relaxed">
{`{
"event": "order.status_changed",
"timestamp": "2024-01-15T10:30:00Z",
"data": {
"order_id": "...",
"status": "delivered"
}
}`}
</pre>
</div>
</div>
</div>
)}
{activeTab === "payments" && (
<AdvancedPayments brandId={brandId} initialStatus={stripeConnect ?? null} />
)}
</div>
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { useState } from "react";
// Icons
const TruckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>
</svg>
);
const ShieldIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const AlertIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
const EyeIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
);
const EyeOffIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
);
export default function AdvancedShipping({ brandId }: { brandId: string }) {
const [carrier, setCarrier] = useState("fedex");
const [accountNumber, setAccountNumber] = useState("");
const [apiKey, setApiKey] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [saving, setSaving] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTestResult(null);
if (!accountNumber.trim()) {
setTestResult({ ok: false, message: "Account number is required" });
return;
}
await new Promise(r => setTimeout(r, 1000));
setTestResult({ ok: true, message: `${carrier.toUpperCase()} connection successful` });
}
async function handleSave() {
setSaving(true);
await new Promise(r => setTimeout(r, 500));
setSaving(false);
setTestResult({ ok: true, message: "Shipping settings saved" });
}
return (
<div className="space-y-5">
{/* Security Warning */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
<ShieldIcon className="h-4 w-4 text-amber-600" />
</div>
<div>
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
<li> <strong>Shipping fraud is real</strong> Stolen carrier credentials enable fake label purchases on your account.</li>
<li> <strong>Carrier API costs</strong> FedEx/UPS APIs charge per rate quote ($0.01-$0.05) and per label ($3-$10).</li>
<li> <strong>Set account limits</strong> Most carriers let you set monthly spending caps. Use them!</li>
<li> <strong>Test in sandbox</strong> Use test credentials before connecting production shipping accounts.</li>
<li> <strong>Audit regularly</strong> Check carrier invoices monthly for unauthorized activity.</li>
</ul>
</div>
</div>
</div>
{/* Shipping Carriers Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-600">
<TruckIcon className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Shipping Carriers</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Configure shipping provider credentials</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
{/* Carrier Selection */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Carrier</label>
<select
value={carrier}
onChange={(e) => setCarrier(e.target.value)}
className="w-full sm:w-auto rounded-lg border border-[var(--admin-border)] bg-white px-4 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
>
<option value="fedex">FedEx</option>
<option value="ups">UPS</option>
<option value="usps">USPS</option>
<option value="dhl">DHL</option>
</select>
</div>
{/* Account Number */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Account Number</label>
<input
type="text"
value={accountNumber}
onChange={(e) => setAccountNumber(e.target.value)}
placeholder="Enter account number"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
{/* API Key / Password */}
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">API Key / Password</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter API key or password"
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 pr-10 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600"
>
{showApiKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
</button>
</div>
</div>
{/* Test Result */}
{testResult && (
<div className={`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"
}`}>
{testResult.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
{testResult.message}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<button
onClick={handleTest}
disabled={saving}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50"
>
Test Connection
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</div>
</div>
{/* Shipping Options Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Shipping Options</h3>
</div>
<div className="p-4 sm:p-6">
<div className="space-y-3">
{[
{ id: "live_rates", label: "Live Rate Quotes", desc: "Show real-time shipping rates at checkout" },
{ id: "label_printing", label: "Label Printing", desc: "Generate shipping labels automatically" },
{ id: "tracking", label: "Tracking Updates", desc: "Send tracking notifications to customers" },
{ id: "insurance", label: "Shipping Insurance", desc: "Protect high-value shipments" },
].map((option) => (
<div key={option.id} className="flex items-center justify-between p-3 bg-stone-50 rounded-lg">
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{option.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{option.desc}</p>
</div>
<button className="relative inline-flex h-5 w-9 items-center rounded-full bg-emerald-600 transition-colors">
<span className="inline-block h-3.5 w-3.5 rounded-full bg-white translate-x-4" />
</button>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { useState } from "react";
// Icons
const ShieldIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const GridIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M3 3h6v6H3V3zm0 12h6v6H3v-6zm12-12h6v6h-6V3zm0 12h6v6h-6v-6z"/>
</svg>
);
const CheckIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const AlertIcon = ({ className }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
);
export default function AdvancedSquareSync({ brandId }: { brandId: string }) {
const [connected, setConnected] = useState(false);
const [appId, setAppId] = useState("");
const [accessToken, setAccessToken] = useState("");
const [locationId, setLocationId] = useState("");
const [saving, setSaving] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTestResult(null);
if (!appId.trim() || !accessToken.trim()) {
setTestResult({ ok: false, message: "App ID and Access Token are required" });
return;
}
// Simulate test
await new Promise(r => setTimeout(r, 1000));
setTestResult({ ok: true, message: "Square connection successful" });
setConnected(true);
}
async function handleSave() {
setSaving(true);
await new Promise(r => setTimeout(r, 500));
setSaving(false);
setTestResult({ ok: true, message: "Square sync settings saved" });
}
return (
<div className="space-y-5">
{/* Security Warning */}
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 flex-shrink-0">
<ShieldIcon className="h-4 w-4 text-amber-600" />
</div>
<div>
<h4 className="text-sm font-semibold text-amber-800">API Key Security</h4>
<ul className="mt-1.5 text-xs text-amber-700 space-y-1">
<li> <strong>Square access = real money</strong> API can process transactions, refunds, and access customer data.</li>
<li> <strong>Monitor API calls</strong> Square charges per API call. Heavy integrations can run up fees.</li>
<li> <strong>Token expiry</strong> Square access tokens expire. Production apps need automatic refresh logic.</li>
<li> <strong>Use sandbox first</strong> Test with Square sandbox before going live to avoid real charges.</li>
<li> <strong>Secure storage</strong> Never expose credentials in client code or public repositories.</li>
</ul>
</div>
</div>
</div>
{/* Square Sync Card */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-black">
<GridIcon className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-sm sm:text-base font-semibold text-[var(--admin-text-primary)]">Square Sync</h3>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Sync inventory with Square POS</p>
</div>
{connected && (
<span className="ml-auto text-xs font-medium text-emerald-600 bg-emerald-50 px-2.5 py-1 rounded-full">Connected</span>
)}
</div>
</div>
<div className="p-4 sm:p-6 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Application ID</label>
<input
type="password"
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="sq0idp-..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Access Token</label>
<input
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="EAAAl..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1.5">Location ID</label>
<input
type="text"
value={locationId}
onChange={(e) => setLocationId(e.target.value)}
placeholder="L..."
className="w-full rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] placeholder:text-stone-400 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
</div>
{testResult && (
<div className={`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"
}`}>
{testResult.ok ? <CheckIcon className="h-4 w-4" /> : <AlertIcon className="h-4 w-4" />}
{testResult.message}
</div>
)}
<div className="flex gap-2">
<button
onClick={handleTest}
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-xs font-medium text-[var(--admin-text-secondary)] hover:bg-stone-50"
>
Test Connection
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</div>
</div>
{/* Sync Options */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-4 py-3 sm:px-6 sm:py-4 border-b border-[var(--admin-border)] bg-stone-50/50">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Sync Options</h3>
</div>
<div className="p-4 sm:p-6">
<div className="space-y-3">
{[
{ id: "inventory", label: "Inventory Sync", desc: "Sync product quantities from Square" },
{ id: "products", label: "Product Sync", desc: "Import/export products from Square catalog" },
{ id: "prices", label: "Price Updates", desc: "Keep prices synchronized" },
].map((option) => (
<div key={option.id} className="flex items-center justify-between p-3 bg-stone-50 rounded-lg">
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{option.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{option.desc}</p>
</div>
<button className="relative inline-flex h-5 w-9 items-center rounded-full bg-emerald-600 transition-colors">
<span className="inline-block h-3.5 w-3.5 rounded-full bg-white translate-x-4" />
</button>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+117 -45
View File
@@ -2,9 +2,9 @@
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";
import GlassModal from "./GlassModal";
type Props = {
brandId: string;
@@ -15,24 +15,25 @@ export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: P
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 [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [showEnableModal, setShowEnableModal] = useState<BrandFeatureKey | null>(null);
const showToast = (msg: string) => {
setToast(msg);
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const handleToggle = async (key: string) => {
const newEnabled = !enabledFeatures[key];
const handleToggle = async (key: BrandFeatureKey, enable: boolean) => {
setToggling(key);
setEnabledFeatures((prev) => ({ ...prev, [key]: newEnabled }));
const result = await toggleBrandFeature(brandId, key as BrandFeatureKey, newEnabled);
setShowEnableModal(null);
setEnabledFeatures((prev) => ({ ...prev, [key]: enable }));
const result = await toggleBrandFeature(brandId, key, enable);
if (result.success) {
showToast(newEnabled ? `${ADDON_CATALOG[key as BrandFeatureKey].name} enabled` : `${ADDON_CATALOG[key as BrandFeatureKey].name} disabled`);
showToast(enable ? `${ADDON_CATALOG[key].name} enabled` : `${ADDON_CATALOG[key].name} disabled`);
router.refresh();
} else {
setEnabledFeatures((prev) => ({ ...prev, [key]: !newEnabled }));
showToast(`Error: ${result.error}`);
setEnabledFeatures((prev) => ({ ...prev, [key]: !enable }));
showToast(`Error: ${result.error}`, 'error');
}
setToggling(null);
};
@@ -42,10 +43,17 @@ export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: P
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
className="fixed bottom-6 right-6 z-50 rounded-xl px-5 py-3 text-sm font-medium shadow-lg backdrop-blur-sm"
style={{
background: toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'rgba(15, 118, 110, 0.95)',
color: 'white',
}}
>
{toast.message}
</div>
)}
<div className="grid gap-5 md:grid-cols-2">
{keys.map((key) => {
const addon = ADDON_CATALOG[key];
@@ -55,63 +63,127 @@ export default function BrandFeatureCards({ brandId, initialEnabledFeatures }: P
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"
}`}
className="rounded-2xl p-6 transition-all"
style={{
background: 'rgba(255, 255, 255, 0.7)',
backdropFilter: 'blur(20px)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.3)' : '1px solid rgba(0, 0, 0, 0.06)',
boxShadow: enabled
? '0 8px 24px rgba(16, 185, 129, 0.1), inset 0 1px 0 rgba(255,255,255,0.8)'
: '0 4px 12px rgba(0, 0, 0, 0.04)',
}}
>
<div className="flex items-start gap-4">
<span className="text-3xl mt-0.5">{addon.icon}</span>
<div
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl"
style={{
background: enabled
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.08) 100%)'
: 'rgba(0, 0, 0, 0.04)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.2)' : '1px solid rgba(0, 0, 0, 0.06)',
}}
>
<span className="text-2xl">{addon.icon}</span>
</div>
<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>
<h3 className="text-base font-semibold text-stone-950">{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"
}`}
className="text-xs font-medium rounded-full px-2.5 py-0.5"
style={{
background: enabled ? 'rgba(16, 185, 129, 0.15)' : 'rgba(0, 0, 0, 0.04)',
color: enabled ? '#047857' : 'rgba(0, 0, 0, 0.4)',
border: enabled ? '1px solid rgba(16, 185, 129, 0.2)' : '1px solid rgba(0, 0, 0, 0.06)',
}}
>
{enabled ? "Enabled" : "Disabled"}
{enabled ? "Active" : "Inactive"}
</span>
</div>
<p className="text-sm text-stone-700 leading-relaxed">
<p className="text-sm text-stone-500 leading-relaxed">
{addon.description}
</p>
{addon.addOnPrice && !enabled && (
<p className="mt-2 text-xs font-medium text-amber-400">
Requires: {addon.addOnPrice}
{addon.addOnPrice && (
<p className="mt-2 text-xs font-medium" style={{ color: '#b45309' }}>
{addon.addOnPrice}/month
</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>
)}
<div className="mt-5 flex items-center justify-between">
<button
onClick={() => handleToggle(key)}
onClick={() => handleToggle(key, !enabled)}
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"
}`}
className="rounded-xl px-5 py-2.5 text-sm font-semibold transition-all"
style={{
background: enabled
? 'rgba(239, 68, 68, 0.1)'
: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
color: enabled ? '#dc2626' : 'white',
border: enabled ? '1px solid rgba(239, 68, 68, 0.2)' : 'none',
boxShadow: enabled ? 'none' : '0 2px 8px rgba(16, 185, 129, 0.25)',
opacity: busy ? 0.5 : 1,
}}
>
{busy ? "..." : enabled ? "Disable" : "Enable"}
</button>
{enabled && addon.adminRoute && (
<a
href={addon.adminRoute}
className="rounded-xl px-4 py-2 text-sm font-medium transition-all"
style={{
background: 'rgba(0, 0, 0, 0.02)',
color: 'rgba(0, 0, 0, 0.6)',
border: '1px solid rgba(0, 0, 0, 0.08)',
}}
>
Open
</a>
)}
</div>
</div>
);
})}
</div>
{/* Enable confirmation modal */}
{showEnableModal && (
<GlassModal
title={`Enable ${ADDON_CATALOG[showEnableModal].name}?`}
subtitle={`This will activate the add-on feature.`}
onClose={() => setShowEnableModal(null)}
>
<div className="space-y-4">
<p className="text-sm text-stone-500">
{ADDON_CATALOG[showEnableModal].description}
</p>
{ADDON_CATALOG[showEnableModal].addOnPrice && (
<p className="text-sm font-medium" style={{ color: '#b45309' }}>
Price: {ADDON_CATALOG[showEnableModal].addOnPrice}/month
</p>
)}
<div className="flex justify-end gap-3 pt-4" style={{ borderTop: '1px solid rgba(0, 0, 0, 0.04)' }}>
<button
onClick={() => setShowEnableModal(null)}
className="rounded-xl px-5 py-2.5 text-sm font-medium text-stone-500 hover:text-stone-700 hover:bg-stone-100 transition-all"
>
Cancel
</button>
<button
onClick={() => handleToggle(showEnableModal, true)}
className="rounded-xl px-6 py-2.5 text-sm font-semibold text-white transition-all"
style={{
background: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.25)',
}}
>
Enable
</button>
</div>
</div>
</GlassModal>
)}
</>
);
}
+301 -93
View File
@@ -11,8 +11,6 @@ import { upsertCampaign, deleteCampaign } from "@/actions/communications/campaig
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" },
@@ -20,18 +18,191 @@ const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
];
const STATUS_COLORS: Record<CampaignStatus, string> = {
draft: "bg-zinc-950 text-zinc-300",
scheduled: "bg-blue-900/40 text-blue-700",
draft: "bg-stone-100 text-stone-600",
scheduled: "bg-blue-100 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",
sent: "bg-emerald-100 text-emerald-700",
canceled: "bg-red-100 text-red-700",
};
export default function CampaignListPanel({ initialCampaigns }: { initialCampaigns: Campaign[] }) {
const TYPE_COLORS: Record<CampaignType, string> = {
operational: "bg-purple-100 text-purple-700",
marketing: "bg-amber-100 text-amber-700",
transactional: "bg-sky-100 text-sky-700",
};
// Icon components
const Icons = {
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
),
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
),
mail: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
),
arrowRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14"/>
<path d="m12 5 7 7-7 7"/>
</svg>
),
};
// New Campaign Modal
function NewCampaignModal({
isOpen,
onClose,
onSuccess,
brandId
}: {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
brandId: string;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [name, setName] = useState("");
const [campaignType, setCampaignType] = useState<CampaignType>("operational");
const [error, setError] = useState("");
if (!isOpen) return null;
const handleCreate = async () => {
if (!name.trim()) {
setError("Campaign name is required");
return;
}
setSaving(true);
setError("");
const result = await upsertCampaign({
brand_id: brandId,
name: name.trim(),
subject: "",
body_text: "",
campaign_type: campaignType,
status: "draft",
audience_rules: { target: "all_customers" },
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to create campaign");
return;
}
setName("");
setCampaignType("operational");
onSuccess();
router.refresh();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl border border-[var(--admin-border)]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--admin-border)]">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
{Icons.mail("h-5 w-5 text-white")}
</div>
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">New Campaign</h2>
<p className="text-xs text-[var(--admin-text-muted)]">Create a new email campaign</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
>
{Icons.x("h-5 w-5 text-[var(--admin-text-muted)]")}
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Campaign Name *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
placeholder="e.g. Weekly Pickup Reminder"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Campaign Type *
</label>
<select
value={campaignType}
onChange={(e) => setCampaignType(e.target.value as CampaignType)}
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
>
{CAMPAIGN_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={saving || !name.trim()}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{saving ? "Creating..." : "Create Campaign"}
{!saving && Icons.arrowRight("h-4 w-4")}
</button>
</div>
</div>
</div>
);
}
export default function CampaignListPanel({ initialCampaigns, brandId = "64294306-5f42-463d-a5e8-2ad6c81a96de" }: { initialCampaigns: Campaign[], brandId?: string }) {
const router = useRouter();
const [campaigns, setCampaigns] = useState(initialCampaigns);
const [filterType, setFilterType] = useState<CampaignType | "all">("all");
const [filterStatus, setFilterStatus] = useState<CampaignStatus | "all">("all");
const [showNewModal, setShowNewModal] = useState(false);
const filtered = campaigns.filter((c) => {
if (filterType !== "all" && c.campaign_type !== filterType) return false;
@@ -40,23 +211,41 @@ export default function CampaignListPanel({ initialCampaigns }: { initialCampaig
});
return (
<div>
<div className="p-4 sm:p-6">
<NewCampaignModal
isOpen={showNewModal}
onClose={() => setShowNewModal(false)}
onSuccess={() => {
setShowNewModal(false);
router.refresh();
}}
brandId={brandId}
/>
{/* Header */}
<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 className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
{Icons.mail("w-4 h-4 text-[var(--admin-bg)]")}
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Campaigns</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">{filtered.length} campaign{filtered.length !== 1 ? "s" : ""}</p>
</div>
</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"
<button
onClick={() => setShowNewModal(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 transition-colors"
>
+ New Campaign
</a>
{Icons.plus("h-3.5 w-3.5 sm:h-4 sm:w-4")}
<span>New Campaign</span>
</button>
</div>
<div className="flex gap-4 mb-4">
{/* Filters */}
<div className="flex gap-3 mb-4">
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-2 bg-white text-[var(--admin-text-primary)]"
value={filterType}
onChange={(e) => setFilterType(e.target.value as CampaignType | "all")}
>
@@ -66,7 +255,7 @@ export default function CampaignListPanel({ initialCampaigns }: { initialCampaig
))}
</select>
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-2 bg-white text-[var(--admin-text-primary)]"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as CampaignStatus | "all")}
>
@@ -78,40 +267,47 @@ export default function CampaignListPanel({ initialCampaigns }: { initialCampaig
</select>
</div>
{/* Table */}
{filtered.length === 0 ? (
<div className="text-center py-12 text-slate-400">No campaigns found</div>
<div className="text-center py-12 text-[var(--admin-text-muted)]">
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>
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-[var(--admin-card)]">
<tr className="border-b border-[var(--admin-border)]">
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Name</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Type</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Status</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Created</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Sent</th>
<th className="text-right px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tbody className="divide-y divide-[var(--admin-border)]">
{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"
}`}>
<tr
key={c.id}
className="hover:bg-[var(--admin-card-hover)] cursor-pointer transition-colors"
onClick={() => router.push(`/admin/communications/campaigns/${c.id}`)}
>
<td className="px-3 sm:px-4 py-3 font-medium text-[var(--admin-text-primary)]">{c.name}</td>
<td className="px-3 sm:px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] sm:text-xs font-medium ${TYPE_COLORS[c.campaign_type]}`}>
{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]}`}>
<td className="px-3 sm:px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] sm: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>
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{formatDate(new Date(c.created_at))}</td>
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{c.sent_at ? formatDate(new Date(c.sent_at)) : "—"}</td>
<td className="px-3 sm:px-4 py-3 text-right">
{Icons.arrowRight("h-4 w-4 text-[var(--admin-text-muted)]")}
</td>
</tr>
))}
</tbody>
@@ -167,7 +363,7 @@ export function CampaignEditPanel({
getCommunicationSegments(brandId).then((result) => {
if (result.success) setSegments(result.segments);
});
}, []);
}, [brandId]);
// When a segment is selected, populate audience fields
function applySegment(segmentId: string) {
@@ -256,37 +452,49 @@ export function CampaignEditPanel({
};
return (
<div className="max-w-3xl">
<div className="p-4 sm:p-6 max-w-3xl">
{/* Header */}
<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 className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
{Icons.mail("w-4 h-4 text-[var(--admin-bg)]")}
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">
{mode === "new" ? "New Campaign" : "Edit Campaign"}
</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
{mode === "new" ? "Create a new email campaign" : "Update campaign settings"}
</p>
</div>
</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>
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">{error}</div>
)}
<div className="space-y-6">
<div className="space-y-4 sm:space-y-6">
{/* Basic info */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Basic Info</h3>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Name *</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
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>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
>
{CAMPAIGN_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
@@ -294,7 +502,7 @@ export function CampaignEditPanel({
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Template</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Template</label>
<select
value={templateId}
onChange={(e) => {
@@ -302,7 +510,7 @@ export function CampaignEditPanel({
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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
>
<option value="">No template</option>
{templates.map((t) => (
@@ -314,16 +522,16 @@ export function CampaignEditPanel({
</div>
{/* Audience builder */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Audience</h3>
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Audience</h3>
{segments.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Saved segment:</span>
<span className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">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"
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-1.5 bg-white"
>
<option value=""> None </option>
{segments.map((s) => (
@@ -334,14 +542,14 @@ export function CampaignEditPanel({
)}
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Target By</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
>
<option value="stop">Stop / Date Range</option>
<option value="zip_code">ZIP Code</option>
@@ -352,31 +560,31 @@ export function CampaignEditPanel({
{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>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
placeholder="UUID"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">From</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">To</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
/>
</div>
</div>
@@ -385,15 +593,15 @@ export function CampaignEditPanel({
type="button"
onClick={loadPreview}
disabled={previewLoading}
className="text-sm text-blue-400 hover:text-blue-700 font-medium"
className="text-xs sm:text-sm text-emerald-600 hover:text-emerald-700 font-semibold"
>
{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
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm">
<span className="font-semibold text-emerald-700">{preview.count}</span> customers match this audience
{preview.sample_customers.length > 0 && (
<div className="mt-2 text-xs text-blue-400">
<div className="mt-2 text-xs text-emerald-600">
Sample: {preview.sample_customers.slice(0, 5).map((c) => c.email).join(", ")}
</div>
)}
@@ -402,61 +610,61 @@ export function CampaignEditPanel({
</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 className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Message Content</h3>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Subject</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
placeholder="Email subject line"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Body (Plain Text)</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
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">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Scheduling</h3>
<div className="flex items-center gap-4 sm:gap-6">
<label className="flex items-center gap-2 text-xs sm:text-sm">
<input
type="radio"
checked={scheduleMode === "now"}
onChange={() => setScheduleMode("now")}
className="text-blue-400"
className="text-emerald-600"
/>
<span className="text-zinc-300">Send immediately</span>
<span className="text-[var(--admin-text-primary)]">Send immediately</span>
</label>
<label className="flex items-center gap-2 text-sm">
<label className="flex items-center gap-2 text-xs sm:text-sm">
<input
type="radio"
checked={scheduleMode === "later"}
onChange={() => setScheduleMode("later")}
className="text-blue-400"
className="text-emerald-600"
/>
<span className="text-zinc-300">Schedule for later</span>
<span className="text-[var(--admin-text-primary)]">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>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] 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 text-sm"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
min={new Date().toISOString().slice(0, 16)}
/>
</div>
@@ -469,7 +677,7 @@ export function CampaignEditPanel({
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"
className="inline-flex items-center rounded-lg bg-emerald-600 px-4 sm:px-5 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{saving ? "Saving..." : scheduleMode === "later" ? "Schedule Campaign" : "Save Draft"}
</button>
@@ -478,17 +686,17 @@ export function CampaignEditPanel({
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"
className="inline-flex items-center rounded-lg bg-emerald-700 px-4 sm:px-5 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-800 disabled:opacity-50 transition-colors"
>
{sending ? "Sending..." : "Send Campaign"}
</button>
)}
{campaign?.id && scheduleMode === "later" && (
<span className="text-sm text-blue-400 font-medium">
<span className="text-xs sm:text-sm text-emerald-600 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">
<a href="/admin/communications" className="text-xs sm:text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] ml-4">
Cancel
</a>
</div>
+105 -30
View File
@@ -1,43 +1,118 @@
"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" },
{ id: "campaigns", label: "Campaigns" },
{ id: "compose", label: "Compose" },
{ id: "segments", label: "Segments" },
{ id: "analytics", label: "Analytics" },
{ id: "templates", label: "Templates" },
{ id: "contacts", label: "Contacts" },
{ id: "logs", label: "Logs" },
];
// Icon components
const Icons = {
mail: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
),
send: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m22 2-11 11"/>
<path d="M22 2 15 22l-4-9-9-4 20-7z"/>
</svg>
),
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
chart: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10"/>
<line x1="18" x2="18" y1="20" y2="4"/>
<line x1="6" x2="6" y1="20" y2="16"/>
</svg>
),
fileText: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" x2="8" y1="13" y2="13"/>
<line x1="16" x2="8" y1="17" y2="17"/>
<line x1="10" x2="8" y1="9" y2="9"/>
</svg>
),
userCheck: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<polyline points="16 11 18 13 22 9"/>
</svg>
),
list: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6"/>
<line x1="8" x2="21" y1="12" y2="12"/>
<line x1="8" x2="21" y1="18" y2="18"/>
<line x1="3" x2="3.01" y1="6" y2="6"/>
<line x1="3" x2="3.01" y1="12" y2="12"/>
<line x1="3" x2="3.01" y1="18" y2="18"/>
</svg>
),
settings: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
),
filter: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
),
};
export default function CommunicationsNav({
activeTab,
}: {
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "settings" | "segments" | "analytics" | "compose";
activeTab: "campaigns" | "templates" | "contacts" | "logs" | "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>
<nav className="grid grid-cols-4 sm:grid-cols-7 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<a
key={tab.id}
href={`/admin/communications/${tab.id}`}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-3 py-2.5 text-[10px] sm:text-xs font-semibold transition-colors ${
isActive
? "bg-emerald-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.id === "campaigns" && Icons.send("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "compose" && Icons.mail("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "segments" && Icons.users("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "analytics" && Icons.chart("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "templates" && Icons.fileText("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "contacts" && Icons.userCheck("h-3.5 w-3.5 sm:h-4 sm:w-4")}
{tab.id === "logs" && Icons.list("h-3.5 w-3.5 sm:h-4 sm:w-4")}
<span>{tab.label}</span>
{isActive && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-4 sm:w-8 bg-white rounded-full" />
)}
</a>
);
})}
</nav>
);
}
export { TABS };
export { TABS, Icons };
+186 -64
View File
@@ -1,10 +1,9 @@
"use client";
import CommunicationsNav from "./CommunicationsNav";
import { useState } from "react";
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";
@@ -12,22 +11,79 @@ import AnalyticsDashboard from "@/components/admin/HarvestReach/AnalyticsDashboa
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";
type Tab = "campaigns" | "templates" | "contacts" | "segments" | "logs" | "analytics";
const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: "campaigns", label: "Campaigns", icon: "mail" },
{ id: "templates", label: "Templates", icon: "file-text" },
{ id: "contacts", label: "Contacts", icon: "users" },
{ id: "segments", label: "Segments", icon: "layers" },
{ id: "logs", label: "Logs", icon: "list" },
{ id: "analytics", label: "Analytics", icon: "chart" },
];
// Icon components
const Icons = {
mail: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
),
fileText: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
),
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
layers: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
),
list: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
),
chart: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
),
};
export default function CommunicationsPage({
campaigns,
templates,
activeTab,
brandId,
editCampaign,
editMode,
editTemplate,
initialLogs = [],
initialSettings = null,
initialContacts = [],
initialContactTotal = 0,
initialSegments = [],
@@ -36,80 +92,146 @@ export default function CommunicationsPage({
}: {
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;
}) {
const [currentTab, setCurrentTab] = useState<Tab>("campaigns");
const [isComposing, setIsComposing] = useState(false);
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 className="min-h-screen bg-[var(--admin-bg)]">
{/* Header */}
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
{/* Title row */}
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
{Icons.mail("h-5 w-5 sm:h-6 sm:w-6 text-white")}
</div>
<div>
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Harvest Reach</h1>
<p className="text-xs text-[var(--admin-text-muted)]">Email campaigns, templates, and contacts</p>
</div>
</div>
)}
{activeTab === "logs" && (
<MessageLogPanel initialLogs={initialLogs} />
)}
{/* Tab navigation */}
<nav className="grid grid-cols-3 sm:grid-cols-6 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => {
setCurrentTab(tab.id);
setIsComposing(false);
}}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-4 py-2.5 sm:py-3 text-[10px] sm:text-sm font-semibold transition-colors ${
currentTab === tab.id
? "bg-emerald-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.icon === "mail" && Icons.mail("h-4 w-4")}
{tab.icon === "file-text" && Icons.fileText("h-4 w-4")}
{tab.icon === "users" && Icons.users("h-4 w-4")}
{tab.icon === "layers" && Icons.layers("h-4 w-4")}
{tab.icon === "list" && Icons.list("h-4 w-4")}
{tab.icon === "chart" && Icons.chart("h-4 w-4")}
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
{currentTab === tab.id && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-12 bg-white rounded-full" />
)}
</button>
))}
</nav>
</div>
{activeTab === "settings" && (
<CommunicationSettingsForm settings={initialSettings} brandId={brandId} />
)}
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
{/* Campaigns Tab */}
{currentTab === "campaigns" && !isComposing && (
editCampaign !== undefined || editMode === "new" ? (
<CampaignEditPanel
campaign={editCampaign ?? undefined}
templates={templates}
mode={editMode ?? "edit"}
brandId={brandId}
/>
) : (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<CampaignListPanel initialCampaigns={campaigns} brandId={brandId} />
</div>
)
)}
{activeTab === "segments" && (
<SegmentBuilderPage brandId={brandId} initialSegments={initialSegments} />
)}
{/* Templates Tab */}
{currentTab === "templates" && !isComposing && (
editTemplate !== undefined || editMode === "new" ? (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<TemplateEditForm
template={editTemplate ?? undefined}
mode={editMode ?? "edit"}
brandId={brandId}
/>
</div>
) : (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<TemplateListPanel templates={templates} brandId={brandId} />
</div>
)
)}
{activeTab === "analytics" && (
<AnalyticsDashboard analytics={initialAnalytics} />
)}
{/* Contacts Tab */}
{currentTab === "contacts" && !isComposing && (
<div className="space-y-4">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<ContactListPanel initialContacts={initialContacts} initialTotal={initialContactTotal} brandId={brandId} />
</div>
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<ContactImportForm brandId={brandId} />
</div>
</div>
)}
{(activeTab === "compose" || (activeTab === "campaigns" && editCampaignId)) && (
<CampaignComposerPage
brandId={brandId}
campaigns={campaigns}
templates={templates}
segments={initialSegments}
editCampaignId={editCampaignId}
/>
)}
{/* Segments Tab */}
{currentTab === "segments" && !isComposing && (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<SegmentBuilderPage brandId={brandId} initialSegments={initialSegments} />
</div>
)}
{/* Logs Tab */}
{currentTab === "logs" && !isComposing && (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<MessageLogPanel brandId={brandId} />
</div>
)}
{/* Analytics Tab */}
{currentTab === "analytics" && !isComposing && (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<AnalyticsDashboard analytics={initialAnalytics} />
</div>
)}
{/* Compose Mode */}
{(isComposing || editCampaignId) && (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white">
<CampaignComposerPage
brandId={brandId}
campaigns={campaigns}
templates={templates}
segments={initialSegments}
editCampaignId={editCampaignId}
/>
</div>
)}
</div>
</div>
);
}
+234 -82
View File
@@ -9,6 +9,7 @@ import {
type ColumnMapping,
type ImportField,
} from "@/actions/communications/contacts";
import { uploadContactsToBucket, processBucketImport, listImportHistory } from "@/actions/communications/import-contacts";
type Step = "idle" | "preview" | "confirming" | "importing" | "result";
@@ -33,6 +34,57 @@ const FIELD_LABELS: Record<string, string> = {
external_id: "External ID",
};
// Icon components
const Icons = {
upload: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
),
uploadCloud: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 16 12 12 8 16"/>
<line x1="12" y1="12" x2="12" y2="21"/>
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/>
<polyline points="16 16 12 12 8 16"/>
</svg>
),
file: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
spinner: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
</path>
</svg>
),
};
// Helper for stat colors
function getStatColor(highlight: boolean, warn: boolean) {
if (highlight) return "bg-emerald-50 border-emerald-200 text-emerald-700";
if (warn) return "bg-amber-50 border-amber-200 text-amber-700";
return "bg-[var(--admin-card)] border-[var(--admin-border)] text-[var(--admin-text-primary)]";
}
function getStatValueColor(highlight: boolean, warn: boolean) {
if (highlight) return "text-emerald-600";
if (warn) return "text-amber-600";
return "text-[var(--admin-text-muted)]";
}
export default function ContactImportForm({ brandId }: { brandId: string }) {
const [step, setStep] = useState<Step>("idle");
const [file, setFile] = useState<File | null>(null);
@@ -46,8 +98,14 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
const [overridingMappings, setOverridingMappings] = useState<
Record<string, ImportField>
>({});
const [useBucket, setUseBucket] = useState(false); // Toggle for bucket upload
const [uploadProgress, setUploadProgress] = useState<string>("");
const [importHistory, setImportHistory] = useState<{ filename: string; size: number; createdAt: string }[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
// For large file detection (farmers with lots of data)
const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB
// ── CSV parsing + preview ──────────────────────────────────────────────────
const handleFile = useCallback(
@@ -57,6 +115,55 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
setGlobalError("");
setOverridingMappings({});
// Check if file is large enough to warrant bucket upload
const isLargeFile = f.size > LARGE_FILE_THRESHOLD;
if (isLargeFile) {
// Use bucket upload for large files
setUploadProgress("Uploading file to storage...");
setUseBucket(true);
const uploadResult = await uploadContactsToBucket(brandId, f);
if (!uploadResult.success) {
setGlobalError(uploadResult.error);
setStep("idle");
return;
}
setUploadProgress(`Processing ${uploadResult.recordCount.toLocaleString()} records...`);
// Process from bucket
const processResult = await processBucketImport(brandId, uploadResult.fileUrl);
setImporting(false);
setUploadProgress("");
if (!processResult.success) {
setGlobalError(processResult.error);
setStep("idle");
return;
}
setResult({
created: processResult.created,
updated: processResult.updated,
skipped: processResult.skipped,
errors: [],
});
setStep("result");
// Load import history
const historyResult = await listImportHistory(brandId);
if (historyResult.success) {
setImportHistory(historyResult.imports);
}
return;
}
// Browser-based processing for smaller files
setUseBucket(false);
const text = await f.text();
const previewResult = await previewContactImport(text);
@@ -75,7 +182,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
setPreview(previewResult.preview);
setStep("preview");
},
[]
[brandId, LARGE_FILE_THRESHOLD]
);
const handleDrop = useCallback(
@@ -255,24 +362,24 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
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">
<table className="w-full text-xs border border-[var(--admin-border)] rounded-lg">
<thead className="bg-[var(--admin-card)]">
<tr>
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
CSV Column
</th>
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
Maps To
</th>
<th className="px-3 py-2 text-left text-zinc-400 font-medium">
<th className="px-3 py-2 text-left text-[var(--admin-text-muted)] font-semibold">
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">
<tr key={m.csvColumn} className="border-t border-[var(--admin-border)]">
<td className="px-3 py-2 text-[var(--admin-text-primary)] font-mono text-xs">
{m.csvColumn}
</td>
<td className="px-3 py-2">
@@ -290,7 +397,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
: (val as ImportField),
}));
}}
className="text-xs border border-zinc-600 rounded px-2 py-1"
className="text-xs border border-[var(--admin-border)] rounded px-2 py-1 bg-white text-[var(--admin-text-primary)]"
>
<option value="ignore"> ignore </option>
{Object.entries(FIELD_LABELS).map(([k, label]) => (
@@ -300,7 +407,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
))}
</select>
</td>
<td className="px-3 py-2 text-zinc-500 text-xs">
<td className="px-3 py-2 text-[var(--admin-text-muted)] text-xs">
{m.sampleValues.length > 0
? m.sampleValues.join(", ")
: "—"}
@@ -312,7 +419,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
</div>
{preview.ignoredColumns.length > 0 && (
<p className="text-xs text-zinc-500">
<p className="text-xs text-[var(--admin-text-muted)]">
Extra columns (stored in metadata):
<span className="font-mono">
{" "}
@@ -348,44 +455,44 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
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">
<table className="w-full text-xs border border-[var(--admin-border)] rounded-lg">
<thead className="bg-[var(--admin-card)]">
<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>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">#</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">email</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">phone</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">first_name</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">last_name</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">full_name</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">email_opt_in</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">sms_opt_in</th>
<th className="px-2 py-1 text-left text-[var(--admin-text-muted)]">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">
<tr key={i} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-card-hover)]">
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{i + 1}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.email ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.phone ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.first_name ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.last_name ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-primary)]">{row.full_name ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-muted)]">
{row.email_opt_in === undefined
? ""
: row.email_opt_in
? "true"
: "false"}
</td>
<td className="px-2 py-1">
<td className="px-2 py-1 text-[var(--admin-text-muted)]">
{row.sms_opt_in === undefined
? ""
: row.sms_opt_in
? "true"
: "false"}
</td>
<td className="px-2 py-1">{row.tags?.join("; ") ?? ""}</td>
<td className="px-2 py-1 text-[var(--admin-text-muted)]">{row.tags?.join("; ") ?? ""}</td>
</tr>
))}
</tbody>
@@ -397,21 +504,50 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
// ── Main render ────────────────────────────────────────────────────────────
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="p-4 sm:p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Import Contacts</h3>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-[var(--admin-text-primary)]">
{Icons.uploadCloud("w-3 h-3 text-[var(--admin-bg)]")}
</div>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Import Contacts</h3>
</div>
{step !== "idle" && (
<button
onClick={handleReset}
className="text-xs text-zinc-500 hover:text-zinc-300"
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Start over
</button>
)}
</div>
{/* Large file banner */}
{file && file.size > LARGE_FILE_THRESHOLD && (
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100">
{Icons.check("h-4 w-4 text-emerald-600")}
</div>
<div>
<p className="font-semibold text-emerald-700">Large file detected</p>
<p className="text-xs text-emerald-600">File will be uploaded to cloud storage for processing</p>
</div>
</div>
)}
{/* Upload progress */}
{uploadProgress && (
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-4 text-sm">
<div className="flex items-center gap-3">
{Icons.spinner("h-5 w-5 text-blue-600 animate-spin")}
<span className="text-blue-700 font-medium">{uploadProgress}</span>
</div>
</div>
)}
{globalError && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm text-red-400">
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{globalError}
</div>
)}
@@ -420,7 +556,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
{step === "idle" && (
<>
<div
className="border-2 border-dashed border-zinc-600 rounded-xl p-8 text-center"
className="border-2 border-dashed border-[var(--admin-border)] rounded-xl p-8 text-center hover:border-emerald-400 transition-colors"
onDragOver={(e) => {
e.preventDefault();
}}
@@ -435,16 +571,19 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
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">
<div className="text-[var(--admin-text-muted)] text-sm">
<span className="font-semibold text-emerald-600 hover:text-emerald-700">
Click to upload
</span>
{" "}or drag and drop a CSV file
</div>
<p className="text-xs text-slate-400 mt-1">
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
Works with Mailchimp, Square, Shopify, WooCommerce, QuickBooks, and
generic spreadsheets
</p>
<p className="text-[10px] text-[var(--admin-text-muted)] mt-2 text-emerald-600">
Large files (5MB+) automatically uploaded to cloud storage
</p>
</label>
</div>
</>
@@ -456,14 +595,14 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
{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"
className="rounded-lg bg-amber-50 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>
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm text-emerald-700">
<p className="font-semibold mb-1">{file?.name}</p>
<p>
Column mappings detected review below, adjust if needed, then
confirm import.
@@ -475,14 +614,14 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
</div>
<div>
<p className="text-xs text-zinc-500 mb-2 font-medium">
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
Import Summary
</p>
{renderStats()}
</div>
<div>
<p className="text-xs text-zinc-500 mb-2 font-medium">
<p className="text-xs text-[var(--admin-text-muted)] mb-2 font-semibold uppercase tracking-wide">
Sample rows (first {preview.sampleRows.length})
</p>
{renderSampleRows()}
@@ -490,17 +629,17 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
{preview.skippedReasons.length > 0 && (
<div>
<p className="text-xs text-zinc-500 mb-1 font-medium">
<p className="text-xs text-[var(--admin-text-muted)] mb-1 font-semibold">
Skipped rows ({preview.skippedReasons.length})
</p>
<div className="max-h-32 overflow-y-auto text-xs text-zinc-500 space-y-1">
<div className="max-h-32 overflow-y-auto text-xs text-[var(--admin-text-muted)] space-y-1">
{preview.skippedReasons.slice(0, 10).map((s) => (
<p key={s.rowIndex}>
Row {s.rowIndex + 1}: {s.reason}
</p>
))}
{preview.skippedReasons.length > 10 && (
<p className="text-slate-400">
<p className="text-[var(--admin-text-muted)]">
...and{" "}
{preview.skippedReasons.length - 10} more
</p>
@@ -510,7 +649,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
)}
{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">
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
Large file ({preview.totalRows.toLocaleString()} rows) confirm to
proceed.
</div>
@@ -519,14 +658,14 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
<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"
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
>
Cancel
</button>
<button
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"
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
Import {preview.validRows} contacts
</button>
@@ -536,7 +675,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
{/* ── 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">
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-6 text-center text-sm text-emerald-700 font-semibold">
Importing {importRows.length} contacts...
</div>
)}
@@ -544,26 +683,29 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
{/* ── 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 className="rounded-lg bg-emerald-50 border border-emerald-200 px-4 py-3 text-sm space-y-1">
<p className="font-semibold text-emerald-700 flex items-center gap-2">
{Icons.check("h-4 w-4")}
Import complete
</p>
<p className="text-emerald-600">Created: {result.created.toLocaleString()}</p>
<p className="text-emerald-600">Updated: {result.updated.toLocaleString()}</p>
<p className="text-emerald-600">Skipped: {result.skipped.toLocaleString()}</p>
</div>
{result.errors.length > 0 && (
<div className="rounded-lg bg-red-900/30 border border-red-200 px-4 py-3 text-sm">
<p className="font-medium text-red-400 mb-2">
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm">
<p className="font-semibold text-red-600 mb-2">
Errors ({result.errors.length})
</p>
<div className="max-h-40 overflow-y-auto space-y-1">
{result.errors.slice(0, 10).map((e, i) => (
<p key={i} className="text-red-400 text-xs">
<p key={i} className="text-red-600 text-xs">
{e.error}: {JSON.stringify(e.row).slice(0, 80)}
</p>
))}
{result.errors.length > 10 && (
<p className="text-red-400 text-xs">
<p className="text-red-600 text-xs">
...and {result.errors.length - 10} more
</p>
)}
@@ -573,12 +715,34 @@ export default function ContactImportForm({ brandId }: { brandId: string }) {
<button
onClick={handleReset}
className="rounded-lg border border-zinc-600 px-4 py-2 text-sm text-zinc-400 hover:bg-zinc-900"
className="rounded-lg border border-[var(--admin-border)] px-4 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
>
Import more
</button>
</div>
)}
{/* Import history for bucket imports */}
{importHistory.length > 0 && (
<div className="border-t border-[var(--admin-border)] pt-4 mt-4">
<p className="text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide mb-3">
Previous Imports
</p>
<div className="space-y-2">
{importHistory.slice(0, 5).map((imp, i) => (
<div key={i} className="flex items-center justify-between text-xs p-2 rounded-lg bg-[var(--admin-card)]">
<div className="flex items-center gap-2">
{Icons.file("h-4 w-4 text-[var(--admin-text-muted)]")}
<span className="text-[var(--admin-text-primary)]">{imp.filename}</span>
</div>
<span className="text-[var(--admin-text-muted)]">
{(imp.size / 1024).toFixed(1)} KB
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -594,28 +758,16 @@ function Stat({
highlight?: boolean;
warn?: boolean;
}) {
const bgColor = highlight ? "bg-emerald-50 border-emerald-200" : warn ? "bg-amber-50 border-amber-200" : "bg-[var(--admin-card)] border-[var(--admin-border)]";
const valueColor = highlight ? "text-emerald-700" : warn ? "text-amber-700" : "text-[var(--admin-text-primary)]";
const labelColor = highlight ? "text-emerald-600" : warn ? "text-amber-600" : "text-[var(--admin-text-muted)]";
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"
}`}
>
<div className={`rounded-lg border px-4 py-3 text-center ${bgColor}`}>
<p className={`text-xl font-bold ${valueColor}`}>
{value.toLocaleString()}
</p>
<p className="text-xs text-zinc-500 mt-0.5">{label}</p>
<p className={`text-xs mt-0.5 ${labelColor}`}>{label}</p>
</div>
);
}
}
+123 -64
View File
@@ -6,10 +6,53 @@ import { getContacts, deleteContact, exportContacts } from "@/actions/communicat
import { formatDate } from "@/lib/format-date";
const SOURCE_COLORS: Record<ContactSource, string> = {
order: "bg-blue-900/40 text-blue-700",
order: "bg-blue-100 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",
manual: "bg-emerald-100 text-emerald-700",
admin: "bg-stone-100 text-stone-600",
};
// Icon components
const Icons = {
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
download: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
trash: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
),
chevronLeft: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
),
chevronRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
),
};
export default function ContactListPanel({
@@ -101,34 +144,46 @@ export default function ContactListPanel({
};
return (
<div>
<div className="p-4 sm:p-6">
{/* Header */}
<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 className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
{Icons.users("w-4 h-4 text-[var(--admin-bg)]")}
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Contacts</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">{total} contact{total !== 1 ? "s" : ""}</p>
</div>
</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"
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--admin-border)] bg-white px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
>
{exporting ? "Exporting..." : "Export Contacts"}
{Icons.download("h-3.5 w-3.5 sm:h-4 sm:w-4")}
<span>{exporting ? "Exporting..." : "Export"}</span>
</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"
/>
<form onSubmit={handleSearch} className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
</div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search email, name, phone..."
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
/>
</div>
<select
value={sourceFilter}
onChange={(e) => handleSourceFilter(e.target.value as ContactSource | "")}
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
className="text-sm border border-[var(--admin-border)] rounded-lg px-3 py-2 bg-white text-[var(--admin-text-primary)]"
>
<option value="">All Sources</option>
<option value="order">Order</option>
@@ -138,7 +193,7 @@ export default function ContactListPanel({
</select>
<button
type="submit"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition-colors"
>
Search
</button>
@@ -146,56 +201,58 @@ export default function ContactListPanel({
{/* Table */}
{loading ? (
<div className="text-center py-12 text-slate-400">Loading...</div>
<div className="text-center py-12 text-[var(--admin-text-muted)]">Loading...</div>
) : contacts.length === 0 ? (
<div className="text-center py-12 text-slate-400">No contacts found</div>
<div className="text-center py-12 text-[var(--admin-text-muted)]">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>
{/* Desktop Table */}
<div className="hidden sm:block overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-[var(--admin-card)]">
<tr className="border-b border-[var(--admin-border)]">
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Name</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Email</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Phone</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Source</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Email</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Unsubscribed</th>
<th className="text-right px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tbody className="divide-y divide-[var(--admin-border)]">
{contacts.map((c) => (
<tr key={c.id} className="hover:bg-zinc-800">
<td className="px-4 py-3 font-medium text-zinc-100">
<tr key={c.id} className="hover:bg-[var(--admin-card-hover)] transition-colors">
<td className="px-3 sm:px-4 py-3 font-medium text-[var(--admin-text-primary)]">
{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]}`}>
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{c.email || "—"}</td>
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{c.phone || "—"}</td>
<td className="px-3 sm:px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] sm:text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
{c.source}
</span>
</td>
<td className="px-4 py-3">
<td className="px-3 sm:px-4 py-3">
{c.email_opt_in ? (
<span className="text-green-600 text-xs font-medium">Opted in</span>
<span className="text-emerald-600 text-xs font-semibold">Opted in</span>
) : (
<span className="text-red-500 text-xs font-medium">Opted out</span>
<span className="text-red-500 text-xs font-semibold">Opted out</span>
)}
</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)] text-xs">
{c.unsubscribed_at
? formatDate(new Date(c.unsubscribed_at))
: "—"}
</td>
<td className="px-4 py-3 text-right">
<td className="px-3 sm: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"
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 disabled:opacity-50 transition-colors"
title="Delete contact"
>
{deleting === c.id ? "..." : "Delete"}
{Icons.trash("h-4 w-4")}
</button>
</td>
</tr>
@@ -207,29 +264,29 @@ export default function ContactListPanel({
{/* 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 key={c.id} className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
<div className="flex items-start justify-between">
<div className="font-medium text-zinc-100 text-sm">
<div className="font-semibold text-[var(--admin-text-primary)] 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"
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 disabled:opacity-50"
>
{deleting === c.id ? "..." : "Delete"}
{Icons.trash("h-4 w-4")}
</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]}`}>
<div className="text-xs text-[var(--admin-text-muted)]">{c.email || "—"}</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${SOURCE_COLORS[c.source]}`}>
{c.source}
</span>
{c.phone && <span className="text-xs text-zinc-500">{c.phone}</span>}
{c.phone && <span className="text-xs text-[var(--admin-text-muted)]">{c.phone}</span>}
{c.email_opt_in ? (
<span className="text-green-600 text-xs font-medium">Opted in</span>
<span className="text-emerald-600 text-xs font-semibold">Opted in</span>
) : (
<span className="text-red-500 text-xs font-medium">Opted out</span>
<span className="text-red-500 text-xs font-semibold">Opted out</span>
)}
</div>
</div>
@@ -237,24 +294,26 @@ export default function ContactListPanel({
</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}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
<span className="text-xs sm:text-sm text-[var(--admin-text-muted)]">
Page {page + 1} {Math.min((page + 1) * limit, total)} 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"
className="inline-flex items-center gap-1 rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-xs sm:text-sm disabled:opacity-50 hover:bg-[var(--admin-card-hover)] transition-colors"
>
Previous
{Icons.chevronLeft("h-4 w-4")}
<span>Previous</span>
</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"
className="inline-flex items-center gap-1 rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-xs sm:text-sm disabled:opacity-50 hover:bg-[var(--admin-card-hover)] transition-colors"
>
Next
<span>Next</span>
{Icons.chevronRight("h-4 w-4")}
</button>
</div>
</div>
@@ -262,4 +321,4 @@ export default function ContactListPanel({
)}
</div>
);
}
}
+376
View File
@@ -0,0 +1,376 @@
"use client";
import { useState, type ReactNode } from "react";
import Link from "next/link";
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
type Section = {
title: string;
href: string;
description: string;
group: "operations" | "fulfillment" | "management" | "tools";
addonKey?: string;
upgradeText?: string;
prominent?: boolean;
};
const sections: Section[] = [
{
title: "Orders",
href: "/admin/orders",
description: "View orders, pickup status, fulfillment, and customer details.",
group: "operations",
prominent: true,
},
{
title: "Products",
href: "/admin/products",
description: "Manage products, pricing, shipping type, and availability.",
group: "operations",
prominent: true,
},
{
title: "Tours & Stops",
href: "/admin/stops",
description: "Manage routes, pickup locations, dates, and cutoff times.",
group: "fulfillment",
},
{
title: "Driver Pickup",
href: "/admin/pickup",
description: "Mobile pickup lookup, QR scanning, and completion tools.",
group: "fulfillment",
},
{
title: "Shipping",
href: "/admin/shipping",
description: "FedEx integration, label creation, and shipment tracking.",
group: "fulfillment",
},
{
title: "Reports",
href: "/admin/reports",
description: "Sales, route, product, pickup, and customer reports.",
group: "management",
},
{
title: "Tax Dashboard",
href: "/admin/taxes",
description: "Sales tax collected, breakdown by state, and exportable reports.",
group: "management",
},
{
title: "Settings",
href: "/admin/settings",
description: "Users, billing, brand, integrations, payments, and shipping.",
group: "management",
},
{
title: "Harvest Reach",
href: "/admin/communications",
description: "Email campaigns, stop blast, templates, and audience segments.",
group: "tools",
addonKey: "harvest_reach",
upgradeText: "Enable to access email & SMS marketing",
},
{
title: "Wholesale Portal",
href: "/admin/wholesale",
description: "Standalone B2B portal with custom pricing, credit limits, and net-30.",
group: "tools",
addonKey: "wholesale_portal",
upgradeText: "Enable to unlock B2B buyer portal",
},
{
title: "Import Center",
href: "/admin/import",
description: "AI-powered import for products, orders, contacts, and stops.",
group: "tools",
addonKey: "ai_tools",
upgradeText: "Enable AI import with smart column mapping",
},
{
title: "AI Intelligence",
href: "/admin/settings/ai",
description: "Campaign writer, pricing advisor, and report explainer.",
group: "tools",
addonKey: "ai_tools",
upgradeText: "Enable AI tools for marketing and pricing",
},
{
title: "Time Tracking",
href: "/admin/time-tracking",
description: "Worker clock-in/out, hours tracking, and overtime management.",
group: "tools",
addonKey: "time_tracking",
upgradeText: "Enable for field worker time tracking",
},
{
title: "Water Log",
href: "/admin/water-log",
description: "Irrigation tracking and water usage reporting.",
group: "tools",
addonKey: "water_log",
upgradeText: "Enable for agricultural water tracking",
},
{
title: "Route Trace",
href: "/admin/route-trace",
description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.",
group: "tools",
addonKey: "route_trace",
upgradeText: "Enable for field-to-delivery traceability",
},
];
type Tab = "operations" | "fulfillment" | "management" | "tools";
const TABS: { id: Tab; label: string; icon: ReactNode }[] = [
{
id: "operations",
label: "Operations",
icon: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
</svg>
),
},
{
id: "fulfillment",
label: "Fulfillment",
icon: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
),
},
{
id: "management",
label: "Management",
icon: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
),
},
{
id: "tools",
label: "Tools",
icon: (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
),
},
];
type Props = {
brandId: string | null;
brandName: string;
planTier: string;
isWaterLogVisible: boolean;
enabledAddons: Record<string, boolean>;
usage: { users: number; stops_this_month: number; products: number };
limits: { max_users: number; max_stops_monthly: number; max_products: number };
};
export default function DashboardClient({
brandId,
brandName,
planTier,
isWaterLogVisible,
enabledAddons,
usage,
limits,
}: Props) {
const [activeTab, setActiveTab] = useState<Tab>("operations");
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
const usagePct = {
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
stops: limits.max_stops_monthly > 0 ? (usage.stops_this_month / limits.max_stops_monthly) * 100 : 0,
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
};
const tabSections = sections.filter((s) => s.group === activeTab);
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
{/* Header */}
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-4 sm:mb-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
<svg className="h-5 w-5 sm:h-6 sm:w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-black text-[var(--admin-text-primary)] tracking-tight">Admin Dashboard</h1>
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">{brandName} Control Center</p>
</div>
</div>
<div className="flex items-center gap-4">
<a href="/admin/settings/billing" className="text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors">
Billing
</a>
{planTier === "starter" && brandId && (
<button
onClick={() => setIsUpgradeOpen(true)}
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
>
Upgrade Plan
</button>
)}
</div>
</div>
{/* Usage stats - compact bar */}
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 mb-4">
<div className="flex items-center gap-4 mb-3">
<span className={`rounded-full px-3 py-1 text-xs font-semibold tracking-wide ${
planTier === "enterprise" ? "bg-amber-100 text-amber-700 border border-amber-200" :
planTier === "farm" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" :
"bg-stone-100 text-stone-600 border border-stone-200"
}`}>
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
</span>
<span className="text-xs text-stone-500">{brandId ? "Tuxedo Corn" : "All Brands"}</span>
</div>
<div className="grid grid-cols-3 gap-4">
{[
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
].map(({ label, value, pct }) => (
<div key={label}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-stone-600">{label}</span>
<span className="text-xs font-semibold text-stone-900">{value}</span>
</div>
<div className="h-1 rounded-full bg-stone-200 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
pct > 85 ? "bg-amber-500" : "bg-emerald-500"
}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* Tab navigation */}
<nav className="grid grid-cols-4 gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)]">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-2 sm:px-4 py-2.5 sm:py-3 text-[10px] sm:text-sm font-semibold transition-colors ${
activeTab === tab.id
? "bg-emerald-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.icon}
<span>{tab.label}</span>
{activeTab === tab.id && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-12 bg-white rounded-full" />
)}
</button>
))}
</nav>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tabSections.map((section) => {
if (section.title === "Water Log" && !isWaterLogVisible) return null;
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
const isAddon = Boolean(section.addonKey);
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
const isProminent = section.prominent;
return (
<Link
key={section.title}
href={section.href}
className={[
"group relative flex flex-col rounded-2xl border bg-white p-5 transition-all hover:-translate-y-0.5 hover:shadow-md",
isAddon && !isEnabled
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
: isProminent
? "border-emerald-200 shadow-md shadow-emerald-100/50"
: "border-stone-200 shadow-sm",
].join(" ")}
>
<div className="flex items-center justify-between mb-4">
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${
isAddon && !isEnabled
? "bg-stone-100 text-stone-400"
: isProminent
? "bg-emerald-100 text-emerald-600"
: "bg-violet-100 text-violet-600"
}`}>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
{isAddon && !isEnabled && (
<span className="text-[10px] font-semibold text-amber-800 bg-amber-100 border border-amber-200 rounded-full px-2.5 py-0.5">
Add-on
</span>
)}
{isAddon && isEnabled && (
<span className="text-[10px] font-semibold text-emerald-800 bg-emerald-100 border border-emerald-200 rounded-full px-2.5 py-0.5">
Active
</span>
)}
{isProminent && (
<span className="text-[10px] font-semibold text-emerald-800 bg-emerald-100 border border-emerald-200 rounded-full px-2.5 py-0.5">
Core
</span>
)}
</div>
<h3 className="text-sm font-semibold leading-tight text-stone-950">
{section.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${
isAddon && !isEnabled ? "text-stone-500" : "text-stone-600"
}`}>
{section.description}
</p>
{isAddon && !isEnabled && section.upgradeText && (
<p className="mt-3 text-xs text-amber-700 font-medium">{section.upgradeText}</p>
)}
</Link>
);
})}
</div>
</div>
{/* Upgrade Modal */}
{planTier === "starter" && brandId && (
<UpgradePlanModal
isOpen={isUpgradeOpen}
onClose={() => setIsUpgradeOpen(false)}
brandId={brandId}
currentTier={planTier}
/>
)}
</div>
);
}
+15 -5
View File
@@ -17,13 +17,23 @@ export default function DashboardHeader({ brandId, brandName, planTier }: Dashbo
return (
<>
<div className="flex items-end justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600 mb-2">Control Center</p>
<h1 className="text-4xl font-semibold tracking-tight text-stone-950">{brandName}</h1>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-600">
<svg className="h-6 w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16.5 9.4 7.55 4.24"/>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)] tracking-tight">Admin Dashboard</h1>
<p className="text-xs sm:text-sm text-[var(--admin-text-muted)]">{brandName} Control Center</p>
</div>
</div>
<div className="flex items-center gap-4">
<a href="/admin/settings/billing" className="text-sm font-medium text-stone-500 hover:text-stone-700 transition-colors">
<a href="/admin/settings/billing" className="text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors">
Billing
</a>
{planTier === "starter" && brandId && (
+142 -131
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState } from "react";
import Link from "next/link";
import { markPickupComplete } from "@/actions/pickup";
@@ -56,7 +56,6 @@ function formatPickupTime(iso: string | null) {
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" });
}
@@ -83,17 +82,13 @@ export default function DriverPickupPanel({
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;
const matchesStop = !stopFilter || o.stop_id === stopFilter;
return matchesSearch && matchesStop;
});
@@ -103,9 +98,7 @@ export default function DriverPickupPanel({
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;
const matchesStop = !stopFilter || o.stop_id === stopFilter;
return matchesSearch && matchesStop;
});
@@ -139,60 +132,71 @@ export default function DriverPickupPanel({
}
return (
<div className="min-h-screen bg-zinc-950">
<div className="min-h-screen bg-stone-50">
{/* Header */}
<div className="bg-slate-900 px-4 py-5">
<div className="mx-auto max-w-6xl">
<header className="border-b border-stone-200 bg-white">
<div className="mx-auto max-w-5xl px-6 py-5">
<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">
<h1 className="text-2xl font-semibold text-stone-950 tracking-tight">Driver Pickup</h1>
<p className="mt-1 text-sm text-stone-500">
{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"
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
All Orders
</Link>
</div>
</div>
</div>
</header>
<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"
/>
<main className="mx-auto max-w-5xl px-6 py-6 space-y-5">
{/* Filters */}
<div className="flex gap-3">
<select
value={stopFilter}
onChange={(e) => setStopFilter(e.target.value)}
className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-700 outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
>
<option value="">All Stops</option>
{stops.map((s) => (
<option key={s.id} value={s.id}>
{s.city}, {s.state} · {s.date}
</option>
))}
</select>
<div className="relative flex-1">
<svg className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
placeholder="Search name, phone, or order #..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white pl-11 pr-4 py-3 text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
</div>
{/* 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
{filteredPending.length === 0 ? (
<div className="rounded-2xl bg-white border border-stone-200 py-16 text-center">
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-stone-100 flex items-center justify-center">
<svg className="h-6 w-6 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
) : (
filteredPending.map((order) => (
<p className="text-lg font-medium text-stone-600">No pending orders</p>
<p className="mt-1 text-sm text-stone-400">Orders awaiting pickup will appear here</p>
</div>
) : (
<div className="space-y-4">
{filteredPending.map((order) => (
<OrderCard
key={order.id}
order={order}
@@ -201,45 +205,45 @@ export default function DriverPickupPanel({
pickingUp={pickingUp}
shortId={shortId(order.id)}
/>
))
)}
</div>
))}
</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"
className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors"
>
{showPickedUp ? " Hide" : ""} {pickedUpOrders.length} picked up
{showPickedUp ? " Hide" : "▶ Show"} {pickedUpOrders.length} picked up
</button>
)}
{showPickedUp && (
{showPickedUp && filteredPickedUp.length > 0 && (
<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)}
/>
))
)}
{filteredPickedUp.map((order) => (
<PickedUpCard
key={order.id}
order={order}
shortId={shortId(order.id)}
/>
))}
</div>
)}
{/* Success toast */}
{showPickedUp && filteredPickedUp.length === 0 && stopFilter && (
<div className="rounded-xl border border-stone-200 bg-stone-50 py-6 text-center text-sm text-stone-500">
No picked-up orders for this stop
</div>
)}
{/* 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">
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-emerald-200/50">
Order picked up
</div>
)}
</div>
</main>
</div>
);
}
@@ -260,104 +264,113 @@ function OrderCard({ order, canManagePickup, onMarkPickup, pickingUp, shortId }:
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="rounded-2xl bg-white border border-stone-200 p-6 shadow-sm">
{/* Header row */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xl font-bold text-zinc-100">{order.customer_name}</p>
<p className="text-lg font-semibold text-stone-950">{order.customer_name}</p>
{order.customer_phone && (
<p className="mt-1 text-base text-zinc-400">{order.customer_phone}</p>
<p className="mt-0.5 text-sm text-stone-500">{order.customer_phone}</p>
)}
<p className="mt-1 font-mono text-xs text-slate-400 uppercase">{shortId}</p>
<p className="mt-1 font-mono text-xs text-stone-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">
<div className="flex flex-col items-end gap-2">
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-700">
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 className="rounded-full bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-700">
Mixed
</span>
)}
</div>
</div>
{/* Products list — pickup items only */}
{/* Items */}
{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">
<div className="mt-5 rounded-xl bg-stone-50 border border-stone-100 p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wide text-stone-500">
Pickup Items
</span>
<span className="text-xs text-stone-400">
{pickupItems.reduce((s, i) => s + i.quantity, 0)} total
</span>
</div>
<ul className="space-y-2">
{pickupItems.map((item) => (
<li key={item.id} className="flex items-center justify-between text-sm">
<span className="text-zinc-300">
<span className="text-stone-700">
{item.quantity > 1 && (
<span className="font-semibold text-zinc-100">{item.quantity}× </span>
<span className="font-semibold text-stone-900">{item.quantity}× </span>
)}
{item.products?.name ?? "Unknown Product"}
</span>
<span className="text-zinc-500">
<span className="text-stone-500 font-medium">
${(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 className="mt-3 border-t border-stone-200 pt-3 flex justify-between">
<span className="text-sm font-medium text-stone-600">Subtotal</span>
<span className="text-sm font-bold text-stone-900">${pickupTotal.toFixed(2)}</span>
</div>
</div>
)}
{/* Shipping items note */}
{/* Shipping warning */}
{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 className="mt-4 rounded-lg bg-violet-50 border border-violet-100 px-4 py-3 text-sm text-violet-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">
{/* Footer */}
<div className="mt-5 flex items-center justify-between">
<div>
{order.stops ? (
<p className="font-medium text-zinc-300">
{order.stops.city}, {order.stops.state}
</p>
<>
<p className="font-medium text-stone-700">{order.stops.city}, {order.stops.state}</p>
<p className="text-sm text-stone-400">
{pickupItems.length} pickup {pickupItems.length === 1 ? "item" : "items"}
</p>
</>
) : (
<p className="text-slate-400">No stop</p>
<p className="text-stone-400">No stop assigned</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>
<p className="text-2xl font-bold text-stone-950">${pickupTotal.toFixed(2)}</p>
</div>
{/* Pickup Button */}
{/* Actions */}
{canManagePickup ? (
<div className="mt-4 flex gap-2">
<a
<div className="mt-5 grid grid-cols-2 gap-3">
<Link
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" }}
className="flex items-center justify-center rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
View Details
</a>
</Link>
<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" }}
className="flex items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{pickingUp === order.id ? "..." : "✓ Pick Up"}
{pickingUp === order.id ? (
"..."
) : (
<>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Pick Up
</>
)}
</button>
</div>
) : (
<div className="mt-4 rounded-xl bg-zinc-950 px-4 py-3 text-center text-sm text-zinc-500">
<div className="mt-5 rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-center text-sm text-stone-400">
No pickup permission
</div>
)}
@@ -377,40 +390,38 @@ function PickedUpCard({ order, shortId }: PickedUpCardProps) {
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 className="rounded-2xl border border-emerald-200 bg-emerald-50/50 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-bold text-green-900">{order.customer_name}</p>
<p className="font-semibold text-stone-900">{order.customer_name}</p>
{order.customer_phone && (
<p className="text-sm text-green-400">{order.customer_phone}</p>
<p className="mt-0.5 text-sm text-stone-500">{order.customer_phone}</p>
)}
<p className="mt-1 text-xs text-green-600">
<p className="mt-1 text-xs text-stone-400">
{shortId} · {order.stops?.city}, {order.stops?.state}
</p>
{pickupItems.length > 0 && (
<ul className="mt-2 space-y-0.5">
<ul className="mt-3 space-y-1">
{pickupItems.map((item) => (
<li key={item.id} className="text-xs text-green-400">
<li key={item.id} className="text-sm text-stone-600">
{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>
<span className="ml-2 text-stone-400">${(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 className="mt-2 text-xs text-stone-500">
{pickupItems.reduce((s, i) => s + i.quantity, 0)} items · ${pickupTotal.toFixed(2)}
{isMixed && <span className="ml-2 text-violet-600">· 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">
<div className="flex flex-col items-end gap-2">
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-bold text-emerald-700">
Picked Up
</span>
{order.pickup_completed_at && (
<p className="mt-1 text-xs text-green-600">
{formatPickupTime(order.pickup_completed_at)}
</p>
<p className="text-xs text-stone-400">{formatPickupTime(order.pickup_completed_at)}</p>
)}
</div>
</div>
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useEffect } from "react";
type Props = {
title: string;
titleIcon?: React.ReactNode;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
};
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
// Lock body scroll
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
// Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={handleBackdropClick}
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
{/* Modal card - solid white with shadow for high contrast */}
<div
className={`relative w-full ${maxWidth} rounded-2xl shadow-2xl`}
style={{
backgroundColor: "#ffffff",
border: "1px solid var(--admin-border)",
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
}}
>
{/* Subtle top border accent */}
<div
className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
style={{
background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)",
}}
/>
{/* Header */}
<div
className="flex items-center justify-between px-6 py-5"
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
>
<div className="flex items-center gap-3">
{titleIcon && (
<div
className="flex h-10 w-10 items-center justify-center rounded-xl"
style={{ backgroundColor: "var(--admin-accent-light)" }}
>
{titleIcon}
</div>
)}
<div>
<h2
className="text-xl font-semibold text-[var(--admin-text-primary)]"
style={{ letterSpacing: "-0.02em" }}
>
{title}
</h2>
{subtitle && (
<p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>
)}
</div>
</div>
<button
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-full transition-all"
style={{
backgroundColor: "rgba(60, 56, 37, 0.04)",
color: "rgba(60, 56, 37, 0.5)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "rgba(60, 56, 37, 0.08)";
e.currentTarget.style.color = "rgba(60, 56, 37, 0.7)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "rgba(60, 56, 37, 0.04)";
e.currentTarget.style.color = "rgba(60, 56, 37, 0.5)";
}}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="relative p-6">
{children}
</div>
</div>
</div>
);
}
@@ -9,11 +9,11 @@ type Props = {
type Period = "30" | "90" | "all";
function RateBar({ value }: { value: number }) {
function RateBar({ value, color = "bg-emerald-500" }: { value: number; color?: string }) {
return (
<div className="w-full bg-zinc-950 rounded-full h-1.5">
<div className="w-full bg-stone-100 rounded-full h-1.5">
<div
className="bg-stone-900 h-1.5 rounded-full"
className={`h-1.5 rounded-full ${color}`}
style={{ width: `${Math.min(100, value)}%` }}
/>
</div>
@@ -22,10 +22,10 @@ function RateBar({ value }: { value: number }) {
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 className="bg-white rounded-xl border border-[var(--admin-border)] p-4 sm:p-5">
<p className="text-[10px] sm:text-xs font-medium text-[var(--admin-text-muted)] uppercase tracking-wide">{label}</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1.5">{value}</p>
{sub && <p className="text-[10px] sm:text-xs text-stone-400 mt-1">{sub}</p>}
</div>
);
}
@@ -42,30 +42,45 @@ export default function AnalyticsDashboard({ analytics }: Props) {
const avgClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0;
return (
<div className="flex flex-col gap-5">
<div className="p-4 sm:p-6">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
<svg className="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Campaign Analytics</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Track your campaign performance</p>
</div>
</div>
{/* Summary stat cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<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}
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} messages` : 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">
<div className="bg-white rounded-xl border border-[var(--admin-border)] overflow-hidden">
<div className="px-4 sm:px-6 py-4 border-b border-[var(--admin-border)] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Campaign Performance</h3>
<div className="flex rounded-lg border border-[var(--admin-border)] bg-stone-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"
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
period === val ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-white"
}`}
>
{val === "30" ? "30 days" : val === "90" ? "90 days" : "All time"}
@@ -76,66 +91,79 @@ export default function AnalyticsDashboard({ analytics }: Props) {
{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 className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
<svg className="h-8 w-8 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
</div>
<p className="text-sm font-medium text-stone-600">No campaign analytics yet</p>
<p className="text-xs text-stone-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>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
<tr>
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Campaign</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Sent</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Delivered</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Opened</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Clicked</th>
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Bounced</th>
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Engagement</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{analytics.map((a) => (
<tr key={a.campaign_id} className="hover:bg-stone-50 transition-colors">
<td className="px-4 sm:px-6 py-3.5">
<div className="flex flex-col">
<span className="font-medium text-[var(--admin-text-primary)]">{a.campaign_name}</span>
<span className="text-xs text-stone-400">
{a.sent_at ? new Date(a.sent_at).toLocaleDateString() : "—"}
</span>
</div>
</td>
<td className="px-4 sm:px-6 py-3.5 text-right text-[var(--admin-text-primary)]">{a.total_sent.toLocaleString()}</td>
<td className="px-4 sm:px-6 py-3.5 text-right">
<span className="text-[var(--admin-text-primary)]">{a.total_delivered.toLocaleString()}</span>
<span className="text-xs text-stone-400 ml-1">({a.delivered_rate}%)</span>
</td>
<td className="px-4 sm:px-6 py-3.5 text-right">
<span className="text-[var(--admin-text-primary)]">{a.total_opened.toLocaleString()}</span>
<span className="text-xs text-stone-400 ml-1">({a.open_rate}%)</span>
</td>
<td className="px-4 sm:px-6 py-3.5 text-right">
<span className="text-[var(--admin-text-primary)]">{a.total_clicked.toLocaleString()}</span>
<span className="text-xs text-stone-400 ml-1">({a.click_rate}%)</span>
</td>
<td className="px-4 sm:px-6 py-3.5 text-right">
<span className={a.total_bounced > 0 ? "text-red-600" : "text-stone-400"}>
{a.total_bounced.toLocaleString()}
</span>
<span className="text-xs text-stone-400 ml-1">({a.bounce_rate}%)</span>
</td>
<td className="px-4 sm: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-stone-500">Open</span>
<span className="font-medium text-[var(--admin-text-primary)]">{a.open_rate}%</span>
</div>
<RateBar value={Number(a.open_rate)} />
<div className="flex items-center justify-between text-xs">
<span className="text-stone-500">Click</span>
<span className="font-medium text-[var(--admin-text-primary)]">{a.click_rate}%</span>
</div>
<RateBar value={Number(a.click_rate)} color="bg-amber-500" />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
@@ -3,9 +3,40 @@
import { useState, useEffect, useRef } from "react";
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
rules: SegmentRuleV2;
// Icon components
const Icons = {
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
spinner: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
</path>
</svg>
),
chevronLeft: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
),
chevronRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
),
};
const DEBOUNCE_MS = 300;
@@ -47,11 +78,11 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
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="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm: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>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">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">
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
{total.toLocaleString()} total
</span>
)}
@@ -59,52 +90,57 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
{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 className="text-center">
{Icons.users("w-10 h-10 text-[var(--admin-text-muted)] mx-auto mb-3")}
<p className="text-sm text-[var(--admin-text-muted)]">
Add filters to see matching customers
</p>
</div>
</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>
<div className="flex items-center gap-2.5 text-[var(--admin-text-muted)]">
{Icons.spinner("h-5 w-5 animate-spin")}
<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"
/>
<div className="relative">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
</div>
<input
type="search"
placeholder="Search by name or email…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
/>
</div>
{paged.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-6">No customers match these filters.</p>
<p className="text-sm text-[var(--admin-text-muted)] 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"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-[var(--admin-card-hover)] 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">
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center text-xs font-semibold text-emerald-700 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>
<p className="text-sm font-medium text-[var(--admin-text-primary)] truncate">{c.name || "(no name)"}</p>
<p className="text-xs text-[var(--admin-text-muted)] 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"
className="inline-flex items-center rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs"
>
{tag}
</span>
@@ -117,24 +153,26 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
)}
{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">
<div className="flex items-center justify-between pt-3 border-t border-[var(--admin-border)]">
<span className="text-xs text-[var(--admin-text-muted)]">
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"
className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
>
Prev
{Icons.chevronLeft("h-4 w-4")}
<span>Prev</span>
</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"
className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
>
Next
<span>Next</span>
{Icons.chevronRight("h-4 w-4")}
</button>
</div>
</div>
@@ -143,4 +181,9 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
)}
</div>
);
}
}
type Props = {
brandId: string;
rules: SegmentRuleV2;
};
@@ -12,6 +12,17 @@ type Props = {
initialSegments: Segment[];
};
// Icon components
const Icons = {
layers: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
),
};
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
const [segments, setSegments] = useState<Segment[]>(initialSegments);
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
@@ -68,21 +79,26 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
}
return (
<div className="flex flex-col gap-6">
{/* Page header */}
<div className="p-4 sm:p-6 space-y-4">
{/* 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 className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
{Icons.layers("w-4 h-4 text-[var(--admin-bg)]")}
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Segment Builder</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
Build filters to define your audience, then save and reuse the segment.
</p>
</div>
</div>
</div>
{/* Main layout */}
<div className="flex gap-5">
<div className="flex flex-col lg:flex-row gap-4">
{/* Left sidebar */}
<div className="w-72 flex-shrink-0">
<div className="lg:w-72 flex-shrink-0">
<SegmentListSidebar
segments={segments}
activeSegmentId={activeSegment?.id}
@@ -93,7 +109,7 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
</div>
{/* Main content: builder + preview */}
<div className="flex-1 grid grid-cols-2 gap-5 min-h-[580px]">
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
<SegmentBuilderPanel
brandId={brandId}
rules={currentRules}
@@ -8,6 +8,15 @@ import {
type SegmentFilterParams,
} from "@/actions/harvest-reach/segments";
// Icon components
const Icons = {
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
),
};
type Props = {
brandId: string;
rules: SegmentRuleV2;
@@ -63,28 +72,28 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
}
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-5">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Filter Rules</h3>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">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">
<span className="text-xs text-[var(--admin-text-muted)]">Match</span>
<div className="flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card)] 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 ${
className={`px-3 py-1 text-xs font-semibold rounded-md transition-colors ${
rules.combinator === c
? "bg-stone-900 text-white"
: "text-zinc-400 hover:bg-zinc-900"
? "bg-emerald-600 text-white"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
}`}
>
{c}
</button>
))}
</div>
<span className="text-xs text-zinc-500">of the following</span>
<span className="text-xs text-[var(--admin-text-muted)]">of the following</span>
</div>
</div>
@@ -92,7 +101,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
<div className="flex flex-col gap-3">
{rules.filters.length === 0 && (
<div className="py-8 text-center">
<p className="text-sm text-slate-400">
<p className="text-sm text-[var(--admin-text-muted)]">
No filters yet. Add a filter below to start building your segment.
</p>
</div>
@@ -114,7 +123,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
<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"
className="px-3 py-1.5 text-xs font-medium rounded-full border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-emerald-50 hover:border-emerald-200 hover:text-emerald-700 transition-colors"
>
+ {ft.label}
</button>
@@ -122,11 +131,11 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
</div>
{/* Save button */}
<div className="pt-1 border-t border-slate-100">
<div className="pt-1 border-t border-[var(--admin-border)]">
<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"
className="w-full py-2.5 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasActiveSegment ? "Update Segment" : "Save Segment"}
</button>
@@ -171,14 +180,14 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
}
return (
<div className="rounded-xl border border-zinc-800 p-4 flex flex-col gap-3">
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card)] 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"
className="text-sm font-medium border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 bg-white text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
{FILTER_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>{ft.label}</option>
@@ -186,12 +195,10 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
</select>
<button
onClick={onRemove}
className="text-slate-400 hover:text-red-500 transition-colors"
className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--admin-text-muted)] 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>
{Icons.x("w-4 h-4")}
</button>
</div>
@@ -203,7 +210,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
<option value="">Select {filter.type === "stop" ? "past" : "upcoming"} stop</option>
{stops.map((s) => (
@@ -215,13 +222,13 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</div>
</>
@@ -237,7 +244,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
<option value="">Select a product</option>
{products.map((p) => (
@@ -245,16 +252,16 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
))}
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-zinc-500">In the last</label>
<label className="text-xs text-[var(--admin-text-muted)]">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"
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-xs text-zinc-500">days</span>
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
</div>
</>
)}
@@ -274,14 +281,14 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
},
})
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</>
)}
@@ -294,23 +301,23 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
{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>
<label className="text-xs text-[var(--admin-text-muted)]">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"
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-xs text-zinc-500">days</span>
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
</div>
</div>
)}
@@ -329,13 +336,13 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
},
})
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
)}
{/* All customers */}
{filter.type === "all_customers" && (
<p className="text-xs text-slate-400">Matches all customers in your brand.</p>
<p className="text-xs text-[var(--admin-text-muted)]">Matches all customers in your brand.</p>
)}
</div>
</div>
@@ -2,6 +2,22 @@
import { useState } from "react";
// Icon components
const Icons = {
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
),
layers: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
),
};
type Props = {
initialName?: string;
initialDescription?: string;
@@ -22,55 +38,78 @@ export default function SegmentEditModal({ initialName = "", initialDescription
}
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>
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl border border-[var(--admin-border)]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--admin-border)]">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
{Icons.layers("h-5 w-5 text-white")}
</div>
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">
{initialName ? "Update Segment" : "Save Segment"}
</h2>
<p className="text-xs text-[var(--admin-text-muted)]">
{initialName ? "Update segment details" : "Create a new audience segment"}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
{Icons.x("h-5 w-5")}
</button>
</div>
<div className="flex flex-col gap-3">
{/* Content */}
<div className="p-6 flex flex-col gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Segment Name</label>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
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>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Description <span className="text-[var(--admin-text-muted)] 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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] resize-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
{/* Footer */}
<div className="flex gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
<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"
className="flex-1 py-2.5 rounded-lg border border-[var(--admin-border)] text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] 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"
className="flex-1 py-2.5 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "Saving…" : "Save Segment"}
</button>
@@ -3,6 +3,29 @@
import { useState } from "react";
import type { Segment } from "@/actions/harvest-reach/segments";
// Icon components
const Icons = {
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
),
trash: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
};
type Props = {
segments: Segment[];
activeSegmentId?: string;
@@ -20,47 +43,52 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
);
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 flex flex-col gap-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white 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>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">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"
className="w-7 h-7 rounded-lg bg-emerald-600 text-white flex items-center justify-center hover:bg-emerald-700 transition-colors"
aria-label="New segment"
>
+
{Icons.plus("w-4 h-4")}
</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="relative">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
</div>
<input
type="search"
placeholder="Search segments…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
/>
</div>
<div className="flex flex-col gap-1.5">
{filtered.length === 0 && (
<p className="text-xs text-slate-400 text-center py-4">
<p className="text-xs text-[var(--admin-text-muted)] 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="rounded-xl border border-red-200 bg-red-50 p-3 flex flex-col gap-2">
<p className="text-xs text-red-600 font-medium">Delete "{segment.name}"?</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"
className="flex-1 py-1.5 text-xs rounded-lg bg-red-600 text-white hover:bg-red-700 font-semibold transition-colors"
>
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"
className="flex-1 py-1.5 text-xs rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] font-medium transition-colors"
>
Cancel
</button>
@@ -71,24 +99,22 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
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"
? "bg-emerald-50 border border-emerald-200"
: "hover:bg-[var(--admin-card-hover)] border border-transparent"
}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{segment.name}</p>
<p className="text-sm font-medium text-[var(--admin-text-primary)] truncate">{segment.name}</p>
{segment.description && (
<p className="text-xs text-slate-400 truncate mt-0.5">{segment.description}</p>
<p className="text-xs text-[var(--admin-text-muted)] 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"
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 transition-all"
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>
{Icons.trash("w-4 h-4")}
</button>
</div>
)}
+316 -117
View File
@@ -1,86 +1,204 @@
"use client";
import { useState } from "react";
import type { MessageLogEntry } from "@/actions/communications/send";
import { useState, useEffect } from "react";
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
import { formatDate } from "@/lib/format-date";
// Icon components
const Icons = {
list: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
refresh: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
<path d="M16 16h5v5"/>
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
),
eye: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
),
mousePointer: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/>
<path d="m13 13 6 6"/>
</svg>
),
messageSquare: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
),
};
const STATUS_COLORS: Record<string, string> = {
queued: "bg-zinc-950 text-zinc-300",
sent: "bg-blue-900/40 text-blue-700",
queued: "bg-stone-100 text-stone-600",
sent: "bg-blue-100 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",
bounced: "bg-red-100 text-red-700",
failed: "bg-red-100 text-red-700",
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",
email: "bg-blue-100 text-blue-700",
sms: "bg-emerald-100 text-emerald-700",
push: "bg-purple-100 text-purple-700",
internal: "bg-zinc-950 text-zinc-400",
internal: "bg-stone-100 text-stone-600",
};
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;
}
const PAGE_SIZE = 20;
export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageLogEntry[] }) {
const [logs] = useState(initialLogs);
const [filterStatus, setFilterStatus] = useState("all");
const [filterMethod, setFilterMethod] = useState("all");
export default function MessageLogPanel({ brandId }: { brandId?: string }) {
const [logs, setLogs] = useState<MessageLogEntry[]>([]);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const filtered = logs.filter((l) => {
if (filterStatus !== "all" && l.status !== filterStatus) return false;
if (filterMethod !== "all" && l.delivery_method !== filterMethod) return false;
return true;
});
const fetchLogs = async () => {
if (!brandId) return;
setIsLoading(true);
const result = await getMessageLogs({
brandId,
status: statusFilter === "all" ? undefined : statusFilter,
limit: 100,
});
if (result.success) {
setLogs(result.logs);
}
setIsLoading(false);
};
useEffect(() => {
fetchLogs();
}, [brandId, statusFilter]);
// Filter logs based on search
const filteredLogs = search
? logs.filter((l: MessageLogEntry) =>
l.customer_email?.toLowerCase().includes(search.toLowerCase()) ||
l.subject?.toLowerCase().includes(search.toLowerCase()) ||
l.status?.toLowerCase().includes(search.toLowerCase())
)
: logs;
// Calculate stats
const stats = {
total: logs.length,
delivered: logs.filter((l: MessageLogEntry) => l.status === "delivered" || l.delivered_at).length,
failed: logs.filter((l: MessageLogEntry) => l.status === "failed" || l.status === "bounced").length,
pending: logs.filter((l: MessageLogEntry) => l.status === "queued" || l.status === "sent").length,
};
// Paginate
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / PAGE_SIZE));
const paginatedLogs = filteredLogs.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const handleRefresh = () => {
fetchLogs();
setPage(1);
};
return (
<div>
<div className="p-4 sm:p-6">
{/* Header */}
<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 className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
{Icons.messageSquare("h-5 w-5 text-white")}
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Message Logs</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
{isLoading ? "Loading..." : `${filteredLogs.length} message${filteredLogs.length !== 1 ? "s" : ""}`}
</p>
</div>
</div>
<button
onClick={handleRefresh}
disabled={isLoading}
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-stone-600 bg-white border border-stone-200 rounded-lg hover:bg-stone-50 hover:text-stone-900 transition-colors disabled:opacity-50"
>
{Icons.refresh("h-4 w-4" + (isLoading ? " animate-spin" : ""))}
<span className="hidden sm:inline">Refresh</span>
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{stats.total}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Delivered</p>
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{stats.delivered}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Failed</p>
<p className="text-xl sm:text-2xl font-bold text-red-600 mt-1">{stats.failed}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
<p className="text-xl sm:text-2xl font-bold text-stone-600 mt-1">{stats.pending}</p>
</div>
</div>
<div className="flex gap-4 mb-4 flex-wrap">
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="relative flex-1">
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
<input
type="text"
placeholder="Search by email or subject..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
/>
</div>
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="px-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<option value="all">All Statuses</option>
<option value="queued">Queued</option>
@@ -91,70 +209,87 @@ export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageL
<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>
{/* Table */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3 text-stone-500">
{Icons.refresh("h-5 w-5 animate-spin")}
<span>Loading logs...</span>
</div>
</div>
) : paginatedLogs.length === 0 ? (
<div className="text-center py-12">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.list("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">No messages logged yet</p>
<p className="text-xs text-stone-400 mt-1">Send a campaign to see logs here</p>
</div>
) : (
<>
{/* Desktop table */}
<div className="hidden sm:block bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<div className="hidden sm:block overflow-hidden rounded-xl border border-[var(--admin-border)]">
<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>
<thead className="bg-stone-50">
<tr className="border-b border-[var(--admin-border)]">
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Sent At</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Recipient</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Method</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Subject</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">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() : "—"}
<tbody className="divide-y divide-[var(--admin-border)] bg-white">
{paginatedLogs.map((log: MessageLogEntry) => (
<tr key={log.id} className="hover:bg-stone-50 transition-colors">
<td className="px-4 py-3 text-[var(--admin-text-muted)] text-xs whitespace-nowrap">
{log.sent_at ? formatDate(log.sent_at) : "—"}
</td>
<td className="px-4 py-3 text-[var(--admin-text-primary)] text-xs">
{log.customer_email ?? "—"}
</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 className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${METHOD_COLORS[log.delivery_method] ?? "bg-stone-100 text-stone-600"}`}>
{log.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 text-[var(--admin-text-primary)] text-xs truncate max-w-[160px]" title={log.subject ?? undefined}>
{log.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 className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${STATUS_COLORS[log.status] ?? "bg-stone-100 text-stone-600"}`}>
{log.status}
</span>
{l.error_message && (
<p className="text-xs text-red-500 mt-0.5 truncate max-w-[120px]">{l.error_message}</p>
{log.error_message && (
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[100px]" title={log.error_message}>
{log.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>
<div className="flex flex-wrap gap-1">
{log.delivered_at && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 px-1.5 py-0.5 text-[10px] font-medium">
{Icons.check("h-3 w-3")} Delivered
</span>
)}
{l.opened_at && (
<span className="text-xs text-violet-600">Opened {formatDate(new Date(l.opened_at))}</span>
{log.opened_at && (
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 border border-violet-200 text-violet-700 px-1.5 py-0.5 text-[10px] font-medium">
{Icons.eye("h-3 w-3")} Opened
</span>
)}
{l.clicked_at && (
<span className="text-xs text-amber-400">Clicked {formatDate(new Date(l.clicked_at))}</span>
{log.clicked_at && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium">
{Icons.mousePointer("h-3 w-3")} Clicked
</span>
)}
{l.bounced_at && (
<span className="text-xs text-red-500"> Bounced{l.bounce_reason ? `: ${l.bounce_reason}` : ""}</span>
{log.bounced_at && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 text-red-700 px-1.5 py-0.5 text-[10px] font-medium">
{Icons.x("h-3 w-3")} Bounced
</span>
)}
</div>
</td>
@@ -166,29 +301,93 @@ export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageL
{/* 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}
{paginatedLogs.map((log: MessageLogEntry) => (
<div key={log.id} className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-[var(--admin-text-primary)]">{log.customer_email ?? "—"}</div>
<div className="text-xs text-[var(--admin-text-muted)] mt-0.5">{log.subject ?? "—"}</div>
</div>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold shrink-0 ${STATUS_COLORS[log.status] ?? "bg-stone-100 text-stone-600"}`}>
{log.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}
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${METHOD_COLORS[log.delivery_method] ?? "bg-stone-100 text-stone-600"}`}>
{log.delivery_method}
</span>
<span className="text-xs text-slate-400">
{l.sent_at ? formatDate(new Date(l.sent_at)) : "—"}
<span className="text-xs text-[var(--admin-text-muted)]">
{log.sent_at ? formatDate(log.sent_at) : "—"}
</span>
</div>
<EngagementBadges log={l} />
<div className="flex flex-wrap gap-1">
{log.delivered_at && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 px-1.5 py-0.5 text-[10px] font-medium">
{Icons.check("h-3 w-3")} Delivered
</span>
)}
{log.opened_at && (
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 border border-violet-200 text-violet-700 px-1.5 py-0.5 text-[10px] font-medium">
{Icons.eye("h-3 w-3")} Opened
</span>
)}
{log.clicked_at && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium">
{Icons.mousePointer("h-3 w-3")} Clicked
</span>
)}
{log.bounced_at && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 text-red-700 px-1.5 py-0.5 text-[10px] font-medium">
{Icons.x("h-3 w-3")} Bounced
</span>
)}
</div>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
<p className="text-xs text-[var(--admin-text-muted)]">
Showing {(page - 1) * PAGE_SIZE + 1} to {Math.min(page * PAGE_SIZE, filteredLogs.length)} of {filteredLogs.length}
</p>
<div className="flex gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1.5 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<button
key={pageNum}
onClick={() => setPage(pageNum)}
className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${
page === pageNum
? "bg-emerald-600 text-white border-emerald-600"
: "bg-white text-[var(--admin-text-primary)] border-[var(--admin-border)] hover:bg-stone-50"
}`}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1.5 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
}
+74 -139
View File
@@ -49,6 +49,10 @@ type EditableItem = {
removed: boolean;
};
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}
export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
const router = useRouter();
const [saving, setSaving] = useState(false);
@@ -157,38 +161,38 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
return (
<div className="space-y-6">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{error}
</div>
)}
{saved && (
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
Order updated successfully.
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700">
Order updated successfully
</div>
)}
{/* Order items */}
<div>
<p className="mb-2 text-sm font-medium text-zinc-300">
<p className="mb-3 text-sm font-semibold text-stone-700">
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 className="rounded-xl border border-dashed border-stone-300 p-4 text-center text-sm text-stone-400">
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"
className="rounded-xl border border-stone-200 bg-white p-4"
>
<div className="flex items-start justify-between gap-3">
<p className="font-medium text-zinc-100">{item.productName}</p>
<p className="font-medium text-stone-900">{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"
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
>
Remove
</button>
@@ -196,9 +200,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
<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>
<label className="mb-1 block text-xs font-medium text-stone-500">Qty</label>
<input
type="number"
min="1"
@@ -206,13 +208,11 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
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"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-500">
Price
</label>
<label className="mb-1 block text-xs font-medium text-stone-500">Price</label>
<input
type="number"
step="0.01"
@@ -221,13 +221,13 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
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"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-emerald-500"
/>
</div>
</div>
<p className="mt-2 text-right text-sm font-semibold text-zinc-100">
${(Number(item.price) * item.quantity).toFixed(2)}
<p className="mt-2 text-right text-sm font-semibold text-stone-900">
{formatCurrency(Number(item.price) * item.quantity)}
</p>
</div>
))}
@@ -235,95 +235,48 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
)}
</div>
{/* Pricing — subtotal derived from items */}
{/* Pricing */}
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Subtotal (auto-calculated)
</label>
<label className="mb-1 block text-xs font-semibold text-stone-500">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"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm text-stone-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>
<label className="mb-1 block text-xs font-semibold text-stone-500">Discount Amount</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"
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-stone-200 bg-stone-50 px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Tax Location
</label>
<label className="mb-1 block text-xs font-semibold text-stone-500">Discount Reason</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"
value={discount_reason}
onChange={(e) => setDiscount_reason(e.target.value)}
placeholder="Optional"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
/>
</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
<div className="rounded-xl border border-stone-200 bg-stone-50 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 className="text-sm font-medium text-stone-600">Total</span>
<span className="text-xl font-bold text-stone-900">
{formatCurrency(subtotal + Number(order.tax_amount ?? 0) - discount_amount)}
</span>
</div>
</div>
@@ -332,57 +285,49 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Customer fields */}
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Name
</label>
<label className="mb-1 block text-xs font-semibold text-stone-500">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"
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
/>
</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 className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Email</label>
<input
type="email"
value={customer_email}
onChange={(e) => setCustomer_email(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Phone</label>
<input
type="tel"
value={customer_phone}
onChange={(e) => setCustomer_phone(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
/>
</div>
</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">
<label className="mb-2 block text-xs font-semibold text-stone-500">Status</label>
<div className="flex gap-2">
{["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 ${
className={`flex-1 rounded-xl px-4 py-2.5 text-sm font-semibold capitalize transition-colors ${
status === s
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
? "bg-emerald-600 text-white"
: "bg-white text-stone-600 border border-stone-200 hover:bg-stone-50"
}`}
>
{s}
@@ -392,49 +337,39 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Pickup
</label>
<label className="mb-2 block text-xs font-semibold text-stone-500">Pickup</label>
<button
onClick={() => setPickup_complete((v) => !v)}
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
className={`w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors ${
pickup_complete
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
? "bg-green-100 text-green-700 border border-green-200"
: "bg-amber-100 text-amber-700 border border-amber-200"
}`}
>
{pickup_complete ? "Picked Up" : "Not Picked Up"}
{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>
<label className="mb-1 block text-xs font-semibold text-stone-500">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"
rows={2}
placeholder="Private notes for staff..."
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500 resize-none"
/>
</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"
className="w-full rounded-xl bg-emerald-600 px-6 py-3 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
);
}
}
+42 -50
View File
@@ -1,7 +1,6 @@
"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";
@@ -29,6 +28,10 @@ type OrderPaymentSectionProps = {
existingRefunds: Refund[];
};
function formatCurrency(amount: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amount);
}
export default function OrderPaymentSection({
orderId,
brandId,
@@ -36,11 +39,8 @@ export default function OrderPaymentSection({
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 ?? "");
@@ -102,28 +102,28 @@ export default function OrderPaymentSection({
const remainingBalance = Math.max(0, orderTotal - totalRefunded);
return (
<div className="space-y-6">
<div className="space-y-5">
{error && (
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">{error}</div>
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{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 className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700">
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>
<label className="mb-1 block text-xs font-semibold text-stone-500">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"
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-emerald-500"
>
<option value=""></option>
{["manual", "stripe", "square", "cash", "venmo", "other"].map((p) => (
@@ -135,13 +135,11 @@ export default function OrderPaymentSection({
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Payment Status
</label>
<label className="mb-1 block text-xs font-semibold text-stone-500">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"
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-emerald-500"
>
{["pending", "paid", "failed", "refunded", "cancelled"].map((s) => (
<option key={s} value={s}>
@@ -153,22 +151,20 @@ export default function OrderPaymentSection({
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
Transaction ID
</label>
<label className="mb-1 block text-xs font-semibold text-stone-500">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"
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
/>
</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"
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : "Save Payment Details"}
</button>
@@ -177,31 +173,31 @@ export default function OrderPaymentSection({
{/* Refund history */}
{existingRefunds.length > 0 && (
<div>
<p className="mb-2 text-sm font-medium text-zinc-300">
<p className="mb-3 text-sm font-semibold text-stone-700">
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"
className="flex items-center justify-between rounded-xl border border-stone-200 bg-white px-4 py-3"
>
<div>
<p className="font-medium text-zinc-100">
${Number(r.amount).toFixed(2)}
<p className="font-semibold text-stone-900">
{formatCurrency(Number(r.amount))}
</p>
{r.reason && (
<p className="text-xs text-zinc-500">{r.reason}</p>
<p className="text-xs text-stone-500">{r.reason}</p>
)}
<p className="text-xs text-slate-400">
{formatDate(new Date(r.created_at))}
<p className="text-xs text-stone-400">
{formatDate(r.created_at)}
</p>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
className={`rounded-full px-2.5 py-0.5 text-xs font-bold ${
r.status === "completed"
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-500"
? "bg-green-50 text-green-700"
: "bg-stone-100 text-stone-500"
}`}
>
{r.status}
@@ -209,28 +205,26 @@ export default function OrderPaymentSection({
</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)}
<div className="mt-2 flex justify-between rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm">
<span className="text-stone-500">Total refunded</span>
<span className="font-semibold text-stone-900">
{formatCurrency(totalRefunded)}
</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>
<div className="rounded-xl border border-dashed border-stone-300 bg-stone-50 p-4">
<p className="mb-3 text-sm font-semibold text-stone-700">Record a Refund</p>
{refundSaved && (
<div className="mb-3 rounded-xl bg-green-900/30 p-3 text-sm text-green-400">
Refund recorded.
<div className="mb-3 rounded-xl bg-green-50 border border-green-200 p-3 text-sm text-green-700">
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>
<label className="mb-1 block text-xs font-semibold text-stone-500">Amount</label>
<input
type="number"
step="0.01"
@@ -238,26 +232,24 @@ export default function OrderPaymentSection({
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"
placeholder={`Max ${formatCurrency(remainingBalance)}`}
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-500">
Reason
</label>
<label className="mb-1 block text-xs font-semibold text-stone-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"
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
/>
</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"
className="w-full rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-600 hover:bg-red-100 disabled:opacity-50"
>
{refunding ? "Processing..." : "Record Refund"}
</button>
@@ -265,4 +257,4 @@ export default function OrderPaymentSection({
</div>
</div>
);
}
}
+21 -38
View File
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { markPickupComplete } from "@/actions/pickup";
type Props = {
type OrderPickupActionProps = {
orderId: string;
brandId: string | null;
currentlyPickedUp: boolean;
@@ -13,51 +14,33 @@ export default function OrderPickupAction({
orderId,
brandId,
currentlyPickedUp,
}: Props) {
const [pickingUp, setPickingUp] = useState(false);
const [toast, setToast] = useState<{ type: "success" | "error"; msg: string } | null>(null);
}: OrderPickupActionProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
async function handlePickup() {
setPickingUp(true);
async function handleMarkPickup() {
if (currentlyPickedUp) return;
setLoading(true);
const result = await markPickupComplete(orderId, brandId);
setPickingUp(false);
setLoading(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." });
setDone(true);
router.refresh();
}
}
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>
);
if (currentlyPickedUp || done) {
return null;
}
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>
<button
onClick={handleMarkPickup}
disabled={loading}
className="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{loading ? "Marking..." : "✓ Mark Picked Up"}
</button>
);
}
+38 -27
View File
@@ -238,33 +238,44 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
</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>
{!settings?.stripe_publishable_key ? (
<div className="space-y-3">
<p className="text-sm text-blue-200">
Connect your Stripe account to process payments. You'll be redirected to Stripe to authorize the connection.
</p>
<a
href="/api/stripe/oauth"
className="inline-block rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-700"
>
Connect with Stripe
</a>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-green-400">
✓ Your Stripe account is connected and ready to accept payments.
</p>
<button
type="button"
onClick={async () => {
if (!confirm("Disconnect Stripe? You'll need to reconnect to accept payments.")) return;
const result = await savePaymentSettings({
brandId: activeBrandId,
provider: null,
stripePublishableKey: "",
stripeSecretKey: "",
});
if (result.success) {
window.location.reload();
}
}}
className="text-sm text-red-400 hover:underline"
>
Disconnect Stripe
</button>
</div>
)}
</div>
)}
+885
View File
@@ -0,0 +1,885 @@
"use client";
import { useState, useCallback, useRef, useTransition } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { createProduct } from "@/actions/products/create-product";
import { updateProduct } from "@/actions/products/update-product";
import { deleteProduct } from "@/actions/products";
import { uploadProductImage } from "@/actions/products/upload-image";
import GlassModal from "@/components/admin/GlassModal";
type Product = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
image_url?: string | null;
brand_id: string;
is_taxable: boolean;
};
type FormData = {
name: string;
description: string;
price: string;
type: string;
active: boolean;
image_url: string;
is_taxable: boolean;
};
type ViewMode = "table" | "cards";
type Props = {
products: Product[];
brandId: string;
};
// Icons
const Icons = {
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
grid: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
),
list: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14"/>
</svg>
),
package: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m7.5 4.27 9 5.15"/>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/>
<path d="m3.3 7 8.7 5 8.7-5"/>
<path d="M12 22V12"/>
</svg>
),
upload: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
),
};
export default function ProductsClient({ products, brandId }: Props) {
const router = useRouter();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [showModal, setShowModal] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [formData, setFormData] = useState<FormData>({
name: "",
description: "",
price: "",
type: "pickup",
active: true,
image_url: "",
is_taxable: true,
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Image upload states
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [pendingImageUrl, setPendingImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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;
});
const activeCount = products.filter((p) => p.active).length;
const inactiveCount = products.filter((p) => !p.active).length;
async function resizeImage(file: File, maxWidth: number): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const img = document.createElement("img");
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 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;
}
setUploadError(null);
setUploading(true);
const resizedBuffer = await resizeImage(file, 1200);
const resizedFile = new File([resizedBuffer], file.name, { type: "image/jpeg" });
const productIdForUpload = editingProduct ? editingProduct.id : "__NEW__";
const result = await uploadProductImage(productIdForUpload, resizedFile);
setUploading(false);
if (result.success) {
setPendingImageUrl(result.imageUrl);
setImagePreview(result.imageUrl);
setFormData({ ...formData, image_url: result.imageUrl });
} else {
setUploadError(result.error ?? "Upload failed.");
}
}
const openAddModal = useCallback(() => {
setEditingProduct(null);
setFormData({
name: "",
description: "",
price: "",
type: "pickup",
active: true,
image_url: "",
is_taxable: true,
});
setImagePreview(null);
setPendingImageUrl(null);
setUploadError(null);
setError(null);
setShowModal(true);
}, []);
const openEditModal = useCallback((product: Product) => {
setEditingProduct(product);
setFormData({
name: product.name,
description: product.description || "",
price: String(product.price),
type: product.type,
active: product.active,
image_url: product.image_url || "",
is_taxable: product.is_taxable,
});
setImagePreview(product.image_url || null);
setPendingImageUrl(null);
setUploadError(null);
setError(null);
setShowModal(true);
}, []);
const closeModal = useCallback(() => {
setShowModal(false);
setEditingProduct(null);
setImagePreview(null);
setPendingImageUrl(null);
setUploadError(null);
setError(null);
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSaving(true);
const price = parseFloat(formData.price);
if (isNaN(price) || price < 0) {
setError("Please enter a valid price");
setSaving(false);
return;
}
try {
let result;
const imageUrl = pendingImageUrl || formData.image_url || null;
if (editingProduct) {
result = await updateProduct(editingProduct.id, brandId, {
name: formData.name.trim(),
description: formData.description.trim(),
price,
type: formData.type,
active: formData.active,
image_url: imageUrl,
is_taxable: formData.is_taxable,
});
} else {
result = await createProduct(brandId, {
name: formData.name.trim(),
description: formData.description.trim(),
price,
type: formData.type,
active: formData.active,
image_url: imageUrl,
is_taxable: formData.is_taxable,
});
}
if (!result.success) {
setError(result.error ?? "Failed to save product");
setSaving(false);
return;
}
closeModal();
startTransition(() => router.refresh());
} catch {
setError("Network error. Please try again.");
setSaving(false);
}
};
const handleDelete = async (productId: string) => {
setDeletingId(productId);
const result = await deleteProduct(productId, null);
setDeletingId(null);
if (result.success) {
setDeleteConfirm(null);
startTransition(() => router.refresh());
}
};
return (
<div className="p-4 sm:p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
{Icons.package("h-5 w-5 text-white")}
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Products</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
{filtered.length} product{filtered.length !== 1 ? "s" : ""}
</p>
</div>
</div>
<button
onClick={openAddModal}
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm font-medium rounded-xl hover:bg-emerald-700 transition-colors"
>
{Icons.plus("h-4 w-4")}
Add Product
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{products.length}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{activeCount}</p>
</div>
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Inactive</p>
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1">{inactiveCount}</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
{/* Status Tabs */}
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
{(["all", "active", "inactive"] as const).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
statusFilter === f
? "bg-emerald-600 text-white"
: "text-stone-500 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Inactive"}
</button>
))}
</div>
{/* Search */}
<div className="relative flex-1">
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
<input
type="text"
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
</div>
{/* View Toggle */}
<div className="flex rounded-xl border border-[var(--admin-border)] bg-white p-1">
<button
onClick={() => setViewMode("table")}
className={`rounded-lg p-2 transition-colors ${
viewMode === "table" ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-stone-50"
}`}
>
{Icons.list("h-4 w-4")}
</button>
<button
onClick={() => setViewMode("cards")}
className={`rounded-lg p-2 transition-colors ${
viewMode === "cards" ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-stone-50"
}`}
>
{Icons.grid("h-4 w-4")}
</button>
</div>
</div>
{/* Content */}
{viewMode === "table" ? (
<TableView
products={filtered}
onEdit={openEditModal}
onDelete={(id) => setDeleteConfirm(id)}
deleteConfirm={deleteConfirm}
onDeleteConfirm={(id) => handleDelete(id)}
onDeleteCancel={() => setDeleteConfirm(null)}
deletingId={deletingId}
/>
) : (
<CardView
products={filtered}
onEdit={openEditModal}
onDelete={(id) => setDeleteConfirm(id)}
deleteConfirm={deleteConfirm}
onDeleteConfirm={(id) => handleDelete(id)}
onDeleteCancel={() => setDeleteConfirm(null)}
deletingId={deletingId}
/>
)}
{/* Modal */}
{showModal && (
<GlassModal
title={editingProduct ? "Edit Product" : "Add Product"}
subtitle={editingProduct ? `Editing ${editingProduct.name}` : "Create a new product"}
onClose={closeModal}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Product name"
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Product description"
rows={2}
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Price</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
<input
type="number"
step="0.01"
min="0"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
placeholder="0.00"
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 pl-8 pr-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
required
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Type</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full rounded-xl border border-[var(--admin-border)] bg-stone-50 px-3 py-2.5 text-sm outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20"
>
<option value="pickup">Pickup</option>
<option value="wholesale">Wholesale</option>
<option value="subscription">Subscription</option>
</select>
</div>
</div>
{/* Image Upload */}
<div>
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Product Image</label>
<p className="text-[10px] text-stone-500 mb-2">JPG, PNG, WebP · 1200px max width · max 5MB</p>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}}
onClick={() => !uploading && 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-[var(--admin-border)] hover:border-emerald-400 hover:bg-emerald-50/50"}
${uploading ? "opacity-50 pointer-events-none" : ""}
`}
>
{uploading ? (
<>
<div className="h-5 w-5 rounded-full border-2 border-emerald-400 border-t-transparent animate-spin" />
<span className="text-sm text-stone-500">Uploading...</span>
</>
) : imagePreview ? (
<div className="relative">
<div className="relative h-40 w-auto">
<Image src={imagePreview} alt="Product preview" fill style={{ objectFit: "contain" }} className="rounded-lg" />
</div>
<span className="block text-center text-xs text-stone-500 mt-2">Click or drop to replace</span>
</div>
) : (
<>
{Icons.upload("h-8 w-8 text-stone-400")}
<span className="text-sm text-stone-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);
e.target.value = "";
}}
/>
</div>
{uploadError && (
<p className="mt-1 text-xs text-red-500">{uploadError}</p>
)}
{(imagePreview || formData.image_url) && (
<button
type="button"
onClick={() => {
setImagePreview(null);
setPendingImageUrl(null);
setFormData({ ...formData, image_url: "" });
}}
className="mt-2 text-xs text-red-500 hover:underline"
>
Remove image
</button>
)}
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
className="h-4 w-4 rounded border-stone-300 text-emerald-600"
/>
<span className="text-sm font-medium text-stone-700">Active</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.is_taxable}
onChange={(e) => setFormData({ ...formData, is_taxable: e.target.checked })}
className="h-4 w-4 rounded border-stone-300 text-emerald-600"
/>
<span className="text-sm font-medium text-stone-700">Taxable</span>
</label>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-stone-100">
<button
type="button"
onClick={closeModal}
className="rounded-xl border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
type="submit"
disabled={saving || !formData.name.trim() || !formData.price}
className="rounded-xl bg-emerald-600 px-5 py-2 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{saving ? "Saving..." : editingProduct ? "Save Changes" : "Create Product"}
</button>
</div>
</form>
</GlassModal>
)}
</div>
);
}
// ── Table View ─────────────────────────────────────────────────────────────────
function TableView({
products,
onEdit,
onDelete,
deleteConfirm,
onDeleteConfirm,
onDeleteCancel,
deletingId,
}: {
products: Product[];
onEdit: (p: Product) => void;
onDelete: (id: string) => void;
deleteConfirm: string | null;
onDeleteConfirm: (id: string) => void;
onDeleteCancel: () => void;
deletingId: string | null;
}) {
return (
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white">
<table className="w-full text-sm">
<thead className="bg-stone-50">
<tr>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Product</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Type</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Price</th>
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
{products.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-16 text-center">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.package("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">No products found</p>
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
</td>
</tr>
) : (
products.map((product) => (
<tr key={product.id} className="hover:bg-stone-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{product.image_url ? (
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-lg border border-[var(--admin-border)]">
<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-10 w-10 rounded-lg bg-stone-100 flex items-center justify-center shrink-0 border border-[var(--admin-border)]">
<span className="text-stone-400"></span>
</div>
)}
<div>
<button
onClick={() => onEdit(product)}
className="font-semibold text-[var(--admin-text-primary)] hover:text-emerald-600 transition-colors text-left"
>
{product.name}
</button>
<div className="text-xs text-stone-500 line-clamp-1">
{product.description || <span className="italic text-stone-400">No description</span>}
</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className="rounded-md bg-stone-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-stone-600">
{product.type}
</span>
</td>
<td className="px-4 py-3 font-mono text-sm font-semibold text-[var(--admin-text-primary)]">
${Number(product.price).toFixed(2)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
product.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"
}`}>
{product.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3 text-right">
<div className="relative inline-flex items-center justify-end gap-1">
<button
onClick={() => onEdit(product)}
className="rounded-lg px-3 py-1.5 text-xs font-semibold text-stone-600 hover:bg-stone-100 transition-colors"
>
Edit
</button>
<button
onClick={() => onDelete(product.id)}
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-red-600 hover:bg-red-50 transition-colors"
>
</button>
{deleteConfirm === product.id && (
<>
<div className="fixed inset-0 z-30" onClick={onDeleteCancel} />
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-1 text-xs text-stone-500">
This will remove the product. If attached to orders, it will be hidden.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] bg-white px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={() => onDeleteConfirm(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}
// ── Card View ──────────────────────────────────────────────────────────────────
function CardView({
products,
onEdit,
onDelete,
deleteConfirm,
onDeleteConfirm,
onDeleteCancel,
deletingId,
}: {
products: Product[];
onEdit: (p: Product) => void;
onDelete: (id: string) => void;
deleteConfirm: string | null;
onDeleteConfirm: (id: string) => void;
onDeleteCancel: () => void;
deletingId: string | null;
}) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{products.length === 0 ? (
<div className="col-span-full text-center py-16 rounded-xl border border-[var(--admin-border)] bg-white">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
{Icons.package("h-8 w-8 text-stone-400")}
</div>
<p className="text-sm font-medium text-stone-600">No products found</p>
<p className="text-xs text-stone-400 mt-1">Try adjusting your filters</p>
</div>
) : (
products.map((product) => (
<div
key={product.id}
className="group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-emerald-200 transition-all overflow-hidden"
>
{/* Image */}
<div className="relative h-40 bg-stone-100">
{product.image_url ? (
<Image
src={product.image_url}
alt={product.name}
fill
style={{ objectFit: "cover" }}
className="transition-transform group-hover:scale-105"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-4xl text-stone-300"></span>
</div>
)}
{/* Status badge */}
<div className="absolute top-3 right-3">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold shadow-sm ${
product.active ? "bg-emerald-500 text-white" : "bg-stone-500 text-white"
}`}>
{product.active ? "Active" : "Inactive"}
</span>
</div>
</div>
{/* Content */}
<div className="p-4">
<button
onClick={() => onEdit(product)}
className="block w-full text-left"
>
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-emerald-600 transition-colors line-clamp-1">
{product.name}
</h3>
<p className="text-xs text-stone-500 mt-1 line-clamp-2 h-8">
{product.description || <span className="italic">No description</span>}
</p>
</button>
<div className="flex items-center justify-between mt-4 pt-3 border-t border-stone-100">
<div>
<span className="text-xs text-stone-500">Price</span>
<p className="text-lg font-bold text-[var(--admin-text-primary)] font-mono">
${Number(product.price).toFixed(2)}
</p>
</div>
<div className="flex items-center gap-1">
<span className="rounded-md bg-stone-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-stone-600">
{product.type}
</span>
{product.is_taxable && (
<span className="rounded-md bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700 border border-amber-200">
Tax
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => onEdit(product)}
className="flex-1 rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-xs font-semibold text-emerald-700 hover:bg-emerald-100 transition-colors"
>
Edit
</button>
<button
onClick={() => onDelete(product.id)}
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-500 hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
>
🗑
</button>
</div>
</div>
{/* Delete confirm */}
{deleteConfirm === product.id && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-xl shadow-xl p-4 max-w-[280px] mx-4">
<p className="text-sm font-semibold text-stone-900">
Delete &quot;{product.name}&quot;?
</p>
<p className="mt-1 text-xs text-stone-500">
This will remove the product. If attached to orders, it will be hidden.
</p>
<div className="flex gap-2 mt-3">
<button
onClick={onDeleteCancel}
className="flex-1 rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-stone-700 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={() => onDeleteConfirm(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white disabled:opacity-50 hover:bg-red-500"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
))
)}
</div>
);
}
+233 -277
View File
@@ -2,6 +2,7 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { createStopsBatch } from "@/actions/stops";
import GlassModal from "@/components/admin/GlassModal";
import type { ParsedStopRow } from "@/lib/csv-parsers";
type ParsedStop = Omit<ParsedStopRow, "_rowIndex" | "_warnings">;
@@ -22,29 +23,8 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
const [dragOver, setDragOver] = useState(false);
const [useAI, setUseAI] = useState(false);
const [created, setCreated] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Animate in
useEffect(() => {
requestAnimationFrame(() => setIsVisible(true));
}, []);
// Lock body scroll
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
// Escape key handler
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
const processFile = useCallback(async (file: File) => {
setError(null);
setStep("parsing");
@@ -146,283 +126,259 @@ export default function ScheduleImportModal({ brandId, onClose, onComplete }: Pr
const hasWarnings = warnings.length > 0;
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div
className={`fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-300 ${isVisible ? "opacity-100" : "opacity-0"}`}
onClick={handleBackdropClick}
<GlassModal
title="📥 Import Schedule"
subtitle="Upload a CSV to bulk-create stops as drafts"
onClose={onClose}
>
<div className="absolute inset-0 bg-black/40 backdrop-blur-md" />
<div
className={`relative w-full max-w-2xl max-h-[90vh] overflow-hidden rounded-2xl bg-white shadow-2xl border border-stone-200 transition-all duration-300 ${isVisible ? "scale-100 opacity-100" : "scale-95 opacity-0"}`}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-stone-100 bg-gradient-to-r from-emerald-600 to-emerald-500 px-6 py-5">
<div>
<h2 className="text-xl font-semibold text-white">Import Schedule</h2>
<p className="mt-0.5 text-sm text-white/80">
Upload a CSV to bulk-create stops as drafts.
</p>
</div>
<button
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
{step === "idle" && (
<div className="space-y-5">
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
{/* AI toggle */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-stone-200 bg-stone-50">
<button
onClick={() => setUseAI((v) => !v)}
className={`flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
useAI
? "bg-violet-100 border border-violet-300 text-violet-700"
: "bg-white border border-stone-200 text-stone-600 hover:border-stone-300"
}`}
>
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${useAI ? "bg-violet-500 text-white" : "bg-stone-200 text-stone-500"}`}>
{useAI ? "✓" : "○"}
</span>
Use AI for text/PDF parsing
</button>
<span className="text-xs text-stone-500">
{useAI
? "AI will parse unstructured text. Requires OPENAI_API_KEY."
: "Best results with CSV. Text/PDF uses AI if enabled."}
</span>
<div className="space-y-5">
{step === "idle" && (
<>
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`cursor-pointer rounded-2xl border-2 border-dashed p-12 text-center transition-all ${
dragOver
? "border-emerald-500 bg-emerald-50"
: "border-stone-300 hover:border-emerald-400 hover:bg-stone-50"
{/* AI toggle */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-stone-200 bg-stone-50">
<button
onClick={() => setUseAI((v) => !v)}
className={`flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
useAI
? "bg-violet-100 border border-violet-300 text-violet-700"
: "bg-white border border-stone-200 text-stone-600 hover:border-stone-300"
}`}
>
<div className="flex justify-center mb-4">
<div className={`flex h-16 w-16 items-center justify-center rounded-2xl ${dragOver ? "bg-emerald-100" : "bg-stone-100"}`}>
<svg className={`h-8 w-8 ${dragOver ? "text-emerald-600" : "text-stone-400"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
</div>
</div>
<p className="text-lg font-semibold text-stone-700">
Drop your schedule file here
</p>
<p className="mt-2 text-sm text-stone-500">
or click to browse CSV, TXT, JSON
</p>
<p className="mt-4 text-xs text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-2 inline-block">
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}
/>
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-xs ${useAI ? "bg-violet-500 text-white" : "bg-stone-200 text-stone-500"}`}>
{useAI ? "✓" : "○"}
</span>
Use AI for text/PDF parsing
</button>
<span className="text-xs text-stone-500">
{useAI ? "AI will parse unstructured text. Requires OPENAI_API_KEY." : "Best results with CSV."}
</span>
</div>
)}
{step === "parsing" && (
<div className="flex flex-col items-center gap-4 py-12">
<div className="h-10 w-10 animate-spin rounded-full border-3 border-emerald-200 border-t-emerald-600" />
<p className="text-stone-600 font-medium">Parsing file</p>
</div>
)}
{step === "review" && (
<div className="space-y-5">
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
{/* 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-8 text-center transition-all ${
dragOver
? "border-emerald-500 bg-emerald-50"
: "border-stone-300 hover:border-emerald-400 hover:bg-stone-50"
}`}
>
<div className="flex justify-center mb-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${dragOver ? "bg-emerald-100" : "bg-stone-100"}`}>
<svg className={`h-6 w-6 ${dragOver ? "text-emerald-600" : "text-stone-400"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
</div>
)}
{hasWarnings && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
<p className="font-semibold text-amber-800">Parsing warnings:</p>
<ul className="mt-1 list-disc list-inside space-y-0.5 text-amber-700">
{warnings.slice(0, 5).map((w, i) => (
<li key={i}>{w}</li>
))}
{warnings.length > 5 && <li>and {warnings.length - 5} more</li>}
</ul>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-stone-700">
{parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found review and edit below
</p>
<span className="text-xs text-stone-500">All will be created as drafts</span>
</div>
<p className="text-sm font-semibold text-stone-700">Drop your schedule file here</p>
<p className="mt-1 text-xs text-stone-500">or click to browse CSV, TXT, JSON</p>
<p className="mt-3 text-[10px] text-stone-400 font-mono bg-stone-100 rounded-lg px-3 py-1.5 inline-block">
CSV: city, state, location, date, time, address, zip, notes
</p>
</div>
{/* Review table */}
<div className="max-h-72 overflow-y-auto rounded-xl border border-stone-200 bg-white">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-stone-50 border-b border-stone-200">
<tr>
<th className="px-3 py-3 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">City</th>
<th className="px-3 py-3 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">State</th>
<th className="px-3 py-3 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">Location</th>
<th className="px-3 py-3 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">Date</th>
<th className="px-3 py-3 text-left text-xs font-semibold uppercase tracking-wide text-stone-500">Time</th>
<th className="w-10 px-3 py-3" />
<input
ref={fileInputRef}
type="file"
accept=".csv,.txt,.json"
className="hidden"
onChange={handleChange}
/>
</>
)}
{step === "parsing" && (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm font-medium text-stone-600">Parsing file</span>
</div>
</div>
)}
{step === "review" && (
<div className="space-y-4">
{error && (
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
{hasWarnings && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm">
<p className="font-semibold text-amber-800">Parsing warnings:</p>
<ul className="mt-1 list-disc list-inside space-y-0.5 text-amber-700">
{warnings.slice(0, 5).map((w, i) => (
<li key={i}>{w}</li>
))}
{warnings.length > 5 && <li>and {warnings.length - 5} more</li>}
</ul>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-stone-700">
{parsedStops.length} stop{parsedStops.length !== 1 ? "s" : ""} found review and edit
</p>
<span className="text-xs text-stone-400">All will be created as drafts</span>
</div>
{/* Review table */}
<div className="max-h-64 overflow-y-auto rounded-xl border border-stone-200 bg-white">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-stone-50 border-b border-stone-100">
<tr>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">City</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">State</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Location</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Date</th>
<th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-stone-500">Time</th>
<th className="w-8 px-3 py-2.5" />
</tr>
</thead>
<tbody className="divide-y divide-stone-50">
{parsedStops.map((stop, idx) => (
<tr key={idx} className="hover:bg-stone-50/50 transition-colors">
<td className="px-2 py-1.5">
<input
value={stop.city}
onChange={(e) => updateStop(idx, "city", e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input
value={stop.state}
onChange={(e) => updateStop(idx, "state", e.target.value)}
className="w-12 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input
value={stop.location}
onChange={(e) => updateStop(idx, "location", e.target.value)}
className="w-full rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input
value={stop.date}
onChange={(e) => updateStop(idx, "date", e.target.value)}
className="w-24 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<input
value={stop.time}
onChange={(e) => updateStop(idx, "time", e.target.value)}
className="w-16 rounded-lg border border-stone-200 bg-white px-2 py-1.5 text-sm outline-none focus:border-emerald-400"
/>
</td>
<td className="px-2 py-1.5">
<button
onClick={() => removeStop(idx)}
className="flex h-6 w-6 items-center justify-center rounded-lg text-stone-400 hover:bg-red-50 hover:text-red-600 transition-colors"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</td>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{parsedStops.map((stop, idx) => (
<tr key={idx} className="hover:bg-stone-50 transition-colors">
<td className="px-2 py-2">
<input
value={stop.city}
onChange={(e) => updateStop(idx, "city", e.target.value)}
className="w-full rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white"
/>
</td>
<td className="px-2 py-2">
<input
value={stop.state}
onChange={(e) => updateStop(idx, "state", e.target.value)}
className="w-14 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white"
/>
</td>
<td className="px-2 py-2">
<input
value={stop.location}
onChange={(e) => updateStop(idx, "location", e.target.value)}
className="w-full rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white"
/>
</td>
<td className="px-2 py-2">
<input
value={stop.date}
onChange={(e) => updateStop(idx, "date", e.target.value)}
className="w-28 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white"
/>
</td>
<td className="px-2 py-2">
<input
value={stop.time}
onChange={(e) => updateStop(idx, "time", e.target.value)}
className="w-20 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-sm text-stone-900 outline-none focus:border-emerald-400 focus:bg-white"
/>
</td>
<td className="px-2 py-2">
<button
onClick={() => removeStop(idx)}
className="flex h-7 w-7 items-center justify-center rounded-lg text-stone-400 hover:bg-red-50 hover:text-red-600 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between pt-2">
<p className="text-sm text-stone-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-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={parsedStops.length === 0}
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50 transition-colors shadow-sm shadow-emerald-200"
>
Create {parsedStops.length} Draft Stop{parsedStops.length !== 1 ? "s" : ""}
</button>
</div>
<div className="flex items-center justify-between pt-2 border-t border-stone-100">
<p className="text-xs text-stone-500">
{parsedStops.length} draft stop{parsedStops.length !== 1 ? "s" : ""} will be created
</p>
<div className="flex gap-2">
<button
onClick={() => { setStep("idle"); setParsedStops([]); setWarnings([]); }}
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={parsedStops.length === 0}
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2 text-sm font-bold text-white disabled:opacity-50 transition-colors"
>
Create {parsedStops.length} Stop{parsedStops.length !== 1 ? "s" : ""}
</button>
</div>
</div>
)}
</div>
)}
{step === "importing" && (
<div className="flex flex-col items-center gap-4 py-12">
<div className="h-10 w-10 animate-spin rounded-full border-3 border-emerald-200 border-t-emerald-600" />
<p className="text-stone-600 font-medium">Creating draft stops</p>
{step === "importing" && (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<svg className="h-5 w-5 animate-spin text-emerald-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm font-medium text-stone-600">Creating draft stops</span>
</div>
)}
</div>
)}
{step === "done" && (
<div className="flex flex-col items-center gap-5 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p className="text-2xl font-semibold text-stone-950">
{created} draft stop{created !== 1 ? "s" : ""} created
</p>
<p className="mt-2 text-sm text-stone-500">
Review them in the stops list and publish when ready.
</p>
</div>
<button
onClick={onClose}
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 px-6 py-3 text-sm font-semibold text-white transition-colors shadow-sm shadow-emerald-200"
>
Back to Stops
</button>
{step === "done" && (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-green-50">
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
)}
<div>
<p className="text-xl font-bold text-stone-950">
{created} draft stop{created !== 1 ? "s" : ""} created
</p>
<p className="mt-1 text-sm text-stone-500">
Review them in the stops list and publish when ready.
</p>
</div>
<button
onClick={onClose}
className="rounded-xl bg-emerald-600 hover:bg-emerald-700 px-5 py-2.5 text-sm font-bold text-white transition-colors"
>
Back to Stops
</button>
</div>
)}
{step === "error" && (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<svg className="h-8 w-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-red-700 font-medium">{error}</p>
<button
onClick={() => setStep("idle")}
className="rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-medium text-stone-600 hover:bg-stone-50 transition-colors"
>
Try Again
</button>
{step === "error" && (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-50">
<svg className="h-7 w-7 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
)}
</div>
<p className="text-sm font-medium text-red-700">{error}</p>
<button
onClick={() => setStep("idle")}
className="rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 transition-colors"
>
Try Again
</button>
</div>
)}
</div>
</div>
</GlassModal>
);
}
+158 -69
View File
@@ -1,19 +1,51 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import SettingsSections from "@/components/admin/SettingsSections";
import UsersPage from "@/components/admin/UsersPage";
import IntegrationsInner from "@/components/admin/IntegrationsInner";
import TimeTrackingSettingsClient from "@/components/admin/TimeTrackingSettingsClient";
type Tab = "general" | "users" | "integrations";
type Tab = "general" | "workers" | "tasks" | "users";
const TABS: { id: Tab; label: string }[] = [
{ id: "general", label: "General" },
{ id: "users", label: "Users" },
{ id: "integrations", label: "Integrations" },
const TABS: { id: Tab; label: string; icon: string; hash: string }[] = [
{ id: "general", label: "General", icon: "settings", hash: "general" },
{ id: "workers", label: "Workers", icon: "users", hash: "workers" },
{ id: "tasks", label: "Tasks", icon: "list", hash: "tasks" },
{ id: "users", label: "Users", icon: "user-check", hash: "users" },
];
// Icon components
const Icons = {
settings: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
),
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
),
"user-check": (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/>
</svg>
),
plug: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
</svg>
),
list: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>
</svg>
),
};
type Brand = { id: string; name: string };
type Props = {
@@ -35,56 +67,116 @@ export default function SettingsClient({
}: Props) {
const [activeTab, setActiveTab] = useState<Tab>("general");
return (
<div className="min-h-screen bg-stone-50 px-6 py-10">
<div className="mx-auto max-w-5xl">
{/* Header */}
<div className="mb-6">
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-3">
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
<span>/</span>
<span className="text-stone-600">Settings</span>
</nav>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Settings</h1>
<p className="mt-1.5 text-sm text-stone-500">Manage your brand, workers, tasks, users, and integrations.</p>
</div>
</div>
// Handle URL hash for sidebar navigation
useEffect(() => {
const hash = window.location.hash.slice(1);
const matchingTab = TABS.find(t => t.hash === hash);
if (matchingTab) {
setActiveTab(matchingTab.id);
}
}, []);
{/* Tab nav */}
<div className="border-b border-stone-200 mt-6">
<nav className="flex gap-1 -mb-px">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-5 py-3 text-sm font-medium border-b-2 transition-colors rounded-t-lg ${
activeTab === tab.id
? "border-emerald-500 text-emerald-700 bg-white"
: "border-transparent text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.label}
</button>
))}
</nav>
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
{/* Header */}
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-emerald-600">
{Icons.settings("h-5 w-5 sm:h-6 sm:w-6 text-white")}
</div>
<div>
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Settings</h1>
<p className="text-xs text-[var(--admin-text-muted)]">Manage your brand, workers, and integrations</p>
</div>
</div>
{/* Tab content */}
{/* Tab navigation */}
<nav className="grid grid-cols-5 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 rounded-lg px-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
activeTab === tab.id
? "bg-emerald-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{tab.icon === "settings" && Icons.settings("h-4 w-4")}
{tab.icon === "users" && Icons.users("h-4 w-4")}
{tab.icon === "user-check" && Icons["user-check"]("h-4 w-4")}
{tab.icon === "plug" && Icons.plug("h-4 w-4")}
{tab.icon === "list" && Icons.list("h-4 w-4")}
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label.substring(0, 3)}</span>
{activeTab === tab.id && (
<div className="absolute bottom-1 sm:bottom-1.5 left-1/2 -translate-x-1/2 h-0.5 w-6 sm:w-8 bg-white rounded-full" />
)}
</button>
))}
</nav>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
{activeTab === "general" && (
<div className="rounded-2xl bg-white border border-stone-200 shadow-sm p-6">
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<SettingsSections brandId={brandId} />
</div>
)}
{activeTab === "users" && (
<div className="rounded-2xl bg-white border border-stone-200 shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-stone-100 bg-stone-50">
<h2 className="text-lg font-bold text-stone-950">Users & Permissions</h2>
{activeTab === "workers" && (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="p-4 sm:p-6 border-b border-[var(--admin-border)]">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-600">
{Icons.users("w-4 h-4 text-white")}
</div>
<div>
<h2 className="text-sm sm:text-lg font-bold text-[var(--admin-text-primary)]">Workers & PINs</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Manage time tracking workers and PIN codes</p>
</div>
</div>
</div>
<div className="p-6">
<div className="p-4 sm:p-6">
<SettingsSections brandId={brandId} workersOnly />
</div>
</div>
)}
{activeTab === "tasks" && (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="p-4 sm:p-6 border-b border-[var(--admin-border)]">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500">
{Icons.list("w-4 h-4 text-white")}
</div>
<div>
<h2 className="text-sm sm:text-lg font-bold text-[var(--admin-text-primary)]">Tasks</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Define tasks workers can clock into</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6">
<SettingsSections brandId={brandId} tasksOnly />
</div>
</div>
)}
{activeTab === "users" && (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="p-4 sm:p-6 border-b border-[var(--admin-border)]">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
{Icons["user-check"]("w-4 h-4 text-[var(--admin-bg)]")}
</div>
<div>
<h2 className="text-sm sm:text-lg font-bold text-[var(--admin-text-primary)]">Users & Permissions</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Manage team access and roles</p>
</div>
</div>
</div>
<div className="p-4 sm:p-6">
<UsersPage
initialUsers={users}
brands={brands}
@@ -98,30 +190,27 @@ export default function SettingsClient({
</div>
)}
{activeTab === "integrations" && (
<div className="rounded-2xl bg-white border border-stone-200 shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-stone-100 bg-stone-50">
<h2 className="text-lg font-bold text-stone-950">Integrations & Exports</h2>
</div>
<div className="p-6 space-y-6">
<IntegrationsInner brandId={brandId} brands={brands} />
{/* Time Tracking Exports */}
<div className="border-t border-stone-200 pt-6">
<h3 className="text-base font-semibold text-stone-800 mb-4">Time Tracking Exports</h3>
<TimeTrackingSettingsClient brandId={brandId} />
<div className="mt-6 p-4 rounded-xl border border-violet-100 bg-violet-50/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100">
<svg className="w-4 h-4 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/>
</svg>
</div>
<div>
<p className="text-sm font-semibold text-stone-900">Advanced Settings</p>
<p className="text-xs text-stone-500">AI, integrations, Square sync, shipping, and webhooks</p>
</div>
<p className="text-xs text-stone-500">
For more settings, see{" "}
<a href="/admin/settings/ai" className="text-emerald-600 hover:text-emerald-700 underline underline-offset-2">AI Tools</a>
{" "}and{" "}
<a href="/admin/settings/shipping" className="text-emerald-600 hover:text-emerald-700 underline underline-offset-2">Shipping</a>
.
</p>
</div>
<a
href="/admin/advanced"
className="px-4 py-2 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-500 transition-colors"
>
Open Advanced
</a>
</div>
)}
</div>
</div>
</div>
);
+249 -240
View File
@@ -75,9 +75,11 @@ function Accordion({ title, id, description, defaultOpen = false, accentColor =
type Props = {
brandId: string;
workersOnly?: boolean;
tasksOnly?: boolean;
};
export default function SettingsSections({ brandId }: Props) {
export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Props) {
const [workers, setWorkers] = useState<TimeWorker[]>([]);
const [tasks, setTasks] = useState<TimeTask[]>([]);
const [settings, setSettings] = useState<TimeTrackingSettings | null>(null);
@@ -292,261 +294,268 @@ export default function SettingsSections({ brandId }: Props) {
return (
<div className="space-y-4">
{(!workersOnly && !tasksOnly) && (
<>
{/* 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>
{/* 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>
{/* 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>
<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>
{/* 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>
<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>
{/* 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>
</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>
</>
)}
{/* 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>
{(workersOnly || (!workersOnly && !tasksOnly)) && (
<Accordion
id="workers"
title="Workers & PINs"
description={`${workers.length} worker${workers.length !== 1 ? "s" : ""}`}
defaultOpen={workersOnly}
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>
))}
</tbody>
</table>
</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>
</div>
</Accordion>
</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>
{(tasksOnly || (!workersOnly && !tasksOnly)) && (
<Accordion
id="tasks"
title="Tasks"
description={`${tasks.length} task${tasks.length !== 1 ? "s" : ""}`}
defaultOpen={tasksOnly}
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>
))}
</tbody>
</table>
</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>
</div>
</Accordion>
</Accordion>
)}
{/* Worker Modal */}
{showWorkerModal && (
+7 -5
View File
@@ -45,11 +45,13 @@ export default function StopsHeaderActions({ brandId }: Props) {
</button>
</div>
<ScheduleImportModal
brandId={brandId}
onClose={() => setShowImport(false)}
onComplete={handleImportComplete}
/>
{showImport && (
<ScheduleImportModal
brandId={brandId}
onClose={() => setShowImport(false)}
onComplete={handleImportComplete}
/>
)}
<AddStopModal
isOpen={showAdd}
+306 -84
View File
@@ -24,6 +24,12 @@ const CAMPAIGN_TYPES: { value: CampaignType; label: string }[] = [
{ value: "transactional", label: "Transactional" },
];
const TYPE_COLORS: Record<string, string> = {
email_template: "bg-blue-100 text-blue-700",
sms_template: "bg-emerald-100 text-emerald-700",
internal_note: "bg-stone-100 text-stone-600",
};
// Sample variables for preview rendering
const SAMPLE_VARS: Record<string, string> = {
first_name: "Jane",
@@ -42,52 +48,242 @@ const SAMPLE_VARS: Record<string, string> = {
unsubscribe_url: "#",
};
export function TemplateListPanel({ templates }: { templates: Template[] }) {
// Icon components
const Icons = {
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
),
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
),
fileText: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
),
arrowRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14"/>
<path d="m12 5 7 7-7 7"/>
</svg>
),
};
// New Template Modal
function NewTemplateModal({
isOpen,
onClose,
onSuccess,
brandId
}: {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
brandId: string;
}) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [name, setName] = useState("");
const [templateType, setTemplateType] = useState<TemplateType>("email_template");
const [error, setError] = useState("");
if (!isOpen) return null;
const handleCreate = async () => {
if (!name.trim()) {
setError("Template name is required");
return;
}
setSaving(true);
setError("");
const result = await upsertTemplate({
brand_id: brandId,
name: name.trim(),
subject: "",
body_text: "",
template_type: templateType,
});
setSaving(false);
if (!result.success) {
setError(result.error ?? "Failed to create template");
return;
}
setName("");
setTemplateType("email_template");
onSuccess();
router.refresh();
};
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 className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl border border-[var(--admin-border)]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--admin-border)]">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
{Icons.fileText("h-5 w-5 text-white")}
</div>
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">New Template</h2>
<p className="text-xs text-[var(--admin-text-muted)]">Create a new email template</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
>
{Icons.x("h-5 w-5 text-[var(--admin-text-muted)]")}
</button>
</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"
{/* Content */}
<div className="p-6 space-y-4">
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Template Name *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
placeholder="e.g. Pickup Reminder"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Template Type *
</label>
<select
value={templateType}
onChange={(e) => setTemplateType(e.target.value as TemplateType)}
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
>
{TEMPLATE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={saving || !name.trim()}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{saving ? "Creating..." : "Create Template"}
{!saving && Icons.arrowRight("h-4 w-4")}
</button>
</div>
</div>
</div>
);
}
export function TemplateListPanel({ templates, brandId = "64294306-5f42-463d-a5e8-2ad6c81a96de" }: { templates: Template[], brandId?: string }) {
const router = useRouter();
const [showNewModal, setShowNewModal] = useState(false);
return (
<div className="p-4 sm:p-6">
<NewTemplateModal
isOpen={showNewModal}
onClose={() => setShowNewModal(false)}
onSuccess={() => {
setShowNewModal(false);
router.refresh();
}}
brandId={brandId}
/>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
{Icons.fileText("w-4 h-4 text-[var(--admin-bg)]")}
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Templates</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">{templates.length} template{templates.length !== 1 ? "s" : ""}</p>
</div>
</div>
<button
onClick={() => setShowNewModal(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 transition-colors"
>
+ New Template
</a>
{Icons.plus("h-3.5 w-3.5 sm:h-4 sm:w-4")}
<span>New Template</span>
</button>
</div>
{/* Table */}
{templates.length === 0 ? (
<div className="text-center py-12 text-slate-400">No templates yet</div>
<div className="text-center py-12 text-[var(--admin-text-muted)]">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>
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-[var(--admin-card)]">
<tr className="border-b border-[var(--admin-border)]">
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Name</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Type</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Subject</th>
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Updated</th>
<th className="text-right px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tbody className="divide-y divide-[var(--admin-border)]">
{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"
}`}>
<tr
key={t.id}
className="hover:bg-[var(--admin-card-hover)] cursor-pointer transition-colors"
onClick={() => router.push(`/admin/communications/templates/${t.id}`)}
>
<td className="px-3 sm:px-4 py-3 font-medium text-[var(--admin-text-primary)]">{t.name}</td>
<td className="px-3 sm:px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] sm:text-xs font-medium ${TYPE_COLORS[t.template_type] || "bg-stone-100 text-stone-600"}`}>
{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>
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)] truncate max-w-[200px] sm:max-w-xs">{t.subject}</td>
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{formatDate(new Date(t.updated_at))}</td>
<td className="px-3 sm:px-4 py-3 text-right">
{Icons.arrowRight("h-4 w-4 text-[var(--admin-text-muted)]")}
</td>
</tr>
))}
</tbody>
@@ -176,23 +372,42 @@ export function TemplateEditForm({
};
return (
<div className="max-w-5xl">
<div className="p-4 sm:p-6 max-w-5xl">
{/* Header */}
<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 items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
{Icons.fileText("w-4 h-4 text-[var(--admin-bg)]")}
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">
{mode === "new" ? "New Template" : "Edit Template"}
</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
{mode === "new" ? "Create a new email template" : "Update template settings"}
</p>
</div>
</div>
<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"}`}
className={`rounded-lg px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
activeTab === "edit"
? "bg-emerald-600 text-white"
: "bg-white border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
}`}
>
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"}`}
className={`rounded-lg px-3 sm:px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
activeTab === "preview"
? "bg-emerald-600 text-white"
: "bg-white border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
}`}
>
Preview
</button>
@@ -200,20 +415,20 @@ export function TemplateEditForm({
</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="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">{error}</div>
)}
{activeTab === "edit" ? (
<div className="space-y-6">
<div className="space-y-4 sm:space-y-6">
{/* Basic info */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Template Info</h3>
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Template Info</h3>
{/* Built-in template picker */}
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Start from:</span>
<span className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Start from:</span>
<select
className="text-sm border border-zinc-600 rounded-lg px-3 py-1.5"
className="text-xs sm:text-sm border border-[var(--admin-border)] rounded-lg px-3 py-1.5 bg-white text-[var(--admin-text-primary)]"
onChange={(e) => {
const tpl = BUILT_IN_TEMPLATES.find((t) => t.id === e.target.value);
if (tpl) applyBuiltIn(tpl);
@@ -229,22 +444,22 @@ export function TemplateEditForm({
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Template Name *</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
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>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
>
{TEMPLATE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
@@ -252,11 +467,11 @@ export function TemplateEditForm({
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Campaign Type</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
>
<option value="">Any</option>
{CAMPAIGN_TYPES.map((t) => (
@@ -268,18 +483,18 @@ export function TemplateEditForm({
</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 className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6 space-y-4">
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Subject Line</h3>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Subject *</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
placeholder="Email subject line"
/>
<p className="mt-1 text-xs text-slate-400">
<p className="mt-1 text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
Variables:{" "}
{TEMPLATE_VARIABLES.slice(0, 6).map((v) => (
<button
@@ -287,10 +502,9 @@ export function TemplateEditForm({
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"
className="ml-1 text-emerald-600 hover:text-emerald-700 font-medium"
>
{`{{${v.key}}}`}
</button>
@@ -300,30 +514,30 @@ export function TemplateEditForm({
</div>
{/* Body editor */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm: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">
<h3 className="text-xs sm:text-sm font-semibold text-[var(--admin-text-muted)] uppercase tracking-wide">Message Body</h3>
<label className="flex items-center gap-2 text-xs sm:text-sm">
<input
type="checkbox"
checked={htmlMode}
onChange={(e) => setHtmlMode(e.target.checked)}
className="rounded border-slate-400"
className="rounded border-[var(--admin-border)] text-emerald-600"
/>
<span className="text-zinc-400">HTML mode</span>
<span className="text-[var(--admin-text-muted)]">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>
<span className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] 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"
className="rounded bg-[var(--admin-card)] border border-[var(--admin-border)] px-2 py-1 text-[10px] sm:text-xs text-[var(--admin-text-muted)] hover:bg-emerald-50 hover:text-emerald-700 hover:border-emerald-200 transition-colors"
>
{`{{${v.key}}}`}
</button>
@@ -333,42 +547,42 @@ export function TemplateEditForm({
{htmlMode ? (
<>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">HTML Body</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm font-mono text-xs bg-white text-[var(--admin-text-primary)]"
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>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
placeholder="Plain text version..."
/>
</div>
</>
) : (
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Body (Plain Text) *</label>
<label className="block text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">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"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white text-[var(--admin-text-primary)]"
placeholder="Message body with variables like {{first_name}}..."
/>
</div>
)}
<p className="text-xs text-slate-400">
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
Tip: Click any variable button above to insert it at the cursor position. Variables are replaced when the email is sent.
</p>
</div>
@@ -379,11 +593,11 @@ export function TemplateEditForm({
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"
className="inline-flex items-center rounded-lg bg-emerald-600 px-4 sm:px-5 py-2 text-xs sm:text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{saving ? "Saving..." : "Save Template"}
</button>
<a href="/admin/communications/templates" className="text-sm text-zinc-500 hover:text-zinc-300 ml-4">
<a href="/admin/communications/templates" className="text-xs sm:text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] ml-4">
Cancel
</a>
</div>
@@ -392,17 +606,25 @@ export function TemplateEditForm({
/* 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">
<span className="text-xs sm:text-sm text-[var(--admin-text-muted)]">Preview:</span>
<div className="flex rounded-lg border border-[var(--admin-border)] 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"}`}
className={`px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
previewDevice === "desktop"
? "bg-emerald-600 text-white"
: "bg-white text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
}`}
>
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"}`}
className={`px-4 py-2 text-xs sm:text-sm font-semibold transition-colors ${
previewDevice === "mobile"
? "bg-emerald-600 text-white"
: "bg-white text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
}`}
>
Mobile
</button>
@@ -411,15 +633,15 @@ export function TemplateEditForm({
{/* 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">
<div className="rounded-xl border border-[var(--admin-border)] bg-white shadow-lg 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="bg-[var(--admin-card)] border-b border-[var(--admin-border)] 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 className="w-3 h-3 rounded-full bg-emerald-400" />
</div>
<div className="flex-1 text-center text-xs text-zinc-500 truncate">
<div className="flex-1 text-center text-xs text-[var(--admin-text-muted)] truncate">
{preview.subject}
</div>
</div>
@@ -430,11 +652,11 @@ export function TemplateEditForm({
/>
{/* 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">
<details className="border-t border-[var(--admin-border)]">
<summary className="px-4 py-2 text-xs text-[var(--admin-text-muted)] cursor-pointer hover:text-[var(--admin-text-primary)]">
View plain text version
</summary>
<pre className="px-4 pb-4 text-xs text-zinc-400 whitespace-pre-wrap">{preview.body_text}</pre>
<pre className="px-4 pb-4 text-xs text-[var(--admin-text-muted)] whitespace-pre-wrap font-mono">{preview.body_text}</pre>
</details>
)}
</div>
@@ -443,4 +665,4 @@ export function TemplateEditForm({
)}
</div>
);
}
}
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,48 @@ import {
type NotificationLogEntry,
} from "@/actions/time-tracking";
// One-color outline icons
const Icons = {
clock: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
),
dollarSign: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="2" y2="22"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
),
clipboard: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
</svg>
),
chart: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10"/>
<line x1="18" x2="18" y1="20" y2="4"/>
<line x1="6" x2="6" y1="20" y2="16"/>
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>
),
alert: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/>
<path d="M12 9v4"/>
<path d="M12 17h.01"/>
</svg>
),
};
type Tab = "dashboard" | "settings";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
@@ -316,7 +358,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<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>
<span className="text-stone-400">{Icons.clock("h-5 w-5")}</span>
<div>
<p className="text-sm font-semibold text-stone-800">QuickBooks</p>
<p className="text-xs text-stone-500">Time import CSV</p>
@@ -324,7 +366,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</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>
<span className="text-stone-400">{Icons.dollarSign("h-5 w-5")}</span>
<div>
<p className="text-sm font-semibold text-stone-800">Payroll</p>
<p className="text-xs text-stone-500">ADP / Gusto / Generic</p>
@@ -332,7 +374,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</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>
<span className="text-stone-400">{Icons.clipboard("h-5 w-5")}</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>
@@ -340,7 +382,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</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>
<span className="text-stone-400">{Icons.chart("h-5 w-5")}</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>
@@ -385,10 +427,10 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<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>
<span className="text-emerald-600">{Icons.check("h-4 w-4")}</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>
<span className="text-stone-300">{entry.sms_sent ? Icons.check("h-4 w-4 text-emerald-600") : "—"}</span>
</td>
</tr>
);
@@ -535,7 +577,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
{/* 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>
<span className="text-amber-600 mt-0.5">{Icons.alert("h-5 w-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>
@@ -0,0 +1,107 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { ReactNode } from "react";
type Action = {
label: string;
onClick: () => void;
variant?: "default" | "danger" | "success";
disabled?: boolean;
};
type AdminActionMenuProps = {
actions: Action[];
triggerLabel?: string;
className?: string;
};
export default function AdminActionMenu({ actions, triggerLabel = "⋮", className = "" }: AdminActionMenuProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleAction = (action: Action) => {
action.onClick();
setOpen(false);
};
return (
<div ref={menuRef} className={`relative inline-flex items-center justify-end ${className}`}>
<button
onClick={() => setOpen(!open)}
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
{triggerLabel}
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-[var(--admin-shadow-lg)] overflow-hidden">
{actions.map((action, i) => (
<button
key={i}
onClick={() => handleAction(action)}
disabled={action.disabled}
className={`w-full text-left px-4 py-2.5 text-sm transition-colors ${
action.variant === "danger"
? "text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)]"
: action.variant === "success"
? "text-[var(--admin-accent-text)] hover:bg-[var(--admin-accent-light)]"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
} ${action.disabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
{action.label}
</button>
))}
</div>
</>
)}
</div>
);
}
// Simple inline action button
type AdminActionButtonProps = {
children: ReactNode;
onClick: () => void;
variant?: "default" | "primary" | "danger";
size?: "sm" | "md";
className?: string;
};
export function AdminActionButton({
children,
onClick,
variant = "default",
size = "sm",
className = ""
}: AdminActionButtonProps) {
const baseClasses = "rounded-lg font-medium transition-colors";
const sizeClasses = size === "sm" ? "px-3 py-1.5 text-xs" : "px-4 py-2 text-sm";
const variantClasses = {
default: "text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)]",
primary: "bg-[var(--admin-accent)] text-white hover:opacity-90",
danger: "text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)]",
};
return (
<button
onClick={onClick}
className={`${baseClasses} ${sizeClasses} ${variantClasses[variant]} ${className}`}
>
{children}
</button>
);
}
@@ -0,0 +1,74 @@
"use client";
type BadgeVariant = "default" | "success" | "warning" | "danger" | "info";
type AdminBadgeProps = {
children: React.ReactNode;
variant?: BadgeVariant;
dot?: boolean;
className?: string;
};
const variantClasses: Record<BadgeVariant, { bg: string; text: string; dot: string }> = {
default: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-text-muted)]" },
success: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" },
warning: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" },
danger: { bg: "bg-[var(--admin-danger-light)]", text: "text-[var(--admin-danger)]", dot: "bg-[var(--admin-danger)]" },
info: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" },
};
export default function AdminBadge({
children,
variant = "default",
dot = false,
className = ""
}: AdminBadgeProps) {
const { bg, text, dot: dotColor } = variantClasses[variant];
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${bg} ${text} ${className}`}>
{dot && <span className={`h-1.5 w-1.5 rounded-full ${dotColor}`} />}
{children}
</span>
);
}
// Status badge with predefined statuses
type AdminStatusBadgeProps = {
status: "active" | "inactive" | "pending" | "draft" | "completed" | "cancelled";
className?: string;
};
const statusConfig: Record<string, { variant: BadgeVariant; dot: boolean; label: string }> = {
active: { variant: "success", dot: true, label: "Active" },
inactive: { variant: "default", dot: true, label: "Inactive" },
pending: { variant: "warning", dot: true, label: "Pending" },
draft: { variant: "default", dot: true, label: "Draft" },
completed: { variant: "success", dot: true, label: "Completed" },
cancelled: { variant: "danger", dot: true, label: "Cancelled" },
};
export function AdminStatusBadge({ status, className = "" }: AdminStatusBadgeProps) {
const config = statusConfig[status] || statusConfig.inactive;
return (
<AdminBadge variant={config.variant} dot={config.dot} className={className}>
{config.label}
</AdminBadge>
);
}
// Count badge (circular)
type AdminCountBadgeProps = {
count: number;
variant?: BadgeVariant;
className?: string;
};
export function AdminCountBadge({ count, variant = "default", className = "" }: AdminCountBadgeProps) {
const { bg, text } = variantClasses[variant];
return (
<span className={`inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 text-xs font-bold ${bg} ${text} ${className}`}>
{count}
</span>
);
}
@@ -0,0 +1,48 @@
"use client";
import { ReactNode, CSSProperties } from "react";
type AdminCardProps = {
children: ReactNode;
className?: string;
noPadding?: boolean;
style?: CSSProperties;
};
export default function AdminCard({ children, className = "", noPadding = false, style }: AdminCardProps) {
return (
<div
className={`rounded-2xl border bg-white shadow-[var(--admin-shadow-sm)] ${noPadding ? "" : "p-5"} ${className}`}
style={{
borderColor: 'var(--admin-border)',
...style
}}
>
{children}
</div>
);
}
type AdminCardHeaderProps = {
title: string;
action?: ReactNode;
};
export function AdminCardHeader({ title, action }: AdminCardHeaderProps) {
return (
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">{title}</h3>
{action}
</div>
);
}
type AdminCardFooterProps = {
children: ReactNode;
};
export function AdminCardFooter({ children }: AdminCardFooterProps) {
return (
<div className="mt-4 pt-4 border-t border-[var(--admin-border-light)]">{children}</div>
);
}
@@ -0,0 +1,16 @@
"use client";
import { ReactNode } from "react";
type AdminContainerProps = {
children: ReactNode;
className?: string;
};
export default function AdminContainer({ children, className = "" }: AdminContainerProps) {
return (
<div className={`mx-auto max-w-6xl ${className}`}>
{children}
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import GlassModal from "@/components/admin/GlassModal";
type AdminDeleteConfirmProps = {
title: string;
itemName: string;
description?: string;
onConfirm: () => Promise<void> | void;
onCancel?: () => void;
deleteLabel?: string;
};
export default function AdminDeleteConfirm({
title,
itemName,
description,
onConfirm,
onCancel,
deleteLabel = "Delete",
}: AdminDeleteConfirmProps) {
const [open, setOpen] = useState(true);
const [deleting, setDeleting] = useState(false);
const handleConfirm = async () => {
setDeleting(true);
try {
await onConfirm();
setOpen(false);
} finally {
setDeleting(false);
}
};
const handleCancel = () => {
setOpen(false);
onCancel?.();
};
if (!open) return null;
return (
<GlassModal title={title} onClose={handleCancel}>
<div className="space-y-4">
<p className="text-sm text-[var(--admin-text-secondary)]">
Are you sure you want to delete <span className="font-semibold text-[var(--admin-text-primary)]">&quot;{itemName}&quot;</span>?
</p>
{description && (
<p className="text-xs text-[var(--admin-text-muted)]">{description}</p>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--admin-border-light)]">
<button
onClick={handleCancel}
className="rounded-xl border border-[var(--admin-border)] px-4 py-2 text-sm font-semibold text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={deleting}
className="rounded-xl bg-[var(--admin-danger)] px-4 py-2 text-sm font-bold text-white hover:opacity-90 disabled:opacity-50 transition-colors"
>
{deleting ? "Deleting..." : deleteLabel}
</button>
</div>
</div>
</GlassModal>
);
}
// Hook for managing delete confirmation state
export function useDeleteConfirm() {
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const confirmDelete = (id: string, name: string) => setDeleteTarget({ id, name });
const cancelDelete = () => setDeleteTarget(null);
return { deleteTarget, confirmDelete, cancelDelete };
}
@@ -0,0 +1,36 @@
"use client";
import { ReactNode } from "react";
type AdminEmptyStateProps = {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
className?: string;
};
export default function AdminEmptyState({
icon,
title,
description,
action,
className = ""
}: AdminEmptyStateProps) {
return (
<div className={`flex flex-col items-center justify-center py-16 px-4 text-center ${className}`}>
{icon ? (
<div className="mb-4 text-[var(--admin-text-muted)]">{icon}</div>
) : (
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--admin-bg-subtle)]">
<svg className="h-8 w-8 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
)}
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)]">{title}</h3>
{description && <p className="mt-1 text-sm text-[var(--admin-text-muted)] max-w-sm">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
@@ -0,0 +1,132 @@
"use client";
type StatusOption = "all" | "active" | "inactive" | string;
type AdminFilterBarProps = {
search: string;
onSearchChange: (value: string) => void;
searchPlaceholder?: string;
statusFilter?: StatusOption;
onStatusChange?: (value: StatusOption) => void;
statusOptions?: { value: StatusOption; label: string }[];
count?: number;
countLabel?: string;
viewMode?: "table" | "cards";
onViewModeChange?: (mode: "table" | "cards") => void;
children?: React.ReactNode;
onAddClick?: () => void;
addLabel?: string;
};
export default function AdminFilterBar({
search,
onSearchChange,
searchPlaceholder = "Search...",
statusFilter,
onStatusChange,
statusOptions,
count,
countLabel = "items",
viewMode,
onViewModeChange,
children,
onAddClick,
addLabel = "Add",
}: AdminFilterBarProps) {
const defaultOptions = [
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
];
const options = statusOptions ?? defaultOptions;
return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 shadow-[var(--admin-shadow-sm)]">
<div className="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[12rem]">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] bg-white py-2 pl-10 pr-4 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)]"
/>
</div>
{/* Status Tabs */}
{onStatusChange && (
<div className="flex gap-1 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-1">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => onStatusChange(opt.value)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition-all ${
statusFilter === opt.value
? "bg-white text-[var(--admin-accent-text)] shadow-[var(--admin-shadow-sm)] border border-[var(--admin-accent-light)]"
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-white/50"
}`}
>
{opt.label}
</button>
))}
</div>
)}
{/* Count */}
{count !== undefined && (
<span className="text-xs text-[var(--admin-text-muted)]">
{count} {countLabel}
</span>
)}
{/* View Toggle */}
{onViewModeChange && viewMode && (
<div className="flex gap-1 rounded-xl border border-[var(--admin-border)] bg-white p-1">
<button
onClick={() => onViewModeChange("table")}
className={`rounded-lg p-1.5 transition-all ${
viewMode === "table"
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)] shadow-[var(--admin-shadow-sm)]"
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<button
onClick={() => onViewModeChange("cards")}
className={`rounded-lg p-1.5 transition-all ${
viewMode === "cards"
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)] shadow-[var(--admin-shadow-sm)]"
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
</div>
)}
{/* Custom children (e.g. extra filters) */}
{children}
{/* Add Button */}
{onAddClick && (
<button
onClick={onAddClick}
className="ml-auto rounded-xl bg-[var(--admin-accent)] px-4 py-2 text-xs font-bold text-white hover:bg-[var(--admin-accent-hover)] transition-colors shadow-[var(--admin-shadow-sm)]"
>
+ {addLabel}
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,191 @@
"use client";
type AdminInputProps = {
label?: string;
error?: string;
helpText?: string;
required?: boolean;
className?: string;
children: React.ReactNode;
};
export function AdminInput({
label,
error,
helpText,
required,
className = "",
children
}: AdminInputProps) {
return (
<div className={className}>
{label && (
<label className="block text-xs font-semibold text-[var(--admin-text-secondary)] mb-1.5">
{label}
{required && <span className="text-[var(--admin-danger)] ml-1">*</span>}
</label>
)}
{children}
{helpText && !error && <p className="mt-1 text-[10px] text-[var(--admin-text-muted)]">{helpText}</p>}
{error && <p className="mt-1 text-xs text-[var(--admin-danger)]">{error}</p>}
</div>
);
}
// Styled text input wrapper
type AdminTextInputProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
type?: string;
disabled?: boolean;
className?: string;
};
export function AdminTextInput({
value,
onChange,
placeholder,
type = "text",
disabled,
className = ""
}: AdminTextInputProps) {
return (
<input
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)] disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
/>
);
}
// Styled textarea wrapper
type AdminTextareaProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
rows?: number;
disabled?: boolean;
className?: string;
};
export function AdminTextarea({
value,
onChange,
placeholder,
rows = 3,
disabled,
className = ""
}: AdminTextareaProps) {
return (
<textarea
value={value}
onChange={onChange}
placeholder={placeholder}
rows={rows}
disabled={disabled}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] placeholder:text-[var(--admin-text-muted)] resize-none disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
/>
);
}
// Styled select wrapper
type AdminSelectProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: { value: string; label: string }[];
disabled?: boolean;
className?: string;
};
export function AdminSelect({
value,
onChange,
options,
disabled,
className = ""
}: AdminSelectProps) {
return (
<select
value={value}
onChange={onChange}
disabled={disabled}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] disabled:bg-[var(--admin-bg-subtle)] disabled:text-[var(--admin-text-muted)] ${className}`}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
// Styled checkbox wrapper
type AdminCheckboxProps = {
checked: boolean;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
label: string;
disabled?: boolean;
className?: string;
};
export function AdminCheckbox({
checked,
onChange,
label,
disabled,
className = ""
}: AdminCheckboxProps) {
return (
<label className={`flex items-center gap-3 cursor-pointer ${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
<input
type="checkbox"
checked={checked}
onChange={onChange}
disabled={disabled}
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
/>
<span className="text-sm font-medium text-[var(--admin-text-secondary)]">{label}</span>
</label>
);
}
// Loading spinner
type AdminSpinnerProps = {
size?: "sm" | "md" | "lg";
className?: string;
};
export function AdminSpinner({ size = "md", className = "" }: AdminSpinnerProps) {
const sizeClasses = {
sm: "h-4 w-4 border-2",
md: "h-6 w-6 border-2",
lg: "h-8 w-8 border-3",
};
return (
<div
className={`animate-spin rounded-full border-[var(--admin-accent)] border-t-transparent ${sizeClasses[size]} ${className}`}
/>
);
}
// Loading overlay
type AdminLoadingOverlayProps = {
message?: string;
};
export function AdminLoadingOverlay({ message = "Loading..." }: AdminLoadingOverlayProps) {
return (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 backdrop-blur-sm z-10">
<div className="flex flex-col items-center gap-3">
<AdminSpinner size="lg" />
<span className="text-sm text-[var(--admin-text-muted)]">{message}</span>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
"use client";
import { ReactNode } from "react";
type AdminLayoutProps = {
children: ReactNode;
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "full";
className?: string;
};
const maxWidthClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-6xl",
full: "max-w-full",
};
export default function AdminLayout({ children, maxWidth = "2xl", className = "" }: AdminLayoutProps) {
return (
<main className="min-h-screen admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className={`mx-auto px-6 py-10 ${maxWidthClasses[maxWidth]} ${className}`}>
{children}
</div>
</main>
);
}
@@ -0,0 +1,68 @@
"use client";
import { useEffect } from "react";
type Props = {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
maxWidth?: string;
};
export default function AdminModal({ title, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
useEffect(() => {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}, []);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
style={{ backgroundColor: "rgba(60, 56, 37, 0.5)" }}
>
<div
className={`relative w-full ${maxWidth} rounded-2xl`}
style={{
backgroundColor: "#ffffff",
border: "1px solid var(--admin-border)",
boxShadow: "0 25px 50px -12px rgba(60, 56, 37, 0.35), 0 12px 24px -8px rgba(60, 56, 37, 0.2)",
}}
>
{/* Accent bar */}
<div className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl overflow-hidden"
style={{ background: "linear-gradient(90deg, var(--admin-accent) 0%, var(--admin-accent-hover) 100%)" }}
/>
{/* Header */}
<div className="flex items-center justify-between px-6 py-5" style={{ borderBottom: "1px solid var(--admin-border-light)" }}>
<div>
<h2 className="text-xl font-semibold text-[var(--admin-text-primary)]" style={{ letterSpacing: "-0.02em" }}>{title}</h2>
{subtitle && <p className="mt-0.5 text-sm text-[var(--admin-text-muted)]">{subtitle}</p>}
</div>
<button
onClick={onClose}
className="flex h-9 w-9 items-center justify-center rounded-full transition-all hover:bg-[var(--admin-bg-subtle)]"
style={{ backgroundColor: "rgba(60, 56, 37, 0.04)" }}
>
<svg className="h-5 w-5 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="p-6">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { ReactNode } from "react";
type AdminPageHeaderProps = {
breadcrumb?: { label: string; href?: string }[];
title: string;
description?: string;
actions?: ReactNode;
};
export default function AdminPageHeader({ breadcrumb, title, description, actions }: AdminPageHeaderProps) {
return (
<div className="mb-6">
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-4">
{breadcrumb.map((item, i) => (
<span key={i} className="flex items-center gap-2">
{item.href ? (
<Link href={item.href} className="hover:text-[var(--admin-text-secondary)] transition-colors">{item.label}</Link>
) : (
<span className="text-[var(--admin-text-secondary)]">{item.label}</span>
)}
{i < breadcrumb.length - 1 && <span>/</span>}
</span>
))}
</nav>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)] tracking-tight">{title}</h1>
{description && <p className="mt-1 text-sm text-[var(--admin-text-muted)]">{description}</p>}
</div>
{actions && <div className="flex items-center gap-3">{actions}</div>}
</div>
</div>
);
}
@@ -0,0 +1,122 @@
"use client";
type AdminPaginationProps = {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
className?: string;
};
export default function AdminPagination({
currentPage,
totalPages,
onPageChange,
className = ""
}: AdminPaginationProps) {
if (totalPages <= 1) return null;
return (
<div className={`flex items-center justify-center gap-2 ${className}`}>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 0}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="h-4 w-4" 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>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i).map((page) => {
// Show first, last, current, and adjacent pages
const showPage =
page === 0 ||
page === totalPages - 1 ||
Math.abs(page - currentPage) <= 1;
// Show ellipsis
const showEllipsisBefore = page === 1 && currentPage > 2;
const showEllipsisAfter = page === totalPages - 2 && currentPage < totalPages - 3;
if (!showPage && !showEllipsisBefore && !showEllipsisAfter) return null;
if (showEllipsisBefore || showEllipsisAfter) {
return (
<span key={`ellipsis-${page}`} className="px-2 text-[var(--admin-text-muted)]">
...
</span>
);
}
return (
<button
key={page}
onClick={() => onPageChange(page)}
className={`flex h-8 min-w-[2rem] items-center justify-center rounded-lg text-sm font-medium transition-colors ${
page === currentPage
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
: "text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)]"
}`}
>
{page + 1}
</button>
);
})}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages - 1}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="h-4 w-4" 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>
);
}
// Simple previous/next pagination
type AdminSimplePaginationProps = {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
className?: string;
};
export function AdminSimplePagination({
page,
totalPages,
onPageChange,
className = ""
}: AdminSimplePaginationProps) {
return (
<div className={`flex items-center justify-between ${className}`}>
<span className="text-xs text-[var(--admin-text-muted)]">
Page {page + 1} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(page - 1)}
disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] 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={() => onPageChange(page + 1)}
disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] 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>
);
}
@@ -0,0 +1,33 @@
"use client";
type StatItem = {
label: string;
value: number;
variant?: "default" | "success" | "warning" | "info";
};
type AdminStatsBarProps = {
stats: StatItem[];
};
const variantClasses = {
default: "bg-[var(--admin-border-light)] border-[var(--admin-border)] text-[var(--admin-text-secondary)]",
success: "bg-[var(--admin-accent-light)] border-[var(--admin-accent)] text-[var(--admin-accent-text)]",
warning: "bg-[var(--admin-warning-light)] border-[var(--admin-warning)] text-[var(--admin-text-primary)]",
info: "bg-[var(--admin-border)] border-[var(--admin-text-muted)] text-[var(--admin-text-secondary)]",
};
export default function AdminStatsBar({ stats }: AdminStatsBarProps) {
return (
<div className="flex items-center gap-6">
{stats.map((stat, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-sm text-[var(--admin-text-muted)]">{stat.label}</span>
<span className={`rounded-full border px-2.5 py-0.5 text-sm font-bold ${variantClasses[stat.variant ?? "default"]}`}>
{stat.value}
</span>
</div>
))}
</div>
);
}
@@ -0,0 +1,84 @@
"use client";
import { ReactNode } from "react";
type Column<T> = {
key: string;
header: string;
render?: (item: T) => ReactNode;
className?: string;
};
type AdminTableProps<T> = {
data: T[];
columns: Column<T>[];
keyExtractor: (item: T) => string;
onRowClick?: (item: T) => void;
emptyMessage?: string;
className?: string;
};
export default function AdminTable<T extends Record<string, unknown>>({
data,
columns,
keyExtractor,
onRowClick,
emptyMessage = "No items found",
className = "",
}: AdminTableProps<T>) {
return (
<div className={`overflow-hidden rounded-2xl border border-[var(--admin-border)] bg-white shadow-[var(--admin-shadow-sm)] ${className}`}>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
{columns.map((col) => (
<th key={col.key} className={`px-5 py-3.5 text-[10px] font-bold uppercase tracking-widest text-[var(--admin-text-muted)] ${col.className ?? ""}`}>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-5 py-16 text-center text-sm text-[var(--admin-text-muted)]">
{emptyMessage}
</td>
</tr>
) : (
data.map((item) => (
<tr
key={keyExtractor(item)}
className={`hover:bg-[var(--admin-bg-subtle)]/40 transition-colors ${onRowClick ? "cursor-pointer" : ""}`}
onClick={() => onRowClick?.(item)}
>
{columns.map((col) => (
<td key={col.key} className={`px-5 py-3.5 ${col.className ?? ""}`}>
{col.render ? col.render(item) : String(item[col.key] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}
// Helper for status badges
export function TableStatusBadge({ status }: { status: "active" | "inactive" | "pending" | "completed" }) {
const config = {
active: { bg: "bg-[var(--admin-accent-light)]", text: "text-[var(--admin-accent-text)]", dot: "bg-[var(--admin-accent)]" },
inactive: { bg: "bg-[var(--admin-border-light)]", text: "text-[var(--admin-text-muted)]", dot: "bg-[var(--admin-text-muted)]" },
pending: { bg: "bg-[var(--admin-warning-light)]", text: "text-[var(--admin-warning)]", dot: "bg-[var(--admin-warning)]" },
completed: { bg: "bg-[var(--admin-border)]", text: "text-[var(--admin-text-secondary)]", dot: "bg-[var(--admin-info)]" },
};
const { bg, text, dot } = config[status];
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] font-bold ${bg} ${text}`}>
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
@@ -0,0 +1,22 @@
// Core components
export { default as AdminLayout } from "./AdminLayout";
export { default as AdminContainer } from "./AdminContainer";
export { default as AdminPageHeader } from "./AdminPageHeader";
export { default as AdminCard, AdminCardHeader, AdminCardFooter } from "./AdminCard";
export { default as AdminStatsBar } from "./AdminStatsBar";
export { default as AdminFilterBar } from "./AdminFilterBar";
export { default as AdminTable, TableStatusBadge } from "./AdminTable";
export { default as AdminEmptyState } from "./AdminEmptyState";
export { default as AdminDeleteConfirm, useDeleteConfirm } from "./AdminDeleteConfirm";
export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu";
export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination";
export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge";
// Form elements
export { AdminInput, AdminTextInput, AdminTextarea, AdminSelect, AdminCheckbox, AdminSpinner, AdminLoadingOverlay } from "./AdminFormElements";
// Modal component
export { default as AdminModal } from "./AdminModal";
// Re-export GlassModal for backward compatibility
export { default as GlassModal } from "@/components/admin/GlassModal";