feat(admin): design system audit fixes

- Add CSS design tokens for consistency (--admin-danger-hover, --admin-accent-dot, shadow-md warm tone)
- Replace unicode icons with inline SVG (⋮, ✕, ▼, ▶)
- Consolidate duplicate PageHeader/AdminPageHeader components
- Standardize border-radius (rounded-xl → rounded-lg for ViewModeTabs)
- Remove hardcoded brand UUID in products page
- Replace hardcoded bg-red-600 with CSS variables in delete buttons
- Add AdminButton, AdminFilterTabs, AdminSearchInput, PageHeader design system components
- Fix GlassModal and AdminModal close button styling
- Remove duplicate 'New Lot' button on Route Trace dashboard
This commit is contained in:
2026-06-02 03:31:54 +00:00
parent 15e939ad7e
commit 7203cf1ead
65 changed files with 3478 additions and 3176 deletions
+5
View File
@@ -1,3 +1,8 @@
/**
* @deprecated Use AdminSidebar instead. AdminHeader is deprecated and will be removed.
* The AdminSidebar provides the complete navigation system for admin pages.
* It includes an expandable Settings sub-menu with all settings pages.
*/
"use client";
import Link from "next/link";
+37 -49
View File
@@ -4,6 +4,8 @@ import { useState, useMemo } from "react";
import Link from "next/link";
import { markPickupComplete } from "@/actions/pickup";
import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge";
import { AdminButton, AdminSearchInput, AdminFilterTabs } from "./design-system";
type OrderItem = {
id: string;
@@ -62,12 +64,6 @@ function formatCurrency(amount: number) {
// 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"/>
@@ -180,7 +176,7 @@ export default function AdminOrdersPanel({
{/* 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">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)]">
{Icons.package("h-5 w-5 text-white")}
</div>
<div>
@@ -191,7 +187,7 @@ export default function AdminOrdersPanel({
</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>
<AdminBadge variant="info">Brand scoped</AdminBadge>
)}
</div>
@@ -207,40 +203,32 @@ export default function AdminOrdersPanel({
</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>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] 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>
<AdminFilterTabs
activeTab={activeTab}
onTabChange={(value) => { setActiveTab(value as StatusTab); setPage(0); }}
tabs={[
{ value: "all", label: "All", count: filteredOrders.length },
{ value: "pending", label: "Pending", count: pendingCount },
{ value: "picked_up", label: "Picked Up", count: pickedUpCount },
]}
size="md"
/>
{/* 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>
<AdminSearchInput
placeholder="Search name, phone, or order #..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
onClear={() => { setSearch(""); setPage(0); }}
showClear={true}
/>
{/* Stop Filter */}
<div className="relative">
@@ -248,14 +236,14 @@ export default function AdminOrdersPanel({
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-accent)] bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)]"
: "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>
<span className="rounded-full bg-[var(--admin-accent)] text-white px-1.5 py-0.5 text-xs font-semibold">{selectedStops.length}</span>
)}
{Icons.chevronDown("h-4 w-4")}
</button>
@@ -266,7 +254,7 @@ export default function AdminOrdersPanel({
<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>
<button onClick={clearStops} className="text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] font-medium">Clear all</button>
</div>
<div className="max-h-64 overflow-y-auto p-2">
{stops.map((stop) => (
@@ -275,7 +263,7 @@ export default function AdminOrdersPanel({
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"
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)]"
/>
<div className="flex-1">
<span className="text-sm font-medium text-stone-700">{stop.city}, {stop.state}</span>
@@ -297,9 +285,9 @@ export default function AdminOrdersPanel({
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">
<span key={stopId} className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-3 py-1 text-xs font-medium text-[var(--admin-accent-text)]">
{stop.city}, {stop.state}
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-emerald-900">
<button onClick={() => toggleStop(stopId)} className="ml-1 hover:text-[var(--admin-accent-hover)]">
{Icons.x("h-3 w-3")}
</button>
</span>
@@ -335,7 +323,7 @@ export default function AdminOrdersPanel({
{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">
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
{shortId(order.id)}
</Link>
</td>
@@ -362,12 +350,12 @@ export default function AdminOrdersPanel({
<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>
<AdminBadge variant="success" dot>Picked Up</AdminBadge>
) : (
<span className="rounded-full bg-amber-100 text-amber-700 px-2.5 py-0.5 text-[10px] font-semibold">Pending</span>
<AdminBadge variant="warning" dot>Pending</AdminBadge>
)}
{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>
<AdminBadge variant="info">Square</AdminBadge>
)}
</div>
</td>
@@ -406,15 +394,15 @@ export default function AdminOrdersPanel({
{/* 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="fixed bottom-6 right-6 z-50 rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] 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 className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--admin-accent)]">
{Icons.check("h-4 w-4 text-white")}
</div>
<span className="font-medium text-emerald-800">Pickup confirmed!</span>
<span className="font-medium text-[var(--admin-accent-text)]">Pickup confirmed!</span>
</div>
</div>
)}
</div>
);
}
}
+94 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
@@ -21,7 +21,23 @@ const TOP_LINKS = [
{ href: "/admin/advanced", label: "Advanced", icon: "advanced" },
];
const SETTINGS_SUB_LINKS: never[] = [];
type SettingsItem = {
href: string;
label: string;
description: string;
};
const SETTINGS_SUB_LINKS: SettingsItem[] = [
{ href: "/admin/settings", label: "General", description: "Brand name, logo, timezone" },
{ href: "/admin/settings/brand", label: "Brand", description: "Brand profile and preferences" },
{ href: "/admin/users", label: "Users & Permissions", description: "Team members and access roles" },
{ href: "/admin/settings/integrations", label: "Integrations", description: "Stripe, Square, Resend, Twilio" },
{ href: "/admin/settings/apps", label: "Add-ons", description: "Enable and manage feature add-ons" },
{ href: "/admin/settings/billing", label: "Billing & Plans", description: "Subscription, invoices, usage" },
{ href: "/admin/settings/payments", label: "Payments", description: "Stripe, Square, payment methods" },
{ href: "/admin/communications/abandoned-carts", label: "Abandoned Cart Recovery", description: "3-email recovery sequence" },
{ href: "/admin/communications/welcome-sequence", label: "Welcome Sequence", description: "4-email onboarding for new subscribers" },
];
function GridIcon({ className }: { className?: string }) {
return (
@@ -132,6 +148,7 @@ 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"
@@ -144,6 +161,19 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
return pathname.startsWith(href);
};
// Check if we're on a settings-related page
const isSettingsPage = pathname.startsWith("/admin/settings") ||
pathname.startsWith("/admin/users") ||
pathname.startsWith("/admin/communications/abandoned-carts") ||
pathname.startsWith("/admin/communications/welcome-sequence");
// Auto-expand settings menu when navigating to a settings page
useEffect(() => {
if (isSettingsPage) {
setSettingsOpen(true);
}
}, [isSettingsPage]);
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
@@ -210,6 +240,64 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
<nav className="flex-1 overflow-y-auto px-3 py-5 space-y-1">
{TOP_LINKS.map((item) => {
const active = isActive(item.href);
// Settings link with expandable sub-menu
if (item.href === "/admin/settings") {
return (
<div key={item.href}>
<button
onClick={() => setSettingsOpen(!settingsOpen)}
className={[
"w-full group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 border-l-2",
isSettingsPage
? "border-l-2"
: "border-l-2 border-transparent",
].join(" ")}
style={isSettingsPage ? {
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" style={{
color: isSettingsPage ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)"
}}>
<SettingsCogIcon open={settingsOpen} />
</span>
Settings
<ChevronIcon open={settingsOpen} />
</button>
{/* Settings sub-links */}
{settingsOpen && (
<div className="ml-6 mt-1 space-y-0.5 border-l border-[var(--admin-sidebar-bg)] pl-3">
{SETTINGS_SUB_LINKS.map((subItem) => {
const subActive = pathname === subItem.href || pathname.startsWith(subItem.href + "/");
return (
<Link
key={subItem.href}
href={subItem.href}
onClick={() => setMobileOpen(false)}
className={[
"block px-3 py-2 rounded-lg text-xs font-medium transition-all duration-200",
subActive
? "bg-emerald-500/10 text-emerald-400"
: "text-[var(--admin-sidebar-text)] hover:bg-white/5 opacity-70 hover:opacity-100",
].join(" ")}
>
{subItem.label}
</Link>
);
})}
</div>
)}
</div>
);
}
return (
<Link
key={item.href}
@@ -221,16 +309,16 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
? "border-l-2"
: "border-l-2 border-transparent hover:border-l-2",
].join(" ")}
style={active ? {
backgroundColor: "rgba(202, 117, 67, 0.15)",
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" style={{
<span className="flex-shrink-0 transition-colors" style={{
color: active ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)"
}}>
{ICON_MAP[item.icon]}
+5 -3
View File
@@ -257,9 +257,11 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
</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"
className="rounded-lg px-2 py-1.5 text-[var(--admin-text-muted)] hover:text-stone-600 hover:bg-stone-100 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="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
{openMenu === stop.id && (
@@ -303,7 +305,7 @@ export default function AdminStopsPanel({ stops, brandId }: Props) {
</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"
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--admin-danger-hover)]"
>
Delete
</button>
+42 -55
View File
@@ -83,49 +83,36 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
}, []);
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>
<div>
{/* Tab navigation */}
<nav className="grid grid-cols-6 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-1 sm:px-3 py-2 text-[9px] sm:text-xs font-semibold transition-colors ${
activeTab === tab.id
? "bg-[var(--admin-accent)] text-white"
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
}`}
>
{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>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
<div className="py-4 sm:py-6">
{activeTab === "integrations" && (
<AdvancedIntegrations brandId={brandId} brands={brands} />
)}
@@ -147,8 +134,8 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
{/* 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 className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--admin-bg)]">
{Icons.webhook("h-6 w-6 text-[var(--admin-text-muted)]")}
</div>
<div className="flex-1">
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">Webhooks</h2>
@@ -161,7 +148,7 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
{/* 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)]">
<div className="bg-[var(--admin-bg)]/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">
@@ -185,9 +172,9 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
{ 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">
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-white border border-[var(--admin-border)]">
<svg className="h-4 w-4 text-[var(--admin-text-muted)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={event.icon}/>
</svg>
</div>
@@ -199,23 +186,23 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
))}
</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="mt-6 p-4 rounded-xl bg-gradient-to-r from-[var(--admin-bg)] to-[var(--admin-bg)]/50 border border-[var(--admin-border)]">
<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">
<svg className="h-4 w-4 text-green-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>
<span className="text-sm text-[var(--admin-text-secondary)]">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">
<div className="mt-6 flex items-center justify-between pt-4 border-t border-[var(--admin-border)]">
<p className="text-xs text-[var(--admin-text-muted)]">
Need webhooks now?{" "}
<button className="text-emerald-600 hover:text-emerald-700 font-medium">
<button className="text-green-600 hover:text-green-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">
<button className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-medium hover:bg-green-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>
@@ -228,8 +215,8 @@ export default function AdvancedSettingsClient({ brandId, brands, stripeConnect
{/* 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">
<div className="bg-[var(--admin-text-primary)] rounded-lg p-4 overflow-x-auto">
<pre className="text-xs text-[var(--admin-bg)] font-mono leading-relaxed">
{`{
"event": "order.status_changed",
"timestamp": "2024-01-15T10:30:00Z",
+100 -155
View File
@@ -10,6 +10,7 @@ import {
uploadOlatheSweetLogoDark,
type BrandSettings,
} from "@/actions/brand-settings";
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
type Props = {
settings: BrandSettings | null;
@@ -202,21 +203,13 @@ export default function BrandSettingsForm({
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
<select
<AdminInput label="Brand" helpText={`Viewing ${activeBrandName}`}>
<AdminSelect
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-emerald-500"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
<p className="mt-1 text-xs text-zinc-500">
Viewing <strong className="text-zinc-300">{activeBrandName}</strong>
</p>
</div>
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
)}
{error && (
@@ -238,46 +231,37 @@ export default function BrandSettingsForm({
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Legal Business Name</label>
<input
type="text"
<AdminInput label="Legal Business Name">
<AdminTextInput
value={legalBusinessName}
onChange={(e) => setLegalBusinessName(e.target.value)}
placeholder="Tuxedo Corn LLC"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Phone</label>
<input
</AdminInput>
<AdminInput label="Phone">
<AdminTextInput
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="(772) 555-0100"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Email</label>
<input
</AdminInput>
<AdminInput label="Email">
<AdminTextInput
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="orders@tuxedocorn.com"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Website</label>
<input
</AdminInput>
<AdminInput label="Website">
<AdminTextInput
type="url"
value={websiteUrl}
onChange={(e) => setWebsiteUrl(e.target.value)}
placeholder="https://tuxedocorn.com"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
</AdminInput>
</div>
</div>
@@ -291,60 +275,49 @@ export default function BrandSettingsForm({
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Street Address</label>
<input
type="text"
<AdminInput label="Street Address">
<AdminTextInput
value={streetAddress}
onChange={(e) => setStreetAddress(e.target.value)}
placeholder="1234 Farm Road"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
</AdminInput>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-300">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Stuart"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<AdminInput label="City">
<AdminTextInput
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Stuart"
/>
</AdminInput>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">State</label>
<input
type="text"
<AdminInput label="State">
<AdminTextInput
value={state}
onChange={(e) => setState(e.target.value)}
placeholder="FL"
maxLength={2}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm uppercase text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">ZIP / Postal Code</label>
<input
type="text"
</AdminInput>
<AdminInput label="ZIP / Postal Code">
<AdminTextInput
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
placeholder="34994"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
</AdminInput>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Country</label>
<select
<AdminInput label="Country">
<AdminSelect
value={country}
onChange={(e) => setCountry(e.target.value)}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
</div>
options={[
{ value: "US", label: "United States" },
{ value: "CA", label: "Canada" },
]}
/>
</AdminInput>
</div>
</div>
@@ -418,37 +391,29 @@ export default function BrandSettingsForm({
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">
Default Email Signature
</label>
<textarea
<AdminInput
label="Default Email Signature"
helpText="Appears at the bottom of all outbound emails. Supports line breaks."
>
<AdminTextarea
value={defaultEmailSignature}
onChange={(e) => setDefaultEmailSignature(e.target.value)}
rows={3}
placeholder="The Tuxedo Corn Team&#10;orders@tuxedocorn.com | (772) 555-0100&#10;Fresh from Florida since 1987"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
placeholder={"The Tuxedo Corn Team\norders@tuxedocorn.com | (772) 555-0100\nFresh from Florida since 1987"}
/>
<p className="mt-1 text-xs text-zinc-500">
Appears at the bottom of all outbound emails. Supports line breaks.
</p>
</div>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">
Invoice Footer Notes
</label>
<textarea
<AdminInput
label="Invoice Footer Notes"
helpText="Printed at the bottom of invoices and order confirmations."
>
<AdminTextarea
value={invoiceFooterNotes}
onChange={(e) => setInvoiceFooterNotes(e.target.value)}
rows={2}
placeholder="Thank you for your order! Pickup available at our farm stand.&#10;Questions? Call (772) 555-0100 or email orders@tuxedocorn.com"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
placeholder={"Thank you for your order! Pickup available at our farm stand.\nQuestions? Call (772) 555-0100 or email orders@tuxedocorn.com"}
/>
<p className="mt-1 text-xs text-zinc-500">
Printed at the bottom of invoices and order confirmations.
</p>
</div>
</AdminInput>
</div>
{/* Storefront Customization */}
@@ -460,71 +425,55 @@ export default function BrandSettingsForm({
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Hero Tagline</label>
<input
type="text"
<AdminInput label="Hero Tagline" helpText="Subtitle below the brand name in the hero section.">
<AdminTextInput
value={heroTagline}
onChange={(e) => setHeroTagline(e.target.value)}
placeholder="Fresh citrus delivered to stops near you."
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">Subtitle below the brand name in the hero section.</p>
</div>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Hero Image URL</label>
<input
<AdminInput
label="Hero Image URL"
helpText="Full-width hero background image. Recommended: 1600×600px, under 1MB. Paste a public image URL. For Tuxedo Corn: corn field photo. For IRD: citrus orchard."
>
<AdminTextInput
type="url"
value={heroImageUrl}
onChange={(e) => setHeroImageUrl(e.target.value)}
placeholder="https://cdn.example.com/hero-corn-field.jpg"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">
Full-width hero background image. Recommended: 1600×600px, under 1MB. Paste a public image URL.
For Tuxedo Corn: corn field photo. For IRD: citrus orchard.
</p>
{heroImageUrl && (
<div className="relative mt-2 rounded-lg overflow-hidden border border-zinc-700 h-32">
<NextImage src={heroImageUrl} alt="Hero preview" fill style={{ objectFit: "cover" }} />
</div>
)}
</div>
</AdminInput>
{heroImageUrl && (
<div className="relative mt-2 rounded-lg overflow-hidden border border-zinc-700 h-32">
<NextImage src={heroImageUrl} alt="Hero preview" fill style={{ objectFit: "cover" }} />
</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">About Page Headline</label>
<input
type="text"
<AdminInput label="About Page Headline">
<AdminTextInput
value={aboutHeadline}
onChange={(e) => setAboutHeadline(e.target.value)}
placeholder="Our Story"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">About Page Subheadline</label>
<input
type="text"
<AdminInput label="About Page Subheadline">
<AdminTextInput
value={aboutSubheadline}
onChange={(e) => setAboutSubheadline(e.target.value)}
placeholder="Growing citrus in the Indian River region since 1985."
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
</div>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Custom Footer Text</label>
<textarea
<AdminInput label="Custom Footer Text" helpText="Appears above the copyright line in the brand footer.">
<AdminTextarea
value={customFooterText}
onChange={(e) => setCustomFooterText(e.target.value)}
rows={2}
placeholder="Fresh from Florida — Ships Sept through May"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">Appears above the copyright line in the brand footer.</p>
</div>
</AdminInput>
</div>
{/* Brand Colors */}
@@ -711,17 +660,14 @@ export default function BrandSettingsForm({
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Schedule PDF Footer Notes</label>
<textarea
<AdminInput label="Schedule PDF Footer Notes" helpText="Printed at the bottom of the generated schedule PDF.">
<AdminTextarea
value={schedulePdfNotes}
onChange={(e) => setSchedulePdfNotes(e.target.value)}
rows={1}
placeholder="All orders prepaid. No refunds on unpicked orders."
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
/>
<p className="mt-1 text-xs text-zinc-500">Printed at the bottom of the generated schedule PDF.</p>
</div>
</AdminInput>
</div>
{loading ? (
@@ -838,27 +784,26 @@ function NexusStateInput({
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type state code (e.g. CO) and press Enter"
className="flex-1 rounded-xl border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500 uppercase"
maxLength={2}
/>
<div className="relative">
<select
<AdminInput label="" className="flex-1">
<AdminTextInput
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type state code (e.g. CO) and press Enter"
maxLength={2}
/>
</AdminInput>
<AdminInput label="" className="w-32">
<AdminSelect
value=""
onChange={(e) => { if (e.target.value) addState(e.target.value); }}
className="h-[42px] rounded-xl border border-zinc-600 bg-zinc-800 px-3 text-sm text-zinc-100 outline-none focus:border-emerald-500"
>
<option value="">Add state</option>
{US_STATES.filter((s) => !value.includes(s)).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
options={[
{ value: "", label: "Add state" },
...US_STATES.filter((s) => !value.includes(s)).map((s) => ({ value: s, label: s })),
]}
/>
</AdminInput>
</div>
</div>
);
+74 -97
View File
@@ -9,6 +9,7 @@ import ContactImportForm from "./ContactImportForm";
import SegmentBuilderPage from "@/components/admin/HarvestReach/SegmentBuilderPage";
import AnalyticsDashboard from "@/components/admin/HarvestReach/AnalyticsDashboard";
import CampaignComposerPage from "@/components/admin/HarvestReach/CampaignComposerPage";
import { PageHeader, AdminFilterTabs } from "@/components/admin/design-system";
import type { Campaign } from "@/actions/communications/campaigns";
import type { Template } from "@/actions/communications/templates";
import type { Contact } from "@/actions/communications/contacts";
@@ -17,65 +18,58 @@ 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" },
];
const MailIcon = () => (
<svg className="h-4 w-4" 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>
);
// 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>
),
};
const FileTextIcon = () => (
<svg className="h-4 w-4" 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>
);
const UsersIcon = () => (
<svg className="h-4 w-4" 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>
);
const LayersIcon = () => (
<svg className="h-4 w-4" 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>
);
const ListIcon = () => (
<svg className="h-4 w-4" 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>
);
const ChartIcon = () => (
<svg className="h-4 w-4" 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,
@@ -107,48 +101,31 @@ export default function CommunicationsPage({
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
{/* Header */}
{/* Page 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>
<PageHeader
title="Harvest Reach"
subtitle="Email campaigns, templates, and contacts"
icon={<MailIcon />}
/>
{/* 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>
<AdminFilterTabs
activeTab={currentTab}
onTabChange={(tab) => {
setCurrentTab(tab as Tab);
setIsComposing(false);
}}
tabs={[
{ value: "campaigns", label: "Campaigns", icon: <MailIcon /> },
{ value: "templates", label: "Templates", icon: <FileTextIcon /> },
{ value: "contacts", label: "Contacts", icon: <UsersIcon /> },
{ value: "segments", label: "Segments", icon: <LayersIcon /> },
{ value: "logs", label: "Logs", icon: <ListIcon /> },
{ value: "analytics", label: "Analytics", icon: <ChartIcon /> },
]}
size="md"
/>
</div>
{/* Content */}
+63 -69
View File
@@ -3,6 +3,7 @@
import { useState, type ReactNode } from "react";
import Link from "next/link";
import UpgradePlanModal from "@/components/admin/UpgradePlanModal";
import { PageHeader, AdminButton, AdminFilterTabs, AdminBadge } from "@/components/admin/design-system";
type Section = {
title: string;
@@ -200,48 +201,50 @@ export default function DashboardClient({
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
{/* Header */}
{/* Page 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>
<PageHeader
icon={
<svg className="h-6 w-6" 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>
}
title="Admin Dashboard"
subtitle={`${brandName} Control Center`}
actions={
<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 && (
<AdminButton
onClick={() => setIsUpgradeOpen(true)}
size="md"
>
Upgrade Plan
</AdminButton>
)}
</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>
}
className="mb-0"
/>
</div>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6 -mt-4">
{/* 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"
}`}>
<AdminBadge variant={
planTier === "enterprise" ? "warning" :
planTier === "farm" ? "success" :
"default"
}>
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
</span>
</AdminBadge>
<span className="text-xs text-stone-500">{brandId ? "Tuxedo Corn" : "All Brands"}</span>
</div>
<div className="grid grid-cols-3 gap-4">
@@ -255,10 +258,10 @@ export default function DashboardClient({
<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-2 rounded-full bg-stone-100 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
pct > 85 ? "bg-amber-500" : "bg-emerald-500"
pct > 90 ? "bg-red-500" : pct > 75 ? "bg-amber-500" : "bg-[var(--admin-accent)]"
}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
@@ -268,30 +271,21 @@ export default function DashboardClient({
</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>
{/* Tab navigation using AdminFilterTabs */}
<AdminFilterTabs
activeTab={activeTab}
onTabChange={(value) => setActiveTab(value as Tab)}
tabs={TABS.map((tab) => ({
value: tab.id,
label: tab.label,
icon: tab.icon,
}))}
size="md"
showCounts={false}
className="mb-4"
/>
{/* Content */}
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
{/* Section Cards Grid */}
<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;
@@ -310,7 +304,7 @@ export default function DashboardClient({
isAddon && !isEnabled
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
: isProminent
? "border-emerald-200 shadow-md shadow-emerald-100/50"
? "border-[var(--admin-accent)]/20 shadow-[0_2px_8px_rgba(0,0,0,0.04)]"
: "border-stone-200 shadow-sm",
].join(" ")}
>
@@ -319,27 +313,27 @@ export default function DashboardClient({
isAddon && !isEnabled
? "bg-stone-100 text-stone-400"
: isProminent
? "bg-emerald-100 text-emerald-600"
: "bg-violet-100 text-violet-600"
? "bg-emerald-50 text-emerald-600"
: "bg-stone-100 text-stone-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">
<AdminBadge variant="warning">
Add-on
</span>
</AdminBadge>
)}
{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">
<AdminBadge variant="success">
Active
</span>
</AdminBadge>
)}
{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">
<AdminBadge variant="success">
Core
</span>
</AdminBadge>
)}
</div>
+11 -11
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { markPickupComplete } from "@/actions/pickup";
import AdminBadge from "./design-system/AdminBadge";
type OrderItem = {
id: string;
@@ -213,9 +214,14 @@ export default function DriverPickupPanel({
{hasAnyPickedUp && (
<button
onClick={() => setShowPickedUp((v) => !v)}
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"
className="w-full rounded-xl border border-emerald-200 bg-emerald-50 px-5 py-3 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors flex items-center gap-2"
>
{showPickedUp ? "▼ Hide" : "▶ Show"} {pickedUpOrders.length} picked up
{showPickedUp ? (
<><svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7"/></svg> Hide</>
) : (
<><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> Show</>
)}
{pickedUpOrders.length} picked up
</button>
)}
@@ -275,13 +281,9 @@ function OrderCard({ order, canManagePickup, onMarkPickup, pickingUp, shortId }:
<p className="mt-1 font-mono text-xs text-stone-400 uppercase">{shortId}</p>
</div>
<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>
<AdminBadge variant="warning">Pending</AdminBadge>
{isMixed && (
<span className="rounded-full bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-700">
Mixed
</span>
<AdminBadge variant="info">Mixed</AdminBadge>
)}
</div>
</div>
@@ -417,9 +419,7 @@ function PickedUpCard({ order, shortId }: PickedUpCardProps) {
</p>
</div>
<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>
<AdminBadge variant="success">Picked Up</AdminBadge>
{order.pickup_completed_at && (
<p className="text-xs text-stone-400">{formatPickupTime(order.pickup_completed_at)}</p>
)}
+7 -28
View File
@@ -11,7 +11,7 @@ type Props = {
maxWidth?: string;
};
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-md" }: Props) {
export default function GlassModal({ title, titleIcon, subtitle, onClose, children, maxWidth = "max-w-lg" }: Props) {
// Lock body scroll
useEffect(() => {
document.body.style.overflow = "hidden";
@@ -39,24 +39,14 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
>
{/* 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)",
}}
className={`relative w-full ${maxWidth} rounded-2xl bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.15)]`}
>
{/* 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%)",
}}
/>
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[var(--admin-accent)] to-[var(--admin-accent-hover)] rounded-t-2xl" />
{/* Header */}
<div
className="flex items-center justify-between px-6 py-5"
className="flex items-center justify-between px-8 py-6"
style={{ borderBottom: "1px solid var(--admin-border-light)" }}
>
<div className="flex items-center gap-3">
@@ -82,19 +72,8 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
</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)";
}}
className="flex h-9 w-9 items-center justify-center rounded-full transition-all duration-150"
style={{ backgroundColor: "var(--admin-bg-subtle)", color: "var(--admin-text-muted)" }}
>
<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" />
@@ -103,7 +82,7 @@ export default function GlassModal({ title, titleIcon, subtitle, onClose, childr
</div>
{/* Content */}
<div className="relative p-6">
<div className="relative px-8 py-8">
{children}
</div>
</div>
@@ -4,6 +4,7 @@ import { useState } from "react";
import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns";
import { type Template } from "@/actions/communications/templates";
import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments";
import { AdminButton } from "@/components/admin/design-system";
type Props = {
brandId: string;
@@ -124,12 +125,9 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
? "Your campaign has been sent to all matching customers."
: `It will be sent on ${new Date(scheduledAt).toLocaleString()}.`}
</p>
<button
onClick={() => window.location.href = "/admin/communications"}
className="px-6 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
<AdminButton onClick={() => window.location.href = "/admin/communications"}>
Back to Campaigns
</button>
</AdminButton>
</div>
);
}
@@ -145,13 +143,13 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
<div key={label} className="flex items-center">
<div className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
step === n
? "bg-stone-900 text-white"
? "bg-[var(--admin-text-primary)] text-white"
: done
? "bg-stone-200 text-stone-700"
? "bg-[var(--admin-border)] text-[var(--admin-text-secondary)]"
: "bg-zinc-950 text-slate-400"
}`}>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs border ${
step === n ? "border-white" : done ? "border-stone-400" : "border-zinc-600"
step === n ? "border-white" : done ? "border-[var(--admin-text-muted)]" : "border-zinc-600"
}`}>
{done ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -162,7 +160,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
<span className="hidden sm:inline">{label}</span>
</div>
{i < STEPS.length - 1 && (
<div className={`h-0.5 w-6 ${done ? "bg-stone-300" : "bg-slate-200"} mx-1`} />
<div className={`h-0.5 w-6 ${done ? "bg-[var(--admin-border)]" : "bg-slate-200"} mx-1`} />
)}
</div>
);
@@ -189,23 +187,20 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
placeholder="Subject line"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
<textarea
placeholder="Email body text…"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={5}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm resize-none outline-none focus:border-slate-900"
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm resize-none outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div className="flex justify-end">
<button
onClick={() => setStep(2)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
<AdminButton onClick={() => setStep(2)}>
Continue
</button>
</AdminButton>
</div>
</div>
) : (
@@ -213,7 +208,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
{/* Start from scratch */}
<button
onClick={() => setStep(2)}
className="rounded-xl border-2 border-dashed border-zinc-600 p-5 text-left hover:border-stone-400 hover:bg-zinc-950 transition-all group"
className="rounded-xl border-2 border-dashed border-zinc-600 p-5 text-left hover:border-[var(--admin-border)] hover:bg-zinc-950 transition-all group"
>
<span className="text-2xl block mb-2">+</span>
<p className="text-sm font-medium text-zinc-300 group-hover:text-zinc-100">Start from scratch</p>
@@ -227,8 +222,8 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
onClick={() => handleTemplateSelect(t)}
className={`rounded-xl border p-4 text-left transition-all ${
selectedTemplateId === t.id
? "border-stone-900 bg-zinc-950"
: "border-zinc-800 hover:border-slate-400 hover:bg-zinc-800"
? "border-[var(--admin-text-primary)] bg-zinc-950"
: "border-zinc-800 hover:border-[var(--admin-border)] hover:bg-zinc-800"
}`}
>
<div className="flex items-start justify-between gap-2">
@@ -263,7 +258,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. May Sweet Corn Promotion"
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
</div>
@@ -275,7 +270,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
onChange={(e) => {
setSelectedSegmentId(e.target.value);
}}
className="flex-1 border border-zinc-600 rounded-lg px-3 py-2.5 text-sm bg-zinc-900 outline-none focus:border-slate-900"
className="flex-1 border border-zinc-600 rounded-lg px-3 py-2.5 text-sm bg-zinc-900 outline-none focus:border-[var(--admin-accent)]"
>
<option value=""> Choose a saved segment </option>
{segments.map((s) => (
@@ -283,12 +278,9 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
))}
</select>
{selectedSegmentId && (
<button
onClick={() => setSelectedSegmentId("")}
className="px-3 text-xs text-slate-400 hover:text-red-500 border border-zinc-800 rounded-lg hover:border-red-200"
>
<AdminButton variant="secondary" size="sm" onClick={() => setSelectedSegmentId("")}>
Clear
</button>
</AdminButton>
)}
</div>
</div>
@@ -297,20 +289,13 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(1)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<AdminButton variant="ghost" onClick={() => setStep(1)}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={() => setStep(3)}
disabled={!name}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 disabled:opacity-40"
>
</AdminButton>
<AdminButton onClick={() => setStep(3)} disabled={!name}>
Continue to Preview
</button>
</AdminButton>
</div>
</div>
)}
@@ -326,7 +311,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
{/* Mock email frame */}
<div className="rounded-xl border border-zinc-800 bg-slate-50 overflow-hidden">
<div className="px-4 py-3 bg-zinc-900 border-b border-zinc-800 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-stone-300" />
<div className="w-8 h-8 rounded-full bg-[var(--admin-border)]" />
<div>
<p className="text-xs font-medium text-zinc-300">Your Brand</p>
<p className="text-xs text-slate-400">To: {"{{customer_name}}"}</p>
@@ -345,24 +330,18 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(2)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<AdminButton variant="ghost" onClick={() => setStep(2)}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={() => setStep(4)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
</AdminButton>
<AdminButton onClick={() => setStep(4)}>
Continue to Schedule
</button>
</AdminButton>
</div>
</div>
)}
@@ -385,12 +364,12 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
onClick={() => setCampaignType(ct.value)}
className={`rounded-xl border py-3 px-4 text-left transition-all ${
campaignType === ct.value
? "border-stone-900 bg-stone-900 text-white"
: "border-zinc-800 hover:border-slate-400 hover:bg-zinc-800"
? "border-[var(--admin-text-primary)] bg-[var(--admin-text-primary)] text-white"
: "border-zinc-800 hover:border-[var(--admin-border)] hover:bg-zinc-800"
}`}
>
<p className={`text-sm font-medium ${campaignType === ct.value ? "text-white" : "text-zinc-300"}`}>{ct.label}</p>
<p className={`text-xs mt-0.5 ${campaignType === ct.value ? "text-stone-300" : "text-slate-400"}`}>{ct.desc}</p>
<p className={`text-xs mt-0.5 ${campaignType === ct.value ? "text-[var(--admin-border)]" : "text-slate-400"}`}>{ct.desc}</p>
</button>
))}
</div>
@@ -405,7 +384,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
type="radio"
checked={sendNow}
onChange={() => setSendNow(true)}
className="accent-stone-900 w-4 h-4"
className="accent-[var(--admin-accent)] w-4 h-4"
/>
<span className="text-sm font-medium text-zinc-300">Send now</span>
</label>
@@ -414,7 +393,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
type="radio"
checked={!sendNow}
onChange={() => setSendNow(false)}
className="accent-stone-900 w-4 h-4"
className="accent-[var(--admin-accent)] w-4 h-4"
/>
<span className="text-sm font-medium text-zinc-300">Schedule for later</span>
</label>
@@ -428,7 +407,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
className="border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
min={new Date().toISOString().slice(0, 16)}
/>
</div>
@@ -452,20 +431,17 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(3)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<AdminButton variant="ghost" onClick={() => setStep(3)}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
</AdminButton>
<AdminButton
onClick={handleCreateOrSchedule}
disabled={saving || (!sendNow && !scheduledAt)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-50"
isLoading={saving}
disabled={(!sendNow && !scheduledAt)}
>
{saving ? "Processing…" : sendNow ? "Send Campaign" : "Schedule Campaign"}
</button>
</AdminButton>
</div>
</div>
)}
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from "react";
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
import { AdminSearchInput, AdminButton } from "@/components/admin/design-system";
// Icon components
const Icons = {
@@ -13,12 +14,6 @@ const Icons = {
<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"/>
@@ -82,7 +77,7 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Matching Customers</h3>
{preview && (
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
<span className="inline-flex items-center rounded-full bg-[var(--admin-accent-light)] px-2.5 py-0.5 text-xs font-semibold text-[var(--admin-accent)]">
{total.toLocaleString()} total
</span>
)}
@@ -106,18 +101,12 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
</div>
) : (
<>
<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>
<AdminSearchInput
placeholder="Search by name or email…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
onClear={() => setSearch("")}
/>
{paged.length === 0 ? (
<p className="text-sm text-[var(--admin-text-muted)] text-center py-6">No customers match these filters.</p>
@@ -128,7 +117,7 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
key={c.id}
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-emerald-100 flex items-center justify-center text-xs font-semibold text-emerald-700 flex-shrink-0">
<div className="w-8 h-8 rounded-full bg-[var(--admin-accent-light)] flex items-center justify-center text-xs font-semibold text-[var(--admin-accent)] flex-shrink-0">
{c.name ? c.name.slice(0, 2).toUpperCase() : "??"}
</div>
<div className="flex-1 min-w-0">
@@ -158,22 +147,26 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
Showing {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, searched.length)} of {searched.length}
</span>
<div className="flex gap-2">
<button
<AdminButton
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
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"
icon={Icons.chevronLeft("h-4 w-4")}
iconPosition="left"
>
{Icons.chevronLeft("h-4 w-4")}
<span>Prev</span>
</button>
<button
Prev
</AdminButton>
<AdminButton
variant="secondary"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * PAGE_SIZE >= searched.length}
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"
icon={Icons.chevronRight("h-4 w-4")}
iconPosition="right"
>
<span>Next</span>
{Icons.chevronRight("h-4 w-4")}
</button>
Next
</AdminButton>
</div>
</div>
)}
@@ -6,6 +6,7 @@ import SegmentBuilderPanel from "./SegmentBuilderPanel";
import MatchingCustomersPanel from "./MatchingCustomersPanel";
import SegmentListSidebar from "./SegmentListSidebar";
import SegmentEditModal from "./SegmentEditModal";
import { PageHeader, AdminButton } from "@/components/admin/design-system";
type Props = {
brandId: string;
@@ -13,15 +14,13 @@ type Props = {
};
// 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>
),
};
const LayersIcon = (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);
@@ -80,20 +79,11 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
return (
<div className="p-4 sm:p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-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>
<PageHeader
icon={<LayersIcon className="w-5 h-5" />}
title="Segment Builder"
subtitle="Build filters to define your audience, then save and reuse the segment."
/>
{/* Main layout */}
<div className="flex flex-col lg:flex-row gap-4">
@@ -7,6 +7,7 @@ import {
type SegmentFilterType,
type SegmentFilterParams,
} from "@/actions/harvest-reach/segments";
import { AdminButton, AdminSearchInput } from "@/components/admin/design-system";
// Icon components
const Icons = {
@@ -85,7 +86,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
onClick={() => setCombinator(c)}
className={`px-3 py-1 text-xs font-semibold rounded-md transition-colors ${
rules.combinator === c
? "bg-emerald-600 text-white"
? "bg-[var(--admin-accent)] text-white"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
}`}
>
@@ -123,7 +124,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-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-emerald-50 hover:border-emerald-200 hover:text-emerald-700 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-[var(--admin-accent-light)] hover:border-[var(--admin-accent)] hover:text-[var(--admin-accent)] transition-colors"
>
+ {ft.label}
</button>
@@ -132,13 +133,13 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
{/* Save button */}
<div className="pt-1 border-t border-[var(--admin-border)]">
<button
<AdminButton
onClick={onSave}
disabled={rules.filters.length === 0}
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"
fullWidth
>
{hasActiveSegment ? "Update Segment" : "Save Segment"}
</button>
</AdminButton>
</div>
</div>
);
@@ -187,7 +188,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
onChange={(e) =>
onChange({ type: e.target.value as SegmentFilterType, params: emptyFilter(e.target.value as SegmentFilterType).params })
}
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"
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-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
>
{FILTER_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>{ft.label}</option>
@@ -210,7 +211,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-[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"
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-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
>
<option value="">Select {filter.type === "stop" ? "past" : "upcoming"} stop</option>
{stops.map((s) => (
@@ -222,13 +223,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-[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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
<input
type="date"
value={filter.params.date_to ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_to: e.target.value } })}
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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
</div>
</>
@@ -244,7 +245,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
if (e.target.value) loadProducts();
}}
onMouseEnter={loadProducts}
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"
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-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
>
<option value="">Select a product</option>
{products.map((p) => (
@@ -259,7 +260,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
max={365}
value={filter.params.days_back ?? 90}
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
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"
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-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
</div>
@@ -281,14 +282,14 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
},
})
}
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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
<input
type="text"
placeholder="City (optional)"
value={filter.params.city ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, city: e.target.value } })}
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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
</>
)}
@@ -301,7 +302,7 @@ 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-[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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
>
{ORDER_HISTORY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
@@ -315,7 +316,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
max={365}
value={filter.params.days_back ?? 90}
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
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"
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-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
</div>
@@ -336,7 +337,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
},
})
}
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"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-[var(--admin-accent)] focus:border-[var(--admin-accent)]"
/>
)}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { AdminButton } from "@/components/admin/design-system";
// Icon components
const Icons = {
@@ -50,7 +51,7 @@ export default function SegmentEditModal({ initialName = "", initialDescription
{/* 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">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)]">
{Icons.layers("h-5 w-5 text-white")}
</div>
<div>
@@ -80,7 +81,7 @@ export default function SegmentEditModal({ initialName = "", initialDescription
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
placeholder="e.g. Fort Pierce Regulars"
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"
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-[var(--admin-accent)] focus:border-[var(--admin-accent)] outline-none"
autoFocus
/>
</div>
@@ -93,26 +94,28 @@ export default function SegmentEditModal({ initialName = "", initialDescription
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-[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"
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-[var(--admin-accent)] focus:border-[var(--admin-accent)] outline-none"
/>
</div>
</div>
{/* Footer */}
<div className="flex gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
<button
<AdminButton
variant="secondary"
onClick={onClose}
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"
fullWidth
>
Cancel
</button>
<button
</AdminButton>
<AdminButton
onClick={handleSave}
disabled={!name.trim() || saving}
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"
isLoading={saving}
fullWidth
>
{saving ? "Saving…" : "Save Segment"}
</button>
</AdminButton>
</div>
</div>
</div>
@@ -2,6 +2,7 @@
import { useState } from "react";
import type { Segment } from "@/actions/harvest-reach/segments";
import { AdminButton, AdminIconButton, AdminSearchInput } from "@/components/admin/design-system";
// Icon components
const Icons = {
@@ -18,12 +19,6 @@ const Icons = {
<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 = {
@@ -46,27 +41,21 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
<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-[var(--admin-text-primary)]">Saved Segments</h3>
<button
<AdminIconButton
onClick={onNew}
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"
label="New segment"
variant="primary"
size="sm"
>
{Icons.plus("w-4 h-4")}
</button>
<Icons.plus className="w-4 h-4" />
</AdminIconButton>
</div>
<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>
<AdminSearchInput
placeholder="Search segments…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex flex-col gap-1.5">
{filtered.length === 0 && (
@@ -80,18 +69,22 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
<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
<AdminButton
variant="danger"
size="sm"
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-semibold transition-colors"
fullWidth
>
Delete
</button>
<button
</AdminButton>
<AdminButton
variant="secondary"
size="sm"
onClick={() => setConfirmDelete(null)}
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"
fullWidth
>
Cancel
</button>
</AdminButton>
</div>
</div>
) : (
@@ -99,7 +92,7 @@ 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-emerald-50 border border-emerald-200"
? "bg-[var(--admin-accent-light)] border border-[var(--admin-accent)]"
: "hover:bg-[var(--admin-card-hover)] border border-transparent"
}`}
>
+32 -36
View File
@@ -3,6 +3,8 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateWaterHeadgate, deleteWaterHeadgate } from "@/actions/water-log/admin";
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system";
import { AdminButton } from "./design-system";
type Headgate = {
id: string;
@@ -56,64 +58,58 @@ export default function HeadgateEditForm({ headgate, backHref = "/admin/water-lo
return (
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
<div className="rounded-lg bg-red-100 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-400 mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
required
/>
<AdminInput label="Name" required>
<AdminTextInput
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Headgate name"
/>
</AdminInput>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Default Unit</label>
<select
<AdminInput label="Default Unit">
<AdminSelect
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
{UNIT_OPTIONS.map((u) => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
options={UNIT_OPTIONS.map((u) => ({ value: u, label: u }))}
/>
</AdminInput>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Status</label>
<select
<AdminInput label="Status">
<AdminSelect
value={active ? "1" : "0"}
onChange={(e) => setActive(e.target.value === "1")}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
options={[
{ value: "1", label: "Active" },
{ value: "0", label: "Inactive" },
]}
/>
</AdminInput>
<div className="flex items-center justify-between pt-2">
<button
<AdminButton
type="button"
variant="danger"
onClick={handleDelete}
disabled={deleting}
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
isLoading={deleting}
>
{deleting ? "..." : "Delete Headgate"}
</button>
<button
Delete Headgate
</AdminButton>
<AdminButton
type="submit"
disabled={saving}
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
isLoading={saving}
>
{saving ? "..." : "Save Changes"}
</button>
Save Changes
</AdminButton>
</div>
</form>
);
+47 -50
View File
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
import { formatDate } from "@/lib/format-date";
import AdminBadge from "./design-system/AdminBadge";
// Icon components
const Icons = {
@@ -61,22 +62,30 @@ const Icons = {
),
};
const STATUS_COLORS: Record<string, string> = {
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-100 text-red-700",
failed: "bg-red-100 text-red-700",
unsubscribed: "bg-orange-100 text-orange-700",
// Status to badge variant mapping
const getStatusBadgeProps = (status: string): { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean } => {
const map: Record<string, { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean }> = {
queued: { variant: "default", dot: true },
sent: { variant: "info", dot: true },
delivered: { variant: "success", dot: true },
opened: { variant: "info", dot: true },
clicked: { variant: "warning", dot: true },
bounced: { variant: "danger", dot: true },
failed: { variant: "danger", dot: true },
unsubscribed: { variant: "warning", dot: true },
};
return map[status] ?? { variant: "default", dot: true };
};
const METHOD_COLORS: Record<string, string> = {
email: "bg-blue-100 text-blue-700",
sms: "bg-emerald-100 text-emerald-700",
push: "bg-purple-100 text-purple-700",
internal: "bg-stone-100 text-stone-600",
// Method to badge variant mapping
const getMethodBadgeProps = (method: string): { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean } => {
const map: Record<string, { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean }> = {
email: { variant: "info", dot: true },
sms: { variant: "success", dot: true },
push: { variant: "warning", dot: true },
internal: { variant: "default", dot: true },
};
return map[method] ?? { variant: "default", dot: true };
};
const PAGE_SIZE = 20;
@@ -252,17 +261,19 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
{log.customer_email ?? "—"}
</td>
<td className="px-4 py-3">
<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>
{(() => {
const props = getMethodBadgeProps(log.delivery_method);
return <AdminBadge variant={props.variant} dot>{log.delivery_method}</AdminBadge>;
})()}
</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-[10px] font-semibold ${STATUS_COLORS[log.status] ?? "bg-stone-100 text-stone-600"}`}>
{log.status}
</span>
{(() => {
const props = getStatusBadgeProps(log.status);
return <AdminBadge variant={props.variant} dot>{log.status}</AdminBadge>;
})()}
{log.error_message && (
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[100px]" title={log.error_message}>
{log.error_message}
@@ -272,24 +283,16 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
<td className="px-4 py-3">
<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>
<AdminBadge variant="success" dot>Delivered</AdminBadge>
)}
{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>
<AdminBadge variant="info" dot>Opened</AdminBadge>
)}
{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>
<AdminBadge variant="warning" dot>Clicked</AdminBadge>
)}
{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>
<AdminBadge variant="danger" dot>Bounced</AdminBadge>
)}
</div>
</td>
@@ -308,38 +311,32 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
<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>
{(() => {
const props = getStatusBadgeProps(log.status);
return <AdminBadge variant={props.variant} dot>{log.status}</AdminBadge>;
})()}
</div>
<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>
{(() => {
const props = getMethodBadgeProps(log.delivery_method);
return <AdminBadge variant={props.variant} dot>{log.delivery_method}</AdminBadge>;
})()}
<span className="text-xs text-[var(--admin-text-muted)]">
{log.sent_at ? formatDate(log.sent_at) : "—"}
</span>
</div>
<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>
<AdminBadge variant="success" dot>Delivered</AdminBadge>
)}
{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>
<AdminBadge variant="info" dot>Opened</AdminBadge>
)}
{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>
<AdminBadge variant="warning" dot>Clicked</AdminBadge>
)}
{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>
<AdminBadge variant="danger" dot>Bounced</AdminBadge>
)}
</div>
</div>
+83 -107
View File
@@ -5,6 +5,7 @@ import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { uploadProductImage } from "@/actions/products/upload-image";
import { createProduct } from "@/actions/products/create-product";
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
export default function NewProductForm() {
const router = useRouter();
@@ -16,6 +17,16 @@ export default function NewProductForm() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingImageUrl, setPendingImageUrl] = useState<string>("");
// Form state
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [price, setPrice] = useState("");
const [type, setType] = useState("Pickup");
const [brandId, setBrandId] = useState("");
const [isTaxable, setIsTaxable] = useState("true");
const [pickupType, setPickupType] = useState("scheduled_stop");
const [active, setActive] = useState("true");
async function handleFileSelect(file: File) {
const validTypes = ["image/png", "image/jpeg", "image/webp"];
if (!validTypes.includes(file.type)) {
@@ -71,33 +82,23 @@ export default function NewProductForm() {
});
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const price = parseFloat(formData.get("price") as string);
const type = formData.get("type") as string;
const brandId = formData.get("brand_id") as string;
const active = formData.get("active") === "true";
// Use pendingImageUrl if image was uploaded, otherwise null
const imageUrl = pendingImageUrl || null;
const isTaxable = formData.get("is_taxable") === "true";
const pickup_type = formData.get("pickup_type") as string;
const result = await createProduct(brandId, {
name,
description,
price,
price: parseFloat(price),
type,
active,
active: active === "true",
image_url: imageUrl,
is_taxable: isTaxable,
pickup_type,
is_taxable: isTaxable === "true",
pickup_type: pickupType,
});
if (!result.success) {
@@ -118,117 +119,92 @@ export default function NewProductForm() {
</div>
)}
<div>
<label className="block text-sm font-medium text-zinc-300">
Product Name
</label>
<input
name="name"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
<AdminInput label="Product Name" required>
<AdminTextInput
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Dozen Sweet Corn"
autoComplete="off"
/>
</div>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">
Description
</label>
<textarea
name="description"
<AdminInput label="Description">
<AdminTextarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
placeholder="e.g. Fresh-picked Olathe sweet corn."
/>
</div>
</AdminInput>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">
Price ($)
</label>
<input
name="price"
<AdminInput label="Price ($)" required>
<AdminTextInput
type="number"
step="0.01"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="12.00"
/>
</div>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">
Type
</label>
<select
name="type"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="Pickup">Pickup</option>
<option value="Shipping">Shipping</option>
<option value="Pickup & Shipping">Pickup & Shipping</option>
</select>
</div>
<AdminInput label="Type" required>
<AdminSelect
value={type}
onChange={(e) => setType(e.target.value)}
options={[
{ value: "Pickup", label: "Pickup" },
{ value: "Shipping", label: "Shipping" },
{ value: "Pickup & Shipping", label: "Pickup & Shipping" },
]}
/>
</AdminInput>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
Brand
</label>
<select
name="brand_id"
required
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="">Select brand...</option>
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">
Tuxedo Corn
</option>
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">
Indian River Direct
</option>
</select>
</div>
<AdminInput label="Brand" required>
<AdminSelect
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
options={[
{ value: "", label: "Select brand..." },
{ value: "64294306-5f42-463d-a5e8-2ad6c81a96de", label: "Tuxedo Corn" },
{ value: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", label: "Indian River Direct" },
]}
/>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">Taxable</label>
<p className="text-xs text-zinc-500 mb-2">Tax applied at checkout for shipping orders in nexus states. Disable for non-taxable items like apparel or cooler boxes.</p>
<select
name="is_taxable"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="true">Yes taxable (default)</option>
<option value="false">No non-taxable</option>
</select>
</div>
<AdminInput label="Taxable" helpText="Tax applied at checkout for shipping orders in nexus states. Disable for non-taxable items like apparel or cooler boxes.">
<AdminSelect
value={isTaxable}
onChange={(e) => setIsTaxable(e.target.value)}
options={[
{ value: "true", label: "Yes — taxable (default)" },
{ value: "false", label: "No — non-taxable" },
]}
/>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">Pickup Type</label>
<p className="text-xs text-zinc-500 mb-2">Shed pickup uses the product description as the location no stop required at checkout.</p>
<select
name="pickup_type"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="scheduled_stop">Scheduled Stop requires stop selection at checkout</option>
<option value="shed">Shed Pickup uses description as location</option>
</select>
</div>
<AdminInput label="Pickup Type" helpText="Shed pickup uses the product description as the location — no stop required at checkout.">
<AdminSelect
value={pickupType}
onChange={(e) => setPickupType(e.target.value)}
options={[
{ value: "scheduled_stop", label: "Scheduled Stop — requires stop selection at checkout" },
{ value: "shed", label: "Shed Pickup — uses description as location" },
]}
/>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">
Active
</label>
<select
name="active"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="true">Yes show on storefront</option>
<option value="false">No hide from storefront</option>
</select>
</div>
<AdminInput label="Active">
<AdminSelect
value={active}
onChange={(e) => setActive(e.target.value)}
options={[
{ value: "true", label: "Yes — show on storefront" },
{ value: "false", label: "No — hide from storefront" },
]}
/>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">Product Image</label>
+79 -105
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createStop } from "@/actions/stops/create-stop";
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system";
type Stop = {
city: string;
@@ -28,30 +29,33 @@ export default function NewStopForm({ duplicateFrom }: Props) {
const defaultBrand = duplicateFrom?.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
// Form state
const [city, setCity] = useState(duplicateFrom?.city ?? "");
const [state, setState] = useState(duplicateFrom?.state ?? "");
const [location, setLocation] = useState(duplicateFrom?.location ?? "");
const [date, setDate] = useState(duplicateFrom?.date ?? "");
const [time, setTime] = useState(duplicateFrom?.time ?? "");
const [brandId, setBrandId] = useState(defaultBrand);
const [active, setActive] = useState("true");
const [address, setAddress] = useState(duplicateFrom?.address ?? "");
const [zip, setZip] = useState(duplicateFrom?.zip ?? "");
const [cutoffTime, setCutoffTime] = useState(duplicateFrom?.cutoff_time ?? "");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const city = formData.get("city") as string;
const state = formData.get("state") as string;
const location = formData.get("location") as string;
const date = formData.get("date") as string;
const time = formData.get("time") as string;
const brandId = formData.get("brand_id") as string;
const active = formData.get("active") === "true";
const result = await createStop(brandId, {
city,
state,
location,
date,
time,
active,
address: (formData.get("address") as string) || null,
zip: (formData.get("zip") as string) || null,
cutoff_time: (formData.get("cutoff_time") as string) || null,
active: active === "true",
address: address || null,
zip: zip || null,
cutoff_time: cutoffTime || null,
});
if (!result.success) {
@@ -77,127 +81,97 @@ export default function NewStopForm({ duplicateFrom }: Props) {
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">City</label>
<input
name="city"
required
defaultValue={duplicateFrom?.city}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
<AdminInput label="City" required>
<AdminTextInput
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="e.g. Denver"
/>
</div>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">State</label>
<input
name="state"
required
maxLength={2}
defaultValue={duplicateFrom?.state}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
<AdminInput label="State" required>
<AdminTextInput
value={state}
onChange={(e) => setState(e.target.value)}
placeholder="e.g. CO"
/>
</div>
</AdminInput>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Location Name</label>
<input
name="location"
required
defaultValue={duplicateFrom?.location}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
<AdminInput label="Location Name" required>
<AdminTextInput
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Southwest Plaza Parking Lot"
/>
</div>
</AdminInput>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Date</label>
<input
name="date"
required
<AdminInput label="Date" required>
<AdminTextInput
type="date"
defaultValue={duplicateFrom?.date}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">Time</label>
<input
name="time"
required
type="text"
defaultValue={duplicateFrom?.time}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
<AdminInput label="Time" required>
<AdminTextInput
value={time}
onChange={(e) => setTime(e.target.value)}
placeholder="e.g. 8:00 AM 2:00 PM"
/>
</div>
</AdminInput>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Brand</label>
<select
name="brand_id"
required
defaultValue={defaultBrand}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="">Select brand...</option>
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">Tuxedo Corn</option>
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">Indian River Direct</option>
</select>
</div>
<AdminInput label="Brand" required>
<AdminSelect
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
options={[
{ value: "", label: "Select brand..." },
{ value: "64294306-5f42-463d-a5e8-2ad6c81a96de", label: "Tuxedo Corn" },
{ value: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", label: "Indian River Direct" },
]}
/>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">Active</label>
<select
name="active"
defaultValue="true"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
>
<option value="true">Yes show on storefront</option>
<option value="false">No hide from storefront</option>
</select>
</div>
<AdminInput label="Active">
<AdminSelect
value={active}
onChange={(e) => setActive(e.target.value)}
options={[
{ value: "true", label: "Yes — show on storefront" },
{ value: "false", label: "No — hide from storefront" },
]}
/>
</AdminInput>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">Street Address</label>
<input
name="address"
defaultValue={duplicateFrom?.address ?? ""}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
<AdminInput label="Street Address">
<AdminTextInput
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
/>
</div>
</AdminInput>
<div>
<label className="block text-sm font-medium text-zinc-300">ZIP Code</label>
<input
name="zip"
maxLength={10}
defaultValue={duplicateFrom?.zip ?? ""}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
<AdminInput label="ZIP Code">
<AdminTextInput
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80102"
/>
</div>
</AdminInput>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">Order Cutoff</label>
<input
name="cutoff_time"
<AdminInput label="Order Cutoff" helpText="Customers must order before this time to be included at this stop.">
<AdminTextInput
type="datetime-local"
defaultValue={duplicateFrom?.cutoff_time ?? ""}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-zinc-100 outline-none focus:border-slate-900"
value={cutoffTime}
onChange={(e) => setCutoffTime(e.target.value)}
/>
<p className="mt-1 text-xs text-slate-400">
Customers must order before this time to be included at this stop.
</p>
</div>
</AdminInput>
<div className="flex gap-3">
<button
+26 -43
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateOrder, updateOrderItem, deleteOrderItem } from "@/actions/orders/update-order";
import { AdminInput, AdminTextInput, AdminTextarea } from "./design-system";
type OrderItem = {
id: string;
@@ -199,8 +200,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-stone-500">Qty</label>
<AdminInput label="Qty">
<input
type="number"
min="1"
@@ -210,10 +210,9 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
}
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-stone-500">Price</label>
<input
</AdminInput>
<AdminInput label="Price">
<AdminTextInput
type="number"
step="0.01"
min="0"
@@ -221,9 +220,8 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
onChange={(e) =>
updateItem(item.id, "price", Number(e.target.value))
}
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>
</AdminInput>
</div>
<p className="mt-2 text-right text-sm font-semibold text-stone-900">
@@ -237,8 +235,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Pricing */}
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Subtotal (auto-calculated)</label>
<AdminInput label="Subtotal (auto-calculated)">
<input
type="number"
step="0.01"
@@ -246,30 +243,25 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
readOnly
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm text-stone-500"
/>
</div>
</AdminInput>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Discount Amount</label>
<input
<AdminInput label="Discount Amount">
<AdminTextInput
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-xs font-semibold text-stone-500">Discount Reason</label>
<input
type="text"
</AdminInput>
<AdminInput label="Discount Reason">
<AdminTextInput
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>
</AdminInput>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
@@ -284,34 +276,27 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
{/* Customer fields */}
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Name</label>
<input
type="text"
<AdminInput label="Name">
<AdminTextInput
value={customer_name}
onChange={(e) => setCustomer_name(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>
</AdminInput>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Email</label>
<input
<AdminInput label="Email">
<AdminTextInput
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
</AdminInput>
<AdminInput label="Phone">
<AdminTextInput
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>
</AdminInput>
</div>
</div>
@@ -352,16 +337,14 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
</div>
{/* Internal notes */}
<div>
<label className="mb-1 block text-xs font-semibold text-stone-500">Internal Notes</label>
<textarea
<AdminInput label="Internal Notes">
<AdminTextarea
value={internal_notes}
onChange={(e) => setInternal_notes(e.target.value)}
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>
</AdminInput>
<button
onClick={handleSave}
+9 -16
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { savePaymentSettings, type PaymentProvider, type PaymentSettings } from "@/actions/payments";
import { syncSquareNow, getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import WebhookLogsSection from "@/components/admin/WebhookLogsSection";
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system";
type InventoryMode = "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
@@ -153,18 +154,13 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
<select
<AdminInput label="Brand">
<AdminSelect
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-zinc-400"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
)}
{error && (
@@ -297,16 +293,13 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-zinc-300">Location ID</label>
<input
type="text"
<AdminInput label="Location ID">
<AdminTextInput
value={squareLocationId}
onChange={(e) => setSquareLocationId(e.target.value)}
placeholder="L..."
className="mt-1 w-full rounded-xl border border-zinc-600 px-4 py-3 text-sm font-mono outline-none focus:border-slate-900"
/>
</div>
</AdminInput>
<button
type="button"
onClick={handleDisconnectSquare}
+43 -55
View File
@@ -5,6 +5,7 @@ import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { uploadProductImage, deleteProductImage } from "@/actions/products/upload-image";
import { updateProduct } from "@/actions/products/update-product";
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
type Brand = {
id: string;
@@ -157,65 +158,51 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Name</label>
<input
type="text"
<AdminInput label="Name">
<AdminTextInput
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
placeholder="Product name"
/>
</div>
</AdminInput>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Description</label>
<textarea
<AdminInput label="Description">
<AdminTextarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
placeholder="Product description"
/>
</div>
</AdminInput>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Price</label>
<input
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
<AdminInput label="Price">
<AdminTextInput
type="number"
step="0.01"
min="0"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
placeholder="0.00"
/>
</AdminInput>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Type</label>
<input
type="text"
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="e.g. Sweet Corn"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
/>
</div>
<AdminInput label="Type">
<AdminTextInput
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="e.g. Sweet Corn"
/>
</AdminInput>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Brand</label>
<select
<AdminInput label="Brand">
<AdminSelect
value={brand_id}
onChange={(e) => setBrand_id(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
@@ -247,18 +234,19 @@ export default function ProductEditForm({ product, brands }: ProductEditFormProp
<p className="mt-1.5 text-xs text-zinc-500">Tax is calculated at checkout for shipping orders in your brand&apos;s nexus states. Non-taxable items (e.g. cooler boxes, apparel) are always exempt.</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Pickup Type</label>
<select
value={pickup_type}
onChange={(e) => setPickup_type(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base text-zinc-100 outline-none focus:border-slate-900"
<AdminInput
label="Pickup Type"
helpText="Shed pickup uses the product description as the pickup location — no stop required at checkout."
>
<option value="scheduled_stop">Scheduled Stop requires stop selection at checkout</option>
<option value="shed">Shed Pickup uses description as location</option>
</select>
<p className="mt-1.5 text-xs text-zinc-500">Shed pickup uses the product description as the pickup location no stop required at checkout.</p>
</div>
<AdminSelect
value={pickup_type}
onChange={(e) => setPickup_type(e.target.value)}
options={[
{ value: "scheduled_stop", label: "Scheduled Stop — requires stop selection at checkout" },
{ value: "shed", label: "Shed Pickup — uses description as location" },
]}
/>
</AdminInput>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Product Image</label>
+9 -12
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { deleteProduct } from "@/actions/products";
import AdminBadge from "./design-system/AdminBadge";
type Product = {
id: string;
@@ -146,22 +147,16 @@ export default function ProductTableBody({
</td>
<td className="px-5 py-4">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
product.active
? "bg-green-900/40 text-green-400"
: "bg-zinc-950 text-zinc-400"
}`}
>
<AdminBadge variant={product.active ? "success" : "default"} dot>
{product.active ? "Active" : "Inactive"}
</span>
</AdminBadge>
</td>
<td className="px-5 py-4">
{product.is_taxable === false ? (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700">Non-taxable</span>
<AdminBadge variant="warning">Non-taxable</AdminBadge>
) : (
<span className="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-700">Taxable</span>
<AdminBadge variant="success">Taxable</AdminBadge>
)}
</td>
@@ -181,7 +176,9 @@ export default function ProductTableBody({
}}
className="rounded-lg px-2 py-1.5 text-xs text-zinc-500 hover:bg-zinc-950"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
</svg>
</button>
{openMenu === product.id && (
@@ -225,7 +222,7 @@ export default function ProductTableBody({
<button
onClick={() => handleDelete(product.id)}
disabled={deletingId === product.id}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
className="flex-1 rounded-lg bg-[var(--admin-danger)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--admin-danger-hover)] disabled:opacity-50"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
+3 -1
View File
@@ -224,7 +224,9 @@ function ProductRowBase({
}}
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
</svg>
</button>
{openMenu === product.id && (
+95 -123
View File
@@ -8,6 +8,7 @@ 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";
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs } from "@/components/admin/design-system";
type Product = {
id: string;
@@ -33,11 +34,6 @@ type FormData = {
type ViewMode = "table" | "cards";
type Props = {
products: Product[];
brandId: string;
};
// Icons
const Icons = {
search: (className: string) => (
@@ -46,24 +42,6 @@ const Icons = {
<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"/>
@@ -86,7 +64,17 @@ const Icons = {
),
};
export default function ProductsClient({ products, brandId }: Props) {
// Page header icon
const PackageIconHeader = () => (
<svg className="h-6 w-6" 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>
);
export default function ProductsClient({ products, brandId }: { products: Product[]; brandId: string }) {
const router = useRouter();
const [, startTransition] = useTransition();
const [search, setSearch] = useState("");
@@ -295,29 +283,29 @@ export default function ProductsClient({ products, brandId }: Props) {
}
};
// Filter tabs configuration
const filterTabs = [
{ value: "all", label: "All", count: products.length },
{ value: "active", label: "Active", count: activeCount },
{ value: "inactive", label: "Inactive", count: inactiveCount },
];
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>
{/* Page Header */}
<PageHeader
icon={<PackageIconHeader />}
title="Products"
subtitle={`${filtered.length} product${filtered.length !== 1 ? "s" : ""}`}
actions={
<AdminButton
onClick={openAddModal}
icon={Icons.plus("h-4 w-4")}
>
Add Product
</AdminButton>
}
/>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-3 mb-6">
@@ -327,7 +315,7 @@ export default function ProductsClient({ products, brandId }: Props) {
</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>
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] 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>
@@ -338,53 +326,27 @@ export default function ProductsClient({ products, brandId }: Props) {
{/* 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>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => setStatusFilter(value as "all" | "active" | "inactive")}
tabs={filterTabs}
size="md"
/>
{/* 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>
<AdminSearchInput
value={search}
onChange={(e) => setSearch(e.target.value)}
onClear={() => setSearch("")}
placeholder="Search products..."
containerClassName="flex-1"
/>
{/* 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>
<AdminViewModeTabs
activeTab={viewMode}
onTabChange={(value) => setViewMode(value as ViewMode)}
/>
</div>
{/* Content */}
@@ -431,7 +393,7 @@ export default function ProductsClient({ products, brandId }: Props) {
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"
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
required
/>
</div>
@@ -443,7 +405,7 @@ export default function ProductsClient({ products, brandId }: Props) {
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"
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] resize-none"
/>
</div>
@@ -459,7 +421,7 @@ export default function ProductsClient({ products, brandId }: Props) {
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"
className="w-full rounded-xl border border-[var(--admin-border)] bg-white pl-8 pr-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
required
/>
</div>
@@ -469,7 +431,7 @@ export default function ProductsClient({ products, brandId }: Props) {
<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"
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
>
<option value="pickup">Pickup</option>
<option value="wholesale">Wholesale</option>
@@ -493,13 +455,13 @@ export default function ProductsClient({ products, brandId }: Props) {
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"}
${uploadError ? "border-red-400" : "border-[var(--admin-border)] hover:border-[var(--admin-accent)] hover:bg-[var(--admin-accent)]/5"}
${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" />
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
<span className="text-sm text-stone-500">Uploading...</span>
</>
) : imagePreview ? (
@@ -553,7 +515,7 @@ export default function ProductsClient({ products, brandId }: Props) {
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"
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
/>
<span className="text-sm font-medium text-stone-700">Active</span>
</label>
@@ -562,27 +524,28 @@ export default function ProductsClient({ products, brandId }: Props) {
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"
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
/>
<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
<AdminButton
type="button"
variant="secondary"
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
</AdminButton>
<AdminButton
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"
variant="primary"
isLoading={saving}
disabled={!formData.name.trim() || !formData.price}
>
{saving ? "Saving..." : editingProduct ? "Save Changes" : "Create Product"}
</button>
{editingProduct ? "Save Changes" : "Create Product"}
</AdminButton>
</div>
</form>
</GlassModal>
@@ -656,7 +619,7 @@ function TableView({
<div>
<button
onClick={() => onEdit(product)}
className="font-semibold text-[var(--admin-text-primary)] hover:text-emerald-600 transition-colors text-left"
className="font-semibold text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] transition-colors text-left"
>
{product.name}
</button>
@@ -679,7 +642,7 @@ function TableView({
<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 ? "bg-[var(--admin-accent)]/10 text-[var(--admin-accent)]" : "bg-stone-100 text-stone-500"
}`}>
{product.active ? "Active" : "Inactive"}
</span>
@@ -687,17 +650,20 @@ function TableView({
<td className="px-4 py-3 text-right">
<div className="relative inline-flex items-center justify-end gap-1">
<button
<AdminButton
variant="ghost"
size="sm"
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>
</AdminButton>
<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"
className="rounded-lg px-2 py-1.5 text-xs text-[var(--admin-text-muted)] hover:text-red-600 hover:bg-red-50 transition-colors"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
{deleteConfirm === product.id && (
@@ -717,13 +683,15 @@ function TableView({
>
Cancel
</button>
<button
<AdminButton
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"
variant="danger"
size="sm"
className="flex-1"
>
{deletingId === product.id ? "..." : "Delete"}
</button>
</AdminButton>
</div>
</div>
</>
@@ -772,7 +740,7 @@ function CardView({
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"
className="group relative rounded-xl border border-[var(--admin-border)] bg-white hover:shadow-md hover:border-[var(--admin-accent)]/30 transition-all overflow-hidden"
>
{/* Image */}
<div className="relative h-40 bg-stone-100">
@@ -793,7 +761,7 @@ function CardView({
{/* 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 ? "bg-[var(--admin-accent)] text-white" : "bg-stone-500 text-white"
}`}>
{product.active ? "Active" : "Inactive"}
</span>
@@ -806,7 +774,7 @@ function CardView({
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">
<h3 className="font-semibold text-[var(--admin-text-primary)] group-hover:text-[var(--admin-accent)] transition-colors line-clamp-1">
{product.name}
</h3>
<p className="text-xs text-stone-500 mt-1 line-clamp-2 h-8">
@@ -834,17 +802,21 @@ function CardView({
</div>
<div className="flex items-center gap-2 mt-3">
<button
<AdminButton
variant="secondary"
size="sm"
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"
className="flex-1"
>
Edit
</button>
</AdminButton>
<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"
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-muted)] hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors"
>
🗑
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
+35 -46
View File
@@ -26,6 +26,7 @@ import {
type CampaignActivityRow,
} from "@/lib/reports-export";
import { formatDate, formatDateRange } from "@/lib/date-utils";
import { AdminButton, AdminFilterTabs } from "@/components/admin/design-system";
type DatePreset = "week" | "month" | "quarter" | "this_year" | "custom";
@@ -105,12 +106,13 @@ function ExportBtn({
onClick: () => void;
}) {
return (
<button
<AdminButton
onClick={onClick}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
variant="secondary"
size="sm"
>
Export CSV
</button>
</AdminButton>
);
}
@@ -122,13 +124,14 @@ function ExplainBtn({
loading: boolean;
}) {
return (
<button
<AdminButton
onClick={onClick}
disabled={loading}
className="rounded-lg border border-violet-300 bg-violet-50 px-3 py-1.5 text-xs font-medium text-violet-700 hover:bg-violet-100 disabled:opacity-50"
variant="secondary"
size="sm"
>
{loading ? "Analyzing..." : "🤖 Explain this Report"}
</button>
</AdminButton>
);
}
@@ -169,19 +172,14 @@ function ReportTable({ headers, rows, renderRow }: {
// ── Tab definitions ─────────────────────────────────────────────────────────
type Tab = {
id: string;
label: string;
};
const TABS: Tab[] = [
{ id: "overview", label: "Overview" },
{ id: "orders-by-stop", label: "Orders by Stop" },
{ id: "sales-by-product", label: "Sales by Product" },
{ id: "fulfillment", label: "Fulfillment" },
{ id: "pickup-status", label: "Pickup Status" },
{ id: "contact-growth", label: "Contact Growth" },
{ id: "campaigns", label: "Campaigns" },
const TABS = [
{ value: "overview", label: "Overview" },
{ value: "orders-by-stop", label: "Orders by Stop" },
{ value: "sales-by-product", label: "Sales by Product" },
{ value: "fulfillment", label: "Fulfillment" },
{ value: "pickup-status", label: "Pickup Status" },
{ value: "contact-growth", label: "Contact Growth" },
{ value: "campaigns", label: "Campaigns" },
];
// ── Main component ──────────────────────────────────────────────────────────
@@ -325,21 +323,18 @@ export default function ReportsDashboard({
)}
{/* ── Filters ────────────────────────────────────────────────────────── */}
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-zinc-900 border border-zinc-800 px-5 py-4">
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-5 py-4">
{/* Date preset */}
<div className="flex gap-1.5">
{(["week", "month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
<button
<AdminButton
key={p}
onClick={() => setPreset(p)}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition-all ${
preset === p
? "bg-blue-600 text-white shadow-black/20"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
variant={preset === p ? "primary" : "secondary"}
size="sm"
>
{p === "week" ? "This Week" : p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
</button>
</AdminButton>
))}
</div>
@@ -376,13 +371,15 @@ export default function ReportsDashboard({
</select>
)}
<button
<AdminButton
onClick={handleFetch}
disabled={loading}
className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
variant="primary"
size="sm"
isLoading={loading}
>
{loading ? "Loading..." : "Refresh"}
</button>
Refresh
</AdminButton>
<span className="ml-auto text-sm font-medium text-zinc-500">
{preset === "quarter" ? quarterLabel(range.start) :
@@ -394,21 +391,13 @@ export default function ReportsDashboard({
</div>
{/* ── Tab bar ────────────────────────────────────────────────────────── */}
<div className="flex gap-1 border-b border-zinc-800">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === tab.id
? "border-blue-600 text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{tab.label}
</button>
))}
</div>
<AdminFilterTabs
activeTab={activeTab}
onTabChange={setActiveTab}
tabs={TABS}
size="md"
showCounts={false}
/>
{/* ── Tab content ──────────────────────────────────────────────────────── */}
+40 -36
View File
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import SettingsSections from "@/components/admin/SettingsSections";
import UsersPage from "@/components/admin/UsersPage";
import { PageHeader, AdminButton } from "@/components/admin/design-system";
type Tab = "general" | "workers" | "tasks" | "users";
@@ -77,41 +78,43 @@ export default function SettingsClient({
}, []);
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
{/* Header */}
<main className="min-h-screen bg-[var(--admin-bg)]">
{/* Page 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>
<PageHeader
title="Settings"
subtitle="Manage your brand, workers, and integrations"
icon={
<svg className="h-5 w-5 sm:h-6 sm:w-6" 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>
}
actions={
<AdminButton
variant="secondary"
size="sm"
onClick={() => window.location.href = "/admin/advanced"}
>
Advanced Settings
</AdminButton>
}
/>
{/* Tab navigation */}
<nav className="grid grid-cols-5 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
<nav className="flex items-center gap-1 p-1.5 rounded-xl bg-white border border-[var(--admin-border)] overflow-x-auto">
{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 ${
className={`relative flex items-center gap-2 rounded-lg px-4 py-2.5 text-xs font-semibold transition-all duration-150 whitespace-nowrap ${
activeTab === tab.id
? "bg-emerald-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
? "bg-[var(--admin-accent)] text-white shadow-sm"
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] 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" />
)}
{/* icons */}
<span>{tab.label}</span>
</button>
))}
</nav>
@@ -129,7 +132,7 @@ export default function SettingsClient({
<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">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-accent)]">
{Icons.users("w-4 h-4 text-white")}
</div>
<div>
@@ -190,28 +193,29 @@ export default function SettingsClient({
</div>
)}
<div className="mt-6 p-4 rounded-xl border border-violet-100 bg-violet-50/50">
<div className="mt-6 p-4 rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)]">
<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}>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-accent)]">
<svg className="w-4 h-4 text-white" 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>
<p className="text-sm font-semibold text-[var(--admin-accent-text)]">Advanced Settings</p>
<p className="text-xs text-[var(--admin-text-muted)]">AI, integrations, Square sync, shipping, and webhooks</p>
</div>
</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"
<AdminButton
variant="secondary"
size="sm"
onClick={() => window.location.href = "/admin/advanced"}
>
Open Advanced
</a>
</AdminButton>
</div>
</div>
</div>
</div>
</main>
);
}
+23 -5
View File
@@ -349,7 +349,9 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
{/* 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>
<svg className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<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>
@@ -402,7 +404,11 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
{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>
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">
<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>
@@ -421,7 +427,11 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
{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>
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">
<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>
@@ -563,7 +573,11 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600">
<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>
<div className="p-6 space-y-4">
{workerError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{workerError}</div>}
@@ -631,7 +645,11 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600">
<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>
<div className="p-6 space-y-4">
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
+30 -65
View File
@@ -7,6 +7,7 @@ import {
testFedExConnection,
type ShippingSettings,
} from "@/actions/shipping/settings";
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect } from "./design-system";
const SERVICE_OPTIONS = [
{ value: "FEDEX_OVERNIGHT", label: "FedEx Overnight" },
@@ -121,18 +122,13 @@ export default function ShippingSettingsForm({
<form onSubmit={handleSave} className="space-y-8">
{/* Platform admin brand picker */}
{isPlatformAdmin && brands.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Brand</label>
<select
<AdminInput label="Brand">
<AdminSelect
value={activeBrandId}
onChange={(e) => setActiveBrandId(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-base text-zinc-100 outline-none focus:border-zinc-400"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
)}
{/* Connection status banner */}
@@ -180,45 +176,29 @@ export default function ShippingSettingsForm({
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300">
FedEx Account Number <span className="text-red-500">*</span>
</label>
<input
type="text"
<AdminInput label="FedEx Account Number *">
<AdminTextInput
value={fedexAccountNumber}
onChange={(e) => setFedexAccountNumber(e.target.value)}
placeholder="000000000000 (12 digits)"
maxLength={12}
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm font-mono text-zinc-100 outline-none focus:border-zinc-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
API Key <span className="text-red-500">*</span>
</label>
<input
type="text"
</AdminInput>
<AdminInput label="API Key *">
<AdminTextInput
value={fedexApiKey}
onChange={(e) => setFedexApiKey(e.target.value)}
placeholder="Your FedEx API key"
className="mt-1 w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
/>
</div>
</AdminInput>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300">
API Secret <span className="text-red-500">*</span>
<span className="ml-2 text-slate-400 font-normal">(password field)</span>
</label>
<div className="relative mt-1">
<input
<AdminInput label="API Secret *" helpText="Password field — click Show/Hide to reveal">
<div className="relative">
<AdminTextInput
type={showSecret ? "text" : "password"}
value={fedexApiSecret}
onChange={(e) => setFedexApiSecret(e.target.value)}
placeholder="Your FedEx API secret"
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 pr-12 text-sm text-zinc-100 outline-none focus:border-zinc-400"
/>
<button
type="button"
@@ -228,7 +208,7 @@ export default function ShippingSettingsForm({
{showSecret ? "Hide" : "Show"}
</button>
</div>
</div>
</AdminInput>
{/* Production toggle */}
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-3">
@@ -287,20 +267,13 @@ export default function ShippingSettingsForm({
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">
Default for non-perishable orders
</label>
<select
<AdminInput label="Default for non-perishable orders">
<AdminSelect
value={defaultServiceType}
onChange={(e) => setDefaultServiceType(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
>
{SERVICE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
options={SERVICE_OPTIONS}
/>
</AdminInput>
</div>
{/* Handling notes */}
@@ -312,34 +285,26 @@ export default function ShippingSettingsForm({
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
🌽 Refrigerated / Perishable Notes
</label>
<textarea
<AdminInput
label="Refrigerated / Perishable Notes"
helpText="Applied to all shipments with perishable items (is_perishable = true)."
>
<AdminTextarea
value={refrigeratedHandlingNotes}
onChange={(e) => setRefrigeratedHandlingNotes(e.target.value)}
rows={3}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
placeholder="e.g. Keep refrigerated. Do not freeze. Contains fresh sweet corn and/or onions."
/>
<p className="mt-1 text-xs text-zinc-500">
Applied to all shipments with perishable items (is_perishable = true).
</p>
</div>
</AdminInput>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">
📦 Fragile Items Notes
</label>
<textarea
<AdminInput label="Fragile Items Notes">
<AdminTextarea
value={fragileHandlingNotes}
onChange={(e) => setFragileHandlingNotes(e.target.value)}
rows={2}
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-zinc-400"
placeholder="e.g. Fragile — handle with care. Do not stack heavy items on top."
/>
</div>
</AdminInput>
</div>
{loading ? (
+62 -88
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateStop } from "@/actions/stops/update-stop";
import { AdminInput, AdminTextInput, AdminSelect } from "./design-system";
type Brand = {
id: string;
@@ -94,113 +95,86 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">City</label>
<input
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<AdminInput label="City">
<AdminTextInput
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="City name"
/>
</AdminInput>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">State</label>
<input
type="text"
value={state}
onChange={(e) => setState(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<AdminInput label="State">
<AdminTextInput
value={state}
onChange={(e) => setState(e.target.value)}
placeholder="State code"
/>
</AdminInput>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Date</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<AdminInput label="Date">
<AdminTextInput
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</AdminInput>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Time</label>
<input
type="text"
value={time}
onChange={(e) => setTime(e.target.value)}
placeholder="e.g. 8:00 AM 2:00 PM"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<AdminInput label="Time">
<AdminTextInput
value={time}
onChange={(e) => setTime(e.target.value)}
placeholder="e.g. 8:00 AM 2:00 PM"
/>
</AdminInput>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Location</label>
<input
type="text"
<AdminInput label="Location">
<AdminTextInput
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Street address or intersection"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
</AdminInput>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Street Address</label>
<input
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<AdminInput label="Street Address">
<AdminTextInput
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="123 Main St"
/>
</AdminInput>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">ZIP Code</label>
<input
type="text"
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80102"
maxLength={10}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
</div>
<AdminInput label="ZIP Code">
<AdminTextInput
value={zip}
onChange={(e) => setZip(e.target.value)}
placeholder="80102"
maxLength={10}
/>
</AdminInput>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-zinc-300">Order Cutoff</label>
<input
type="datetime-local"
value={cutoff_time}
onChange={(e) => setCutoff_time(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
/>
<p className="mt-1 text-xs text-slate-400">
Customers must order before this time to be included at this stop.
</p>
</div>
<AdminInput
label="Order Cutoff"
helpText="Customers must order before this time to be included at this stop."
>
<AdminTextInput
type="datetime-local"
value={cutoff_time}
onChange={(e) => setCutoff_time(e.target.value)}
/>
</AdminInput>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Brand</label>
<select
<AdminInput label="Brand">
<AdminSelect
value={brand_id}
onChange={(e) => setBrand_id(e.target.value)}
className="w-full rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-3 text-base outline-none focus:border-slate-900"
>
{brands.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
<div>
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
+95 -62
View File
@@ -4,6 +4,7 @@ import React, { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { publishStop } from "@/actions/stops";
import { AdminSearchInput, AdminFilterTabs, AdminButton, AdminIconButton } from "@/components/admin/design-system";
type Stop = {
id: string;
@@ -45,6 +46,20 @@ export default function StopTableClient({ stops }: Props) {
return matchesSearch && matchesStatus;
});
const statusCounts = {
all: stops.length,
active: stops.filter(s => s.active && s.status !== "draft").length,
inactive: stops.filter(s => !s.active && s.status !== "draft").length,
draft: stops.filter(s => s.status === "draft").length,
};
const tabs = [
{ value: "all", label: "All", count: statusCounts.all },
{ value: "active", label: "Active", count: statusCounts.active },
{ value: "inactive", label: "Inactive", count: statusCounts.inactive },
{ value: "draft", label: "Draft", count: statusCounts.draft },
];
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
@@ -58,48 +73,53 @@ export default function StopTableClient({ stops }: Props) {
return (
<>
{/* Filter bar */}
<div className="border-b border-stone-200 px-5 py-3 flex gap-3 flex-wrap items-center">
<input
type="search"
<div className="border-b border-[var(--admin-border)] px-5 py-3 flex gap-4 flex-wrap items-center">
<AdminSearchInput
placeholder="Search stops..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="flex-1 min-w-40 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm text-stone-900 outline-none focus:border-emerald-500 placeholder:text-stone-400 transition-colors"
onClear={() => { setSearch(""); setPage(0); }}
showClear={true}
className="flex-1 min-w-48 max-w-64"
/>
<div className="flex gap-1 rounded-lg border border-stone-200 bg-white p-1">
{(["all", "active", "inactive", "draft"] as const).map((f) => (
<button
key={f}
onClick={() => { setStatusFilter(f); setPage(0); }}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === f
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : f === "inactive" ? "Inactive" : "Draft"}
</button>
))}
</div>
<span className="text-xs text-stone-500">{filtered.length} stops</span>
<AdminFilterTabs
activeTab={statusFilter}
onTabChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(0); }}
tabs={tabs}
size="sm"
showCounts={true}
/>
<span className="text-xs text-[var(--admin-text-muted)] ml-auto">{filtered.length} stops</span>
{totalPages > 1 && (
<div className="flex items-center gap-1 ml-auto">
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
<div className="flex items-center gap-1">
<AdminIconButton
variant="secondary"
size="sm"
label="Previous page"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="!rounded-lg border border-[var(--admin-border)]"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<span className="text-xs text-stone-500 px-1">{page + 1}/{totalPages}</span>
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
className="flex h-7 w-7 items-center justify-center rounded border border-stone-200 text-stone-500 hover:text-stone-700 hover:bg-stone-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
</AdminIconButton>
<span className="text-xs text-[var(--admin-text-muted)] px-1">{page + 1}/{totalPages}</span>
<AdminIconButton
variant="secondary"
size="sm"
label="Next page"
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="!rounded-lg border border-[var(--admin-border)]"
>
<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>
</AdminIconButton>
</div>
)}
</div>
{/* Delete error */}
{deleteError && (
<div className="mx-5 my-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<div className="mx-5 my-3 rounded-lg border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)]">
{deleteError}{" "}
<button onClick={() => setDeleteError(null)} className="underline hover:no-underline">
Dismiss
@@ -109,7 +129,7 @@ export default function StopTableClient({ stops }: Props) {
{/* Table */}
<table className="w-full text-left text-sm">
<thead className="bg-stone-50 text-stone-500">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-4 font-semibold">City</th>
<th className="px-5 py-4 font-semibold">Location</th>
@@ -120,10 +140,10 @@ export default function StopTableClient({ stops }: Props) {
<th className="px-5 py-4 font-semibold" />
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
<tbody className="divide-y divide-[var(--admin-border)]">
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-10 text-center text-sm text-stone-500">
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
{search || statusFilter !== "all"
? "No stops match your search."
: "No stops found. Create one to get started."}
@@ -196,23 +216,23 @@ function StopRowBase({
}
return (
<tr className="hover:bg-stone-50 transition-colors relative">
<tr className="hover:bg-[var(--admin-bg-subtle)] transition-colors relative">
<td className="px-5 py-4">
<Link
href={`/admin/stops/${stop.id}`}
className="font-medium text-stone-900 hover:text-emerald-600 transition-colors"
className="font-medium text-[var(--admin-text-primary)] hover:text-[var(--admin-accent)] 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 text-[var(--admin-text-secondary)]">{stop.location}</td>
<td className="px-5 py-4 font-mono text-stone-500 text-xs">{stop.date}</td>
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.date}</td>
<td className="px-5 py-4 font-mono text-stone-500 text-xs">{stop.time}</td>
<td className="px-5 py-4 font-mono text-[var(--admin-text-muted)] text-xs">{stop.time}</td>
<td className="px-5 py-4 text-stone-600">
<td className="px-5 py-4 text-[var(--admin-text-secondary)]">
{Array.isArray(stop.brands)
? stop.brands[0]?.name
: stop.brands?.name}
@@ -224,8 +244,8 @@ function StopRowBase({
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"
? "bg-[var(--admin-accent-light)] text-[var(--admin-accent-text)] border border-[var(--admin-accent)]"
: "bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)] border border-[var(--admin-border)]"
}`}
>
{stop.status === "draft" ? "Draft" : stop.active ? "Active" : "Inactive"}
@@ -236,19 +256,23 @@ function StopRowBase({
<div className="relative inline-flex items-center justify-end gap-2">
<Link
href={`/admin/stops/${stop.id}`}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-stone-600 hover:text-stone-900 hover:bg-stone-100 transition-colors"
className="rounded-lg px-3 py-1.5 text-xs font-medium text-[var(--admin-text-secondary)] hover:text-[var(--admin-text-primary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
Edit
</Link>
<button
<AdminIconButton
variant="ghost"
size="sm"
label="More options"
onClick={(e) => {
e.preventDefault();
setOpenMenu(openMenu === stop.id ? null : stop.id);
}}
className="rounded-lg px-2 py-1.5 text-xs text-stone-400 hover:text-stone-600 hover:bg-stone-100 transition-colors"
>
</button>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
</svg>
</AdminIconButton>
{openMenu === stop.id && (
<>
@@ -256,28 +280,34 @@ function StopRowBase({
className="fixed inset-0 z-10"
onClick={() => { setOpenMenu(null); setConfirmDelete(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-stone-200 shadow-xl overflow-hidden">
<div className="absolute right-0 top-full mt-1 z-20 w-44 rounded-xl bg-white border border-[var(--admin-border)] shadow-xl overflow-hidden">
{stop.status === "draft" && (
<button
<AdminButton
variant="ghost"
size="sm"
fullWidth
onClick={() => handlePublish(stop.id)}
disabled={publishingId === stop.id}
className="w-full text-left px-4 py-2.5 text-sm text-emerald-600 hover:bg-emerald-50 disabled:opacity-50 transition-colors"
className="!justify-start !text-[var(--admin-accent)] hover:!bg-[var(--admin-accent-light)]"
>
{publishingId === stop.id ? "Publishing..." : "Publish"}
</button>
</AdminButton>
)}
<a
href={`/admin/stops/new?duplicate=${stop.id}`}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-stone-600 hover:bg-stone-50 transition-colors"
className="flex items-center gap-2 px-4 py-2.5 text-sm text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
Duplicate
</a>
<button
<AdminButton
variant="ghost"
size="sm"
fullWidth
onClick={() => { setOpenMenu(null); setConfirmDelete(stop.id); }}
className="w-full text-left px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
className="!justify-start !text-[var(--admin-danger)] hover:!bg-[var(--admin-danger)]/10"
>
Delete
</button>
</AdminButton>
</div>
</>
)}
@@ -288,27 +318,30 @@ function StopRowBase({
className="fixed inset-0 z-30"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
/>
<div className="absolute right-0 top-full mt-1 z-40 w-72 rounded-xl bg-white border border-stone-200 shadow-xl p-4">
<p className="text-sm font-semibold text-stone-900">
<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-[var(--admin-text-primary)]">
Delete &quot;{stop.city}, {stop.state}&quot;?
</p>
<p className="mt-1.5 text-xs text-stone-500">
<p className="mt-1.5 text-xs text-[var(--admin-text-muted)]">
This will remove the stop. If it has active orders, you must resolve those first.
</p>
<div className="mt-4 flex gap-2">
<button
<AdminButton
variant="secondary"
size="sm"
onClick={() => { setConfirmDelete(null); setOpenMenu(null); }}
className="flex-1 rounded-lg border border-stone-200 px-3 py-2 text-xs font-medium text-stone-700 hover:bg-stone-50 transition-colors"
>
Cancel
</button>
<button
</AdminButton>
<AdminButton
variant="danger"
size="sm"
onClick={() => handleDelete(stop.id)}
disabled={deletingId === stop.id}
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors shadow-sm"
isLoading={deletingId === stop.id}
>
{deletingId === stop.id ? "..." : "Delete"}
</button>
</AdminButton>
</div>
</div>
</>
+17 -12
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import ScheduleImportModal from "@/components/admin/ScheduleImportModal";
import AddStopModal from "@/components/admin/AddStopModal";
import { useRouter } from "next/navigation";
import { AdminButton } from "@/components/admin/design-system";
type Props = {
brandId: string;
@@ -25,24 +26,28 @@ export default function StopsHeaderActions({ brandId }: Props) {
return (
<>
<div className="flex gap-3">
<button
<AdminButton
variant="secondary"
onClick={() => setShowImport(true)}
className="flex items-center gap-2 rounded-xl border border-stone-300 bg-white px-4 py-2.5 text-sm font-medium text-stone-700 hover:bg-stone-50 hover:border-stone-400 transition-colors"
icon={
<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>
}
>
<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
</AdminButton>
<AdminButton
variant="primary"
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white transition-colors shadow-sm shadow-emerald-200"
icon={
<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>
}
>
<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>
</AdminButton>
</div>
{showImport && (
+25 -32
View File
@@ -8,6 +8,7 @@ import {
type TaxOrderRow,
type TaxByStateRow,
} from "@/lib/reports-export";
import { AdminButton, AdminFilterTabs } from "@/components/admin/design-system";
type DatePreset = "month" | "quarter" | "this_year" | "custom";
@@ -80,12 +81,13 @@ function SummaryCard({
function ExportBtn({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
<AdminButton
onClick={onClick}
className="rounded-lg border border-zinc-600 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800"
variant="secondary"
size="sm"
>
Export CSV
</button>
</AdminButton>
);
}
@@ -123,8 +125,8 @@ function ReportTable({ headers, rows, renderRow }: {
}
const TABS = [
{ id: "summary", label: "Tax Summary" },
{ id: "orders", label: "Taxable Orders" },
{ value: "summary", label: "Tax Summary" },
{ value: "orders", label: "Taxable Orders" },
];
export default function TaxDashboard({
@@ -279,20 +281,17 @@ export default function TaxDashboard({
)}
{/* ── Filters ── */}
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-zinc-900 border border-zinc-800 px-5 py-4">
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-5 py-4">
<div className="flex gap-1">
{(["month", "quarter", "this_year", "custom"] as DatePreset[]).map((p) => (
<button
<AdminButton
key={p}
onClick={() => setPreset(p)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium ${
preset === p
? "bg-slate-900 text-white"
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
}`}
variant={preset === p ? "primary" : "secondary"}
size="sm"
>
{p === "month" ? "This Month" : p === "quarter" ? "This Quarter" : p === "this_year" ? "This Year" : "Custom"}
</button>
</AdminButton>
))}
</div>
@@ -331,13 +330,15 @@ export default function TaxDashboard({
</select>
)}
<button
<AdminButton
onClick={handleFetch}
disabled={loading || !selectedBrandId}
className="rounded-lg bg-blue-600 px-4 py-1.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
variant="primary"
size="sm"
isLoading={loading}
>
{loading ? "Loading..." : "Refresh"}
</button>
Refresh
</AdminButton>
<span className="ml-auto text-xs text-zinc-500">
{preset === "quarter" ? quarterLabel(range.start, range.end) :
@@ -347,21 +348,13 @@ export default function TaxDashboard({
</div>
{/* ── Tab bar ── */}
<div className="flex gap-1 border-b border-zinc-800">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === tab.id
? "border-blue-600 text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{tab.label}
</button>
))}
</div>
<AdminFilterTabs
activeTab={activeTab}
onTabChange={setActiveTab}
tabs={TABS}
size="md"
showCounts={false}
/>
{/* ── TAX SUMMARY TAB ── */}
{activeTab === "summary" && (
+8 -8
View File
@@ -58,9 +58,9 @@ export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
if (loading) {
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-amber-400 border-t-transparent animate-spin" />
<span className="text-xs text-amber-700">Loading tax summary...</span>
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-4 py-3 flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
<span className="text-xs text-[var(--admin-text-secondary)]">Loading tax summary...</span>
</div>
);
}
@@ -72,25 +72,25 @@ export default function TaxQuarterlySummary({ brandId }: { brandId: string }) {
: 0;
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-amber-700">
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-secondary)]">
{quarterLabel()} Tax Collected
</span>
</div>
<Link
href="/admin/taxes"
className="text-xs text-amber-600 hover:text-amber-800 font-medium"
className="text-xs text-[var(--admin-accent-text)] hover:text-[var(--admin-accent)] font-medium transition-colors"
>
View Details
</Link>
</div>
<div className="mt-2 flex items-baseline gap-4">
<span className="text-xl font-bold text-amber-800">
<span className="text-xl font-bold text-[var(--admin-text-primary)]">
${data.total_tax_collected.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
<span className="text-xs text-amber-600">
<span className="text-xs text-[var(--admin-text-muted)]">
on ${data.total_gross_sales.toLocaleString(undefined, { minimumFractionDigits: 2 })} gross sales · {effectiveRate.toFixed(3)}% rate · {data.order_count} orders
</span>
</div>
+97 -129
View File
@@ -21,6 +21,7 @@ import {
} from "@/actions/time-tracking";
import { formatDate } from "@/lib/format-date";
import TimeTrackingSettingsClient from "./TimeTrackingSettingsClient";
import { AdminButton, AdminFilterTabs } from "./design-system";
// One-color outline icons
const Icons = {
@@ -112,7 +113,7 @@ const PAGE_SIZE = 50;
function StatusBadge({ active }: { active: boolean }) {
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold ${
active ? "bg-green-50 text-green-700" : "bg-stone-100 text-stone-500"
active ? "bg-green-100 text-green-700" : "bg-stone-100 text-stone-500"
}`}>
<span className={`h-1.5 w-1.5 rounded-full ${active ? "bg-green-500" : "bg-stone-400"}`} />
{active ? "Active" : "Inactive"}
@@ -135,12 +136,12 @@ function formatMinutes(minutes: number): string {
type Tab = "summary" | "workers" | "tasks" | "logs" | "settings";
const TABS: { id: Tab; label: string; description: string }[] = [
{ id: "summary", label: "Summary", description: "Overview & stats" },
{ id: "workers", label: "Workers", description: "Manage team" },
{ id: "tasks", label: "Tasks", description: "Job types" },
{ id: "logs", label: "Logs", description: "Time entries" },
{ id: "settings", label: "Settings", description: "Configuration" },
const TABS = [
{ value: "summary", label: "Summary", icon: Icons.chart("h-4 w-4") },
{ value: "workers", label: "Workers", icon: Icons.user("h-4 w-4") },
{ value: "tasks", label: "Tasks", icon: Icons.clipboard("h-4 w-4") },
{ value: "logs", label: "Logs", icon: Icons.clock("h-4 w-4") },
{ value: "settings", label: "Settings", icon: Icons.settings("h-4 w-4") },
];
export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }) {
@@ -193,7 +194,7 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
if (!brandId) {
return (
<div className="text-center py-20 text-stone-500">
<div className="text-center py-20 text-[var(--admin-text-muted)]">
<p className="text-sm">Select a brand to view time tracking.</p>
</div>
);
@@ -205,7 +206,7 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6 pb-2">
<div className="flex items-center justify-between mb-4">
<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">
<div className="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-xl bg-[var(--admin-accent)]">
{Icons.clock("h-5 w-5 sm:h-6 sm:w-6 text-white")}
</div>
<div>
@@ -213,40 +214,23 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
<p className="text-xs text-[var(--admin-text-muted)]">{dateRange.start} {dateRange.end}</p>
</div>
</div>
<button
<AdminButton
onClick={() => setExportModal(true)}
className="inline-flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-600 hover:bg-stone-50 shadow-sm transition-colors"
icon={Icons.download("h-4 w-4")}
variant="secondary"
>
{Icons.download("h-4 w-4")}
<span>Export</span>
</button>
Export
</AdminButton>
</div>
{/* Tab navigation - Route Trace style */}
<nav className="grid grid-cols-5 gap-1 p-1.5 rounded-xl bg-white border border-stone-200">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.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 ${
tab === t.id
? "bg-emerald-600 text-white"
: "text-stone-500 hover:text-stone-700 hover:bg-stone-50"
}`}
>
{t.id === "summary" && Icons.chart("h-4 w-4")}
{t.id === "workers" && Icons.user("h-4 w-4")}
{t.id === "tasks" && Icons.clipboard("h-4 w-4")}
{t.id === "logs" && Icons.clock("h-4 w-4")}
{t.id === "settings" && Icons.settings("h-4 w-4")}
<span className="hidden sm:inline">{t.label}</span>
<span className="sm:hidden">{t.label.substring(0, 3)}</span>
{tab === t.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>
{/* Tab navigation - using AdminFilterTabs */}
<AdminFilterTabs
activeTab={tab}
onTabChange={(t) => setTab(t as Tab)}
tabs={TABS}
size="md"
showCounts={false}
/>
</div>
{/* Content */}
@@ -356,17 +340,17 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
{exportModal && (
<GlassModal title="Export Time Logs" onClose={() => setExportModal(false)}>
<div className="p-6">
<p className="text-sm text-stone-600 mb-4">Download time logs for the selected date range.</p>
<button
<p className="text-sm text-[var(--admin-text-secondary)] mb-4">Download time logs for the selected date range.</p>
<AdminButton
onClick={() => {
window.location.href = `/api/time-tracking/export?brandId=${brandId}&start=${dateRange.start}&end=${dateRange.end}`;
setExportModal(false);
}}
className="w-full rounded-xl bg-[var(--admin-accent)] py-3 text-sm font-bold text-white hover:opacity-90 flex items-center justify-center gap-2"
icon={Icons.download("h-4 w-4")}
fullWidth
>
{Icons.download("h-4 w-4")}
Download CSV
</button>
</AdminButton>
</div>
</GlassModal>
)}
@@ -377,28 +361,28 @@ export default function TimeTrackingAdminPanel({ brandId }: { brandId?: string }
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Worker</p>
<p className="text-sm font-semibold text-stone-900">{logModal.worker_name}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Worker</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.worker_name}</p>
</div>
<div>
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Task</p>
<p className="text-sm font-semibold text-stone-900">{logModal.task_name || "—"}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Task</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.task_name || "—"}</p>
</div>
<div>
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Clock In</p>
<p className="text-sm font-semibold text-stone-900">{logModal.clock_in ? formatDate(logModal.clock_in) : "—"}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Clock In</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.clock_in ? formatDate(logModal.clock_in) : "—"}</p>
</div>
<div>
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Clock Out</p>
<p className="text-sm font-semibold text-stone-900">{logModal.clock_out ? formatDate(logModal.clock_out) : "Open"}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Clock Out</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{logModal.clock_out ? formatDate(logModal.clock_out) : "Open"}</p>
</div>
<div>
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Duration</p>
<p className="text-sm font-semibold text-stone-900">{formatMinutes(logModal.total_minutes)}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Duration</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{formatMinutes(logModal.total_minutes)}</p>
</div>
<div>
<p className="text-xs text-stone-500 uppercase tracking-wider mb-1">Notes</p>
<p className="text-sm text-stone-600">{logModal.notes || "—"}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-wider mb-1">Notes</p>
<p className="text-sm text-[var(--admin-text-secondary)]">{logModal.notes || "—"}</p>
</div>
</div>
</div>
@@ -429,7 +413,7 @@ function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefr
type="date"
value={dateRange.start}
onChange={(e) => setDateRange((prev) => ({ ...prev, start: e.target.value }))}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600"
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div className="space-y-1">
@@ -438,16 +422,12 @@ function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefr
type="date"
value={dateRange.end}
onChange={(e) => setDateRange((prev) => ({ ...prev, end: e.target.value }))}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600"
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<button
onClick={onRefresh}
className="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold transition-colors flex items-center gap-2"
>
{Icons.refresh("h-4 w-4")}
<span>Refresh</span>
</button>
<AdminButton onClick={onRefresh} icon={Icons.refresh("h-4 w-4")}>
Refresh
</AdminButton>
</div>
</div>
@@ -502,12 +482,12 @@ function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefr
{/* By Worker Table */}
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-stone-50/40">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">By Worker</h3>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-[var(--admin-border)] bg-stone-50/50">
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Worker</th>
<th className="px-5 py-3 text-right text-xs font-bold text-[var(--admin-text-muted)] uppercase">Entries</th>
<th className="px-5 py-3 text-right text-xs font-bold text-[var(--admin-text-muted)] uppercase">Total Hours</th>
@@ -518,7 +498,7 @@ function SummaryTab({ summary, workers, loading, dateRange, setDateRange, onRefr
<tr><td colSpan={3} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No data</td></tr>
) : (
(summary.by_worker ?? []).map((w) => (
<tr key={w.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors">
<tr key={w.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)]">{w.name}</td>
<td className="px-5 py-4 text-sm text-[var(--admin-text-secondary)] text-right">{w.entry_count}</td>
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)] text-right">{formatMinutes(w.total_hours * 60)}</td>
@@ -549,19 +529,15 @@ function WorkersTab({ workers, onAdd, onEdit, brandId, onSave }: {
}) {
return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between bg-stone-50/40">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between bg-[var(--admin-bg-subtle)]">
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Workers ({workers.length})</h3>
<button
onClick={onAdd}
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors"
>
{Icons.plus("h-4 w-4")}
<span>Add Worker</span>
</button>
<AdminButton onClick={onAdd} icon={Icons.plus("h-4 w-4")}>
Add Worker
</AdminButton>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-[var(--admin-border)] bg-stone-50/50">
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Name</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">PIN</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Status</th>
@@ -574,7 +550,7 @@ function WorkersTab({ workers, onAdd, onEdit, brandId, onSave }: {
<tr><td colSpan={5} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No workers yet</td></tr>
) : (
workers.map((w) => (
<tr key={w.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors">
<tr key={w.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)]">{w.name}</td>
<td className="px-5 py-4 text-sm font-mono text-[var(--admin-text-secondary)]">{w.pin || "—"}</td>
<td className="px-5 py-4"><StatusBadge active={w.active} /></td>
@@ -604,19 +580,15 @@ function TasksTab({ tasks, onAdd, onEdit, brandId, onSave }: {
}) {
return (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between bg-stone-50/40">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between bg-[var(--admin-bg-subtle)]">
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Tasks ({tasks.length})</h3>
<button
onClick={onAdd}
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700 transition-colors"
>
{Icons.plus("h-4 w-4")}
<span>Add Task</span>
</button>
<AdminButton onClick={onAdd} icon={Icons.plus("h-4 w-4")}>
Add Task
</AdminButton>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-[var(--admin-border)] bg-stone-50/50">
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Name</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Unit</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Status</th>
@@ -628,7 +600,7 @@ function TasksTab({ tasks, onAdd, onEdit, brandId, onSave }: {
<tr><td colSpan={4} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No tasks yet</td></tr>
) : (
tasks.map((t) => (
<tr key={t.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors">
<tr key={t.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-4">
<div className="text-sm font-semibold text-[var(--admin-text-primary)]">{t.name}</div>
{t.name_es && <div className="text-xs text-[var(--admin-text-muted)]">{t.name_es}</div>}
@@ -676,7 +648,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
type="date"
value={dateRange.start}
onChange={(e) => setDateRange((prev) => ({ ...prev, start: e.target.value }))}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600"
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div className="space-y-1">
@@ -685,7 +657,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
type="date"
value={dateRange.end}
onChange={(e) => setDateRange((prev) => ({ ...prev, end: e.target.value }))}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600"
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div className="space-y-1">
@@ -693,7 +665,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
<select
value={selectedWorker}
onChange={(e) => setSelectedWorker(e.target.value)}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600 min-w-[160px]"
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)] min-w-[160px]"
>
<option value="">All Workers</option>
{workers.map((w) => (
@@ -706,7 +678,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
<select
value={selectedTask}
onChange={(e) => setSelectedTask(e.target.value)}
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-stone-50 text-stone-900 text-sm focus:outline-none focus:border-emerald-600 min-w-[160px]"
className="px-3 py-2 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] text-sm focus:outline-none focus:border-[var(--admin-accent)] min-w-[160px]"
>
<option value="">All Tasks</option>
{tasks.map((t) => (
@@ -714,13 +686,9 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
))}
</select>
</div>
<button
onClick={onRefresh}
className="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold transition-colors flex items-center gap-2"
>
{Icons.refresh("h-4 w-4")}
<span>Refresh</span>
</button>
<AdminButton onClick={onRefresh} icon={Icons.refresh("h-4 w-4")}>
Refresh
</AdminButton>
</div>
</div>
@@ -737,12 +705,12 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
</div>
) : (
<div className="rounded-2xl border border-[var(--admin-border)] bg-white overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-stone-50/40">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<h3 className="text-sm font-bold text-[var(--admin-text-primary)]">Time Logs</h3>
</div>
<table className="w-full">
<thead>
<tr className="border-b border-[var(--admin-border)] bg-stone-50/50">
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Worker</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Task</th>
<th className="px-5 py-3 text-left text-xs font-bold text-[var(--admin-text-muted)] uppercase">Clock In</th>
@@ -755,7 +723,7 @@ function LogsTab({ logs, workers, tasks, dateRange, setDateRange, selectedWorker
<tr><td colSpan={5} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No logs yet</td></tr>
) : (
logs.map((log) => (
<tr key={log.id} className="border-b border-stone-50 last:border-0 hover:bg-stone-50/40 transition-colors cursor-pointer" onClick={() => onViewLog(log)}>
<tr key={log.id} className="border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] transition-colors cursor-pointer" onClick={() => onViewLog(log)}>
<td className="px-5 py-4 text-sm font-semibold text-[var(--admin-text-primary)]">{log.worker_name}</td>
<td className="px-5 py-4 text-sm text-[var(--admin-text-secondary)]">{log.task_name || "—"}</td>
<td className="px-5 py-4 text-sm text-[var(--admin-text-muted)]">{log.clock_in ? formatDate(log.clock_in) : "—"}</td>
@@ -822,42 +790,42 @@ function WorkerModal({ worker, brandId, onClose, onSave }: {
<GlassModal title={isEdit ? "Edit Worker" : "Add Worker"} onClose={onClose}>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} required className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600" />
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} required className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Role</label>
<select value={role} onChange={(e) => setRole(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600">
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Role</label>
<select value={role} onChange={(e) => setRole(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<option value="worker">Worker</option>
<option value="supervisor">Supervisor</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Language</label>
<select value={lang} onChange={(e) => setLang(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600">
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Language</label>
<select value={lang} onChange={(e) => setLang(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} className="rounded border-[var(--admin-border)]" />
<span className="text-sm text-stone-700">Active</span>
<span className="text-sm text-[var(--admin-text-secondary)]">Active</span>
</label>
<div className="flex gap-2 pt-2">
{isEdit && worker && (
<>
<button type="button" onClick={handleResetPin} disabled={loading} className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-xs font-semibold text-[var(--admin-text-secondary)] hover:bg-stone-50 disabled:opacity-50 flex items-center gap-1">
{Icons.refresh("h-3.5 w-3.5")} Reset PIN
</button>
<button type="button" onClick={handleDelete} disabled={loading} className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-semibold text-red-600 hover:bg-red-100 ml-auto flex items-center gap-1">
{Icons.trash("h-3.5 w-3.5")} Delete
</button>
<AdminButton type="button" variant="secondary" size="sm" onClick={handleResetPin} disabled={loading} icon={Icons.refresh("h-3.5 w-3.5")}>
Reset PIN
</AdminButton>
<AdminButton type="button" variant="danger" size="sm" onClick={handleDelete} disabled={loading} icon={Icons.trash("h-3.5 w-3.5")}>
Delete
</AdminButton>
</>
)}
<button type="submit" disabled={loading} className="rounded-lg bg-[var(--admin-accent)] px-4 py-2.5 text-sm font-bold text-white hover:opacity-90 disabled:opacity-50 ml-auto">
<AdminButton type="submit" disabled={loading} isLoading={loading} className="ml-auto">
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</AdminButton>
</div>
</form>
</GlassModal>
@@ -907,16 +875,16 @@ function TaskModal({ task, brandId, onClose, onSave }: {
<GlassModal title={isEdit ? "Edit Task" : "Add Task"} onClose={onClose}>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Task Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} required placeholder="e.g., Harvesting" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600" />
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Task Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} required placeholder="e.g., Harvesting" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Name in Spanish</label>
<input value={nameEs} onChange={(e) => setNameEs(e.target.value)} placeholder="e.g., Cosecha" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600" />
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Name in Spanish</label>
<input value={nameEs} onChange={(e) => setNameEs(e.target.value)} placeholder="e.g., Cosecha" className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">Unit</label>
<select value={unit} onChange={(e) => setUnit(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-emerald-600">
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit</label>
<select value={unit} onChange={(e) => setUnit(e.target.value)} className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2.5 text-sm focus:outline-none focus:border-[var(--admin-accent)]">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
<option value="units">Units</option>
@@ -924,17 +892,17 @@ function TaskModal({ task, brandId, onClose, onSave }: {
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} className="rounded border-[var(--admin-border)]" />
<span className="text-sm text-stone-700">Active</span>
<span className="text-sm text-[var(--admin-text-secondary)]">Active</span>
</label>
<div className="flex gap-2 pt-2">
{isEdit && task && (
<button type="button" onClick={handleDelete} disabled={loading} className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs font-semibold text-red-600 hover:bg-red-100 flex items-center gap-1">
{Icons.trash("h-3.5 w-3.5")} Delete
</button>
<AdminButton type="button" variant="danger" size="sm" onClick={handleDelete} disabled={loading} icon={Icons.trash("h-3.5 w-3.5")}>
Delete
</AdminButton>
)}
<button type="submit" disabled={loading} className="rounded-lg bg-[var(--admin-accent)] px-4 py-2.5 text-sm font-bold text-white hover:opacity-90 disabled:opacity-50 ml-auto">
<AdminButton type="submit" disabled={loading} isLoading={loading} className="ml-auto">
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</AdminButton>
</div>
</form>
</GlassModal>
@@ -19,6 +19,7 @@ import {
type TimeTrackingSettings,
type NotificationLogEntry,
} from "@/actions/time-tracking";
import { AdminButton } from "@/components/admin/design-system";
// One-color outline icons
const Icons = {
@@ -306,107 +307,87 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
daily_reached: { en: "Daily OT Reached", color: "bg-red-100 text-red-700" },
weekly_approaching: { en: "Weekly OT Approaching", color: "bg-amber-100 text-amber-700" },
weekly_reached: { en: "Weekly OT Reached", color: "bg-red-100 text-red-700" },
end_of_period_summary:{ en: "Period Summary", color: "bg-emerald-100 text-emerald-700" },
end_of_period_summary:{ en: "Period Summary", color: "bg-green-100 text-green-700" },
};
if (loading) return <div className="text-center py-16 text-stone-500 text-sm">Loading...</div>;
if (loading) return <div className="text-center py-16 text-[var(--admin-text-muted)] text-sm">Loading...</div>;
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-stone-900">Time Tracking</h2>
<p className="text-sm text-stone-500 mt-0.5">
{tab === "dashboard" ? "Summary, exports & recent activity" : "Workers, tasks & overtime rules"}
</p>
</div>
</div>
{/* Two-tab nav */}
<div className="flex gap-1 border-b border-stone-200">
<button onClick={() => setTab("dashboard")} className={`px-4 py-2 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "dashboard" ? "text-stone-900 border-stone-900" : "text-stone-500 border-transparent hover:text-stone-700"}`}>
Dashboard
</button>
<button onClick={() => setTab("settings")} className={`px-4 py-2 text-sm font-semibold transition-all border-b-2 -mb-px ${tab === "settings" ? "text-stone-900 border-stone-900" : "text-stone-500 border-transparent hover:text-stone-700"}`}>
Settings
</button>
</div>
{/* Dashboard tab */}
{tab === "dashboard" && (
<div className="space-y-6">
{/* Stat row */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
<p className="text-3xl font-bold text-stone-900">{workers.length}</p>
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Workers</p>
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-5 text-center">
<p className="text-3xl font-bold text-[var(--admin-text-primary)]">{workers.length}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">Workers</p>
</div>
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
<p className="text-3xl font-bold text-stone-900">{tasks.length}</p>
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Tasks</p>
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-5 text-center">
<p className="text-3xl font-bold text-[var(--admin-text-primary)]">{tasks.length}</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">Tasks</p>
</div>
<div className="bg-white border border-stone-200 rounded-xl p-5 text-center shadow-sm">
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-5 text-center">
<p className="text-3xl font-bold text-amber-600">{notificationLog.filter(n => n.email_sent || n.sms_sent).length}</p>
<p className="text-xs text-stone-500 uppercase tracking-widest mt-1">Alerts Sent</p>
<p className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest mt-1">Alerts Sent</p>
</div>
</div>
{/* Quick export section */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-4 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Export Data</h3>
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-4">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Export Data</h3>
<div className="grid grid-cols-2 gap-3">
<button onClick={() => triggerDownload(buildExportUrl("quickbooks"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-emerald-500 transition-all text-left">
<span className="text-stone-400">{Icons.clock("h-5 w-5")}</span>
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-[var(--admin-accent)] transition-all text-left">
<span className="text-[var(--admin-text-muted)]">{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>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">QuickBooks</p>
<p className="text-xs text-[var(--admin-text-muted)]">Time import CSV</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("payroll"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-blue-500 transition-all text-left">
<span className="text-stone-400">{Icons.dollarSign("h-5 w-5")}</span>
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-blue-500 transition-all text-left">
<span className="text-[var(--admin-text-muted)]">{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>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Payroll</p>
<p className="text-xs text-[var(--admin-text-muted)]">ADP / Gusto / Generic</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("detailed"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-violet-500 transition-all text-left">
<span className="text-stone-400">{Icons.clipboard("h-5 w-5")}</span>
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-violet-500 transition-all text-left">
<span className="text-[var(--admin-text-muted)]">{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>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Detailed Log</p>
<p className="text-xs text-[var(--admin-text-muted)]">Full audit report</p>
</div>
</button>
<button onClick={() => triggerDownload(buildExportUrl("summary"))}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-stone-50 border border-stone-200 hover:border-amber-500 transition-all text-left">
<span className="text-stone-400">{Icons.chart("h-5 w-5")}</span>
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] hover:border-amber-500 transition-all text-left">
<span className="text-[var(--admin-text-muted)]">{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>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Period Summary</p>
<p className="text-xs text-[var(--admin-text-muted)]">Per-worker totals</p>
</div>
</button>
</div>
<div className="flex items-center gap-4 text-xs text-stone-500">
<div className="flex items-center gap-4 text-xs text-[var(--admin-text-muted)]">
<span>From:</span>
<input type="date" value={exportStart} onChange={e => setExportStart(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-stone-200 bg-white text-stone-700 focus:outline-none focus:border-emerald-500" />
className="px-3 py-1.5 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)]" />
<span>To:</span>
<input type="date" value={exportEnd} onChange={e => setExportEnd(e.target.value)}
className="px-3 py-1.5 rounded-lg border border-stone-200 bg-white text-stone-700 focus:outline-none focus:border-emerald-500" />
className="px-3 py-1.5 rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)]" />
</div>
</div>
{/* Notification log */}
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Recent Alerts</h3>
<div className="bg-white border border-[var(--admin-border)] rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Recent Alerts</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
<th className="text-left px-5 py-3 font-medium">Time</th>
<th className="text-left px-5 py-3 font-medium">Worker</th>
<th className="text-left px-5 py-3 font-medium">Alert</th>
@@ -416,21 +397,21 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</thead>
<tbody>
{notificationLog.length === 0 ? (
<tr><td colSpan={5} className="px-5 py-8 text-center text-stone-400">No alerts sent yet</td></tr>
<tr><td colSpan={5} className="px-5 py-8 text-center text-[var(--admin-text-muted)]">No alerts sent yet</td></tr>
) : notificationLog.slice(0, 20).map(entry => {
const meta = triggerLabel[entry.trigger_type] ?? { en: entry.trigger_type, color: "bg-stone-100 text-stone-500" };
return (
<tr key={entry.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-500 text-xs">{new Date(entry.created_at).toLocaleString()}</td>
<td className="px-5 py-3.5 text-stone-800 font-medium">{entry.worker_name ?? "—"}</td>
<tr key={entry.id} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{new Date(entry.created_at).toLocaleString()}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-medium">{entry.worker_name ?? "—"}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${meta.color}`}>{meta.en}</span>
</td>
<td className="px-5 py-3.5 text-center">
<span className="text-emerald-600">{Icons.check("h-4 w-4")}</span>
<span className="text-[var(--admin-accent)]">{Icons.check("h-4 w-4")}</span>
</td>
<td className="px-5 py-3.5 text-center">
<span className="text-stone-300">{entry.sms_sent ? Icons.check("h-4 w-4 text-emerald-600") : "—"}</span>
<span className="text-[var(--admin-text-muted)]">{entry.sms_sent ? Icons.check("h-4 w-4 text-[var(--admin-accent)]") : "—"}</span>
</td>
</tr>
);
@@ -445,15 +426,16 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
{tab === "settings" && (
<div className="space-y-6">
{/* Workers section */}
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Workers & PINs</h3>
<button onClick={() => { setEditingWorker(null); setWorkerName(""); setWorkerRole("worker"); setWorkerLang("en"); setWorkerActive(true); setShowWorkerModal(true); setWorkerError(null); setResetPinResult(null); }}
className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Worker</button>
<div className="bg-white border border-[var(--admin-border)] rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Workers & PINs</h3>
<AdminButton onClick={openAddWorker} size="sm">
+ Add Worker
</AdminButton>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
<th className="text-left px-5 py-3 font-medium">Name</th>
<th className="text-left px-5 py-3 font-medium">Role</th>
<th className="text-left px-5 py-3 font-medium">Lang</th>
@@ -464,23 +446,23 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</thead>
<tbody>
{workers.length === 0 ? (
<tr><td colSpan={6} className="px-5 py-12 text-center text-stone-400">No workers yet</td></tr>
<tr><td colSpan={6} className="px-5 py-12 text-center text-[var(--admin-text-muted)]">No workers yet</td></tr>
) : workers.map(w => (
<tr key={w.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-800 font-medium">{w.name}</td>
<td className="px-5 py-3.5 text-stone-500 capitalize">{w.role}</td>
<td className="px-5 py-3.5 text-stone-400 uppercase text-xs font-mono">{w.lang}</td>
<tr key={w.id} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-medium">{w.name}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] capitalize">{w.role}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] uppercase text-xs font-mono">{w.lang}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${w.active ? "bg-green-100 text-green-700" : "bg-stone-100 text-stone-500"}`}>
{w.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-3.5 text-stone-400 text-xs">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{w.last_used_at ? new Date(w.last_used_at).toLocaleDateString() : "—"}</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => openEditWorker(w)} className="text-xs text-stone-500 hover:text-stone-800 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleResetPin(w.id)} className="text-xs text-amber-600 hover:text-amber-800 px-2 py-1 rounded-lg hover:bg-amber-50 transition-all">Reset PIN</button>
<button onClick={() => handleDeleteWorker(w.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
<AdminButton variant="ghost" size="sm" onClick={() => openEditWorker(w)}>Edit</AdminButton>
<AdminButton variant="ghost" size="sm" onClick={() => handleResetPin(w.id)}>Reset PIN</AdminButton>
<AdminButton variant="danger" size="sm" onClick={() => handleDeleteWorker(w.id)}>Delete</AdminButton>
</div>
</td>
</tr>
@@ -490,15 +472,16 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</div>
{/* Tasks section */}
<div className="bg-white border border-stone-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Tasks</h3>
<button onClick={() => { setEditingTask(null); setTaskName(""); setTaskNameEs(""); setTaskUnit("hours"); setTaskSortOrder(0); setTaskActive(true); setShowTaskModal(true); setTaskError(null); }}
className="text-xs bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg font-semibold transition-all">+ Add Task</button>
<div className="bg-white border border-[var(--admin-border)] rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Tasks</h3>
<AdminButton onClick={openAddTask} size="sm">
+ Add Task
</AdminButton>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-stone-500 uppercase tracking-widest border-b border-stone-100">
<tr className="text-xs text-[var(--admin-text-muted)] uppercase tracking-widest border-b border-[var(--admin-border)]">
<th className="text-left px-5 py-3 font-medium">Name</th>
<th className="text-left px-5 py-3 font-medium">Name (ES)</th>
<th className="text-left px-5 py-3 font-medium">Unit</th>
@@ -509,22 +492,22 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</thead>
<tbody>
{tasks.length === 0 ? (
<tr><td colSpan={6} className="px-5 py-12 text-center text-stone-400">No tasks yet</td></tr>
<tr><td colSpan={6} className="px-5 py-12 text-center text-[var(--admin-text-muted)]">No tasks yet</td></tr>
) : tasks.map(t => (
<tr key={t.id} className="border-t border-stone-100 hover:bg-stone-50 transition-colors">
<td className="px-5 py-3.5 text-stone-800 font-medium">{t.name}</td>
<td className="px-5 py-3.5 text-stone-500">{t.name_es ?? "—"}</td>
<td className="px-5 py-3.5 text-stone-400 text-xs font-mono">{t.unit}</td>
<td className="px-5 py-3.5 text-stone-400 text-xs">{t.sort_order}</td>
<tr key={t.id} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg-subtle)] transition-colors">
<td className="px-5 py-3.5 text-[var(--admin-text-primary)] font-medium">{t.name}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)]">{t.name_es ?? "—"}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs font-mono">{t.unit}</td>
<td className="px-5 py-3.5 text-[var(--admin-text-muted)] text-xs">{t.sort_order}</td>
<td className="px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${t.active ? "bg-green-100 text-green-700" : "bg-stone-100 text-stone-500"}`}>
{t.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-3.5 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => openEditTask(t)} className="text-xs text-stone-500 hover:text-stone-800 px-2 py-1 rounded-lg hover:bg-stone-100 transition-all">Edit</button>
<button onClick={() => handleDeleteTask(t.id)} className="text-xs text-red-500 hover:text-red-700 px-2 py-1 rounded-lg hover:bg-red-50 transition-all">Delete</button>
<AdminButton variant="ghost" size="sm" onClick={() => openEditTask(t)}>Edit</AdminButton>
<AdminButton variant="danger" size="sm" onClick={() => handleDeleteTask(t.id)}>Delete</AdminButton>
</div>
</td>
</tr>
@@ -534,41 +517,41 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</div>
{/* Pay Period & Overtime section */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-5 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Pay Period & Overtime</h3>
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Pay Period & Overtime</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Work week starts on</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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">
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] 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>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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>
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
<span className="text-sm text-[var(--admin-text-muted)]">days</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Daily overtime threshold</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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>
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
<span className="text-sm text-[var(--admin-text-muted)]">hrs</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Weekly overtime threshold</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] 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>
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
<span className="text-sm text-[var(--admin-text-muted)]">hrs</span>
</div>
</div>
</div>
@@ -586,36 +569,36 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</div>
{/* Alert Settings */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-5 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Alert Settings</h3>
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-5">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Alert Settings</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Daily overtime alerts</p>
<p className="text-xs text-stone-500">Send notification when worker hits daily threshold</p>
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Daily overtime alerts</p>
<p className="text-xs text-[var(--admin-text-muted)]">Send notification when worker hits daily threshold</p>
</div>
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Weekly overtime alerts</p>
<p className="text-xs text-stone-500">Send notification when worker hits weekly threshold</p>
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Weekly overtime alerts</p>
<p className="text-xs text-[var(--admin-text-muted)]">Send notification when worker hits weekly threshold</p>
</div>
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-stone-700 font-medium">Show overtime warning on employee screen</p>
<p className="text-xs text-stone-500">Display alert when worker approaches thresholds</p>
<p className="text-sm text-[var(--admin-text-secondary)] font-medium">Show overtime warning on employee screen</p>
<p className="text-xs text-[var(--admin-text-muted)]">Display alert when worker approaches thresholds</p>
</div>
<button onClick={() => setOvertimeNotifications(!overtimeNotifications)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${overtimeNotifications ? "bg-emerald-600" : "bg-stone-300"}`}>
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${overtimeNotifications ? "bg-[var(--admin-accent)]" : "bg-stone-300"}`}>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${overtimeNotifications ? "translate-x-4" : "translate-x-1"}`} />
</button>
</div>
@@ -623,42 +606,42 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</div>
{/* Notification Recipients */}
<div className="bg-white border border-stone-200 rounded-xl p-6 space-y-4 shadow-sm">
<h3 className="text-sm font-semibold text-stone-800">Notification Recipients</h3>
<div className="bg-white border border-[var(--admin-border)] rounded-xl p-6 space-y-4">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Notification Recipients</h3>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-2">Email addresses</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2">Email addresses</label>
<div className="flex gap-2">
<input value={newEmail}
onChange={e => setNewEmail(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addEmail(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="manager@farm.com" />
<button onClick={addEmail} className="px-4 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm transition-all">Add</button>
<AdminButton onClick={addEmail}>Add</AdminButton>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationEmails.map(e => (
<span key={e} className="inline-flex items-center gap-1 bg-stone-100 text-stone-700 text-xs px-2 py-1 rounded-lg">
<span key={e} className="inline-flex items-center gap-1 bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] text-xs px-2 py-1 rounded-lg">
{e}
<button onClick={() => removeEmail(e)} className="text-stone-400 hover:text-red-500 ml-1">&times;</button>
<button onClick={() => removeEmail(e)} className="text-[var(--admin-text-muted)] 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>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-2">SMS numbers</label>
<div className="flex gap-2">
<input value={newSms}
onChange={e => setNewSms(e.target.value)}
onKeyDown={e => e.key === "Enter" && (addSms(), e.preventDefault())}
className="flex-1 px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
className="flex-1 px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="+1234567890" />
<button onClick={addSms} className="px-4 py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm transition-all">Add</button>
<AdminButton onClick={addSms}>Add</AdminButton>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{notificationSmsNumbers.map(n => (
<span key={n} className="inline-flex items-center gap-1 bg-stone-100 text-stone-700 text-xs px-2 py-1 rounded-lg">
<span key={n} className="inline-flex items-center gap-1 bg-[var(--admin-bg-subtle)] text-[var(--admin-text-secondary)] text-xs px-2 py-1 rounded-lg">
{n}
<button onClick={() => removeSms(n)} className="text-stone-400 hover:text-red-500 ml-1">&times;</button>
<button onClick={() => removeSms(n)} className="text-[var(--admin-text-muted)] hover:text-red-500 ml-1">&times;</button>
</span>
))}
</div>
@@ -669,51 +652,50 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
)}
{settingsSaved && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4 text-emerald-700 text-sm">Settings saved successfully.</div>
<div className="bg-green-50 border border-green-200 rounded-xl py-3 px-4 text-green-700 text-sm">Settings saved successfully.</div>
)}
<button onClick={handleSaveNotifications} disabled={settingsSaving}
className="px-6 py-3 rounded-xl bg-stone-900 hover:bg-stone-800 disabled:opacity-40 font-semibold text-sm text-white transition-all">
{settingsSaving ? "Saving..." : "Save Settings"}
</button>
<AdminButton onClick={handleSaveNotifications} isLoading={settingsSaving}>
Save Settings
</AdminButton>
</div>
)}
{/* Worker Modal */}
{showWorkerModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button onClick={() => setShowWorkerModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
<div className="bg-white border border-[var(--admin-border)] rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-lg font-bold text-[var(--admin-text-primary)]">{editingWorker ? "Edit Worker" : "Add Worker"}</h3>
<button onClick={() => setShowWorkerModal(false)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{workerError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{workerError}</div>}
{resetPinResult && (
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4">
<p className="text-emerald-700 text-sm font-semibold mb-1">New PIN:</p>
<p className="text-2xl font-mono font-bold text-stone-900">{resetPinResult}</p>
<p className="text-stone-500 text-xs mt-1">Show this to the worker it will not be shown again.</p>
<div className="bg-green-50 border border-green-200 rounded-xl py-3 px-4">
<p className="text-green-700 text-sm font-semibold mb-1">New PIN:</p>
<p className="text-2xl font-mono font-bold text-[var(--admin-text-primary)]">{resetPinResult}</p>
<p className="text-[var(--admin-text-muted)] text-xs mt-1">Show this to the worker it will not be shown again.</p>
</div>
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name *</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Name *</label>
<input value={workerName} onChange={e => setWorkerName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="Worker name" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Role</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Role</label>
<select value={workerRole} onChange={e => setWorkerRole(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
<option value="worker">Worker</option>
<option value="time_admin">Time Admin</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Lang</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Lang</label>
<select value={workerLang} onChange={e => setWorkerLang(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
<option value="en">English</option>
<option value="es">Español</option>
</select>
@@ -721,25 +703,23 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="workerActive" checked={workerActive} onChange={e => setWorkerActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="workerActive" className="text-sm text-stone-700">Active</label>
className="w-4 h-4 rounded border-[var(--admin-border)] bg-white text-[var(--admin-accent)]" />
<label htmlFor="workerActive" className="text-sm text-[var(--admin-text-secondary)]">Active</label>
</div>
{editingWorker && (
<div className="pt-2 border-t border-stone-100">
<button onClick={() => handleResetPin(editingWorker.id)} className="text-xs text-amber-600 hover:text-amber-800 font-semibold underline underline-offset-2">
<div className="pt-2 border-t border-[var(--admin-border)]">
<AdminButton variant="secondary" size="sm" onClick={() => handleResetPin(editingWorker.id)}>
Reset PIN for this worker
</button>
</AdminButton>
</div>
)}
<div className="flex gap-3 pt-2">
<button onClick={() => setShowWorkerModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
<AdminButton type="button" variant="secondary" onClick={() => setShowWorkerModal(false)} fullWidth>
Cancel
</button>
<button onClick={handleSaveWorker} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
</AdminButton>
<AdminButton type="button" onClick={handleSaveWorker} isLoading={submitting} fullWidth>
{submitting ? "..." : editingWorker ? "Save" : "Add Worker"}
</button>
</AdminButton>
</div>
</div>
</div>
@@ -749,55 +729,53 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett
{/* Task Modal */}
{showTaskModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white border border-stone-200 rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-lg font-bold text-stone-900">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button onClick={() => setShowTaskModal(false)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">&times;</button>
<div className="bg-white border border-[var(--admin-border)] rounded-2xl w-full max-w-md shadow-xl">
<div className="px-6 py-4 border-b border-[var(--admin-border)] flex items-center justify-between">
<h3 className="text-lg font-bold text-[var(--admin-text-primary)]">{editingTask ? "Edit Task" : "Add Task"}</h3>
<button onClick={() => setShowTaskModal(false)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-xl leading-none">&times;</button>
</div>
<div className="p-6 space-y-4">
{taskError && <div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{taskError}</div>}
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (EN) *</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Name (EN) *</label>
<input value={taskName} onChange={e => setTaskName(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="e.g. Harvesting" />
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Name (ES)</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Name (ES)</label>
<input value={taskNameEs} onChange={e => setTaskNameEs(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:border-emerald-500 transition-colors"
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] placeholder:text-[var(--admin-text-muted)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors"
placeholder="e.g. Cosecha" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Unit</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Unit</label>
<select value={taskUnit} onChange={e => setTaskUnit(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors">
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors">
<option value="hours">Hours</option>
<option value="pieces">Pieces</option>
<option value="units">Units</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-widest text-stone-500 mb-1.5">Sort Order</label>
<label className="block text-xs font-semibold uppercase tracking-widest text-[var(--admin-text-muted)] mb-1.5">Sort Order</label>
<input type="number" value={taskSortOrder} onChange={e => setTaskSortOrder(parseInt(e.target.value) || 0)}
className="w-full px-4 py-3 rounded-xl border border-stone-200 bg-white text-stone-900 focus:outline-none focus:border-emerald-500 transition-colors" />
className="w-full px-4 py-3 rounded-xl border border-[var(--admin-border)] bg-white text-[var(--admin-text-primary)] focus:outline-none focus:border-[var(--admin-accent)] transition-colors" />
</div>
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="taskActive" checked={taskActive} onChange={e => setTaskActive(e.target.checked)}
className="w-4 h-4 rounded border-stone-300 bg-white text-emerald-600" />
<label htmlFor="taskActive" className="text-sm text-stone-700">Active</label>
className="w-4 h-4 rounded border-[var(--admin-border)] bg-white text-[var(--admin-accent)]" />
<label htmlFor="taskActive" className="text-sm text-[var(--admin-text-secondary)]">Active</label>
</div>
<div className="flex gap-3 pt-2">
<button onClick={() => setShowTaskModal(false)}
className="flex-1 py-3 rounded-xl font-semibold text-sm text-stone-500 hover:text-stone-700 border border-stone-200 hover:border-stone-300 transition-all">
<AdminButton type="button" variant="secondary" onClick={() => setShowTaskModal(false)} fullWidth>
Cancel
</button>
<button onClick={handleSaveTask} disabled={submitting}
className="flex-1 py-3 rounded-xl font-bold text-sm bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white transition-all">
</AdminButton>
<AdminButton type="button" onClick={handleSaveTask} isLoading={submitting} fullWidth>
{submitting ? "..." : editingTask ? "Save" : "Add Task"}
</button>
</AdminButton>
</div>
</div>
</div>
+5 -1
View File
@@ -300,7 +300,11 @@ export default function UsersPage({ initialUsers, brands, currentUser }: UsersPa
{error && (
<div className="mb-4 flex items-start justify-between rounded-lg bg-red-50 p-4 text-sm text-red-700 gap-3 border border-red-200">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0"></button>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
File diff suppressed because it is too large Load Diff
+31 -39
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateWaterEntry, deleteWaterEntry } from "@/actions/water-log/admin";
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect, AdminButton } from "./design-system";
type WaterEntry = {
id: string;
@@ -63,53 +64,45 @@ export default function WaterLogEntryEditForm({ entry, backHref = "/admin/water-
return (
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
<div className="rounded-lg bg-red-100 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Headgate</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.headgate_name}</p>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Headgate</label>
<p className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-3 py-2 text-sm text-[var(--admin-text-secondary)]">{entry.headgate_name}</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">User</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.user_name}</p>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">User</label>
<p className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-3 py-2 text-sm text-[var(--admin-text-secondary)]">{entry.user_name}</p>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Measurement</label>
<input
<AdminInput label="Measurement" required>
<AdminTextInput
type="number"
step="any"
value={measurement}
onChange={(e) => setMeasurement(parseFloat(e.target.value) || 0)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Unit</label>
<select
</AdminInput>
<AdminInput label="Unit">
<AdminSelect
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
>
{UNIT_OPTIONS.map((u) => (
<option key={u} value={u}>{u}</option>
))}
</select>
options={UNIT_OPTIONS.map((u) => ({ value: u, label: u }))}
/>
</AdminInput>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Submitted Via</label>
<p className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-3 py-2 text-sm text-[var(--admin-text-secondary)]">{entry.submitted_via}</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Submitted Via</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">{entry.submitted_via}</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Logged</label>
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-300">
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Logged</label>
<p className="rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-3 py-2 text-sm text-[var(--admin-text-secondary)]">
{new Date(entry.logged_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
@@ -120,33 +113,32 @@ export default function WaterLogEntryEditForm({ entry, backHref = "/admin/water-
</div>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Notes</label>
<textarea
<AdminInput label="Notes" className="col-span-2">
<AdminTextarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900 resize-none"
placeholder="Optional notes..."
/>
</div>
</AdminInput>
<div className="flex items-center justify-between pt-2">
<button
<AdminButton
type="button"
variant="danger"
onClick={handleDelete}
disabled={deleting}
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
isLoading={deleting}
>
{deleting ? "..." : "Delete Entry"}
</button>
<button
Delete Entry
</AdminButton>
<AdminButton
type="submit"
disabled={saving}
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
isLoading={saving}
>
{saving ? "..." : "Save Changes"}
</button>
Save Changes
</AdminButton>
</div>
</form>
);
+30 -27
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateWaterIrrigator, resetWaterIrrigatorPin, deleteWaterUser } from "@/actions/water-log/admin";
import { AdminButton } from "./design-system";
type WaterUser = {
id: string;
@@ -70,53 +71,53 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
return (
<form onSubmit={handleSave} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-200 p-3 text-sm text-red-400">
<div className="rounded-lg bg-red-100 border border-red-200 p-3 text-sm text-red-700">
{error}
</div>
)}
{newPin && (
<div className="rounded-xl bg-yellow-50 border border-yellow-200 p-4">
<p className="font-semibold text-yellow-800">New PIN for {name}:</p>
<p className="mt-1 text-2xl font-bold tracking-widest text-yellow-900">{newPin}</p>
<p className="mt-1 text-xs text-yellow-600">Write this down it will not be shown again.</p>
<button onClick={() => setNewPin(null)} className="mt-2 text-xs text-yellow-700 underline">Dismiss</button>
<div className="rounded-xl bg-amber-100 border border-amber-200 p-4">
<p className="font-semibold text-amber-800">New PIN for {name}:</p>
<p className="mt-1 text-2xl font-bold tracking-widest text-amber-900">{newPin}</p>
<p className="mt-1 text-xs text-amber-600">Write this down it will not be shown again.</p>
<button onClick={() => setNewPin(null)} className="mt-2 text-xs text-amber-700 underline">Dismiss</button>
</div>
)}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Name</label>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm outline-none focus:border-slate-900"
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Role</label>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as "irrigator" | "water_admin")}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
>
<option value="irrigator">Irrigator</option>
<option value="water_admin">Admin</option>
</select>
<p className="text-xs text-slate-400 mt-0.5">
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">
{role === "water_admin"
? "Can manage headgates, users, and entries"
: "Can only submit water log entries"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Language</label>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Language</label>
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm"
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
>
<option value="en">English</option>
<option value="es">Español</option>
@@ -126,44 +127,46 @@ export default function WaterUserEditForm({ waterUser, backHref = "/admin/water-
<div className="flex items-center gap-4">
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">Status</label>
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Status</label>
<select
value={active ? "1" : "0"}
onChange={(e) => setActive(e.target.value === "1")}
className="rounded-lg border border-zinc-600 px-3 py-2 text-sm"
className="rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm"
>
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div className="pt-4">
<button
<AdminButton
type="button"
variant="secondary"
onClick={handleResetPin}
disabled={resetting}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-900 disabled:opacity-50"
isLoading={resetting}
>
{resetting ? "..." : "Reset PIN"}
</button>
Reset PIN
</AdminButton>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<button
<AdminButton
type="button"
variant="danger"
onClick={handleDelete}
disabled={deleting}
className="rounded-lg border border-red-200 bg-zinc-900 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/30 disabled:opacity-50"
isLoading={deleting}
>
{deleting ? "..." : "Delete User"}
</button>
<button
Delete User
</AdminButton>
<AdminButton
type="submit"
disabled={saving}
className="rounded-lg bg-green-600 px-6 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
isLoading={saving}
>
{saving ? "..." : "Save Changes"}
</button>
Save Changes
</AdminButton>
</div>
</form>
);
@@ -16,7 +16,7 @@ type AdminActionMenuProps = {
className?: string;
};
export default function AdminActionMenu({ actions, triggerLabel = "⋮", className = "" }: AdminActionMenuProps) {
export default function AdminActionMenu({ actions, triggerLabel, className = "" }: AdminActionMenuProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -41,7 +41,9 @@ export default function AdminActionMenu({ actions, triggerLabel = "⋮", classNa
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}
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/>
</svg>
</button>
{open && (
@@ -0,0 +1,135 @@
"use client";
import { ReactNode, ButtonHTMLAttributes } from "react";
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
type ButtonSize = "sm" | "md" | "lg";
type AdminButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: ReactNode;
iconPosition?: "left" | "right";
isLoading?: boolean;
fullWidth?: boolean;
};
const variantClasses: Record<ButtonVariant, string> = {
primary: `
bg-[var(--admin-accent)] text-white border border-transparent
hover:bg-[var(--admin-accent-hover)]
active:bg-[var(--admin-accent-hover)]
`,
secondary: `
bg-[var(--admin-card-bg)] text-[var(--admin-text-secondary)] border border-[var(--admin-border)]
hover:bg-[var(--admin-bg)] hover:border-[var(--admin-text-muted)]
active:bg-[var(--admin-bg)]
`,
danger: `
bg-[var(--admin-danger)] text-white border border-transparent
hover:bg-[var(--admin-danger-hover)]
active:bg-[var(--admin-danger-hover)]
`,
ghost: `
bg-transparent text-[var(--admin-text-secondary)] border border-transparent
hover:bg-[var(--admin-bg-subtle)] hover:text-[var(--admin-text-primary)]
active:bg-[var(--admin-bg-subtle)]
`,
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "px-3 py-1.5 text-xs rounded-lg gap-1.5",
md: "px-4 py-2 text-sm rounded-xl gap-2",
lg: "px-5 py-3 text-base rounded-xl gap-2",
};
export default function AdminButton({
children,
variant = "primary",
size = "md",
icon,
iconPosition = "left",
isLoading = false,
fullWidth = false,
className = "",
disabled,
...props
}: AdminButtonProps) {
const baseClasses = `
inline-flex items-center justify-center font-semibold
transition-all duration-200 cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed
shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
`;
const variantClass = variantClasses[variant];
const sizeClass = sizeClasses[size];
const widthClass = fullWidth ? "w-full" : "";
const content = (
<>
{isLoading && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
{!isLoading && icon && iconPosition === "left" && (
<span className="flex-shrink-0">{icon}</span>
)}
{children && <span>{children}</span>}
{!isLoading && icon && iconPosition === "right" && (
<span className="flex-shrink-0">{icon}</span>
)}
</>
);
return (
<button
className={`${baseClasses} ${variantClass} ${sizeClass} ${widthClass} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{content}
</button>
);
}
// Icon Button variant for icon-only buttons
type AdminIconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
label: string; // For accessibility
};
const iconSizeClasses: Record<ButtonSize, string> = {
sm: "p-1.5",
md: "p-2",
lg: "p-2.5",
};
export function AdminIconButton({
variant = "ghost",
size = "md",
label,
className = "",
...props
}: AdminIconButtonProps) {
const baseClasses = `
inline-flex items-center justify-center rounded-lg
transition-all duration-150 cursor-pointer
disabled:opacity-50 disabled:cursor-not-allowed
`;
const variantClass = variantClasses[variant];
const sizeClass = iconSizeClasses[size];
return (
<button
className={`${baseClasses} ${variantClass} ${sizeClass} ${className}`}
aria-label={label}
title={label}
{...props}
/>
);
}
@@ -0,0 +1,252 @@
"use client";
type TabItem = {
/** Unique value for the tab */
value: string;
/** Display label for the tab */
label: string;
/** Optional count badge */
count?: number;
/** Optional icon element */
icon?: React.ReactNode;
/** Disable this tab */
disabled?: boolean;
};
type AdminFilterTabsProps = {
/** Currently active tab value */
activeTab: string;
/** Callback when tab changes */
onTabChange: (value: string) => void;
/** Array of tab items */
tabs: TabItem[];
/** CSS class for the tabs container */
className?: string;
/** Size variant */
size?: "sm" | "md";
/** Show count badges */
showCounts?: boolean;
};
const sizeClasses = {
sm: {
container: "p-0.5 gap-0.5",
tab: "px-2.5 py-1.5 text-[10px]",
},
md: {
container: "p-1 gap-0.5",
tab: "px-3 py-1.5 text-xs",
},
};
export default function AdminFilterTabs({
activeTab,
onTabChange,
tabs,
className = "",
size = "md",
showCounts = true,
}: AdminFilterTabsProps) {
const sizes = sizeClasses[size];
return (
<div
className={`
flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card-bg-alt)]
${sizes.container} ${className}
`}
role="tablist"
aria-label="Filter tabs"
>
{tabs.map((tab) => {
const isActive = activeTab === tab.value;
const isDisabled = tab.disabled ?? false;
return (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={isActive}
disabled={isDisabled}
onClick={() => !isDisabled && onTabChange(tab.value)}
className={`
flex items-center gap-1.5 rounded-lg font-semibold transition-all duration-150
disabled:opacity-50 disabled:cursor-not-allowed
${sizes.tab}
${isActive
? `
bg-white text-emerald-700
border border-emerald-200
shadow-sm
`
: `
text-stone-600
hover:text-emerald-700
hover:bg-emerald-50/50
`
}
`}
>
{/* Tab Icon */}
{tab.icon && (
<span className={`flex-shrink-0 ${isActive ? "text-emerald-600" : "text-stone-400"}`}>
{tab.icon}
</span>
)}
{/* Tab Label */}
<span>{tab.label}</span>
{/* Count Badge */}
{showCounts && tab.count !== undefined && (
<span
className={`
ml-0.5 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full px-1
text-[10px] font-bold
${isActive
? "bg-[var(--admin-accent)] text-white"
: "bg-[var(--admin-border)] text-[var(--admin-text-muted)]"
}
`}
>
{tab.count}
</span>
)}
</button>
);
})}
</div>
);
}
// Convenience components for common tab configurations
export type StatusFilter = "all" | "active" | "inactive" | "pending" | "draft" | string;
type StatusFilterTabsProps = {
activeTab: StatusFilter;
onTabChange: (value: StatusFilter) => void;
counts?: {
all?: number;
active?: number;
inactive?: number;
pending?: number;
draft?: number;
};
className?: string;
size?: "sm" | "md";
};
export function AdminStatusFilterTabs({
activeTab,
onTabChange,
counts,
className = "",
size = "md",
}: StatusFilterTabsProps) {
const defaultTabs: TabItem[] = [
{ value: "all", label: "All", count: counts?.all },
{ value: "active", label: "Active", count: counts?.active },
{ value: "pending", label: "Pending", count: counts?.pending },
{ value: "draft", label: "Draft", count: counts?.draft },
{ value: "inactive", label: "Inactive", count: counts?.inactive },
];
return (
<AdminFilterTabs
activeTab={activeTab}
onTabChange={onTabChange}
tabs={defaultTabs}
className={className}
size={size}
/>
);
}
// View mode tabs (table/card view)
type ViewMode = "table" | "cards" | "list" | string;
type ViewModeTabsProps = {
activeTab: ViewMode;
onTabChange: (value: ViewMode) => void;
showTable?: boolean;
showCards?: boolean;
showList?: boolean;
className?: string;
};
const TableIcon = () => (
<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>
);
const CardsIcon = () => (
<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>
);
const ListIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
);
export function AdminViewModeTabs({
activeTab,
onTabChange,
showTable = true,
showCards = true,
showList = false,
className = "",
}: ViewModeTabsProps) {
const tabs: TabItem[] = [];
if (showTable) tabs.push({ value: "table", label: "Table", icon: <TableIcon /> });
if (showCards) tabs.push({ value: "cards", label: "Cards", icon: <CardsIcon /> });
if (showList) tabs.push({ value: "list", label: "List", icon: <ListIcon /> });
return (
<div
className={`
flex rounded-lg border border-[var(--admin-border)] bg-white p-1
${className}
`}
role="tablist"
aria-label="View mode"
>
{tabs.map((tab) => {
const isActive = activeTab === tab.value;
return (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onTabChange(tab.value)}
className={`
flex items-center justify-center rounded-lg p-1.5 transition-all duration-150
${isActive
? `
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)]
`
}
`}
>
{tab.icon}
</button>
);
})}
</div>
);
}
@@ -33,72 +33,71 @@ export function AdminInput({
}
// Styled text input wrapper
type AdminTextInputProps = {
value: string;
type AdminTextInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'type'> & {
value: string | number;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
type?: string;
disabled?: boolean;
className?: string;
};
export function AdminTextInput({
value,
onChange,
placeholder,
type = "text",
disabled,
className = ""
className = "",
maxLength,
autoComplete,
step,
min,
max,
...rest
}: 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}`}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] disabled:opacity-50 ${className}`}
maxLength={maxLength}
autoComplete={autoComplete}
step={step}
min={min}
max={max}
{...rest}
/>
);
}
// Styled textarea wrapper
type AdminTextareaProps = {
value: string;
type AdminTextareaProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'value'> & {
value: string | number;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
rows?: number;
disabled?: boolean;
className?: string;
};
export function AdminTextarea({
value,
onChange,
placeholder,
rows = 3,
disabled,
className = ""
className = "",
...rest
}: 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}`}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] resize-none disabled:opacity-50 ${className}`}
{...rest}
/>
);
}
// Styled select wrapper
type AdminSelectProps = {
value: string;
type AdminSelectProps = Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange' | 'value'> & {
value: string | number;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: { value: string; label: string }[];
disabled?: boolean;
className?: string;
};
export function AdminSelect({
@@ -106,14 +105,16 @@ export function AdminSelect({
onChange,
options,
disabled,
className = ""
className = "",
...rest
}: 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}`}
className={`w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 disabled:opacity-50 ${className}`}
{...rest}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -147,7 +148,7 @@ export function AdminCheckbox({
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)]"
className="h-4 w-4 rounded border-[var(--admin-border)] text-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/30 focus:ring-offset-1 cursor-pointer"
/>
<span className="text-sm font-medium text-[var(--admin-text-secondary)]">{label}</span>
</label>
@@ -33,7 +33,7 @@ export default function AdminModal({ title, subtitle, onClose, children, maxWidt
<div
className={`relative w-full ${maxWidth} rounded-2xl`}
style={{
backgroundColor: "#ffffff",
backgroundColor: "var(--admin-card-bg)",
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)",
}}
@@ -1,39 +1,20 @@
"use client";
import Link from "next/link";
import PageHeader from "./PageHeader";
import { ReactNode } from "react";
// Re-export PageHeader with description prop mapped to subtitle for backward compatibility
type AdminPageHeaderProps = {
breadcrumb?: { label: string; href?: string }[];
icon?: ReactNode;
title: string;
description?: string;
actions?: ReactNode;
className?: string;
};
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>
);
export default function AdminPageHeader(props: AdminPageHeaderProps) {
// Convert description to subtitle for PageHeader
const { description, ...rest } = props;
return <PageHeader {...rest} subtitle={description} />;
}
@@ -0,0 +1,118 @@
"use client";
import { InputHTMLAttributes, forwardRef } from "react";
type AdminSearchInputProps = InputHTMLAttributes<HTMLInputElement> & {
/** Search icon element (defaults to magnifying glass) */
icon?: React.ReactNode;
/** Icon position */
iconPosition?: "left" | "right";
/** Callback when clear button is clicked */
onClear?: () => void;
/** Show clear button when value exists */
showClear?: boolean;
/** Container CSS class */
containerClassName?: string;
/** Input wrapper CSS class */
inputClassName?: string;
};
const SearchIcon = () => (
<svg
className="h-5 w-5"
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>
);
const ClearIcon = () => (
<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>
);
const AdminSearchInput = forwardRef<HTMLInputElement, AdminSearchInputProps>(({
icon,
iconPosition = "left",
onClear,
showClear = true,
containerClassName = "",
inputClassName = "",
value,
className = "",
...props
}, ref) => {
const searchIcon = icon ?? <SearchIcon />;
const hasValue = value !== undefined && value !== "";
const shouldShowClear = showClear && hasValue && onClear;
const inputBaseClasses = `
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 transition-colors duration-150
placeholder:text-[var(--admin-text-muted)]
focus:border-[var(--admin-accent)]
disabled:bg-[var(--admin-bg-subtle)] disabled:cursor-not-allowed
`;
return (
<div className={`relative flex-1 min-w-[12rem] ${containerClassName}`}>
{/* Left Icon */}
{iconPosition === "left" && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)] pointer-events-none">
{searchIcon}
</div>
)}
{/* Input */}
<input
ref={ref}
type="search"
value={value}
className={`${inputBaseClasses} ${inputClassName} ${className}`}
{...props}
/>
{/* Right Icon (Clear button or custom) */}
{iconPosition === "right" && !shouldShowClear && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)] pointer-events-none">
{searchIcon}
</div>
)}
{/* Clear Button */}
{shouldShowClear && (
<button
type="button"
onClick={onClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] transition-colors p-0.5"
aria-label="Clear search"
>
<ClearIcon />
</button>
)}
</div>
);
});
AdminSearchInput.displayName = "AdminSearchInput";
export default AdminSearchInput;
@@ -0,0 +1,84 @@
"use client";
import Link from "next/link";
import { ReactNode } from "react";
type PageHeaderProps = {
/** Breadcrumb navigation items */
breadcrumb?: { label: string; href?: string }[];
/** Icon displayed before the title */
icon?: ReactNode;
/** Main title text */
title: string;
/** Optional subtitle/description below the title */
subtitle?: string;
/** Optional action buttons/elements to display on the right */
actions?: ReactNode;
/** Additional CSS classes */
className?: string;
};
export default function PageHeader({
breadcrumb,
icon,
title,
subtitle,
actions,
className = "",
}: PageHeaderProps) {
return (
<div className={`mb-6 ${className}`}>
{/* Breadcrumb Navigation */}
{breadcrumb && breadcrumb.length > 0 && (
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-4">
{breadcrumb.map((crumb, index) => (
<span key={index} className="flex items-center gap-2">
{crumb.href ? (
<Link
href={crumb.href}
className="hover:text-[var(--admin-text-secondary)] transition-colors"
>
{crumb.label}
</Link>
) : (
<span className="text-[var(--admin-text-secondary)]">{crumb.label}</span>
)}
{index < breadcrumb.length - 1 && (
<span className="text-[var(--admin-text-muted)]">/</span>
)}
</span>
))}
</nav>
)}
{/* Title Row with Icon and Actions */}
<div className="flex items-center justify-between">
{/* Left: Icon + Title + Subtitle */}
<div className="flex items-center gap-4">
{icon && (
<div className="flex items-center justify-center w-12 h-12 rounded-2xl bg-[var(--admin-accent-light)] text-[var(--admin-accent)] shadow-[var(--admin-shadow-sm)]">
{icon}
</div>
)}
<div>
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)] tracking-tight">
{title}
</h1>
{subtitle && (
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{subtitle}
</p>
)}
</div>
</div>
{/* Right: Action Buttons */}
{actions && (
<div className="flex items-center gap-3">
{actions}
</div>
)}
</div>
</div>
);
}
+7 -4
View File
@@ -12,11 +12,14 @@ export { default as AdminActionMenu, AdminActionButton } from "./AdminActionMenu
export { default as AdminPagination, AdminSimplePagination } from "./AdminPagination";
export { default as AdminBadge, AdminStatusBadge, AdminCountBadge } from "./AdminBadge";
// Design system components
export { default as AdminButton, AdminIconButton } from "./AdminButton";
export { default as PageHeader } from "./PageHeader";
export { default as AdminSearchInput } from "./AdminSearchInput";
export { default as AdminFilterTabs, AdminStatusFilterTabs, AdminViewModeTabs } from "./AdminFilterTabs";
// 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
// Modal component - GlassModal is the standard modal (max-w-lg default), AdminModal is a smaller variant (max-w-md default)
export { default as GlassModal } from "@/components/admin/GlassModal";