feat: comprehensive frontend polish - UI/UX improvements across all pages
Public Pages: - Landing page with server/client split and metadata export - Cart page with ARIA accessibility and loading states - Checkout page with form accessibility and proper design system - Contact page with SEO metadata and improved accessibility - Pricing page with enhanced FAQ accessibility - Login page with Suspense boundaries and secure patterns Brand Storefronts: - Premium loading skeletons for Tuxedo and Indian River Direct - Branded error boundaries with animations - Loading.tsx for about, contact, FAQ, stops pages - Error.tsx for all storefront subpages Admin Dashboard: - AdminSidebar: ARIA labels, keyboard navigation, mobile improvements - DashboardClient: Stats cards, quick actions, usage progress - Admin layout: Toast provider integration Admin Orders/Products/Stops: - Toast notification system with auto-dismiss - Skeleton loading components (Table, Card, Stats, Form) - Bulk actions (mark picked up, publish stops) - Form validation with error styling Admin Communications: - AnalyticsDashboard: sparklines, stat cards, engagement badges - CampaignComposerPage: step wizard, template selection, email preview - SegmentBuilderPage: empty state, active segment handling - ContactListPanel: loading skeletons, professional empty states - MessageLogPanel: stats cards, engagement indicators - HarvestReachNav: branded tab navigation - All pages: metadata exports and loading.tsx Admin Settings: - SquareSyncSettingsClient: design system colors, save/cancel UX - ShippingSettingsForm: validation, dirty state tracking - Integrations page: proper layout with AI and communications sections - Billing: improved plan comparison, add-on cards - PaymentSettings: toggle components, validation Wholesale Portal: - Portal: loading skeletons, quantity stepper, search/filter - Login/Register: FormField validation, success states - Success/Cancel pages: animated checkmarks - Employee portal: skeletons, empty states, mobile responsive Water Log: - FieldClient: loading step, progress indicator, spinner - AdminClient: loading skeletons - Admin pages: loading.tsx files New Components: - Toast.tsx/ToastContainer.tsx: comprehensive notification system - Skeleton.tsx: shimmer loading components - AdminToggle.tsx: consistent toggle/switch - CommunicationsLoading.tsx: loading skeleton for comms - ToastExport.ts: exports CSS Improvements: - Shimmer animation keyframes - Toast slide-in animation Accessibility: - ARIA labels throughout - Keyboard navigation - Focus states - Semantic HTML - Screen reader support
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } 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";
|
||||
import { AdminButton, AdminSearchInput, AdminFilterTabs, AdminIconButton, useToast } from "./design-system";
|
||||
import { Skeleton } from "./design-system";
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
@@ -104,6 +105,12 @@ const Icons = {
|
||||
<line x1="12" y1="22" x2="12" y2="12"/>
|
||||
</svg>
|
||||
),
|
||||
selectAll: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<polyline points="9 11 12 14 22 4"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function AdminOrdersPanel({
|
||||
@@ -111,6 +118,7 @@ export default function AdminOrdersPanel({
|
||||
initialStops,
|
||||
brandId,
|
||||
}: AdminOrdersPanelProps) {
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [orders, setOrders] = useState<Order[]>(initialOrders);
|
||||
const [stops] = useState<Stop[]>(initialStops);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -119,9 +127,18 @@ export default function AdminOrdersPanel({
|
||||
const [showStopDropdown, setShowStopDropdown] = useState(false);
|
||||
const [pickingUp, setPickingUp] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [pickupToast, setPickupToast] = useState<string | null>(null);
|
||||
const [selectedOrders, setSelectedOrders] = useState<Set<string>>(new Set());
|
||||
const [bulkMarkingUp, setBulkMarkingUp] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// Simulate loading when orders change
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => setIsLoading(false), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [page, activeTab, search, selectedStops.length]);
|
||||
|
||||
const filteredOrders = useMemo(() => {
|
||||
return orders.filter((order) => {
|
||||
if (activeTab === "pending" && order.pickup_complete) return false;
|
||||
@@ -142,6 +159,27 @@ export default function AdminOrdersPanel({
|
||||
const pendingCount = orders.filter((o) => !o.pickup_complete).length;
|
||||
const pickedUpCount = orders.filter((o) => o.pickup_complete).length;
|
||||
|
||||
// Bulk selection
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedOrders.size === paginatedOrders.length) {
|
||||
setSelectedOrders(new Set());
|
||||
} else {
|
||||
setSelectedOrders(new Set(paginatedOrders.map(o => o.id)));
|
||||
}
|
||||
}, [selectedOrders.size, paginatedOrders]);
|
||||
|
||||
const toggleOrderSelection = useCallback((orderId: string) => {
|
||||
setSelectedOrders(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(orderId)) {
|
||||
next.delete(orderId);
|
||||
} else {
|
||||
next.add(orderId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
function toggleStop(stopId: string) {
|
||||
setSelectedStops((prev) =>
|
||||
prev.includes(stopId) ? prev.filter((id) => id !== stopId) : [...prev, stopId]
|
||||
@@ -158,8 +196,7 @@ export default function AdminOrdersPanel({
|
||||
setPickingUp(orderId);
|
||||
const result = await markPickupComplete(orderId, brandId);
|
||||
if (result.success) {
|
||||
setPickupToast(orderId);
|
||||
setTimeout(() => setPickupToast(null), 3000);
|
||||
showSuccess("Pickup confirmed", `${result.pickup_completed_at ? 'Order marked as picked up' : 'Success'}`);
|
||||
setOrders((prev) =>
|
||||
prev.map((o) =>
|
||||
o.id === orderId
|
||||
@@ -167,10 +204,45 @@ export default function AdminOrdersPanel({
|
||||
: o
|
||||
)
|
||||
);
|
||||
} else {
|
||||
showError("Failed to mark pickup", result.error ?? "Please try again");
|
||||
}
|
||||
setPickingUp(null);
|
||||
}
|
||||
|
||||
async function handleBulkMarkPickup() {
|
||||
if (selectedOrders.size === 0) return;
|
||||
|
||||
setBulkMarkingUp(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const orderId of selectedOrders) {
|
||||
const result = await markPickupComplete(orderId, brandId);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
setOrders((prev) =>
|
||||
prev.map((o) =>
|
||||
o.id === orderId
|
||||
? { ...o, pickup_complete: true, pickup_completed_at: result.pickup_completed_at, pickup_completed_by: result.pickup_completed_by }
|
||||
: o
|
||||
)
|
||||
);
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setBulkMarkingUp(false);
|
||||
setSelectedOrders(new Set());
|
||||
|
||||
if (failCount === 0) {
|
||||
showSuccess(`${successCount} order${successCount !== 1 ? 's' : ''} marked as picked up`);
|
||||
} else {
|
||||
showError("Some orders failed", `${successCount} succeeded, ${failCount} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
@@ -195,15 +267,15 @@ export default function AdminOrdersPanel({
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{orders.length}</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{isLoading ? <Skeleton variant="text" className="w-16 h-8" /> : orders.length}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-amber-600 mt-1">{pendingCount}</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-amber-600 mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Picked Up</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-accent)] mt-1">{pickedUpCount}</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,8 +368,47 @@ export default function AdminOrdersPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedOrders.size > 0 && (
|
||||
<div className="mb-4 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
|
||||
{selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedOrders(new Set())}
|
||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleBulkMarkPickup}
|
||||
isLoading={bulkMarkingUp}
|
||||
icon={Icons.check("h-4 w-4")}
|
||||
>
|
||||
Mark All as Picked Up
|
||||
</AdminButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Orders Table */}
|
||||
{paginatedOrders.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton variant="rect" className="h-5 w-5 rounded" />
|
||||
<Skeleton variant="text" className="flex-1" />
|
||||
<Skeleton variant="text" className="w-24" />
|
||||
<Skeleton variant="text" className="w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : paginatedOrders.length === 0 ? (
|
||||
<div className="text-center py-12 rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.package("h-8 w-8 text-stone-400")}
|
||||
@@ -310,6 +421,14 @@ export default function AdminOrdersPanel({
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead className="bg-stone-50">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<th className="w-10 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Order</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Customer</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden md:table-cell">Stop</th>
|
||||
@@ -322,6 +441,14 @@ export default function AdminOrdersPanel({
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{paginatedOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedOrders.has(order.id)}
|
||||
onChange={() => toggleOrderSelection(order.id)}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
|
||||
{shortId(order.id)}
|
||||
@@ -357,6 +484,17 @@ export default function AdminOrdersPanel({
|
||||
{order.payment_processor === "square" && (
|
||||
<AdminBadge variant="info">Square</AdminBadge>
|
||||
)}
|
||||
{!order.pickup_complete && (
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMarkPickup(order.id)}
|
||||
disabled={pickingUp === order.id}
|
||||
isLoading={pickingUp === order.id}
|
||||
>
|
||||
{pickingUp === order.id ? "..." : "Pick Up"}
|
||||
</AdminButton>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -373,33 +511,27 @@ export default function AdminOrdersPanel({
|
||||
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, filteredOrders.length)} of {filteredOrders.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Previous page"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--admin-border)] text-stone-500 hover:bg-stone-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{Icons.chevronLeft("h-4 w-4")}
|
||||
</button>
|
||||
</AdminIconButton>
|
||||
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
|
||||
<button
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Next page"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--admin-border)] text-stone-500 hover:bg-stone-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{Icons.chevronRight("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{pickupToast && (
|
||||
<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-[var(--admin-accent)]">
|
||||
{Icons.check("h-4 w-4 text-white")}
|
||||
</div>
|
||||
<span className="font-medium text-[var(--admin-accent-text)]">Pickup confirmed!</span>
|
||||
</AdminIconButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -9,7 +9,6 @@ import { supabase } from "@/lib/supabase";
|
||||
// Elegant warm sidebar design
|
||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||
|
||||
// Flat admin navigation - no dropdowns
|
||||
type NavItem = {
|
||||
href?: string;
|
||||
label: string;
|
||||
@@ -37,9 +36,10 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/admin/settings/square-sync", label: "Square Sync", icon: "square" },
|
||||
];
|
||||
|
||||
// Icon components
|
||||
function GridIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H15.75a2.25 2.25 0 01-2.25-2.25v-2.25z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -47,7 +47,7 @@ function GridIcon({ className }: { className?: string }) {
|
||||
|
||||
function CartIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -55,7 +55,7 @@ function CartIcon({ className }: { className?: string }) {
|
||||
|
||||
function MapPinIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
@@ -64,7 +64,7 @@ function MapPinIcon({ className }: { className?: string }) {
|
||||
|
||||
function PackageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -72,7 +72,7 @@ function PackageIcon({ className }: { className?: string }) {
|
||||
|
||||
function ClipboardIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.801 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -80,7 +80,7 @@ function ClipboardIcon({ className }: { className?: string }) {
|
||||
|
||||
function ClockIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -88,7 +88,7 @@ function ClockIcon({ className }: { className?: string }) {
|
||||
|
||||
function MailIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
);
|
||||
@@ -96,7 +96,7 @@ function MailIcon({ className }: { className?: string }) {
|
||||
|
||||
function SparkleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -104,7 +104,7 @@ function SparkleIcon({ className }: { className?: string }) {
|
||||
|
||||
function SettingsCogIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg className={`w-4 h-4 transition-transform ${open ? "rotate-45" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={`w-4 h-4 transition-transform duration-200 ${open ? "rotate-45" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09.542.56.94 1.11.94h2.64c.55 0 1.02-.398 1.11-.94l.213-1.999c.018-.158.04-.315.062-.472a.563.563 0 00-.122-.519l-.79-2.758A.562.562 0 0014.56 0H9.44a.563.563 0 00-.424.264l-.79 2.758a.563.563 0 00-.122.519c.022.157.044.314.062.472l.213 1.999zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -112,7 +112,7 @@ function SettingsCogIcon({ open }: { open: boolean }) {
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg className={`w-3.5 h-3.5 transition-transform ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className={`w-3.5 h-3.5 transition-transform duration-200 ${open ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
@@ -120,15 +120,23 @@ function ChevronIcon({ open }: { open: boolean }) {
|
||||
|
||||
function HamburgerIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m3.75 0h3m-3.75 0h3m3.75 0h3m3.75 0h3" />
|
||||
</svg>
|
||||
);
|
||||
@@ -136,7 +144,7 @@ function BillingIcon({ className }: { className?: string }) {
|
||||
|
||||
function PuzzleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 00.658-.663 48.422 48.422 0 00-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 01-.61-.58v0z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -144,7 +152,7 @@ function PuzzleIcon({ className }: { className?: string }) {
|
||||
|
||||
function PlugIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
);
|
||||
@@ -152,7 +160,7 @@ function PlugIcon({ className }: { className?: string }) {
|
||||
|
||||
function TruckIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||
</svg>
|
||||
);
|
||||
@@ -160,12 +168,20 @@ function TruckIcon({ className }: { className?: string }) {
|
||||
|
||||
function SquareIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className={className ?? "w-4 h-4"} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className ?? "w-4 h-4"} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
grid: <GridIcon />,
|
||||
"shopping-cart": <CartIcon />,
|
||||
@@ -192,146 +208,275 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
: userRole === "brand_admin" ? "Brand Admin"
|
||||
: userRole === "store_employee" ? "Store Employee"
|
||||
: null;
|
||||
|
||||
const isActive = (href: string) => {
|
||||
const isActive = useCallback((href: string) => {
|
||||
if (href === "/admin") return pathname === "/admin";
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
// Close mobile menu with animation
|
||||
const closeMobileMenu = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setMobileOpen(false);
|
||||
setIsClosing(false);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "Escape" && mobileOpen) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [mobileOpen, closeMobileMenu]);
|
||||
|
||||
// Focus trap and body scroll lock
|
||||
useEffect(() => {
|
||||
if (mobileOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
// Focus close button after animation
|
||||
setTimeout(() => {
|
||||
closeButtonRef.current?.focus();
|
||||
}, 100);
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
// Keyboard navigation for nav items
|
||||
const handleNavKeyDown = useCallback((e: KeyboardEvent<HTMLAnchorElement>, href: string, index: number) => {
|
||||
const navLinks = NAV_ITEMS.filter(item => !item.divider);
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const nextIndex = (index + 1) % navLinks.length;
|
||||
const nextLink = document.querySelector(`[data-nav-index="${nextIndex}"]`) as HTMLAnchorElement;
|
||||
nextLink?.focus();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const prevIndex = index === 0 ? navLinks.length - 1 : index - 1;
|
||||
const prevLink = document.querySelector(`[data-nav-index="${prevIndex}"]`) as HTMLAnchorElement;
|
||||
prevLink?.focus();
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
router.push(href);
|
||||
if (mobileOpen) closeMobileMenu();
|
||||
}
|
||||
}, [router, mobileOpen, closeMobileMenu]);
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
setMobileOpen(false);
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile hamburger button */}
|
||||
{/* Mobile hamburger button - accessible */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="fixed top-4 left-4 z-50 lg:hidden"
|
||||
aria-label="Open sidebar"
|
||||
className="fixed top-4 left-4 z-50 lg:hidden rounded-xl bg-white shadow-lg border border-[var(--admin-border)] p-3 transition-transform hover:scale-105 active:scale-95"
|
||||
aria-label="Open navigation menu"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="admin-sidebar"
|
||||
type="button"
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-white shadow-md border border-[var(--admin-border)]">
|
||||
<HamburgerIcon />
|
||||
</div>
|
||||
<HamburgerIcon />
|
||||
</button>
|
||||
|
||||
{/* Mobile overlay backdrop */}
|
||||
{/* Mobile overlay backdrop with blur */}
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-sm z-40 lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-200"
|
||||
style={{ opacity: isClosing ? 0 : 1 }}
|
||||
onClick={closeMobileMenu}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar panel - Elegant warm dark */}
|
||||
<aside className={[
|
||||
"fixed top-0 left-0 h-full w-60 z-50",
|
||||
"border-r border-[var(--admin-sidebar-bg)]",
|
||||
"flex flex-col transition-transform duration-300 lg:translate-x-0",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full"
|
||||
].join(" ")}
|
||||
style={{ backgroundColor: "var(--admin-sidebar-bg)" }}>
|
||||
|
||||
{/* Logo row */}
|
||||
<div className="flex items-center h-16 px-5 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Sidebar panel - Elegant warm dark with smooth transitions */}
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
id="admin-sidebar"
|
||||
className={[
|
||||
"fixed top-0 left-0 h-full w-60 z-50",
|
||||
"border-r flex flex-col",
|
||||
"transition-transform duration-300 ease-out lg:translate-x-0",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
isClosing ? "opacity-90" : "opacity-100"
|
||||
].join(" ")}
|
||||
style={{
|
||||
backgroundColor: "var(--admin-sidebar-bg)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)"
|
||||
}}
|
||||
role="navigation"
|
||||
aria-label="Admin navigation"
|
||||
>
|
||||
{/* Logo row with close button on mobile */}
|
||||
<div
|
||||
className="flex items-center h-16 px-5 border-b flex-shrink-0"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center gap-3 text-white hover:opacity-90 transition-colors"
|
||||
onClick={() => closeMobileMenu()}
|
||||
className="flex items-center gap-3 text-white hover:opacity-90 transition-opacity"
|
||||
aria-label="Admin Dashboard home"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm" style={{ backgroundColor: "var(--admin-accent)" }}>
|
||||
<svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<div
|
||||
className="flex h-9 w-9 items-center justify-center rounded-xl shadow-sm transition-transform hover:scale-105"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
>
|
||||
<svg className="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight">Admin</span>
|
||||
</Link>
|
||||
<a
|
||||
href="/"
|
||||
className="text-xs transition-colors" style={{ color: "var(--admin-sidebar-text)" }}
|
||||
|
||||
{/* Mobile close button */}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
onClick={closeMobileMenu}
|
||||
className="lg:hidden p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Close navigation menu"
|
||||
type="button"
|
||||
>
|
||||
← Back to Site
|
||||
</a>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex-1 overflow-hidden px-3 py-5">
|
||||
{NAV_ITEMS.map((item, index) => {
|
||||
// Divider with optional label
|
||||
if (item.divider) {
|
||||
{/* Back to site link */}
|
||||
<div className="px-5 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||
<a
|
||||
href="/"
|
||||
className="text-xs transition-colors flex items-center gap-1.5 hover:text-white"
|
||||
style={{ color: "var(--admin-sidebar-text)" }}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
Back to Site
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Nav links with keyboard navigation */}
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
|
||||
<ul className="space-y-1" role="list">
|
||||
{NAV_ITEMS.map((item, index) => {
|
||||
// Divider with optional label
|
||||
if (item.divider) {
|
||||
return (
|
||||
<li key={`divider-${index}`} role="separator" aria-hidden="true">
|
||||
<div className="flex items-center gap-2 px-3 py-3 mt-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest" style={{ color: "rgba(195, 195, 193, 0.5)" }}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const active = isActive(item.href!);
|
||||
const icon = item.icon ? ICON_MAP[item.icon] : null;
|
||||
const navIndex = NAV_ITEMS.filter(i => !i.divider).findIndex(i => i.href === item.href);
|
||||
|
||||
return (
|
||||
<div key={`divider-${index}`} className="flex items-center gap-2 px-3 py-3 mt-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest" style={{ color: "rgba(195, 195, 193, 0.5)" }}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href!}
|
||||
data-nav-index={navIndex}
|
||||
onClick={() => closeMobileMenu()}
|
||||
onKeyDown={(e) => handleNavKeyDown(e, item.href!, navIndex)}
|
||||
className={[
|
||||
"group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--admin-sidebar-bg)]",
|
||||
active
|
||||
? "border-l-[3px]"
|
||||
: "border-l-[3px] hover:border-l-[3px]",
|
||||
].join(" ")}
|
||||
style={active ? {
|
||||
backgroundColor: "rgba(202, 117, 67, 0.15)",
|
||||
color: "#dea889",
|
||||
borderColor: "var(--admin-accent)",
|
||||
borderLeftColor: "var(--admin-accent)"
|
||||
} : {
|
||||
color: "var(--admin-sidebar-text)",
|
||||
borderColor: "transparent"
|
||||
}}
|
||||
aria-current={active ? "page" : undefined}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0 transition-colors duration-200"
|
||||
style={{
|
||||
color: active ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)"
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{active && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0 animate-pulse"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const active = isActive(item.href!);
|
||||
const icon = item.icon ? ICON_MAP[item.icon] : null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href!}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={[
|
||||
"group flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 mb-1",
|
||||
active
|
||||
? "border-l-2"
|
||||
: "border-l-2 border-transparent hover:border-l-2",
|
||||
].join(" ")}
|
||||
style={active ? {
|
||||
backgroundColor: "rgba(202, 117, 67, 0.15)",
|
||||
color: "#dea889",
|
||||
borderColor: "var(--admin-accent)"
|
||||
} : {
|
||||
color: "var(--admin-sidebar-text)",
|
||||
borderColor: "transparent"
|
||||
}}
|
||||
>
|
||||
<span className="flex-shrink-0 transition-colors" style={{
|
||||
color: active ? "var(--admin-accent)" : "rgba(208, 203, 180, 0.6)"
|
||||
}}>
|
||||
{icon}
|
||||
</span>
|
||||
{item.label}
|
||||
{active && (
|
||||
<span className="ml-auto w-1.5 h-1.5 rounded-full" style={{ backgroundColor: "var(--admin-accent)" }} />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Bottom: role + sign out */}
|
||||
<div className="px-4 py-5 border-t flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||
<div
|
||||
className="px-4 py-5 border-t flex-shrink-0"
|
||||
style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}
|
||||
>
|
||||
{roleLabel && (
|
||||
<div className="px-3 py-2 mb-3 rounded-lg border" style={{
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)"
|
||||
}}>
|
||||
<p className="text-[10px] font-medium uppercase tracking-widest mb-0.5" style={{ color: "rgba(195, 195, 193, 0.6)" }}>{roleLabel}</p>
|
||||
<div
|
||||
className="px-3 py-2.5 mb-3 rounded-xl border"
|
||||
style={{
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.2)"
|
||||
}}
|
||||
>
|
||||
<p className="text-[10px] font-medium uppercase tracking-widest mb-0.5" style={{ color: "rgba(195, 195, 193, 0.6)" }}>
|
||||
{roleLabel}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: "rgba(195, 195, 193, 0.8)" }}>Signed in</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm font-medium transition-all border border-transparent"
|
||||
className="w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-all flex items-center gap-2 hover:bg-white/10"
|
||||
style={{ color: "rgba(195, 195, 193, 0.7)" }}
|
||||
aria-label="Sign out of admin"
|
||||
type="button"
|
||||
>
|
||||
<LogoutIcon />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
// Loading skeleton components for Communications page
|
||||
function SkeletonBlock({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div className={`animate-pulse bg-stone-200 rounded ${className}`} />
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonBlock className="h-10 w-10 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<SkeletonBlock className="h-5 w-32" />
|
||||
<SkeletonBlock className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonBlock className="h-9 w-28 rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-stone-50 rounded-xl border border-[var(--admin-border)] p-4 sm:p-5">
|
||||
<SkeletonBlock className="h-3 w-16 mb-2" />
|
||||
<SkeletonBlock className="h-6 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SkeletonBlock className="h-64 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonTableRow() {
|
||||
return (
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<td className="px-4 sm:px-6 py-3.5">
|
||||
<SkeletonBlock className="h-4 w-32" />
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5">
|
||||
<SkeletonBlock className="h-4 w-16" />
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5">
|
||||
<SkeletonBlock className="h-4 w-20" />
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5">
|
||||
<SkeletonBlock className="h-4 w-16" />
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5">
|
||||
<SkeletonBlock className="h-4 w-16" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonTable({ rows = 5 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
|
||||
<tr>
|
||||
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
|
||||
<SkeletonBlock className="h-3 w-16" />
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
|
||||
<SkeletonBlock className="h-3 w-12" />
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
|
||||
<SkeletonBlock className="h-3 w-16" />
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
|
||||
<SkeletonBlock className="h-3 w-12" />
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">
|
||||
<SkeletonBlock className="h-3 w-12" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<SkeletonTableRow key={i} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonComposer() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Step indicator skeleton */}
|
||||
<div className="flex items-center gap-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
<SkeletonBlock className={`h-10 rounded-full ${i === 1 ? "w-24" : "w-20"}`} />
|
||||
{i < 4 && <SkeletonBlock className="h-0.5 w-6 mx-1" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main card skeleton */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
|
||||
<div className="space-y-4">
|
||||
<SkeletonBlock className="h-6 w-40" />
|
||||
<SkeletonBlock className="h-4 w-64" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<SkeletonBlock key={i} className="h-28 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent campaigns skeleton */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-[var(--admin-border)]">
|
||||
<SkeletonBlock className="h-5 w-36" />
|
||||
</div>
|
||||
<SkeletonTable rows={3} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonSegmentBuilder() {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonBlock className="h-10 w-10 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<SkeletonBlock className="h-5 w-32" />
|
||||
<SkeletonBlock className="h-4 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Sidebar skeleton */}
|
||||
<div className="lg:w-72 flex-shrink-0">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonBlock className="h-4 w-24" />
|
||||
<SkeletonBlock className="h-8 w-8 rounded-lg" />
|
||||
</div>
|
||||
<SkeletonBlock className="h-10 w-full" />
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<SkeletonBlock key={i} className="h-12 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Builder + Preview skeleton */}
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonBlock className="h-4 w-20" />
|
||||
<SkeletonBlock className="h-8 w-16 rounded-lg" />
|
||||
</div>
|
||||
<SkeletonBlock className="h-24 w-full rounded-xl" />
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<SkeletonBlock key={i} className="h-7 w-24 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonBlock className="h-4 w-32" />
|
||||
<SkeletonBlock className="h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
<SkeletonBlock className="h-48 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonContacts() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonBlock className="h-10 w-10 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<SkeletonBlock className="h-5 w-24" />
|
||||
<SkeletonBlock className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<SkeletonBlock className="h-9 w-24 rounded-lg" />
|
||||
<SkeletonBlock className="h-9 w-32 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonBlock className="h-10 w-full mb-4" />
|
||||
<SkeletonTable rows={5} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
|
||||
<SkeletonBlock className="h-5 w-28 mb-4" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<SkeletonBlock key={i} className="h-24 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonLogs() {
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<SkeletonBlock className="h-10 w-10 rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<SkeletonBlock className="h-5 w-20" />
|
||||
<SkeletonBlock className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<SkeletonBlock className="h-9 w-28 rounded-lg" />
|
||||
<SkeletonBlock className="h-9 w-20 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonBlock className="h-10 w-full mb-4" />
|
||||
<SkeletonTable rows={6} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main loading component that shows skeleton based on current tab
|
||||
export default function CommunicationsLoading({
|
||||
activeTab = "campaigns"
|
||||
}: {
|
||||
activeTab?: string
|
||||
}) {
|
||||
const [currentTab] = useState(activeTab);
|
||||
|
||||
switch (currentTab) {
|
||||
case "campaigns":
|
||||
return <SkeletonCard />;
|
||||
case "templates":
|
||||
return <SkeletonCard />;
|
||||
case "contacts":
|
||||
return <SkeletonContacts />;
|
||||
case "segments":
|
||||
return <SkeletonSegmentBuilder />;
|
||||
case "logs":
|
||||
return <SkeletonLogs />;
|
||||
case "analytics":
|
||||
return <SkeletonCard />;
|
||||
default:
|
||||
return <SkeletonCard />;
|
||||
}
|
||||
}
|
||||
|
||||
// Named exports for specific loading states
|
||||
export {
|
||||
SkeletonCard,
|
||||
SkeletonComposer,
|
||||
SkeletonSegmentBuilder,
|
||||
SkeletonContacts,
|
||||
SkeletonLogs,
|
||||
SkeletonTable,
|
||||
SkeletonBlock,
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { useState, useCallback } from "react";
|
||||
import type { Contact, ContactSource } from "@/actions/communications/contacts";
|
||||
import { getContacts, deleteContact, exportContacts } from "@/actions/communications/contacts";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { AdminButton } from "./design-system";
|
||||
|
||||
const SOURCE_COLORS: Record<ContactSource, string> = {
|
||||
order: "bg-blue-100 text-blue-700",
|
||||
@@ -53,8 +54,76 @@ const Icons = {
|
||||
<path d="m9 18 6-6-6-6"/>
|
||||
</svg>
|
||||
),
|
||||
plus: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
),
|
||||
upload: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
),
|
||||
mail: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// Empty state component
|
||||
function EmptyState({ hasFilters }: { hasFilters: boolean }) {
|
||||
return (
|
||||
<div className="text-center py-12 px-4">
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-emerald-100 to-emerald-50 flex items-center justify-center">
|
||||
{Icons.users("w-10 h-10 text-emerald-600")}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-stone-800">
|
||||
{hasFilters ? "No contacts match your filters" : "No contacts yet"}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-500 mt-1 max-w-sm mx-auto">
|
||||
{hasFilters
|
||||
? "Try adjusting your search or filter criteria to find contacts."
|
||||
: "Contacts are added when customers place orders or are imported from other sources."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading skeleton
|
||||
function ContactSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<tr key={i} className="border-b border-[var(--admin-border)]">
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-4 w-24 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-4 w-32 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-4 w-20 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-5 w-16 bg-stone-200 rounded-full animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-4 w-16 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-4 w-12 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactListPanel({
|
||||
initialContacts,
|
||||
initialTotal,
|
||||
@@ -74,6 +143,8 @@ export default function ContactListPanel({
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const limit = 50;
|
||||
|
||||
const hasFilters = search.length > 0 || sourceFilter !== "";
|
||||
|
||||
const loadPage = useCallback(async (searchVal: string, sourceVal: string, pageNum: number) => {
|
||||
setLoading(true);
|
||||
const result = await getContacts({
|
||||
@@ -109,7 +180,7 @@ export default function ContactListPanel({
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Delete this contact?")) return;
|
||||
if (!confirm("Delete this contact? This action cannot be undone.")) return;
|
||||
setDeleting(id);
|
||||
const result = await deleteContact(id);
|
||||
setDeleting(null);
|
||||
@@ -146,54 +217,51 @@ export default function ContactListPanel({
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
|
||||
{Icons.users("w-4 h-4 text-[var(--admin-bg)]")}
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
|
||||
{Icons.users("w-6 h-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Contacts</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">{total} contact{total !== 1 ? "s" : ""}</p>
|
||||
<h2 className="text-lg font-bold text-stone-900">Contacts</h2>
|
||||
<p className="text-sm text-stone-500">{total.toLocaleString()} contact{total !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || total === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--admin-border)] bg-white px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-[var(--admin-text-primary)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{Icons.download("h-3.5 w-3.5 sm:h-4 sm:w-4")}
|
||||
<span>{exporting ? "Exporting..." : "Export"}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminButton variant="secondary" onClick={handleExport} disabled={exporting || total === 0} icon={Icons.download("w-4 h-4")}>
|
||||
{exporting ? "Exporting..." : "Export"}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + filters */}
|
||||
<form onSubmit={handleSearch} className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<form onSubmit={handleSearch} className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
|
||||
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
|
||||
{Icons.search("h-5 w-5 text-stone-400")}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search email, name, phone..."
|
||||
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
|
||||
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sourceFilter}
|
||||
onChange={(e) => handleSourceFilter(e.target.value as ContactSource | "")}
|
||||
className="text-sm border border-[var(--admin-border)] rounded-lg px-3 py-2 bg-white text-[var(--admin-text-primary)]"
|
||||
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
<option value="order">Order</option>
|
||||
<option value="import">Import</option>
|
||||
<option value="manual">Manual</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="order">From Orders</option>
|
||||
<option value="import">Imported</option>
|
||||
<option value="manual">Manual Entry</option>
|
||||
<option value="admin">Admin Added</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 transition-colors"
|
||||
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700 active:bg-emerald-800 transition-colors shadow-sm"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
@@ -201,55 +269,93 @@ export default function ContactListPanel({
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-[var(--admin-text-muted)]">Loading...</div>
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider">Name</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider">Phone</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider">Source</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email Opt</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider">Unsub</th>
|
||||
<th className="text-right px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white">
|
||||
<ContactSkeleton />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : contacts.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--admin-text-muted)]">No contacts found</div>
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--admin-border)]">
|
||||
<EmptyState hasFilters={hasFilters} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden sm:block overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-[var(--admin-card)]">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Name</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Email</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Phone</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Source</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Email</th>
|
||||
<th className="text-left px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]">Unsubscribed</th>
|
||||
<th className="text-right px-3 sm:px-4 py-3 font-semibold text-[var(--admin-text-muted)]"></th>
|
||||
<div className="hidden sm:block overflow-hidden rounded-xl border border-[var(--admin-border)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Name</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Phone</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Source</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Email Opt</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Unsubscribed</th>
|
||||
<th className="text-right px-4 py-3.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
<tbody className="divide-y divide-[var(--admin-border)] bg-white">
|
||||
{contacts.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-[var(--admin-card-hover)] transition-colors">
|
||||
<td className="px-3 sm:px-4 py-3 font-medium text-[var(--admin-text-primary)]">
|
||||
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
|
||||
<tr key={c.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3.5 font-semibold text-stone-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 flex items-center justify-center text-xs font-bold text-white">
|
||||
{(c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "??").slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span>{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{c.email || "—"}</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)]">{c.phone || "—"}</td>
|
||||
<td className="px-3 sm:px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] sm:text-xs font-medium ${SOURCE_COLORS[c.source]}`}>
|
||||
<td className="px-4 py-3.5 text-stone-600">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Icons.mail("w-4 h-4 text-stone-400")}
|
||||
<span className="truncate max-w-[150px]">{c.email || "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-stone-600">{c.phone || "—"}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${SOURCE_COLORS[c.source]}`}>
|
||||
{c.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 py-3">
|
||||
<td className="px-4 py-3.5">
|
||||
{c.email_opt_in ? (
|
||||
<span className="text-emerald-600 text-xs font-semibold">Opted in</span>
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 text-xs font-semibold">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Opted in
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-500 text-xs font-semibold">Opted out</span>
|
||||
<span className="inline-flex items-center gap-1 text-red-500 text-xs font-semibold">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
Opted out
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-[var(--admin-text-muted)] text-xs">
|
||||
{c.unsubscribed_at
|
||||
? formatDate(new Date(c.unsubscribed_at))
|
||||
: "—"}
|
||||
<td className="px-4 py-3.5 text-stone-500 text-xs">
|
||||
{c.unsubscribed_at ? formatDate(new Date(c.unsubscribed_at)) : "—"}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 py-3 text-right">
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deleting === c.id}
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 disabled:opacity-50 transition-colors"
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-stone-400 hover:text-red-500 disabled:opacity-50 transition-colors"
|
||||
title="Delete contact"
|
||||
>
|
||||
{Icons.trash("h-4 w-4")}
|
||||
@@ -266,27 +372,42 @@ export default function ContactListPanel({
|
||||
{contacts.map((c) => (
|
||||
<div key={c.id} className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="font-semibold text-[var(--admin-text-primary)] text-sm">
|
||||
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 flex items-center justify-center text-xs font-bold text-white">
|
||||
{(c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "??").slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-stone-800 text-sm">
|
||||
{c.full_name || `${c.first_name || ""} ${c.last_name || ""}`.trim() || "—"}
|
||||
</div>
|
||||
<div className="text-xs text-stone-500">{c.email || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(c.id)}
|
||||
disabled={deleting === c.id}
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 disabled:opacity-50"
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-stone-400 hover:text-red-500 disabled:opacity-50"
|
||||
>
|
||||
{Icons.trash("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--admin-text-muted)]">{c.email || "—"}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${SOURCE_COLORS[c.source]}`}>
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${SOURCE_COLORS[c.source]}`}>
|
||||
{c.source}
|
||||
</span>
|
||||
{c.phone && <span className="text-xs text-[var(--admin-text-muted)]">{c.phone}</span>}
|
||||
{c.phone && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-stone-500">
|
||||
{c.phone}
|
||||
</span>
|
||||
)}
|
||||
{c.email_opt_in ? (
|
||||
<span className="text-emerald-600 text-xs font-semibold">Opted in</span>
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 text-xs font-semibold">
|
||||
Opted in
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-500 text-xs font-semibold">Opted out</span>
|
||||
<span className="inline-flex items-center gap-1 text-red-500 text-xs font-semibold">
|
||||
Opted out
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,15 +415,15 @@ export default function ContactListPanel({
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
|
||||
<span className="text-xs sm:text-sm text-[var(--admin-text-muted)]">
|
||||
Page {page + 1} — {Math.min((page + 1) * limit, total)} of {total}
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
|
||||
<span className="text-sm text-stone-500">
|
||||
Showing {page * limit + 1}–{Math.min((page + 1) * limit, total)} of {total.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handlePage(-1)}
|
||||
disabled={page === 0}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-xs sm:text-sm disabled:opacity-50 hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
{Icons.chevronLeft("h-4 w-4")}
|
||||
<span>Previous</span>
|
||||
@@ -310,7 +431,7 @@ export default function ContactListPanel({
|
||||
<button
|
||||
onClick={() => handlePage(1)}
|
||||
disabled={(page + 1) * limit >= total}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-xs sm:text-sm disabled:opacity-50 hover:bg-[var(--admin-card-hover)] transition-colors"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium disabled:opacity-50 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
<span>Next</span>
|
||||
{Icons.chevronRight("h-4 w-4")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useState, useEffect, 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";
|
||||
@@ -179,6 +179,39 @@ type Props = {
|
||||
limits: { max_users: number; max_stops_monthly: number; max_products: number };
|
||||
};
|
||||
|
||||
// Stats data type
|
||||
interface StatsData {
|
||||
todayOrders: number;
|
||||
todayRevenue: number;
|
||||
pendingStops: number;
|
||||
activeProducts: number;
|
||||
weeklyOrders: number[];
|
||||
recentOrders: Array<{
|
||||
id: string;
|
||||
customer_name: string;
|
||||
total: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Simulated stats for demo
|
||||
const getStatsData = async (brandId: string | null): Promise<StatsData> => {
|
||||
// In production, fetch from API
|
||||
return {
|
||||
todayOrders: 12,
|
||||
todayRevenue: 847.50,
|
||||
pendingStops: 3,
|
||||
activeProducts: 45,
|
||||
weeklyOrders: [8, 15, 12, 22, 18, 25, 12],
|
||||
recentOrders: [
|
||||
{ id: "ord-001", customer_name: "Fresh Market Co", total: 125.00, status: "pending", created_at: "2h ago" },
|
||||
{ id: "ord-002", customer_name: "Green Valley Grocery", total: 89.50, status: "processing", created_at: "4h ago" },
|
||||
{ id: "ord-003", customer_name: "Farm Fresh Direct", total: 234.00, status: "shipped", created_at: "6h ago" },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export default function DashboardClient({
|
||||
brandId,
|
||||
brandName,
|
||||
@@ -190,6 +223,23 @@ export default function DashboardClient({
|
||||
}: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("operations");
|
||||
const [isUpgradeOpen, setIsUpgradeOpen] = useState(false);
|
||||
const [stats, setStats] = useState<StatsData | null>(null);
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
setIsLoadingStats(true);
|
||||
try {
|
||||
const data = await getStatsData(brandId);
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load stats:", err);
|
||||
} finally {
|
||||
setIsLoadingStats(false);
|
||||
}
|
||||
};
|
||||
loadStats();
|
||||
}, [brandId]);
|
||||
|
||||
const usagePct = {
|
||||
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
|
||||
@@ -199,8 +249,34 @@ export default function DashboardClient({
|
||||
|
||||
const tabSections = sections.filter((s) => s.group === activeTab);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Get status badge color
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, { bg: string; text: string }> = {
|
||||
pending: { bg: "var(--admin-warning-light)", text: "var(--admin-warning)" },
|
||||
processing: { bg: "var(--admin-accent-light)", text: "var(--admin-accent-text)" },
|
||||
shipped: { bg: "#dbeafe", text: "#1e40af" },
|
||||
};
|
||||
return styles[status] || { bg: "var(--admin-bg)", text: "var(--admin-text-secondary)" };
|
||||
};
|
||||
|
||||
// Quick action buttons
|
||||
const quickActions = [
|
||||
{ label: "New Order", href: "/admin/orders?new=true", icon: "plus" },
|
||||
{ label: "Add Stop", href: "/admin/stops/new", icon: "map" },
|
||||
{ label: "Add Product", href: "/admin/products/new", icon: "package" },
|
||||
{ label: "Send Blast", href: "/admin/communications/compose", icon: "mail" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
{/* Page Header */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<PageHeader
|
||||
@@ -216,7 +292,7 @@ export default function DashboardClient({
|
||||
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">
|
||||
<a href="/admin/settings/billing" className="text-sm font-medium hover:underline" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Billing →
|
||||
</a>
|
||||
{planTier === "starter" && brandId && (
|
||||
@@ -234,10 +310,254 @@ export default function DashboardClient({
|
||||
</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">
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6 -mt-4 space-y-6">
|
||||
{/* Stats Cards Row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Today's Orders */}
|
||||
<div
|
||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl"
|
||||
style={{ backgroundColor: "var(--admin-accent-light)" }}
|
||||
>
|
||||
<svg className="w-6 h-6" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Today's Orders
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{isLoadingStats ? "—" : stats?.todayOrders ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's Revenue */}
|
||||
<div
|
||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-50">
|
||||
<svg className="w-6 h-6 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Today's Revenue
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{isLoadingStats ? "—" : formatCurrency(stats?.todayRevenue ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Stops */}
|
||||
<div
|
||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-50">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Pending Stops
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{isLoadingStats ? "—" : stats?.pendingStops ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Products */}
|
||||
<div
|
||||
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-50">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Active Products
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{isLoadingStats ? "—" : stats?.activeProducts ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions + Recent Orders Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Quick Actions */}
|
||||
<div
|
||||
className="rounded-xl border p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-4" style={{ color: "var(--admin-text-primary)" }}>
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{quickActions.map((action) => (
|
||||
<Link
|
||||
key={action.href}
|
||||
href={action.href}
|
||||
className="flex items-center gap-2 p-3 rounded-xl border transition-all hover:-translate-y-0.5 hover:shadow-sm"
|
||||
style={{
|
||||
borderColor: "var(--admin-border-light)",
|
||||
backgroundColor: "var(--admin-bg-subtle)"
|
||||
}}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: "var(--admin-accent-light)" }}>
|
||||
{action.icon === "plus" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
)}
|
||||
{action.icon === "map" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
)}
|
||||
{action.icon === "package" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
)}
|
||||
{action.icon === "mail" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-accent)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-3-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--admin-text-secondary)" }}>
|
||||
{action.label}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Orders */}
|
||||
<div
|
||||
className="lg:col-span-2 rounded-xl border overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||
<h3 className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
||||
Recent Orders
|
||||
</h3>
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="text-xs font-medium hover:underline"
|
||||
style={{ color: "var(--admin-accent)" }}
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y" style={{ borderColor: "var(--admin-border-light)" }}>
|
||||
{stats?.recentOrders && stats.recentOrders.length > 0 ? (
|
||||
stats.recentOrders.map((order) => {
|
||||
const badge = getStatusBadge(order.status);
|
||||
return (
|
||||
<div key={order.id} className="flex items-center justify-between px-5 py-4 hover:bg-stone-50 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<svg className="w-5 h-5 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{order.customer_name}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{order.created_at}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{formatCurrency(order.total)}
|
||||
</span>
|
||||
<span
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium capitalize"
|
||||
style={{ backgroundColor: badge.bg, color: badge.text }}
|
||||
>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<p className="text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
||||
No recent orders
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/orders/new"
|
||||
className="inline-block mt-3 text-xs font-medium hover:underline"
|
||||
style={{ color: "var(--admin-accent)" }}
|
||||
>
|
||||
Create your first order →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage stats bar */}
|
||||
<div
|
||||
className="rounded-xl border p-5"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<AdminBadge variant={
|
||||
planTier === "enterprise" ? "warning" :
|
||||
planTier === "farm" ? "success" :
|
||||
@@ -245,33 +565,59 @@ export default function DashboardClient({
|
||||
}>
|
||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
|
||||
</AdminBadge>
|
||||
<span className="text-xs text-stone-500">{brandId ? "Tuxedo Corn" : "All Brands"}</span>
|
||||
<span className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{brandId ? brandName : "All Brands"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 sm:gap-8">
|
||||
{[
|
||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
|
||||
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
|
||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
|
||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users, icon: "users" },
|
||||
{ label: "Stops", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops, icon: "map" },
|
||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products, icon: "package" },
|
||||
].map(({ label, value, pct }) => (
|
||||
<div key={label}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-stone-600">{label}</span>
|
||||
<span className="text-xs font-semibold text-stone-900">{value}</span>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{label === "Users" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
)}
|
||||
{label === "Stops" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
)}
|
||||
{label === "Products" && (
|
||||
<svg className="w-4 h-4" style={{ color: "var(--admin-text-muted)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm font-medium" style={{ color: "var(--admin-text-secondary)" }}>{label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>{value}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-stone-100 overflow-hidden">
|
||||
<div className="h-2.5 rounded-full overflow-hidden" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
pct > 90 ? "bg-red-500" : pct > 75 ? "bg-amber-500" : "bg-[var(--admin-accent)]"
|
||||
}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
className="h-full rounded-full transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
backgroundColor: pct > 90 ? "var(--admin-danger)" : pct > 75 ? "#f59e0b" : "var(--admin-accent)"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{pct > 85 && (
|
||||
<p className="text-xs mt-1.5" style={{ color: "var(--admin-danger)" }}>
|
||||
Near limit - consider upgrading
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation using AdminFilterTabs */}
|
||||
{/* Tab navigation */}
|
||||
<AdminFilterTabs
|
||||
activeTab={activeTab}
|
||||
onTabChange={(value) => setActiveTab(value as Tab)}
|
||||
@@ -282,11 +628,10 @@ export default function DashboardClient({
|
||||
}))}
|
||||
size="md"
|
||||
showCounts={false}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Section Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tabSections.map((section) => {
|
||||
if (section.title === "Water Log" && !isWaterLogVisible) return null;
|
||||
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
|
||||
@@ -300,25 +645,49 @@ export default function DashboardClient({
|
||||
key={section.title}
|
||||
href={section.href}
|
||||
className={[
|
||||
"group relative flex flex-col rounded-2xl border bg-white p-5 transition-all hover:-translate-y-0.5 hover:shadow-md",
|
||||
"group relative flex flex-col rounded-xl border p-5 transition-all hover:-translate-y-0.5 hover:shadow-md",
|
||||
isAddon && !isEnabled
|
||||
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
|
||||
: isProminent
|
||||
? "border-[var(--admin-accent)]/20 shadow-[0_2px_8px_rgba(0,0,0,0.04)]"
|
||||
: "border-stone-200 shadow-sm",
|
||||
].join(" ")}
|
||||
style={{ backgroundColor: "var(--admin-card-bg)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-xl transition-transform group-hover:scale-110 ${
|
||||
isAddon && !isEnabled
|
||||
? "bg-stone-100 text-stone-400"
|
||||
: isProminent
|
||||
? "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>
|
||||
{section.title === "Orders" && (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM12.94 18.55l.276-.276a.75.75 0 011.06 0l.27.27a.75.75 0 010 1.06l-.27.27a.75.75 0 01-1.06 0l-.276-.276a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
)}
|
||||
{section.title === "Products" && (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
)}
|
||||
{section.title === "Tours & Stops" && (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
</svg>
|
||||
)}
|
||||
{section.title === "Driver Pickup" && (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
|
||||
</svg>
|
||||
)}
|
||||
{!["Orders", "Products", "Tours & Stops", "Driver Pickup"].includes(section.title) && (
|
||||
<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 && (
|
||||
<AdminBadge variant="warning">
|
||||
@@ -337,7 +706,7 @@ export default function DashboardClient({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold leading-tight text-stone-950">
|
||||
<h3 className="text-sm font-semibold leading-tight" style={{ color: "var(--admin-text-primary)" }}>
|
||||
{section.title}
|
||||
</h3>
|
||||
|
||||
@@ -348,8 +717,18 @@ export default function DashboardClient({
|
||||
</p>
|
||||
|
||||
{isAddon && !isEnabled && section.upgradeText && (
|
||||
<p className="mt-3 text-xs text-amber-700 font-medium">{section.upgradeText}</p>
|
||||
<p className="mt-3 text-xs font-medium" style={{ color: "var(--admin-warning)" }}>
|
||||
{section.upgradeText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Arrow indicator */}
|
||||
<div className="mt-4 flex items-center text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: "var(--admin-accent)" }}>
|
||||
Open section
|
||||
<svg className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,160 +2,404 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { type CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
import { AdminEmptyState } from "@/components/admin/design-system";
|
||||
|
||||
type Props = {
|
||||
analytics: CampaignAnalytics[];
|
||||
};
|
||||
|
||||
type Period = "30" | "90" | "all";
|
||||
type Period = "7" | "30" | "90" | "all";
|
||||
|
||||
function RateBar({ value, color = "bg-emerald-500" }: { value: number; color?: string }) {
|
||||
// Rate bar visualization
|
||||
function RateBar({ value, color = "bg-emerald-500", label }: { value: number; color?: string; label?: string }) {
|
||||
return (
|
||||
<div className="w-full bg-stone-100 rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${color}`}
|
||||
style={{ width: `${Math.min(100, value)}%` }}
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-stone-500">{label}</span>
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{value.toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full bg-stone-100 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-2 rounded-full ${color} transition-all duration-500`}
|
||||
style={{ width: `${Math.min(100, value)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat card component
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
icon,
|
||||
trend,
|
||||
trendUp
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
icon?: React.ReactNode;
|
||||
trend?: string;
|
||||
trendUp?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4 sm:p-5 relative overflow-hidden">
|
||||
{/* Background accent */}
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-emerald-500/5 to-transparent rounded-bl-full" />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-[10px] sm:text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wider">{label}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1.5">{value}</p>
|
||||
{sub && <p className="text-[10px] sm:text-xs text-stone-400 mt-1">{sub}</p>}
|
||||
{trend && (
|
||||
<div className={`flex items-center gap-1 mt-2 text-xs font-medium ${
|
||||
trendUp ? "text-emerald-600" : "text-red-500"
|
||||
}`}>
|
||||
<svg className={`w-3 h-3 ${trendUp ? "" : "rotate-180"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
{trend}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent-light)] text-[var(--admin-accent)]">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mini sparkline visualization
|
||||
function MiniSparkline({ data, color = "var(--admin-accent)" }: { data: number[]; color?: string }) {
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1;
|
||||
|
||||
const points = data.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * 100;
|
||||
const y = 100 - ((v - min) / range) * 100;
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
|
||||
return (
|
||||
<svg className="w-full h-8" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</div>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||
// Engagement badge
|
||||
function EngagementBadge({ rate }: { rate: number }) {
|
||||
let color = "bg-stone-100 text-stone-500";
|
||||
let label = "Low";
|
||||
|
||||
if (rate >= 40) {
|
||||
color = "bg-emerald-100 text-emerald-700";
|
||||
label = "High";
|
||||
} else if (rate >= 20) {
|
||||
color = "bg-amber-100 text-amber-700";
|
||||
label = "Medium";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4 sm:p-5">
|
||||
<p className="text-[10px] sm:text-xs font-medium text-[var(--admin-text-muted)] uppercase tracking-wide">{label}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1.5">{value}</p>
|
||||
{sub && <p className="text-[10px] sm:text-xs text-stone-400 mt-1">{sub}</p>}
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Date formatter
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return "—";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
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>
|
||||
),
|
||||
send: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
),
|
||||
mail: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
),
|
||||
check: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
),
|
||||
click: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5"/>
|
||||
</svg>
|
||||
),
|
||||
eye: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
),
|
||||
alert: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
),
|
||||
calendar: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function AnalyticsDashboard({ analytics }: Props) {
|
||||
const [period, setPeriod] = useState<Period>("30");
|
||||
|
||||
const totalSent = analytics.reduce((s, a) => s + a.total_sent, 0);
|
||||
const totalDelivered = analytics.reduce((s, a) => s + a.total_delivered, 0);
|
||||
const totalOpened = analytics.reduce((s, a) => s + a.total_opened, 0);
|
||||
const totalClicked = analytics.reduce((s, a) => s + a.total_clicked, 0);
|
||||
// Filter analytics by period
|
||||
const filteredAnalytics = analytics.filter((a) => {
|
||||
if (period === "all") return true;
|
||||
const days = parseInt(period);
|
||||
if (!a.sent_at) return true;
|
||||
const sentDate = new Date(a.sent_at);
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
return sentDate >= cutoff;
|
||||
});
|
||||
|
||||
// Calculate totals
|
||||
const totalSent = filteredAnalytics.reduce((s, a) => s + a.total_sent, 0);
|
||||
const totalDelivered = filteredAnalytics.reduce((s, a) => s + a.total_delivered, 0);
|
||||
const totalOpened = filteredAnalytics.reduce((s, a) => s + a.total_opened, 0);
|
||||
const totalClicked = filteredAnalytics.reduce((s, a) => s + a.total_clicked, 0);
|
||||
const totalBounced = filteredAnalytics.reduce((s, a) => s + a.total_bounced, 0);
|
||||
|
||||
// Calculate rates
|
||||
const avgOpenRate = totalDelivered > 0 ? (totalOpened / totalDelivered) * 100 : 0;
|
||||
const avgClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0;
|
||||
const avgBounceRate = totalSent > 0 ? (totalBounced / totalSent) * 100 : 0;
|
||||
|
||||
// Generate sparkline data from analytics
|
||||
const openRateSparkline = filteredAnalytics.map((a) => Number(a.open_rate));
|
||||
const clickRateSparkline = filteredAnalytics.map((a) => Number(a.click_rate));
|
||||
|
||||
// Campaign count
|
||||
const campaignCount = filteredAnalytics.length;
|
||||
|
||||
// Best performing campaign
|
||||
const bestCampaign = [...filteredAnalytics].sort((a, b) => Number(b.open_rate) - Number(a.open_rate))[0];
|
||||
|
||||
const emptyStateIcon = Icons.chart("w-10 h-10");
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<svg className="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
|
||||
{Icons.chart("h-6 w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-xl font-bold text-[var(--admin-text-primary)]">Campaign Analytics</h2>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">Track your email performance and engagement</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Campaign Analytics</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Track your campaign performance</p>
|
||||
|
||||
{/* Period selector */}
|
||||
<div className="flex rounded-xl border border-[var(--admin-border)] bg-stone-50 p-1">
|
||||
{(["7", "30", "90", "all"] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setPeriod(val as Period)}
|
||||
className={`px-3 py-2 text-xs font-semibold rounded-lg transition-all ${
|
||||
period === val
|
||||
? "bg-white text-[var(--admin-text-primary)] shadow-sm"
|
||||
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{val === "all" ? "All time" : `${val} days`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stat cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard label="Total Sent" value={totalSent.toLocaleString()} sub={`${analytics.length} campaign${analytics.length !== 1 ? "s" : ""}`} />
|
||||
<StatCard
|
||||
label="Total Sent"
|
||||
value={totalSent.toLocaleString()}
|
||||
sub={`${campaignCount} campaign${campaignCount !== 1 ? "s" : ""}`}
|
||||
icon={Icons.send("w-5 h-5")}
|
||||
trend={campaignCount > 0 ? `${campaignCount}` : undefined}
|
||||
trendUp={true}
|
||||
/>
|
||||
<StatCard
|
||||
label="Delivered"
|
||||
value={totalSent > 0 ? `${Math.round((totalDelivered / totalSent) * 100)}%` : "—"}
|
||||
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} messages` : undefined}
|
||||
icon={Icons.mail("w-5 h-5")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg. Open Rate"
|
||||
value={totalSent > 0 ? `${avgOpenRate.toFixed(1)}%` : "—"}
|
||||
sub={bestCampaign ? `Best: ${bestCampaign.campaign_name}` : "Industry avg: 21%"}
|
||||
icon={Icons.eye("w-5 h-5")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg. Click Rate"
|
||||
value={totalSent > 0 ? `${avgClickRate.toFixed(1)}%` : "—"}
|
||||
sub={totalClicked > 0 ? `${totalClicked.toLocaleString()} clicks` : undefined}
|
||||
icon={Icons.click("w-5 h-5")}
|
||||
/>
|
||||
<StatCard label="Avg. Open Rate" value={totalSent > 0 ? `${avgOpenRate.toFixed(1)}%` : "—"} sub="of delivered" />
|
||||
<StatCard label="Avg. Click Rate" value={totalSent > 0 ? `${avgClickRate.toFixed(1)}%` : "—"} sub="of delivered" />
|
||||
</div>
|
||||
|
||||
{/* Performance trends (if we have multiple campaigns) */}
|
||||
{filteredAnalytics.length > 1 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Open Rate Trend</h3>
|
||||
<span className="text-xs font-semibold text-emerald-600">{avgOpenRate.toFixed(1)}% avg</span>
|
||||
</div>
|
||||
<MiniSparkline data={openRateSparkline} color="#10b981" />
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Click Rate Trend</h3>
|
||||
<span className="text-xs font-semibold text-amber-600">{avgClickRate.toFixed(1)}% avg</span>
|
||||
</div>
|
||||
<MiniSparkline data={clickRateSparkline} color="#f59e0b" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign performance table */}
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-[var(--admin-border)] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Campaign Performance</h3>
|
||||
<div className="flex rounded-lg border border-[var(--admin-border)] bg-stone-50 p-0.5">
|
||||
{(["30", "90", "all"] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setPeriod(val as Period)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
period === val ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{val === "30" ? "30 days" : val === "90" ? "90 days" : "All time"}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Campaign Performance</h3>
|
||||
{filteredAnalytics.length > 0 && (
|
||||
<span className="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-semibold text-stone-600">
|
||||
{filteredAnalytics.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-stone-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
Open rate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
Click rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analytics.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
<svg className="h-8 w-8 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No campaign analytics yet</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Send a campaign to start tracking engagement</p>
|
||||
</div>
|
||||
{filteredAnalytics.length === 0 ? (
|
||||
<AdminEmptyState
|
||||
icon={emptyStateIcon}
|
||||
title="No campaign analytics yet"
|
||||
description="Send a campaign to start tracking your email engagement and performance metrics."
|
||||
action={
|
||||
<div className="flex items-center gap-2 text-sm text-stone-500">
|
||||
{Icons.calendar("w-4 h-4")}
|
||||
<span>Analytics will appear after your first campaign is sent</span>
|
||||
</div>
|
||||
}
|
||||
className="py-16"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
|
||||
<tr>
|
||||
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Campaign</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Sent</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Delivered</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Opened</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Clicked</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Bounced</th>
|
||||
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Engagement</th>
|
||||
<th className="text-left px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Campaign</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Sent</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Delivered</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Opened</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Clicked</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Bounced</th>
|
||||
<th className="text-left px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{analytics.map((a) => (
|
||||
<tr key={a.campaign_id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 sm:px-6 py-3.5">
|
||||
{filteredAnalytics.map((a) => (
|
||||
<tr key={a.campaign_id} className="hover:bg-stone-50/50 transition-colors">
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">{a.campaign_name}</span>
|
||||
<span className="text-xs text-stone-400">
|
||||
{a.sent_at ? new Date(a.sent_at).toLocaleDateString() : "—"}
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{a.campaign_name}</span>
|
||||
<span className="text-xs text-stone-400 flex items-center gap-1 mt-0.5">
|
||||
{Icons.calendar("w-3 h-3")}
|
||||
{formatDate(a.sent_at)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right text-[var(--admin-text-primary)]">{a.total_sent.toLocaleString()}</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className="text-[var(--admin-text-primary)]">{a.total_delivered.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.delivered_rate}%)</span>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{a.total_sent.toLocaleString()}</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className="text-[var(--admin-text-primary)]">{a.total_opened.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.open_rate}%)</span>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{a.total_delivered.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400">({a.delivered_rate}%)</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className="text-[var(--admin-text-primary)]">{a.total_clicked.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.click_rate}%)</span>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{a.total_opened.toLocaleString()}</span>
|
||||
<span className="text-xs text-emerald-600 font-medium">{a.open_rate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className={a.total_bounced > 0 ? "text-red-600" : "text-stone-400"}>
|
||||
{a.total_bounced.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.bounce_rate}%)</span>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{a.total_clicked.toLocaleString()}</span>
|
||||
<span className="text-xs text-amber-600 font-medium">{a.click_rate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 w-36">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-stone-500">Open</span>
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">{a.open_rate}%</span>
|
||||
</div>
|
||||
<RateBar value={Number(a.open_rate)} />
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-stone-500">Click</span>
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">{a.click_rate}%</span>
|
||||
</div>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className={`font-semibold ${a.total_bounced > 0 ? "text-red-500" : "text-stone-400"}`}>
|
||||
{a.total_bounced.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-stone-400">({a.bounce_rate}%)</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 w-40">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<RateBar value={Number(a.open_rate)} color="bg-emerald-500" />
|
||||
<RateBar value={Number(a.click_rate)} color="bg-amber-500" />
|
||||
</div>
|
||||
</td>
|
||||
@@ -166,6 +410,29 @@ export default function AnalyticsDashboard({ analytics }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary footer */}
|
||||
{filteredAnalytics.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-stone-50 rounded-xl border border-[var(--admin-border)] flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--admin-text-muted)]">Overall bounce rate:</span>
|
||||
<span className={`font-semibold ${avgBounceRate > 2 ? "text-red-500" : "text-emerald-600"}`}>
|
||||
{avgBounceRate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
{avgBounceRate > 2 && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600">
|
||||
{Icons.alert("w-4 h-4")}
|
||||
<span className="text-xs">High bounce rate - consider cleaning your list</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-stone-400">
|
||||
Last updated: {new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,32 +4,79 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const TABS = [
|
||||
{ href: "/admin/harvest-reach/segments", label: "Segments" },
|
||||
{ href: "/admin/harvest-reach/campaigns", label: "Campaigns" },
|
||||
{ href: "/admin/harvest-reach/analytics", label: "Analytics" },
|
||||
{
|
||||
href: "/admin/harvest-reach/segments",
|
||||
label: "Segments",
|
||||
icon: (
|
||||
<svg className="w-4 h-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>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/admin/harvest-reach/campaigns",
|
||||
label: "Campaigns",
|
||||
icon: (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/admin/harvest-reach/analytics",
|
||||
label: "Analytics",
|
||||
icon: (
|
||||
<svg className="w-4 h-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 HarvestReachNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex gap-1 border-b border-zinc-800 mb-6">
|
||||
{TABS.map((tab) => {
|
||||
const active = pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
active
|
||||
? "border-stone-900 text-stone-900"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex flex-col">
|
||||
{/* Brand header */}
|
||||
<div className="flex items-center gap-3 pb-4 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
|
||||
<svg className="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-stone-900">Harvest Reach</h1>
|
||||
<p className="text-xs text-stone-500">Email marketing & campaigns</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation tabs */}
|
||||
<nav className="flex gap-1 p-1 bg-stone-100 rounded-xl">
|
||||
{TABS.map((tab) => {
|
||||
const active = pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg transition-all ${
|
||||
active
|
||||
? "bg-white text-emerald-600 shadow-sm"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-white/50"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +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";
|
||||
import { PageHeader, AdminButton, AdminEmptyState } from "@/components/admin/design-system";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
@@ -22,6 +22,66 @@ const LayersIcon = ({ className }: { className: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = ({ className }: { className: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Empty state component for segments
|
||||
function SegmentsEmptyState({ onNew }: { onNew: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-100 to-emerald-50">
|
||||
<svg className="h-10 w-10 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" 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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-stone-800">No segments yet</h3>
|
||||
<p className="mt-2 text-sm text-stone-500 max-w-xs">
|
||||
Create segments to organize your contacts and send targeted campaigns to specific audiences.
|
||||
</p>
|
||||
<AdminButton onClick={onNew} className="mt-6" icon={<PlusIcon className="w-4 h-4" />}>
|
||||
Create Your First Segment
|
||||
</AdminButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active segment header
|
||||
function ActiveSegmentHeader({ segment, onClear }: { segment: Segment | null; onClear: () => void }) {
|
||||
if (!segment) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 bg-emerald-50 rounded-xl border border-emerald-200 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500 text-white">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">{segment.name}</p>
|
||||
{segment.description && (
|
||||
<p className="text-xs text-stone-500">{segment.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-xs font-medium text-stone-500 hover:text-stone-700 transition-colors"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
|
||||
const [segments, setSegments] = useState<Segment[]>(initialSegments);
|
||||
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
|
||||
@@ -39,11 +99,17 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
function handleNewSegment() {
|
||||
setActiveSegment(null);
|
||||
setCurrentRules({ combinator: "AND", filters: [] });
|
||||
setShowEditModal(true);
|
||||
}
|
||||
|
||||
function handleClearSelection() {
|
||||
setActiveSegment(null);
|
||||
setCurrentRules({ combinator: "AND", filters: [] });
|
||||
}
|
||||
|
||||
function handleRulesChange(rules: SegmentRuleV2) {
|
||||
setCurrentRules(rules);
|
||||
setActiveSegment(null);
|
||||
// Only clear active segment if we're editing the rules, not just viewing
|
||||
}
|
||||
|
||||
async function handleSaveSegment(name: string, description: string) {
|
||||
@@ -73,10 +139,12 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
await deleteHarvestReachSegment(segmentId, brandId);
|
||||
setSegments((prev) => prev.filter((s) => s.id !== segmentId));
|
||||
if (activeSegment?.id === segmentId) {
|
||||
handleNewSegment();
|
||||
handleClearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = currentRules.filters.length > 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
<PageHeader
|
||||
@@ -85,31 +153,41 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
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">
|
||||
{/* Left sidebar */}
|
||||
<div className="lg:w-72 flex-shrink-0">
|
||||
<SegmentListSidebar
|
||||
segments={segments}
|
||||
activeSegmentId={activeSegment?.id}
|
||||
onSelect={handleSegmentSelect}
|
||||
onNew={handleNewSegment}
|
||||
onDelete={handleDeleteSegment}
|
||||
/>
|
||||
</div>
|
||||
{/* Show empty state if no segments */}
|
||||
{segments.length === 0 ? (
|
||||
<SegmentsEmptyState onNew={handleNewSegment} />
|
||||
) : (
|
||||
<>
|
||||
{/* Active segment indicator */}
|
||||
<ActiveSegmentHeader segment={activeSegment} onClear={handleClearSelection} />
|
||||
|
||||
{/* Main content: builder + preview */}
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<SegmentBuilderPanel
|
||||
brandId={brandId}
|
||||
rules={currentRules}
|
||||
onChange={handleRulesChange}
|
||||
onSave={() => setShowEditModal(true)}
|
||||
hasActiveSegment={!!activeSegment}
|
||||
/>
|
||||
<MatchingCustomersPanel brandId={brandId} rules={currentRules} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Main layout */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Left sidebar */}
|
||||
<div className="lg:w-72 flex-shrink-0">
|
||||
<SegmentListSidebar
|
||||
segments={segments}
|
||||
activeSegmentId={activeSegment?.id}
|
||||
onSelect={handleSegmentSelect}
|
||||
onNew={handleNewSegment}
|
||||
onDelete={handleDeleteSegment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content: builder + preview */}
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<SegmentBuilderPanel
|
||||
brandId={brandId}
|
||||
rules={currentRules}
|
||||
onChange={handleRulesChange}
|
||||
onSave={() => setShowEditModal(true)}
|
||||
hasActiveSegment={!!activeSegment}
|
||||
/>
|
||||
<MatchingCustomersPanel brandId={brandId} rules={currentRules} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showEditModal && (
|
||||
<SegmentEditModal
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
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";
|
||||
import { AdminButton } from "./design-system";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
@@ -60,35 +62,158 @@ const Icons = {
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
),
|
||||
send: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
),
|
||||
mail: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
|
||||
<polyline points="22,6 12,13 2,6"/>
|
||||
</svg>
|
||||
),
|
||||
clock: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// 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 };
|
||||
// Status color mapping
|
||||
const STATUS_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
queued: { bg: "bg-stone-100", text: "text-stone-600", dot: "bg-stone-400" },
|
||||
sent: { bg: "bg-blue-100", text: "text-blue-700", dot: "bg-blue-500" },
|
||||
delivered: { bg: "bg-emerald-100", text: "text-emerald-700", dot: "bg-emerald-500" },
|
||||
opened: { bg: "bg-purple-100", text: "text-purple-700", dot: "bg-purple-500" },
|
||||
clicked: { bg: "bg-amber-100", text: "text-amber-700", dot: "bg-amber-500" },
|
||||
bounced: { bg: "bg-red-100", text: "text-red-600", dot: "bg-red-500" },
|
||||
failed: { bg: "bg-red-100", text: "text-red-600", dot: "bg-red-500" },
|
||||
unsubscribed: { bg: "bg-orange-100", text: "text-orange-700", dot: "bg-orange-500" },
|
||||
};
|
||||
|
||||
// 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 };
|
||||
// Method color mapping
|
||||
const METHOD_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
email: { bg: "bg-blue-100", text: "text-blue-700", dot: "bg-blue-500" },
|
||||
sms: { bg: "bg-green-100", text: "text-green-700", dot: "bg-green-500" },
|
||||
push: { bg: "bg-amber-100", text: "text-amber-700", dot: "bg-amber-500" },
|
||||
internal: { bg: "bg-stone-100", text: "text-stone-600", dot: "bg-stone-400" },
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
// Empty state component
|
||||
function LogsEmptyState({ hasFilters }: { hasFilters: boolean }) {
|
||||
return (
|
||||
<div className="text-center py-16 px-4">
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-stone-100 to-stone-50 flex items-center justify-center">
|
||||
<svg className="w-10 h-10 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-stone-800">
|
||||
{hasFilters ? "No messages match your filters" : "No message logs yet"}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-500 mt-1 max-w-sm mx-auto">
|
||||
{hasFilters
|
||||
? "Try adjusting your search or status filter to find messages."
|
||||
: "Send your first campaign to start tracking message delivery and engagement."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading skeleton
|
||||
function LogSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<tr key={i} className="border-b border-[var(--admin-border)]">
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-3 w-20 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-3 w-32 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-5 w-14 bg-stone-200 rounded-full animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-3 w-40 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-5 w-20 bg-stone-200 rounded-full animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-5 w-24 bg-stone-200 rounded-full animate-pulse" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Status badge component
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors = STATUS_COLORS[status] || STATUS_COLORS.queued;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${colors.bg} ${colors.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Method badge component
|
||||
function MethodBadge({ method }: { method: string }) {
|
||||
const colors = METHOD_COLORS[method] || METHOD_COLORS.internal;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${colors.bg} ${colors.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
|
||||
{method}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Engagement indicator
|
||||
function EngagementIndicator({ log }: { log: MessageLogEntry }) {
|
||||
const hasEngagement = log.delivered_at || log.opened_at || log.clicked_at || log.bounced_at;
|
||||
|
||||
if (!hasEngagement) {
|
||||
return <span className="text-xs text-stone-400">No engagement yet</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{log.delivered_at && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 font-medium" title="Delivered">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Delivered
|
||||
</span>
|
||||
)}
|
||||
{log.opened_at && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-purple-600 font-medium" title="Opened">
|
||||
{Icons.eye("w-3.5 h-3.5")}
|
||||
Opened
|
||||
</span>
|
||||
)}
|
||||
{log.clicked_at && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-amber-600 font-medium" title="Clicked">
|
||||
{Icons.mousePointer("w-3.5 h-3.5")}
|
||||
Clicked
|
||||
</span>
|
||||
)}
|
||||
{log.bounced_at && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-red-600 font-medium" title="Bounced">
|
||||
{Icons.x("w-3.5 h-3.5")}
|
||||
Bounced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
const [logs, setLogs] = useState<MessageLogEntry[]>([]);
|
||||
@@ -141,55 +266,58 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasFilters = search.length > 0 || statusFilter !== "all";
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.messageSquare("h-5 w-5 text-white")}
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
|
||||
{Icons.messageSquare("h-6 h-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Message Logs</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
<h2 className="text-lg font-bold text-stone-900">Message Logs</h2>
|
||||
<p className="text-sm text-stone-500">
|
||||
{isLoading ? "Loading..." : `${filteredLogs.length} message${filteredLogs.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-stone-600 bg-white border border-stone-200 rounded-lg hover:bg-stone-50 hover:text-stone-900 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{Icons.refresh("h-4 w-4" + (isLoading ? " animate-spin" : ""))}
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</button>
|
||||
<AdminButton variant="secondary" onClick={handleRefresh} disabled={isLoading} icon={Icons.refresh("w-4 h-4" + (isLoading ? " animate-spin" : ""))}>
|
||||
Refresh
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{stats.total}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-stone-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Total</p>
|
||||
<p className="text-2xl font-bold text-stone-800 mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Delivered</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{stats.delivered}</p>
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-emerald-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Delivered</p>
|
||||
<p className="text-2xl font-bold text-emerald-600 mt-1">{stats.delivered}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Failed</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-red-600 mt-1">{stats.failed}</p>
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-red-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Failed</p>
|
||||
<p className="text-2xl font-bold text-red-600 mt-1">{stats.failed}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-stone-600 mt-1">{stats.pending}</p>
|
||||
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-12 h-12 bg-blue-50 rounded-bl-full" />
|
||||
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Pending</p>
|
||||
<p className="text-2xl font-bold text-blue-600 mt-1">{stats.pending}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div className="relative flex-1">
|
||||
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
|
||||
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
|
||||
{Icons.search("h-5 w-5 text-stone-400")}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by email or subject..."
|
||||
@@ -198,7 +326,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
@@ -207,7 +335,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
@@ -222,80 +350,70 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3 text-stone-500">
|
||||
{Icons.refresh("h-5 w-5 animate-spin")}
|
||||
<span>Loading logs...</span>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-stone-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-stone-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white">
|
||||
<LogSkeleton />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : paginatedLogs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.list("h-8 w-8 text-stone-400")}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No messages logged yet</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Send a campaign to see logs here</p>
|
||||
<div className="overflow-hidden rounded-xl border border-stone-200">
|
||||
<LogsEmptyState hasFilters={hasFilters} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block overflow-hidden rounded-xl border border-[var(--admin-border)]">
|
||||
<div className="hidden sm:block overflow-hidden rounded-xl border border-stone-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Sent At</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Recipient</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Method</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Subject</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Engagement</th>
|
||||
<thead className="bg-stone-50 border-b border-stone-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)] bg-white">
|
||||
<tbody className="divide-y divide-stone-100 bg-white">
|
||||
{paginatedLogs.map((log: MessageLogEntry) => (
|
||||
<tr key={log.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3 text-[var(--admin-text-muted)] text-xs whitespace-nowrap">
|
||||
<td className="px-4 py-3.5 text-stone-500 text-xs whitespace-nowrap">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--admin-text-primary)] text-xs">
|
||||
<td className="px-4 py-3.5 text-stone-800 text-sm font-medium">
|
||||
{log.customer_email ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{(() => {
|
||||
const props = getMethodBadgeProps(log.delivery_method);
|
||||
return <AdminBadge variant={props.variant} dot>{log.delivery_method}</AdminBadge>;
|
||||
})()}
|
||||
<td className="px-4 py-3.5">
|
||||
<MethodBadge method={log.delivery_method} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--admin-text-primary)] text-xs truncate max-w-[160px]" title={log.subject ?? undefined}>
|
||||
<td className="px-4 py-3.5 text-stone-700 text-sm truncate max-w-[180px]" title={log.subject ?? undefined}>
|
||||
{log.subject ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{(() => {
|
||||
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}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{log.delivered_at && (
|
||||
<AdminBadge variant="success" dot>Delivered</AdminBadge>
|
||||
)}
|
||||
{log.opened_at && (
|
||||
<AdminBadge variant="info" dot>Opened</AdminBadge>
|
||||
)}
|
||||
{log.clicked_at && (
|
||||
<AdminBadge variant="warning" dot>Clicked</AdminBadge>
|
||||
)}
|
||||
{log.bounced_at && (
|
||||
<AdminBadge variant="danger" dot>Bounced</AdminBadge>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<StatusBadge status={log.status} />
|
||||
{log.error_message && (
|
||||
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[120px]" title={log.error_message}>
|
||||
{log.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<EngagementIndicator log={log} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -305,55 +423,36 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{paginatedLogs.map((log: MessageLogEntry) => (
|
||||
<div key={log.id} className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
|
||||
<div key={log.id} className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--admin-text-primary)]">{log.customer_email ?? "—"}</div>
|
||||
<div className="text-xs text-[var(--admin-text-muted)] mt-0.5">{log.subject ?? "—"}</div>
|
||||
<div className="text-sm font-semibold text-stone-800">{log.customer_email ?? "—"}</div>
|
||||
<div className="text-xs text-stone-500 mt-0.5 truncate max-w-[200px]">{log.subject ?? "—"}</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const props = getStatusBadgeProps(log.status);
|
||||
return <AdminBadge variant={props.variant} dot>{log.status}</AdminBadge>;
|
||||
})()}
|
||||
<StatusBadge status={log.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(() => {
|
||||
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)]">
|
||||
<MethodBadge method={log.delivery_method} />
|
||||
<span className="text-xs text-stone-500">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{log.delivered_at && (
|
||||
<AdminBadge variant="success" dot>Delivered</AdminBadge>
|
||||
)}
|
||||
{log.opened_at && (
|
||||
<AdminBadge variant="info" dot>Opened</AdminBadge>
|
||||
)}
|
||||
{log.clicked_at && (
|
||||
<AdminBadge variant="warning" dot>Clicked</AdminBadge>
|
||||
)}
|
||||
{log.bounced_at && (
|
||||
<AdminBadge variant="danger" dot>Bounced</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
<EngagementIndicator log={log} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
|
||||
<p className="text-sm text-stone-500">
|
||||
Showing {(page - 1) * PAGE_SIZE + 1} to {Math.min(page * PAGE_SIZE, filteredLogs.length)} of {filteredLogs.length}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1.5 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
@@ -363,10 +462,10 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${
|
||||
className={`px-4 py-2 text-sm border rounded-xl transition-colors ${
|
||||
page === pageNum
|
||||
? "bg-emerald-600 text-white border-emerald-600"
|
||||
: "bg-white text-[var(--admin-text-primary)] border-[var(--admin-border)] hover:bg-stone-50"
|
||||
: "bg-white text-stone-700 border-stone-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
@@ -376,7 +475,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1.5 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
@@ -3,7 +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";
|
||||
import { AdminInput, AdminTextInput, AdminSelect, AdminButton, useToast } from "./design-system";
|
||||
|
||||
type Stop = {
|
||||
city: string;
|
||||
@@ -24,6 +24,7 @@ type Props = {
|
||||
|
||||
export default function NewStopForm({ duplicateFrom }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -41,8 +42,43 @@ export default function NewStopForm({ duplicateFrom }: Props) {
|
||||
const [zip, setZip] = useState(duplicateFrom?.zip ?? "");
|
||||
const [cutoffTime, setCutoffTime] = useState(duplicateFrom?.cutoff_time ?? "");
|
||||
|
||||
// Validation errors
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
function validateForm(): boolean {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!city.trim()) {
|
||||
errors.city = "City is required";
|
||||
}
|
||||
if (!state.trim()) {
|
||||
errors.state = "State is required";
|
||||
}
|
||||
if (!location.trim()) {
|
||||
errors.location = "Location is required";
|
||||
}
|
||||
if (!date.trim()) {
|
||||
errors.date = "Date is required";
|
||||
}
|
||||
if (!time.trim()) {
|
||||
errors.time = "Time is required";
|
||||
}
|
||||
if (!brandId) {
|
||||
errors.brandId = "Brand is required";
|
||||
}
|
||||
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
showError("Validation failed", "Please fix the errors below");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -59,11 +95,14 @@ export default function NewStopForm({ duplicateFrom }: Props) {
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
showError("Failed to create stop", result.error ?? "Please try again");
|
||||
setError(result.error ?? "Failed to create stop");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess("Stop created", `${city}, ${state} has been added`);
|
||||
|
||||
if (result.id) {
|
||||
router.push(`/admin/stops/${result.id}`);
|
||||
} else {
|
||||
@@ -75,116 +114,231 @@ export default function NewStopForm({ duplicateFrom }: Props) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-red-400 text-sm">
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AdminInput label="City" required>
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
City <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setCity(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.city;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. Denver"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.city
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
</AdminInput>
|
||||
{fieldErrors.city && <p className="mt-1 text-xs text-red-500">{fieldErrors.city}</p>}
|
||||
</div>
|
||||
|
||||
<AdminInput label="State" required>
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
State <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setState(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.state;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. CO"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.state
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
</AdminInput>
|
||||
{fieldErrors.state && <p className="mt-1 text-xs text-red-500">{fieldErrors.state}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminInput label="Location Name" required>
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Location Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setLocation(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.location;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. Southwest Plaza Parking Lot"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.location
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
</AdminInput>
|
||||
{fieldErrors.location && <p className="mt-1 text-xs text-red-500">{fieldErrors.location}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AdminInput label="Date" required>
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.date;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.date
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
</AdminInput>
|
||||
{fieldErrors.date && <p className="mt-1 text-xs text-red-500">{fieldErrors.date}</p>}
|
||||
</div>
|
||||
|
||||
<AdminInput label="Time" required>
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Time <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setTime(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.time;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. 8:00 AM – 2:00 PM"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.time
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
</AdminInput>
|
||||
{fieldErrors.time && <p className="mt-1 text-xs text-red-500">{fieldErrors.time}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminInput label="Brand" required>
|
||||
<AdminSelect
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Brand <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
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>
|
||||
onChange={(e) => {
|
||||
setBrandId(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.brandId;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.brandId
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
>
|
||||
<option value="">Select brand...</option>
|
||||
<option value="64294306-5f42-463d-a5e8-2ad6c81a96de">Tuxedo Corn</option>
|
||||
<option value="b1cb7a96-d82b-40b1-80b1-d6dd26c56e28">Indian River Direct</option>
|
||||
</select>
|
||||
{fieldErrors.brandId && <p className="mt-1 text-xs text-red-500">{fieldErrors.brandId}</p>}
|
||||
</div>
|
||||
|
||||
<AdminInput label="Active">
|
||||
<AdminSelect
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Active</label>
|
||||
<select
|
||||
value={active}
|
||||
onChange={(e) => setActive(e.target.value)}
|
||||
options={[
|
||||
{ value: "true", label: "Yes — show on storefront" },
|
||||
{ value: "false", label: "No — hide from storefront" },
|
||||
]}
|
||||
/>
|
||||
</AdminInput>
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="true">Yes — show on storefront</option>
|
||||
<option value="false">No — hide from storefront</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AdminInput label="Street Address">
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<AdminInput label="ZIP Code">
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">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-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminInput label="Order Cutoff" helpText="Customers must order before this time to be included at this stop.">
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Order Cutoff</label>
|
||||
<p className="text-[10px] text-stone-500 mb-2">Customers must order before this time to be included at this stop.</p>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={cutoffTime}
|
||||
onChange={(e) => setCutoffTime(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-xl bg-slate-900 px-6 py-3 font-medium text-white disabled:opacity-50"
|
||||
isLoading={loading}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
{loading ? "Creating..." : "Create Stop"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
|
||||
<a
|
||||
href="/admin/stops"
|
||||
className="rounded-xl border border-zinc-600 px-6 py-3 font-medium text-zinc-300"
|
||||
className="rounded-xl border border-[var(--admin-border)] px-6 py-3 font-medium text-stone-600 hover:bg-stone-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
|
||||
@@ -3,7 +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";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea, useToast, AdminButton } from "./design-system";
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
@@ -56,6 +56,7 @@ function formatCurrency(amount: number) {
|
||||
|
||||
export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
@@ -87,10 +88,34 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
);
|
||||
const total = Math.max(0, subtotal - discount_amount);
|
||||
|
||||
// Validation
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
function validateForm(): boolean {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!customer_name.trim()) {
|
||||
errors.customer_name = "Customer name is required";
|
||||
}
|
||||
|
||||
if (discount_amount < 0) {
|
||||
errors.discount_amount = "Discount cannot be negative";
|
||||
}
|
||||
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
function updateItem(id: string, field: "quantity" | "price", value: number) {
|
||||
setItems((prev) =>
|
||||
prev.map((i) => (i.id === id ? { ...i, [field]: value } : i))
|
||||
);
|
||||
// Clear error for the field when changed
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
@@ -100,6 +125,11 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!validateForm()) {
|
||||
showError("Validation failed", "Please fix the errors below");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
@@ -107,72 +137,75 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
const toSave = items.filter((i) => !i.removed);
|
||||
const toRemove = items.filter((i) => i.removed);
|
||||
|
||||
for (const item of toRemove) {
|
||||
const result = await deleteOrderItem(item.id);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to remove item");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of toSave) {
|
||||
const orig = order.order_items.find((o) => o.id === item.id);
|
||||
if (!orig) continue;
|
||||
if (
|
||||
Number(orig.quantity) !== item.quantity ||
|
||||
Number(orig.price) !== item.price
|
||||
) {
|
||||
const result = await updateOrderItem(item.id, {
|
||||
quantity: item.quantity,
|
||||
price: Number(item.price),
|
||||
});
|
||||
try {
|
||||
for (const item of toRemove) {
|
||||
const result = await deleteOrderItem(item.id);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to update item");
|
||||
showError("Failed to remove item", result.error ?? "Please try again");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateOrder(order.id, brandId, {
|
||||
customer_name,
|
||||
customer_email: customer_email || null,
|
||||
customer_phone: customer_phone || null,
|
||||
discount_amount,
|
||||
discount_reason: discount_reason || null,
|
||||
internal_notes: internal_notes || null,
|
||||
status,
|
||||
pickup_complete,
|
||||
pickup_completed_at: pickup_complete ? new Date().toISOString() : null,
|
||||
subtotal,
|
||||
});
|
||||
for (const item of toSave) {
|
||||
const orig = order.order_items.find((o) => o.id === item.id);
|
||||
if (!orig) continue;
|
||||
if (
|
||||
Number(orig.quantity) !== item.quantity ||
|
||||
Number(orig.price) !== item.price
|
||||
) {
|
||||
const result = await updateOrderItem(item.id, {
|
||||
quantity: item.quantity,
|
||||
price: Number(item.price),
|
||||
});
|
||||
if (!result.success) {
|
||||
showError("Failed to update item", result.error ?? "Please try again");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save");
|
||||
const result = await updateOrder(order.id, brandId, {
|
||||
customer_name,
|
||||
customer_email: customer_email || null,
|
||||
customer_phone: customer_phone || null,
|
||||
discount_amount,
|
||||
discount_reason: discount_reason || null,
|
||||
internal_notes: internal_notes || null,
|
||||
status,
|
||||
pickup_complete,
|
||||
pickup_completed_at: pickup_complete ? new Date().toISOString() : null,
|
||||
subtotal,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
showError("Failed to save", result.error ?? "Please try again");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess("Order updated", "Changes have been saved");
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
showError("Network error", "Please check your connection and try again");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700">
|
||||
✓ Order updated successfully
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">
|
||||
@@ -200,7 +233,8 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<AdminInput label="Qty">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">Qty</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -208,20 +242,25 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, "quantity", 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"
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</AdminInput>
|
||||
<AdminInput label="Price">
|
||||
<AdminTextInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={item.price}
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, "price", Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-500 mb-1">Price</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={item.price}
|
||||
onChange={(e) =>
|
||||
updateItem(item.id, "price", Number(e.target.value))
|
||||
}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-8 pr-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-right text-sm font-semibold text-stone-900">
|
||||
@@ -246,22 +285,37 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
</AdminInput>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<AdminInput label="Discount Amount">
|
||||
<AdminTextInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={discount_amount}
|
||||
onChange={(e) => setDiscount_amount(Number(e.target.value))}
|
||||
/>
|
||||
</AdminInput>
|
||||
<AdminInput label="Discount Reason">
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-500 mb-1.5">Discount Amount</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={discount_amount}
|
||||
onChange={(e) => setDiscount_amount(Number(e.target.value))}
|
||||
className={`w-full rounded-xl border px-8 pr-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.discount_amount
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-stone-200 bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{fieldErrors.discount_amount && (
|
||||
<p className="mt-1 text-xs text-red-500">{fieldErrors.discount_amount}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-500 mb-1.5">Discount Reason</label>
|
||||
<input
|
||||
type="text"
|
||||
value={discount_reason}
|
||||
onChange={(e) => setDiscount_reason(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
@@ -276,12 +330,31 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
|
||||
{/* Customer fields */}
|
||||
<div className="space-y-4">
|
||||
<AdminInput label="Name">
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-500 mb-1.5">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customer_name}
|
||||
onChange={(e) => setCustomer_name(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setCustomer_name(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.customer_name;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.customer_name
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-stone-200 bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
</AdminInput>
|
||||
{fieldErrors.customer_name && (
|
||||
<p className="mt-1 text-xs text-red-500">{fieldErrors.customer_name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<AdminInput label="Email">
|
||||
<AdminTextInput
|
||||
@@ -308,10 +381,11 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
{["pending", "confirmed", "cancelled"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setStatus(s)}
|
||||
className={`flex-1 rounded-xl px-4 py-2.5 text-sm font-semibold capitalize transition-colors ${
|
||||
status === s
|
||||
? "bg-emerald-600 text-white"
|
||||
? "bg-[var(--admin-accent)] text-white"
|
||||
: "bg-white text-stone-600 border border-stone-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
@@ -324,6 +398,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold text-stone-500">Pickup</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickup_complete((v) => !v)}
|
||||
className={`w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-colors ${
|
||||
pickup_complete
|
||||
@@ -346,13 +421,16 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<button
|
||||
<AdminButton
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full rounded-xl bg-emerald-600 px-6 py-3 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
isLoading={saving}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { updateOrder } from "@/actions/orders/update-order";
|
||||
import { createRefund } from "@/actions/orders/create-refund";
|
||||
import { useToast, AdminButton, AdminInput } from "./design-system";
|
||||
|
||||
type Refund = {
|
||||
id: string;
|
||||
@@ -41,6 +43,8 @@ export default function OrderPaymentSection({
|
||||
payment_transaction_id,
|
||||
existingRefunds,
|
||||
}: OrderPaymentSectionProps) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [processor, setProcessor] = useState(payment_processor ?? "");
|
||||
const [status, setStatus] = useState(payment_status ?? "manual");
|
||||
const [transactionId, setTransactionId] = useState(payment_transaction_id ?? "");
|
||||
@@ -52,6 +56,28 @@ export default function OrderPaymentSection({
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [refundSaved, setRefundSaved] = useState(false);
|
||||
|
||||
const totalRefunded = existingRefunds
|
||||
.filter((r) => r.status === "completed")
|
||||
.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||
const remainingBalance = Math.max(0, orderTotal - totalRefunded);
|
||||
|
||||
// Validation for refund
|
||||
const [refundError, setRefundError] = useState<string | null>(null);
|
||||
|
||||
function validateRefund(): boolean {
|
||||
const amount = Number(refundAmount);
|
||||
if (!amount || amount <= 0) {
|
||||
setRefundError("Please enter a valid refund amount");
|
||||
return false;
|
||||
}
|
||||
if (amount > remainingBalance) {
|
||||
setRefundError(`Amount cannot exceed remaining balance of ${formatCurrency(remainingBalance)}`);
|
||||
return false;
|
||||
}
|
||||
setRefundError(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSavePayment(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
@@ -65,8 +91,10 @@ export default function OrderPaymentSection({
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
showError("Failed to save", result.error ?? "Please try again");
|
||||
setError(result.error ?? "Failed to save");
|
||||
} else {
|
||||
showSuccess("Payment saved", "Payment details have been updated");
|
||||
setSaved(true);
|
||||
}
|
||||
setSaving(false);
|
||||
@@ -74,8 +102,9 @@ export default function OrderPaymentSection({
|
||||
|
||||
async function handleRefund(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!validateRefund()) return;
|
||||
|
||||
const amount = Number(refundAmount);
|
||||
if (!amount || amount <= 0) return;
|
||||
|
||||
setRefunding(true);
|
||||
setError(null);
|
||||
@@ -87,33 +116,55 @@ export default function OrderPaymentSection({
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
showError("Failed to record refund", result.error ?? "Please try again");
|
||||
setError(result.error ?? "Failed to record refund");
|
||||
} else {
|
||||
showSuccess("Refund recorded", `${formatCurrency(amount)} has been refunded`);
|
||||
setRefundAmount("");
|
||||
setRefundReason("");
|
||||
setRefundSaved(true);
|
||||
router.refresh();
|
||||
}
|
||||
setRefunding(false);
|
||||
}
|
||||
|
||||
const totalRefunded = existingRefunds
|
||||
.filter((r) => r.status === "completed")
|
||||
.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||
const remainingBalance = Math.max(0, orderTotal - totalRefunded);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600">
|
||||
{error}
|
||||
{/* Balance summary */}
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-stone-500">Order Total</p>
|
||||
<p className="text-lg font-bold text-stone-900">{formatCurrency(orderTotal)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium text-stone-500">Refunded</p>
|
||||
<p className="text-lg font-bold text-green-600">{formatCurrency(totalRefunded)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-medium text-stone-500">Balance Due</p>
|
||||
<p className="text-lg font-bold text-[var(--admin-accent)]">{formatCurrency(remainingBalance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment details */}
|
||||
<form onSubmit={handleSavePayment} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700">
|
||||
✓ Payment details saved
|
||||
<div className="rounded-xl bg-green-50 border border-green-200 p-4 text-sm text-green-700 flex items-center gap-3">
|
||||
<svg className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Payment details saved
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -123,9 +174,9 @@ export default function OrderPaymentSection({
|
||||
<select
|
||||
value={processor}
|
||||
onChange={(e) => setProcessor(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="">— None —</option>
|
||||
{["manual", "stripe", "square", "cash", "venmo", "other"].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
@@ -139,7 +190,7 @@ export default function OrderPaymentSection({
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
>
|
||||
{["pending", "paid", "failed", "refunded", "cancelled"].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
@@ -157,24 +208,25 @@ export default function OrderPaymentSection({
|
||||
value={transactionId}
|
||||
onChange={(e) => setTransactionId(e.target.value)}
|
||||
placeholder="External payment reference"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-emerald-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
isLoading={saving}
|
||||
variant="primary"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Payment Details"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</form>
|
||||
|
||||
{/* Refund history */}
|
||||
{existingRefunds.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">
|
||||
Refunds ({existingRefunds.length})
|
||||
Refund History ({existingRefunds.length})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{existingRefunds.map((r) => (
|
||||
@@ -196,8 +248,8 @@ export default function OrderPaymentSection({
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-bold ${
|
||||
r.status === "completed"
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-stone-100 text-stone-500"
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: "bg-stone-100 text-stone-500 border border-stone-200"
|
||||
}`}
|
||||
>
|
||||
{r.status}
|
||||
@@ -205,56 +257,70 @@ export default function OrderPaymentSection({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm">
|
||||
<span className="text-stone-500">Total refunded</span>
|
||||
<span className="font-semibold text-stone-900">
|
||||
{formatCurrency(totalRefunded)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Record a refund */}
|
||||
<div className="rounded-xl border border-dashed border-stone-300 bg-stone-50 p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">Record a Refund</p>
|
||||
{refundSaved && (
|
||||
<div className="mb-3 rounded-xl bg-green-50 border border-green-200 p-3 text-sm text-green-700">
|
||||
✓ Refund recorded
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleRefund} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={remainingBalance}
|
||||
value={refundAmount}
|
||||
onChange={(e) => setRefundAmount(e.target.value)}
|
||||
placeholder={`Max ${formatCurrency(remainingBalance)}`}
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Reason</label>
|
||||
<input
|
||||
type="text"
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
placeholder="Customer request, defective product, etc."
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!refundAmount || Number(refundAmount) <= 0 || refunding}
|
||||
className="w-full rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-600 hover:bg-red-100 disabled:opacity-50"
|
||||
>
|
||||
{refunding ? "Processing..." : "Record Refund"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{remainingBalance > 0 && (
|
||||
<div className="rounded-xl border border-dashed border-stone-300 bg-stone-50 p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-stone-700">Record a Refund</p>
|
||||
{refundSaved && (
|
||||
<div className="mb-3 rounded-xl bg-green-50 border border-green-200 p-3 text-sm text-green-700 flex items-center gap-2">
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Refund recorded successfully
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleRefund} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Amount</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={remainingBalance}
|
||||
value={refundAmount}
|
||||
onChange={(e) => {
|
||||
setRefundAmount(e.target.value);
|
||||
setRefundError(null);
|
||||
}}
|
||||
placeholder={`Max ${formatCurrency(remainingBalance)}`}
|
||||
className={`w-full rounded-xl border bg-white pl-8 pr-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
refundError
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-stone-200 focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{refundError && (
|
||||
<p className="mt-1 text-xs text-red-500">{refundError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-stone-500">Reason</label>
|
||||
<input
|
||||
type="text"
|
||||
value={refundReason}
|
||||
onChange={(e) => setRefundReason(e.target.value)}
|
||||
placeholder="Customer request, defective product, etc."
|
||||
className="w-full rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
<AdminButton
|
||||
type="submit"
|
||||
disabled={!refundAmount || Number(refundAmount) <= 0 || refunding}
|
||||
isLoading={refunding}
|
||||
variant="danger"
|
||||
fullWidth
|
||||
>
|
||||
{refunding ? "Processing..." : `Record Refund (Max ${formatCurrency(remainingBalance)})`}
|
||||
</AdminButton>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ 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";
|
||||
import { AdminInput, AdminTextInput, AdminSelect, AdminButton } from "./design-system";
|
||||
import { AdminToggle } from "./design-system/AdminToggle";
|
||||
|
||||
type InventoryMode = "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
|
||||
|
||||
@@ -25,6 +26,10 @@ type Props = {
|
||||
isPlatformAdmin?: boolean;
|
||||
};
|
||||
|
||||
interface ValidationErrors {
|
||||
squareLocationId?: string;
|
||||
}
|
||||
|
||||
export default function PaymentSettingsForm({ settings, brandId, brands = [], isPlatformAdmin = false }: Props) {
|
||||
const [activeBrandId, setActiveBrandId] = useState(brandId);
|
||||
const [provider, setProvider] = useState<PaymentProvider | "">(
|
||||
@@ -45,8 +50,12 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [showStripe, setShowStripe] = useState(settings?.provider === "stripe" || settings?.provider === "square" ? settings.provider === "stripe" : false);
|
||||
const [showSquare, setShowSquare] = useState(settings?.provider === "square");
|
||||
const [showSecretStripe, setShowSecretStripe] = useState(false);
|
||||
const [showSecretSquare, setShowSecretSquare] = useState(false);
|
||||
|
||||
// Square Sync state
|
||||
const [squareSyncEnabled, setSquareSyncEnabled] = useState(
|
||||
@@ -56,10 +65,12 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
settings?.square_inventory_mode ?? "none"
|
||||
);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncingType, setSyncingType] = useState<string | null>(null);
|
||||
const [syncResult, setSyncResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [syncLog, setSyncLog] = useState<SyncLogEntry[]>([]);
|
||||
|
||||
const hasSquareToken = !!settings?.square_access_token;
|
||||
const hasStripeKeys = !!settings?.stripe_publishable_key;
|
||||
|
||||
// Read URL params to show connection success/error
|
||||
useEffect(() => {
|
||||
@@ -84,10 +95,38 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
}
|
||||
}, [hasSquareToken, activeBrandId]);
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
provider !== ((settings?.provider) ?? "") ||
|
||||
squareSyncEnabled !== (settings?.square_sync_enabled ?? false) ||
|
||||
squareInventoryMode !== (settings?.square_inventory_mode ?? "none") ||
|
||||
squareLocationId !== (settings?.square_location_id ?? "");
|
||||
setDirty(hasChanges);
|
||||
}, [provider, squareSyncEnabled, squareInventoryMode, squareLocationId, settings]);
|
||||
|
||||
function validate(): boolean {
|
||||
const newErrors: ValidationErrors = {};
|
||||
|
||||
if (squareLocationId && !squareLocationId.startsWith("L")) {
|
||||
newErrors.squareLocationId = "Location ID should start with 'L'";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
setError("Please correct the errors below.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
const result = await savePaymentSettings({
|
||||
brandId: activeBrandId,
|
||||
@@ -105,12 +144,14 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
setError(result.error ?? "Failed to save");
|
||||
} else {
|
||||
setSaved(true);
|
||||
setDirty(false);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncNow(type: string) {
|
||||
setSyncing(true);
|
||||
setSyncingType(type);
|
||||
setSyncResult(null);
|
||||
const result = await syncSquareNow(activeBrandId, type as "products" | "orders" | "all");
|
||||
setSyncResult({
|
||||
@@ -120,13 +161,14 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
: `Sync failed: ${result.errors?.[0] ?? "Unknown error"}`,
|
||||
});
|
||||
setSyncing(false);
|
||||
setSyncingType(null);
|
||||
// Refresh sync log
|
||||
const logResult = await getSyncLog(brandId);
|
||||
const logResult = await getSyncLog(activeBrandId);
|
||||
if (logResult.success) setSyncLog(logResult.logs);
|
||||
}
|
||||
|
||||
async function handleDisconnectSquare() {
|
||||
if (!confirm("Disconnect Square? This will clear the access token.")) return;
|
||||
if (!confirm("Disconnect Square? This will clear the access token and disable sync.")) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const result = await savePaymentSettings({
|
||||
@@ -142,6 +184,7 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
setError(result.error ?? "Failed to disconnect");
|
||||
} else {
|
||||
setSaved(true);
|
||||
setDirty(false);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
}
|
||||
}
|
||||
@@ -154,104 +197,141 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
<form onSubmit={handleSave} className="space-y-8">
|
||||
{/* Platform admin brand picker */}
|
||||
{isPlatformAdmin && brands.length > 0 && (
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
value={activeBrandId}
|
||||
onChange={(e) => setActiveBrandId(e.target.value)}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
<div className="p-4 rounded-xl bg-[var(--admin-bg)] border border-[var(--admin-border)]">
|
||||
<AdminInput label="Brand" helpText="Select a brand to configure payment settings for:">
|
||||
<AdminSelect
|
||||
value={activeBrandId}
|
||||
onChange={(e) => setActiveBrandId(e.target.value)}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error/Success messages */}
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-950/50 border border-red-800 p-4 text-sm text-red-300">{error}</div>
|
||||
<div className="rounded-xl border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-[var(--admin-danger)] shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-950/50 border border-green-800 p-4 text-sm text-green-300">
|
||||
Payment settings saved.
|
||||
<div className="rounded-xl border border-[var(--admin-success)]/30 bg-[var(--admin-success)]/10 px-4 py-3 flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-[var(--admin-success)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Payment settings saved successfully!
|
||||
</div>
|
||||
)}
|
||||
{syncResult && (
|
||||
<div className={`rounded-xl p-4 text-sm border ${
|
||||
syncResult.success ? "bg-green-950/50 border-green-800 text-green-300" : "bg-red-950/50 border-red-800 text-red-300"
|
||||
<div className={`rounded-xl border px-4 py-3 flex items-start gap-3 ${
|
||||
syncResult.success
|
||||
? "border-[var(--admin-success)]/30 bg-[var(--admin-success)]/10"
|
||||
: "border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10"
|
||||
}`}>
|
||||
{syncResult.success ? (
|
||||
<svg className="w-5 h-5 text-[var(--admin-success)] shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-[var(--admin-danger)] shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
)}
|
||||
{syncResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected status banner */}
|
||||
{settings?.stripe_publishable_key && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-green-600 text-white text-xs font-bold">✓</span>
|
||||
{hasStripeKeys && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-[var(--admin-success)]/30 bg-[var(--admin-success)]/10 px-4 py-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--admin-success)] text-white">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-green-800">Stripe Connected</p>
|
||||
<p className="text-xs text-green-600">Payments are configured for this brand</p>
|
||||
<p className="text-sm font-semibold text-[var(--admin-success)]">Stripe Connected</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Payments are configured for this brand</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--admin-text-primary)]">Payment Provider</label>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Choose how to process payments for this brand.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-3">
|
||||
{[
|
||||
{ value: "", label: "None / Manual" },
|
||||
{ value: "stripe", label: "Stripe" },
|
||||
{ value: "square", label: "Square" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProvider(opt.value as PaymentProvider | "");
|
||||
setShowStripe(opt.value === "stripe");
|
||||
setShowSquare(opt.value === "square");
|
||||
}}
|
||||
className={`rounded-xl border px-4 py-3 text-sm font-medium transition-colors ${
|
||||
provider === opt.value
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)] text-white"
|
||||
: "border-[var(--admin-border)] text-[var(--admin-text-secondary)] hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<AdminInput
|
||||
label="Payment Provider"
|
||||
helpText="Choose how to process payments for this brand."
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{[
|
||||
{ value: "", label: "None / Manual", icon: "—" },
|
||||
{ value: "stripe", label: "Stripe", icon: "💳" },
|
||||
{ value: "square", label: "Square", icon: "◼️" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProvider(opt.value as PaymentProvider | "");
|
||||
setShowStripe(opt.value === "stripe");
|
||||
setShowSquare(opt.value === "square");
|
||||
setDirty(true);
|
||||
}}
|
||||
className={`rounded-xl border px-5 py-3.5 text-sm font-medium transition-all ${
|
||||
provider === opt.value
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)] text-white shadow-sm"
|
||||
: "border-[var(--admin-border)] text-[var(--admin-text-secondary)] hover:border-[var(--admin-accent)]/50 hover:bg-[var(--admin-bg)]"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">{opt.icon}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
{/* Stripe credentials */}
|
||||
{showStripe && (
|
||||
<div className="space-y-4 rounded-xl border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="space-y-4 rounded-xl border border-[var(--admin-accent)]/30 bg-[var(--admin-accent)]/5 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-blue-900">Stripe</h3>
|
||||
{settings?.stripe_publishable_key && (
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-green-100 px-2.5 py-1 text-xs font-semibold text-green-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-600"></span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">💳</span>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">Stripe</h3>
|
||||
</div>
|
||||
{hasStripeKeys && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--admin-success)]/10 px-3 py-1 text-xs font-medium text-[var(--admin-success)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!settings?.stripe_publishable_key ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-blue-800">
|
||||
Connect your Stripe account to process payments. You'll be redirected to Stripe to authorize the connection.
|
||||
{!hasStripeKeys ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[var(--admin-text-secondary)]">
|
||||
Connect your Stripe account to process payments. You'll be redirected to Stripe to authorize the connection.
|
||||
</p>
|
||||
<a
|
||||
href="/api/stripe/oauth"
|
||||
className="inline-block rounded-xl bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-700"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-[var(--admin-accent)] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[var(--admin-accent-hover)] transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
Connect with Stripe
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-green-700">
|
||||
✓ Your Stripe account is connected and ready to accept payments.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-[var(--admin-success)]/10 border border-[var(--admin-success)]/30 p-3">
|
||||
<p className="text-sm text-[var(--admin-success)]">
|
||||
✓ Your Stripe account is connected and ready to accept payments.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
@@ -266,8 +346,11 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-[var(--admin-danger)] hover:underline"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
Disconnect Stripe
|
||||
</button>
|
||||
</div>
|
||||
@@ -277,34 +360,63 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
|
||||
{/* Square credentials */}
|
||||
{showSquare && (
|
||||
<div className="space-y-4 rounded-xl border border-green-200 bg-green-50 p-4">
|
||||
<h3 className="font-semibold text-green-900">Square</h3>
|
||||
<div className="space-y-4 rounded-xl border border-[var(--admin-success)]/30 bg-[var(--admin-success)]/5 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">◼️</span>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">Square</h3>
|
||||
</div>
|
||||
{hasSquareToken && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--admin-success)]/10 px-3 py-1 text-xs font-medium text-[var(--admin-success)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" />
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!hasSquareToken ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-green-800">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[var(--admin-text-secondary)]">
|
||||
Connect your Square account via OAuth to enable sync.
|
||||
</p>
|
||||
<a
|
||||
href="/api/square/oauth"
|
||||
className="inline-block rounded-xl bg-green-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-green-700"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-[var(--admin-success)] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[var(--admin-success)]/90 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
Connect Square
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AdminInput label="Location ID">
|
||||
<AdminTextInput
|
||||
value={squareLocationId}
|
||||
onChange={(e) => setSquareLocationId(e.target.value)}
|
||||
placeholder="L..."
|
||||
/>
|
||||
<AdminInput
|
||||
label="Location ID"
|
||||
error={errors.squareLocationId}
|
||||
helpText="Starts with 'L' (e.g., LXXXXXXXXX)"
|
||||
>
|
||||
<div className="relative">
|
||||
<AdminTextInput
|
||||
value={squareLocationId}
|
||||
onChange={(e) => {
|
||||
setSquareLocationId(e.target.value);
|
||||
setDirty(true);
|
||||
if (errors.squareLocationId) {
|
||||
setErrors({ ...errors, squareLocationId: undefined });
|
||||
}
|
||||
}}
|
||||
placeholder="L..."
|
||||
/>
|
||||
</div>
|
||||
</AdminInput>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisconnectSquare}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-[var(--admin-danger)] hover:underline"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
Disconnect Square
|
||||
</button>
|
||||
</>
|
||||
@@ -314,7 +426,7 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
|
||||
{/* Square Sync section */}
|
||||
{hasSquareToken && provider === "square" && (
|
||||
<div className="space-y-5 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-light)] p-5">
|
||||
<div className="space-y-5 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] p-5">
|
||||
<div>
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">Square Sync</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
@@ -323,103 +435,126 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
</div>
|
||||
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSquareSyncEnabled(!squareSyncEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
squareSyncEnabled ? "bg-[var(--admin-accent)]" : "bg-stone-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
squareSyncEnabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-[var(--admin-text-primary)]">
|
||||
{squareSyncEnabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<div className="flex items-center gap-4 p-4 rounded-xl bg-white border border-[var(--admin-border)]">
|
||||
<AdminToggle
|
||||
checked={squareSyncEnabled}
|
||||
onChange={(checked) => {
|
||||
setSquareSyncEnabled(checked);
|
||||
setDirty(true);
|
||||
}}
|
||||
label="Enable Square Sync"
|
||||
description="Automatically sync products and orders with Square"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{squareSyncEnabled && (
|
||||
<>
|
||||
{/* Inventory mode */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-[var(--admin-text-primary)]">Inventory sync direction</p>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Inventory sync direction</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "rc_to_square", label: "RC → Square" },
|
||||
{ value: "square_to_rc", label: "Square → RC" },
|
||||
{ value: "bidirectional", label: "Bidirectional" },
|
||||
{ value: "none", label: "None", desc: "No sync" },
|
||||
{ value: "rc_to_square", label: "RC → Square", desc: "Push to Square" },
|
||||
{ value: "square_to_rc", label: "Square → RC", desc: "Pull from Square" },
|
||||
{ value: "bidirectional", label: "Bidirectional", desc: "Sync both" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSquareInventoryMode(opt.value as InventoryMode)}
|
||||
className={`rounded-lg border px-3 py-2 text-xs font-medium ${
|
||||
onClick={() => {
|
||||
setSquareInventoryMode(opt.value as InventoryMode);
|
||||
setDirty(true);
|
||||
}}
|
||||
className={`rounded-xl border px-3 py-2.5 text-left transition-all ${
|
||||
squareInventoryMode === opt.value
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent)] text-white"
|
||||
: "border-[var(--admin-border)] text-[var(--admin-text-secondary)] hover:bg-stone-50"
|
||||
? "border-[var(--admin-accent)] bg-[var(--admin-accent-light)] ring-2 ring-[var(--admin-accent)]"
|
||||
: "border-[var(--admin-border)] hover:border-[var(--admin-accent)]/50"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{opt.label}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Now buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSyncNow("products")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-white px-4 py-2 text-sm font-medium text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "Syncing..." : "Sync Products Now"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSyncNow("orders")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-white px-4 py-2 text-sm font-medium text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "Syncing..." : "Sync Orders Now"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSyncNow("all")}
|
||||
disabled={syncing}
|
||||
className="rounded-lg bg-[var(--admin-accent)] px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "Syncing..." : "Sync All Now"}
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Manual Sync</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleSyncNow("products")}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing && syncingType === "products" ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Syncing...
|
||||
</span>
|
||||
) : (
|
||||
"Sync Products Now"
|
||||
)}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleSyncNow("orders")}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing && syncingType === "orders" ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Syncing...
|
||||
</span>
|
||||
) : (
|
||||
"Sync Orders Now"
|
||||
)}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleSyncNow("all")}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing && syncingType === "all" ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Syncing...
|
||||
</span>
|
||||
) : (
|
||||
"Sync All Now"
|
||||
)}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last sync info */}
|
||||
<div className="text-xs text-[var(--admin-text-muted)]">
|
||||
Last sync: {lastSyncAt}
|
||||
{settings?.square_last_sync_error && (
|
||||
<span className="ml-2 text-red-600">— {settings.square_last_sync_error}</span>
|
||||
)}
|
||||
<div className="rounded-lg bg-[var(--admin-bg)] border border-[var(--admin-border)] px-4 py-3">
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
<span className="font-medium text-[var(--admin-text-secondary)]">Last sync:</span> {lastSyncAt}
|
||||
{settings?.square_last_sync_error && (
|
||||
<span className="ml-2 text-[var(--admin-danger)]">— {settings.square_last_sync_error}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sync log preview */}
|
||||
{syncLog.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
|
||||
Recent sync activity
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{syncLog.map((entry) => (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{syncLog.slice(0, 10).map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-xs ${
|
||||
entry.status === "success"
|
||||
? "border-green-200 bg-green-50 text-green-700"
|
||||
: "border-red-200 bg-red-50 text-red-700"
|
||||
? "border-[var(--admin-success)]/30 bg-[var(--admin-success)]/5 text-[var(--admin-success)]"
|
||||
: "border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/5 text-[var(--admin-danger)]"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
@@ -441,13 +576,32 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
|
||||
{/* Wholesale webhook log */}
|
||||
<WebhookLogsSection brandId={activeBrandId} />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-[var(--admin-accent)] px-6 py-3 text-sm font-bold text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Payment Settings"}
|
||||
</button>
|
||||
{/* Save button */}
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-[var(--admin-border)]">
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
Save Payment Settings
|
||||
</>
|
||||
)}
|
||||
</AdminButton>
|
||||
{dirty && !saving && (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">You have unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +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";
|
||||
import { PageHeader, AdminButton, AdminIconButton, AdminSearchInput, AdminFilterTabs, AdminViewModeTabs, useToast, Skeleton } from "@/components/admin/design-system";
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
@@ -76,6 +76,7 @@ const PackageIconHeader = () => (
|
||||
|
||||
export default function ProductsClient({ products, brandId }: { products: Product[]; brandId?: string }) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
|
||||
@@ -95,6 +96,7 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Image upload states
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
@@ -225,11 +227,20 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
setIsLoading(true);
|
||||
|
||||
const price = parseFloat(formData.price);
|
||||
if (isNaN(price) || price < 0) {
|
||||
setError("Please enter a valid price");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setError("Product name is required");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -237,6 +248,7 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
if (!brandId) {
|
||||
setError("Brand ID is required");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -267,15 +279,21 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to save product");
|
||||
showError("Failed to save product", result.error ?? "Please try again");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess(editingProduct ? "Product updated" : "Product created", `${formData.name} has been saved`);
|
||||
closeModal();
|
||||
setIsLoading(false);
|
||||
startTransition(() => router.refresh());
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
showError("Network error", "Please check your connection and try again");
|
||||
setSaving(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -285,7 +303,10 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
setDeletingId(null);
|
||||
if (result.success) {
|
||||
setDeleteConfirm(null);
|
||||
showSuccess("Product deleted", "The product has been removed");
|
||||
startTransition(() => router.refresh());
|
||||
} else {
|
||||
showError("Failed to delete product", result.error ?? "Please try again");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -317,15 +338,15 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{products.length}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-6" /> : products.length}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Active</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1">{activeCount}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-10 h-6" /> : activeCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Inactive</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1">{inactiveCount}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-stone-400 mt-1">{isLoading ? <Skeleton variant="text" className="w-8 h-6" /> : inactiveCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -387,19 +408,28 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600">
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Name</label>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Product name"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-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)]"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)] transition-colors ${
|
||||
error && !formData.name.trim()
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -417,7 +447,9 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Price</label>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Price <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400 text-sm">$</span>
|
||||
<input
|
||||
@@ -427,7 +459,11 @@ export default function ProductsClient({ products, brandId }: { products: Produc
|
||||
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-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"
|
||||
className={`w-full rounded-xl border pl-8 pr-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
error && (!formData.price || parseFloat(formData.price) < 0)
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type TimeTrackingSettings,
|
||||
type NotificationLogEntry,
|
||||
} from "@/actions/time-tracking";
|
||||
import { AdminToggle } from "./design-system/AdminToggle";
|
||||
import { AdminInput, AdminTextInput, AdminSelect, AdminButton } from "./design-system";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
@@ -363,25 +365,25 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
<div className="bg-stone-50 border border-stone-200 rounded-xl p-5">
|
||||
<h3 className="text-sm font-semibold text-stone-800 mb-4">Alert Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white border border-[var(--admin-border)]">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Daily overtime alerts</p>
|
||||
<p className="text-xs text-stone-500">Notify when worker hits daily limit</p>
|
||||
<p className="text-sm text-[var(--admin-text-primary)] font-medium">Daily overtime alerts</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Notify when worker hits daily limit</p>
|
||||
</div>
|
||||
<button onClick={() => setEnableDailyAlerts(!enableDailyAlerts)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableDailyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableDailyAlerts ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
<AdminToggle
|
||||
checked={enableDailyAlerts}
|
||||
onChange={setEnableDailyAlerts}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white border border-[var(--admin-border)]">
|
||||
<div>
|
||||
<p className="text-sm text-stone-700 font-medium">Weekly overtime alerts</p>
|
||||
<p className="text-xs text-stone-500">Notify when worker hits weekly limit</p>
|
||||
<p className="text-sm text-[var(--admin-text-primary)] font-medium">Weekly overtime alerts</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Notify when worker hits weekly limit</p>
|
||||
</div>
|
||||
<button onClick={() => setEnableWeeklyAlerts(!enableWeeklyAlerts)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enableWeeklyAlerts ? "bg-emerald-600" : "bg-stone-300"}`}>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${enableWeeklyAlerts ? "translate-x-4" : "translate-x-1"}`} />
|
||||
</button>
|
||||
<AdminToggle
|
||||
checked={enableWeeklyAlerts}
|
||||
onChange={setEnableWeeklyAlerts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,15 +442,43 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr
|
||||
</div>
|
||||
|
||||
{settingsError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl py-3 px-4 text-red-700 text-sm">{settingsError}</div>
|
||||
<div className="rounded-xl border border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10 px-4 py-3 text-sm text-[var(--admin-danger)] flex items-center gap-3">
|
||||
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
{settingsError}
|
||||
</div>
|
||||
)}
|
||||
{settingsSaved && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-xl py-3 px-4 text-emerald-700 text-sm">Settings saved.</div>
|
||||
<div className="rounded-xl border border-[var(--admin-success)]/30 bg-[var(--admin-success)]/10 px-4 py-3 text-sm text-[var(--admin-success)] flex items-center gap-3">
|
||||
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Settings saved successfully!
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleSaveNotifications} disabled={settingsSaving}
|
||||
className="px-6 py-3 rounded-xl bg-stone-900 hover:bg-stone-800 disabled:opacity-40 font-semibold text-sm text-white transition-all">
|
||||
{settingsSaving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleSaveNotifications}
|
||||
disabled={settingsSaving}
|
||||
>
|
||||
{settingsSaving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ShippingSettings,
|
||||
} from "@/actions/shipping/settings";
|
||||
import { AdminInput, AdminTextInput, AdminTextarea, AdminSelect, AdminButton } from "./design-system";
|
||||
import { AdminToggle } from "./design-system/AdminToggle";
|
||||
|
||||
const SERVICE_OPTIONS = [
|
||||
{ value: "FEDEX_OVERNIGHT", label: "FedEx Overnight" },
|
||||
@@ -23,6 +24,12 @@ type Props = {
|
||||
isPlatformAdmin?: boolean;
|
||||
};
|
||||
|
||||
interface ValidationErrors {
|
||||
fedexAccountNumber?: string;
|
||||
fedexApiKey?: string;
|
||||
fedexApiSecret?: string;
|
||||
}
|
||||
|
||||
export default function ShippingSettingsForm({
|
||||
settings,
|
||||
brandId: initialBrandId,
|
||||
@@ -47,14 +54,32 @@ export default function ShippingSettingsForm({
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
|
||||
const isConfigured = !!settings?.fedex_api_key && !!settings?.fedex_api_secret && !!settings?.fedex_account_number;
|
||||
|
||||
// Track dirty state
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
const hasChanges =
|
||||
fedexAccountNumber !== (settings.fedex_account_number ?? "") ||
|
||||
fedexApiKey !== (settings.fedex_api_key ?? "") ||
|
||||
fedexApiSecret !== (settings.fedex_api_secret ?? "") ||
|
||||
fedexUseProduction !== (settings.fedex_use_production ?? false) ||
|
||||
defaultServiceType !== (settings.default_service_type ?? "FEDEX_GROUND") ||
|
||||
refrigeratedHandlingNotes !== (settings.refrigerated_handling_notes ?? "") ||
|
||||
fragileHandlingNotes !== (settings.fragile_handling_notes ?? "");
|
||||
setDirty(hasChanges);
|
||||
}
|
||||
}, [fedexAccountNumber, fedexApiKey, fedexApiSecret, fedexUseProduction, defaultServiceType, refrigeratedHandlingNotes, fragileHandlingNotes, settings]);
|
||||
|
||||
// Reload settings when brand changes
|
||||
useEffect(() => {
|
||||
if (!isPlatformAdmin) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setDirty(false);
|
||||
getShippingSettings(activeBrandId).then((result) => {
|
||||
setLoading(false);
|
||||
if (result.success && result.settings) {
|
||||
@@ -66,6 +91,7 @@ export default function ShippingSettingsForm({
|
||||
setDefaultServiceType(s.default_service_type);
|
||||
setRefrigeratedHandlingNotes(s.refrigerated_handling_notes ?? "");
|
||||
setFragileHandlingNotes(s.fragile_handling_notes ?? "");
|
||||
setErrors({});
|
||||
} else {
|
||||
// Reset form for new brand
|
||||
setFedexAccountNumber("");
|
||||
@@ -75,12 +101,39 @@ export default function ShippingSettingsForm({
|
||||
setDefaultServiceType("FEDEX_GROUND");
|
||||
setRefrigeratedHandlingNotes("Keep refrigerated. Do not freeze. Handle with care — contains fresh sweet corn and/or onions.");
|
||||
setFragileHandlingNotes("");
|
||||
setErrors({});
|
||||
}
|
||||
});
|
||||
}, [activeBrandId, isPlatformAdmin]);
|
||||
|
||||
function validate(): boolean {
|
||||
const newErrors: ValidationErrors = {};
|
||||
|
||||
if (fedexAccountNumber && fedexAccountNumber.length !== 12) {
|
||||
newErrors.fedexAccountNumber = "FedEx account number must be 12 digits";
|
||||
}
|
||||
|
||||
if (fedexApiKey && fedexApiKey.length < 10) {
|
||||
newErrors.fedexApiKey = "API Key appears too short - verify it from FedEx developer portal";
|
||||
}
|
||||
|
||||
if (fedexApiSecret && fedexApiSecret.length < 10) {
|
||||
newErrors.fedexApiSecret = "API Secret appears too short - verify it from FedEx developer portal";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate before saving
|
||||
if (!validate()) {
|
||||
setError("Please correct the errors below before saving.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
@@ -102,13 +155,14 @@ export default function ShippingSettingsForm({
|
||||
setError(result.error ?? "Failed to save");
|
||||
} else {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
setDirty(false);
|
||||
setTimeout(() => setSaved(false), 4000);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
if (!fedexApiKey || !fedexApiSecret) {
|
||||
setTestResult({ success: false, message: "Enter API Key and API Secret before testing." });
|
||||
setTestResult({ success: false, message: "Please enter both API Key and API Secret before testing." });
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
@@ -122,13 +176,15 @@ export default function ShippingSettingsForm({
|
||||
<form onSubmit={handleSave} className="space-y-8">
|
||||
{/* Platform admin brand picker */}
|
||||
{isPlatformAdmin && brands.length > 0 && (
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
value={activeBrandId}
|
||||
onChange={(e) => setActiveBrandId(e.target.value)}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
<div className="p-4 rounded-xl bg-[var(--admin-bg)] border border-[var(--admin-border)]">
|
||||
<AdminInput label="Brand" helpText="Select a brand to configure shipping settings for:">
|
||||
<AdminSelect
|
||||
value={activeBrandId}
|
||||
onChange={(e) => setActiveBrandId(e.target.value)}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection status banner */}
|
||||
@@ -136,52 +192,75 @@ export default function ShippingSettingsForm({
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-medium border"
|
||||
style={{
|
||||
backgroundColor: isConfigured ? "rgba(16, 185, 129, 0.1)" : "rgba(245, 158, 11, 0.1)",
|
||||
borderColor: isConfigured ? "var(--admin-accent)" : "rgba(245, 158, 11, 0.3)",
|
||||
color: isConfigured ? "var(--admin-accent)" : "rgb(245, 158, 11)",
|
||||
borderColor: isConfigured ? "var(--admin-success)" : "rgba(245, 158, 11, 0.3)",
|
||||
color: isConfigured ? "var(--admin-success)" : "rgb(245, 158, 11)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: isConfigured ? "var(--admin-accent)" : "rgb(245, 158, 11)" }}
|
||||
className="h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: isConfigured ? "var(--admin-success)" : "rgb(245, 158, 11)" }}
|
||||
/>
|
||||
{isConfigured
|
||||
? `FedEx Connected — ${settings?.fedex_use_production ? "Production" : "Sandbox"} mode`
|
||||
: "FedEx Not Configured — enter credentials below to enable shipping rates and label generation"}
|
||||
{isConfigured ? (
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold">FedEx Connected</span>
|
||||
<span className="ml-2 text-[var(--admin-text-muted)]">
|
||||
— {settings?.fedex_use_production ? "Production Mode" : "Sandbox/Test Mode"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>FedEx Not Configured — enter credentials below to enable shipping rates</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error/Success messages */}
|
||||
{error && (
|
||||
<div
|
||||
className="rounded-xl p-4 text-sm border"
|
||||
className="rounded-xl p-4 text-sm border flex items-start gap-3"
|
||||
style={{
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
borderColor: "rgba(239, 68, 68, 0.3)",
|
||||
color: "rgb(239, 68, 68)"
|
||||
}}
|
||||
>
|
||||
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{saved && (
|
||||
<div
|
||||
className="rounded-xl p-4 text-sm border"
|
||||
className="rounded-xl p-4 text-sm border flex items-center gap-3"
|
||||
style={{
|
||||
backgroundColor: "rgba(16, 185, 129, 0.1)",
|
||||
borderColor: "rgba(16, 185, 129, 0.3)",
|
||||
color: "var(--admin-accent)"
|
||||
color: "var(--admin-success)"
|
||||
}}
|
||||
>
|
||||
Shipping settings saved.
|
||||
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Shipping settings saved successfully!
|
||||
</div>
|
||||
)}
|
||||
{testResult && (
|
||||
<div
|
||||
className="rounded-xl p-4 text-sm border"
|
||||
className="rounded-xl p-4 text-sm border flex items-start gap-3"
|
||||
style={{
|
||||
backgroundColor: testResult.success ? "rgba(16, 185, 129, 0.1)" : "rgba(239, 68, 68, 0.1)",
|
||||
borderColor: testResult.success ? "rgba(16, 185, 129, 0.3)" : "rgba(239, 68, 68, 0.3)",
|
||||
color: testResult.success ? "var(--admin-accent)" : "rgb(239, 68, 68)"
|
||||
color: testResult.success ? "var(--admin-success)" : "rgb(239, 68, 68)"
|
||||
}}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
@@ -195,9 +274,9 @@ export default function ShippingSettingsForm({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold" style={{ color: "var(--admin-text-primary)" }}>FedEx API Credentials</h3>
|
||||
<p className="mt-1 text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Used for shipping fresh sweet corn and onions. Get your credentials from{" "}
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">FedEx API Credentials</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Get your credentials from{" "}
|
||||
<a
|
||||
href="https://developer.fedex.com"
|
||||
target="_blank"
|
||||
@@ -207,39 +286,73 @@ export default function ShippingSettingsForm({
|
||||
>
|
||||
developer.fedex.com
|
||||
</a>
|
||||
.
|
||||
. These are used for shipping fresh produce.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<AdminInput label="FedEx Account Number *">
|
||||
<AdminInput
|
||||
label="FedEx Account Number"
|
||||
required
|
||||
error={errors.fedexAccountNumber}
|
||||
helpText="12-digit FedEx account number"
|
||||
>
|
||||
<AdminTextInput
|
||||
value={fedexAccountNumber}
|
||||
onChange={(e) => setFedexAccountNumber(e.target.value)}
|
||||
placeholder="000000000000 (12 digits)"
|
||||
onChange={(e) => {
|
||||
setFedexAccountNumber(e.target.value);
|
||||
setDirty(true);
|
||||
if (errors.fedexAccountNumber) {
|
||||
setErrors({ ...errors, fedexAccountNumber: undefined });
|
||||
}
|
||||
}}
|
||||
placeholder="000000000000"
|
||||
maxLength={12}
|
||||
/>
|
||||
</AdminInput>
|
||||
<AdminInput label="API Key *">
|
||||
<AdminInput
|
||||
label="API Key"
|
||||
required
|
||||
error={errors.fedexApiKey}
|
||||
helpText="From FedEx Developer Portal"
|
||||
>
|
||||
<AdminTextInput
|
||||
value={fedexApiKey}
|
||||
onChange={(e) => setFedexApiKey(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFedexApiKey(e.target.value);
|
||||
setDirty(true);
|
||||
if (errors.fedexApiKey) {
|
||||
setErrors({ ...errors, fedexApiKey: undefined });
|
||||
}
|
||||
}}
|
||||
placeholder="Your FedEx API key"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<AdminInput label="API Secret *" helpText="Password field — click Show/Hide to reveal">
|
||||
<AdminInput
|
||||
label="API Secret"
|
||||
required
|
||||
error={errors.fedexApiSecret}
|
||||
helpText="Password field — click Show/Hide to reveal"
|
||||
>
|
||||
<div className="relative">
|
||||
<AdminTextInput
|
||||
type={showSecret ? "text" : "password"}
|
||||
value={fedexApiSecret}
|
||||
onChange={(e) => setFedexApiSecret(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFedexApiSecret(e.target.value);
|
||||
setDirty(true);
|
||||
if (errors.fedexApiSecret) {
|
||||
setErrors({ ...errors, fedexApiSecret: undefined });
|
||||
}
|
||||
}}
|
||||
placeholder="Your FedEx API secret"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecret(!showSecret)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs px-2 py-1 rounded-lg hover:bg-[var(--admin-bg)] transition-colors"
|
||||
style={{ color: "var(--admin-text-muted)" }}
|
||||
>
|
||||
{showSecret ? "Hide" : "Show"}
|
||||
@@ -249,57 +362,52 @@ export default function ShippingSettingsForm({
|
||||
|
||||
{/* Production toggle */}
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-3 border"
|
||||
className="flex items-center gap-4 rounded-xl px-4 py-3 border"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg)",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFedexUseProduction(!fedexUseProduction)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{
|
||||
backgroundColor: fedexUseProduction ? "var(--admin-accent)" : "var(--admin-text-muted)"
|
||||
<AdminToggle
|
||||
checked={fedexUseProduction}
|
||||
onChange={(checked) => {
|
||||
setFedexUseProduction(checked);
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 rounded-full transition-transform"
|
||||
style={{
|
||||
backgroundColor: "white",
|
||||
transform: fedexUseProduction ? "translateX(26px)" : "translateX(4px)"
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Use Production Mode</span>
|
||||
<p className="text-xs" style={{ color: "var(--admin-text-muted)" }}>
|
||||
{fedexUseProduction
|
||||
? "Live rates, real labels. Sandbox is currently active."
|
||||
: "Sandbox/test mode. Use for testing before going live."}
|
||||
</p>
|
||||
</div>
|
||||
label="Use Production Mode"
|
||||
description={fedexUseProduction ? "Live rates, real labels" : "Sandbox/test mode — safe for testing"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test connection */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !fedexApiKey || !fedexApiSecret}
|
||||
>
|
||||
{testing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Testing...
|
||||
</span>
|
||||
) : (
|
||||
"Test Connection"
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.55 4.05l7.97 7.97-2.12 2.12-7.85-7.85-2.12 2.12 7.97 7.97-2.12 2.12-7.97-7.97 2.12-2.12 7.97 7.97L3.58 6.17l7.97-7.97 2.12 2.12-7.22 7.73z" />
|
||||
</svg>
|
||||
Test Connection
|
||||
</>
|
||||
)}
|
||||
</AdminButton>
|
||||
{testResult?.success && (
|
||||
<span className="text-sm font-medium" style={{ color: "var(--admin-accent)" }}>✓ Connection verified</span>
|
||||
<span className="text-sm font-medium flex items-center gap-1.5" style={{ color: "var(--admin-success)" }}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Connection verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,16 +421,19 @@ export default function ShippingSettingsForm({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold" style={{ color: "var(--admin-text-primary)" }}>Default Shipping Service</h3>
|
||||
<p className="mt-1 text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Applied automatically when creating shipments. Perishable orders always require Overnight or 2-Day Air regardless of this setting.
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">Default Shipping Service</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
Applied automatically for non-perishable orders. Perishable orders always require Overnight or 2-Day Air.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminInput label="Default for non-perishable orders">
|
||||
<AdminInput label="Default for non-perishable orders" helpText="Used when creating shipments for non-perishable items">
|
||||
<AdminSelect
|
||||
value={defaultServiceType}
|
||||
onChange={(e) => setDefaultServiceType(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setDefaultServiceType(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
options={SERVICE_OPTIONS}
|
||||
/>
|
||||
</AdminInput>
|
||||
@@ -337,48 +448,72 @@ export default function ShippingSettingsForm({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold" style={{ color: "var(--admin-text-primary)" }}>Handling Instructions</h3>
|
||||
<p className="mt-1 text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
||||
These notes are attached to shipments containing perishable or fragile items (sweet corn, onions, etc.). Appear on the carrier label and in the warehouse.
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)]">Handling Instructions</h3>
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
|
||||
These notes are attached to shipments for perishable or fragile items. Appear on carrier labels and in warehouse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminInput
|
||||
label="Refrigerated / Perishable Notes"
|
||||
helpText="Applied to all shipments with perishable items (is_perishable = true)."
|
||||
helpText="Applied to all shipments with perishable items (sweet corn, onions, etc.)"
|
||||
>
|
||||
<AdminTextarea
|
||||
value={refrigeratedHandlingNotes}
|
||||
onChange={(e) => setRefrigeratedHandlingNotes(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setRefrigeratedHandlingNotes(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
rows={3}
|
||||
placeholder="e.g. Keep refrigerated. Do not freeze. Contains fresh sweet corn and/or onions."
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="Fragile Items Notes">
|
||||
<AdminInput label="Fragile Items Notes" helpText="Applied to shipments with fragile items">
|
||||
<AdminTextarea
|
||||
value={fragileHandlingNotes}
|
||||
onChange={(e) => setFragileHandlingNotes(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFragileHandlingNotes(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="e.g. Fragile — handle with care. Do not stack heavy items on top."
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
{/* Save/Cancel buttons */}
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-3" style={{ color: "var(--admin-text-muted)" }}>
|
||||
<div className="h-5 w-5 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Loading settings...
|
||||
</div>
|
||||
) : (
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving..." : "Save Shipping Settings"}
|
||||
</AdminButton>
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-[var(--admin-border)]">
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-4 w-4 rounded-full border-2 border-current border-t-transparent animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
Save Shipping Settings
|
||||
</>
|
||||
)}
|
||||
</AdminButton>
|
||||
{dirty && !saving && (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">You have unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -3,7 +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";
|
||||
import { AdminInput, AdminTextInput, AdminSelect, AdminButton, useToast } from "./design-system";
|
||||
|
||||
type Brand = {
|
||||
id: string;
|
||||
@@ -31,6 +31,7 @@ type StopEditFormProps = {
|
||||
|
||||
export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
@@ -46,17 +47,36 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
||||
const [zip, setZip] = useState(stop.zip ?? "");
|
||||
const [cutoff_time, setCutoff_time] = useState(stop.cutoff_time ?? "");
|
||||
|
||||
// Validation errors
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
function validateForm(): boolean {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!city.trim()) {
|
||||
errors.city = "City is required";
|
||||
}
|
||||
if (!state.trim()) {
|
||||
errors.state = "State is required";
|
||||
}
|
||||
if (!date.trim()) {
|
||||
errors.date = "Date is required";
|
||||
}
|
||||
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!city.trim() || !state.trim() || !date.trim()) {
|
||||
setError("City, state, and date are required.");
|
||||
if (!validateForm()) {
|
||||
showError("Validation failed", "Please fix the errors below");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
|
||||
const slug = city.toLowerCase().replace(/\s+/g, "-") + "-" + date;
|
||||
|
||||
const result = await updateStop(stop.id, brand_id, {
|
||||
city,
|
||||
state,
|
||||
@@ -70,11 +90,13 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
showError("Failed to save", result.error ?? "Please try again");
|
||||
setError(result.error ?? "Failed to save");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccess("Stop updated", `${city}, ${state} has been saved`);
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
router.refresh();
|
||||
@@ -83,90 +105,150 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-red-900/30 p-4 text-sm text-red-400">
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-600 flex items-start gap-3">
|
||||
<svg className="h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved && (
|
||||
<div className="rounded-xl bg-green-900/30 p-4 text-sm text-green-400">
|
||||
Stop updated successfully.
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
City <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => {
|
||||
setCity(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.city;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="City name"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.city
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.city && <p className="mt-1 text-xs text-red-500">{fieldErrors.city}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AdminInput label="City">
|
||||
<AdminTextInput
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
placeholder="City name"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="State">
|
||||
<AdminTextInput
|
||||
value={state}
|
||||
onChange={(e) => setState(e.target.value)}
|
||||
placeholder="State code"
|
||||
/>
|
||||
</AdminInput>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
State <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={state}
|
||||
onChange={(e) => {
|
||||
setState(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.state;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="State code"
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.state
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.state && <p className="mt-1 text-xs text-red-500">{fieldErrors.state}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AdminInput label="Date">
|
||||
<AdminTextInput
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</AdminInput>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">
|
||||
Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value);
|
||||
setFieldErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.date;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`w-full rounded-xl border px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-[var(--admin-accent)]/20 ${
|
||||
fieldErrors.date
|
||||
? "border-red-400 bg-red-50"
|
||||
: "border-[var(--admin-border)] bg-white focus:border-[var(--admin-accent)]"
|
||||
}`}
|
||||
/>
|
||||
{fieldErrors.date && <p className="mt-1 text-xs text-red-500">{fieldErrors.date}</p>}
|
||||
</div>
|
||||
|
||||
<AdminInput label="Time">
|
||||
<AdminTextInput
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
placeholder="e.g. 8:00 AM – 2:00 PM"
|
||||
/>
|
||||
</AdminInput>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">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-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminInput label="Location">
|
||||
<AdminTextInput
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Location Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Street address or intersection"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</AdminInput>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AdminInput label="Street Address">
|
||||
<AdminTextInput
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</AdminInput>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">Street Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminInput label="ZIP Code">
|
||||
<AdminTextInput
|
||||
value={zip}
|
||||
onChange={(e) => setZip(e.target.value)}
|
||||
placeholder="80102"
|
||||
maxLength={10}
|
||||
/>
|
||||
</AdminInput>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-700 mb-1.5">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-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
label="Order Cutoff"
|
||||
helpText="Customers must order before this time to be included at this stop."
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={cutoff_time}
|
||||
onChange={(e) => setCutoff_time(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
|
||||
/>
|
||||
</AdminInput>
|
||||
|
||||
<AdminInput label="Brand">
|
||||
<AdminSelect
|
||||
@@ -177,26 +259,30 @@ export default function StopEditForm({ stop, brands }: StopEditFormProps) {
|
||||
</AdminInput>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-zinc-300">Status</label>
|
||||
<label className="mb-2 block text-sm font-medium text-stone-700">Status</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive((v) => !v)}
|
||||
className={`w-full rounded-xl px-4 py-3 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-zinc-950 text-zinc-400 hover:bg-slate-200"
|
||||
? "bg-green-100 text-green-700 border border-green-200"
|
||||
: "bg-stone-100 text-stone-500 border border-stone-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{active ? "Active" : "Inactive"}
|
||||
{active ? "✓ Active — visible on storefront" : "○ Inactive — hidden from storefront"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<AdminButton
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-bold text-white disabled:opacity-50"
|
||||
isLoading={saving}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</AdminButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useTransition } from "react";
|
||||
import React, { useState, useTransition, useEffect } 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";
|
||||
import { AdminSearchInput, AdminFilterTabs, AdminButton, AdminIconButton, useToast, Skeleton } from "@/components/admin/design-system";
|
||||
|
||||
type Stop = {
|
||||
id: string;
|
||||
@@ -26,13 +26,24 @@ type Props = {
|
||||
|
||||
export default function StopTableClient({ stops }: Props) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all");
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedStops, setSelectedStops] = useState<Set<string>>(new Set());
|
||||
const [bulkPublishing, setBulkPublishing] = useState(false);
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// Simulate loading when filters change
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => setIsLoading(false), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [page, statusFilter, search]);
|
||||
|
||||
const filtered = stops.filter((s) => {
|
||||
const matchesSearch =
|
||||
!search ||
|
||||
@@ -62,6 +73,59 @@ export default function StopTableClient({ stops }: Props) {
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const paginatedStops = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const draftStops = stops.filter(s => s.status === "draft" && s.active);
|
||||
|
||||
// Bulk selection
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedStops.size === paginatedStops.length) {
|
||||
setSelectedStops(new Set());
|
||||
} else {
|
||||
setSelectedStops(new Set(paginatedStops.map(s => s.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStopSelection = (stopId: string) => {
|
||||
setSelectedStops(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(stopId)) {
|
||||
next.delete(stopId);
|
||||
} else {
|
||||
next.add(stopId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
async function handleBulkPublish() {
|
||||
if (selectedStops.size === 0) return;
|
||||
|
||||
setBulkPublishing(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const stopId of selectedStops) {
|
||||
const stop = stops.find(s => s.id === stopId);
|
||||
if (stop && stop.status === "draft") {
|
||||
const result = await publishStop(stopId, stop.brand_id);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBulkPublishing(false);
|
||||
setSelectedStops(new Set());
|
||||
|
||||
if (failCount === 0) {
|
||||
showSuccess(`${successCount} stop${successCount !== 1 ? 's' : ''} published`);
|
||||
} else {
|
||||
showError("Some stops failed", `${successCount} succeeded, ${failCount} failed`);
|
||||
}
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
}
|
||||
|
||||
function handleDeleted() {
|
||||
setDeleteError(null);
|
||||
@@ -97,7 +161,7 @@ export default function StopTableClient({ stops }: Props) {
|
||||
size="sm"
|
||||
label="Previous page"
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
disabled={page === 0 || isLoading}
|
||||
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>
|
||||
@@ -108,7 +172,7 @@ export default function StopTableClient({ stops }: Props) {
|
||||
size="sm"
|
||||
label="Next page"
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
disabled={page >= totalPages - 1 || isLoading}
|
||||
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>
|
||||
@@ -117,6 +181,31 @@ export default function StopTableClient({ stops }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedStops.size > 0 && (
|
||||
<div className="mx-5 my-3 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
|
||||
{selectedStops.size} stop{selectedStops.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedStops(new Set())}
|
||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleBulkPublish}
|
||||
isLoading={bulkPublishing}
|
||||
>
|
||||
Publish Selected
|
||||
</AdminButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete error */}
|
||||
{deleteError && (
|
||||
<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)]">
|
||||
@@ -131,6 +220,14 @@ export default function StopTableClient({ stops }: Props) {
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
|
||||
<tr>
|
||||
<th className="w-10 px-5 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStops.size === paginatedStops.length && paginatedStops.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-5 py-4 font-semibold">City</th>
|
||||
<th className="px-5 py-4 font-semibold">Location</th>
|
||||
<th className="px-5 py-4 font-semibold">Date</th>
|
||||
@@ -141,7 +238,20 @@ export default function StopTableClient({ stops }: Props) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{filtered.length === 0 ? (
|
||||
{isLoading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="hover:bg-[var(--admin-bg-subtle)] transition-colors">
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="h-5 w-5" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-24 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-32 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-16 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="text" className="w-20 h-4" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="w-16 h-6 rounded-full" /></td>
|
||||
<td className="px-5 py-4"><Skeleton variant="rect" className="w-12 h-6" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-5 py-10 text-center text-sm text-[var(--admin-text-muted)]">
|
||||
{search || statusFilter !== "all"
|
||||
@@ -156,6 +266,8 @@ export default function StopTableClient({ stops }: Props) {
|
||||
stop={stop}
|
||||
onDeleted={handleDeleted}
|
||||
onDeleteError={setDeleteError}
|
||||
isSelected={selectedStops.has(stop.id)}
|
||||
onToggleSelect={() => toggleStopSelection(stop.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -169,12 +281,17 @@ function StopRowBase({
|
||||
stop,
|
||||
onDeleted,
|
||||
onDeleteError,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
}: {
|
||||
stop: Stop;
|
||||
onDeleted: () => void;
|
||||
onDeleteError: (msg: string) => void;
|
||||
isSelected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [, startTransition] = useTransition();
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
@@ -187,7 +304,10 @@ function StopRowBase({
|
||||
const result = await publishStop(stopId, stop.brand_id);
|
||||
setPublishingId(null);
|
||||
if (result.success) {
|
||||
showSuccess("Stop published", "The stop is now visible on the storefront");
|
||||
startTransition(() => router.refresh());
|
||||
} else {
|
||||
showError("Failed to publish", result.error ?? "Please try again");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +329,7 @@ function StopRowBase({
|
||||
setConfirmDelete(null);
|
||||
setOpenMenu(null);
|
||||
if (data.success) {
|
||||
showSuccess("Stop deleted", "The stop has been removed");
|
||||
onDeleted();
|
||||
} else {
|
||||
onDeleteError(data.error ?? "Delete failed");
|
||||
@@ -216,7 +337,15 @@ function StopRowBase({
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-[var(--admin-bg-subtle)] transition-colors relative">
|
||||
<tr className={`hover:bg-[var(--admin-bg-subtle)] transition-colors relative ${isSelected ? 'bg-[var(--admin-accent-light)]/30' : ''}`}>
|
||||
<td className="px-5 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={onToggleSelect}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<Link
|
||||
href={`/admin/stops/${stop.id}`}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from "react";
|
||||
|
||||
export type ToastType = "success" | "error" | "info" | "warning";
|
||||
|
||||
export type Toast = {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ToastContextType = {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, "id">) => void;
|
||||
removeToast: (id: string) => void;
|
||||
success: (message: string, description?: string) => void;
|
||||
error: (message: string, description?: string) => void;
|
||||
info: (message: string, description?: string) => void;
|
||||
warning: (message: string, description?: string) => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = useCallback((toast: Omit<Toast, "id">) => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
setToasts((prev) => [...prev, { ...toast, id }]);
|
||||
|
||||
// Auto-dismiss after 4 seconds for success/info, 6 seconds for error/warning
|
||||
const duration = toast.type === "success" || toast.type === "info" ? 4000 : 6000;
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, duration);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const success = useCallback((message: string, description?: string) => {
|
||||
addToast({ type: "success", message, description });
|
||||
}, [addToast]);
|
||||
|
||||
const error = useCallback((message: string, description?: string) => {
|
||||
addToast({ type: "error", message, description });
|
||||
}, [addToast]);
|
||||
|
||||
const info = useCallback((message: string, description?: string) => {
|
||||
addToast({ type: "info", message, description });
|
||||
}, [addToast]);
|
||||
|
||||
const warning = useCallback((message: string, description?: string) => {
|
||||
addToast({ type: "warning", message, description });
|
||||
}, [addToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast, success, error, info, warning }}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Hook for simpler usage when you only need success/error
|
||||
export function useToastActions() {
|
||||
const { success, error, info, warning } = useToast();
|
||||
return { success, error, info, warning };
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useToast, Toast as ToastType } from "./Toast";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Icons for each toast type
|
||||
const ToastIcons = {
|
||||
success: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="h-5 w-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>
|
||||
),
|
||||
};
|
||||
|
||||
const toastStyles: Record<ToastType["type"], { bg: string; border: string; icon: string; text: string }> = {
|
||||
success: {
|
||||
bg: "bg-white",
|
||||
border: "border-[var(--admin-accent)]",
|
||||
icon: "text-[var(--admin-accent)]",
|
||||
text: "text-[var(--admin-text-primary)]",
|
||||
},
|
||||
error: {
|
||||
bg: "bg-white",
|
||||
border: "border-[var(--admin-danger)]",
|
||||
icon: "text-[var(--admin-danger)]",
|
||||
text: "text-[var(--admin-text-primary)]",
|
||||
},
|
||||
info: {
|
||||
bg: "bg-white",
|
||||
border: "border-blue-500",
|
||||
icon: "text-blue-500",
|
||||
text: "text-[var(--admin-text-primary)]",
|
||||
},
|
||||
warning: {
|
||||
bg: "bg-white",
|
||||
border: "border-amber-500",
|
||||
icon: "text-amber-500",
|
||||
text: "text-[var(--admin-text-primary)]",
|
||||
},
|
||||
};
|
||||
|
||||
// Individual toast item
|
||||
function ToastItem({ toast, onDismiss }: { toast: ToastType; onDismiss: () => void }) {
|
||||
const styles = toastStyles[toast.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-start gap-3 rounded-xl border p-4 shadow-lg backdrop-blur-sm
|
||||
animate-in slide-in-from-right-5 fade-in duration-300
|
||||
${styles.bg} ${styles.border}
|
||||
`}
|
||||
role="alert"
|
||||
>
|
||||
<div className={`shrink-0 ${styles.icon}`}>
|
||||
{ToastIcons[toast.type]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-semibold ${styles.text}`}>{toast.message}</p>
|
||||
{toast.description && (
|
||||
<p className="mt-0.5 text-xs text-[var(--admin-text-muted)]">{toast.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="shrink-0 rounded-lg p-1 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-stone-100 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Container component - renders in a fixed position
|
||||
export function ToastContainer() {
|
||||
const { toasts, removeToast } = useToast();
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-6 right-6 z-[100] flex flex-col gap-2 max-w-sm w-full"
|
||||
aria-live="polite"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onDismiss={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple inline toast for use without provider (legacy support)
|
||||
interface InlineToastProps {
|
||||
type: ToastType["type"];
|
||||
message: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function InlineToast({ type, message, onDismiss }: InlineToastProps) {
|
||||
const styles = toastStyles[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3 rounded-xl border p-4 shadow-lg
|
||||
${styles.bg} ${styles.border}
|
||||
`}
|
||||
role="alert"
|
||||
>
|
||||
<div className={`shrink-0 ${styles.icon}`}>
|
||||
{ToastIcons[type]}
|
||||
</div>
|
||||
<p className={`flex-1 text-sm font-semibold ${styles.text}`}>{message}</p>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="shrink-0 rounded-lg p-1 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-stone-100 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
type AdminToggleProps = {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "md";
|
||||
label?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminToggle({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
size = "md",
|
||||
label,
|
||||
description,
|
||||
className = "",
|
||||
}: AdminToggleProps) {
|
||||
const sizeClasses = {
|
||||
sm: {
|
||||
track: "h-6 w-11",
|
||||
thumb: "h-4 w-4",
|
||||
translate: checked ? "translate-x-5" : "translate-x-1",
|
||||
},
|
||||
md: {
|
||||
track: "h-7 w-12",
|
||||
thumb: "h-5 w-5",
|
||||
translate: checked ? "translate-x-6" : "translate-x-1",
|
||||
},
|
||||
};
|
||||
|
||||
const classes = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<label className={`flex items-start gap-3 ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={`relative inline-flex shrink-0 items-center rounded-full transition-colors ${
|
||||
checked
|
||||
? "bg-[var(--admin-accent)]"
|
||||
: "bg-[var(--admin-text-muted)]"
|
||||
} ${classes.track}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block rounded-full bg-white shadow transition-transform ${classes.thumb} ${classes.translate}`}
|
||||
/>
|
||||
</button>
|
||||
{(label || description) && (
|
||||
<div className="flex flex-col">
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-[var(--admin-text-primary)]">{label}</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact toggle for use within form rows (no label, smaller)
|
||||
export function AdminToggleCompact({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
|
||||
checked ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
checked ? "translate-x-5" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{label && (
|
||||
<span className="text-sm text-[var(--admin-text-secondary)]">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminToggle;
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
// Shimmer skeleton components for loading states
|
||||
// Uses CSS animation for smooth shimmer effect
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
variant?: "text" | "rect" | "circle" | "card";
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export function Skeleton({ className = "", variant = "rect", width, height }: SkeletonProps) {
|
||||
const variantClasses = {
|
||||
text: "h-4 rounded",
|
||||
rect: "rounded-xl",
|
||||
circle: "rounded-full",
|
||||
card: "rounded-xl",
|
||||
};
|
||||
|
||||
const style: React.CSSProperties = {};
|
||||
if (width) style.width = typeof width === "number" ? `${width}px` : width;
|
||||
if (height) style.height = typeof height === "number" ? `${height}px` : height;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse bg-gradient-to-r from-stone-200 via-stone-100 to-stone-200 bg-[length:200%_100%] shimmer ${variantClasses[variant]} ${className}`}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Table row skeleton
|
||||
export function SkeletonTable({ rows = 5, cols = 5 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} className="flex items-center gap-4 p-4">
|
||||
{Array.from({ length: cols }).map((_, colIndex) => (
|
||||
<Skeleton key={colIndex} variant="text" className="flex-1" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card skeleton for grid views
|
||||
export function SkeletonCard({ showImage = true }: { showImage?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
{showImage && <Skeleton className="h-40 w-full" />}
|
||||
<div className="p-4 space-y-3">
|
||||
<Skeleton variant="text" className="w-3/4 h-5" />
|
||||
<Skeleton variant="text" className="w-1/2 h-4" />
|
||||
<div className="flex justify-between pt-2">
|
||||
<Skeleton variant="text" className="w-1/4 h-6" />
|
||||
<Skeleton variant="rect" className="w-16 h-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stats card skeleton
|
||||
export function SkeletonStats({ count = 3 }: { count?: number }) {
|
||||
return (
|
||||
<div className={`grid grid-cols-${Math.min(count, 4)} gap-3`}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<Skeleton variant="text" className="w-1/3 h-3 mb-2" />
|
||||
<Skeleton variant="text" className="w-1/2 h-6" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full page loading skeleton
|
||||
export function PageSkeleton() {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton variant="circle" className="h-10 w-10" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton variant="text" className="w-32 h-6" />
|
||||
<Skeleton variant="text" className="w-24 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton variant="rect" className="w-28 h-10" />
|
||||
</div>
|
||||
|
||||
{/* Stats skeleton */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<Skeleton variant="text" className="w-1/2 h-3 mb-2" />
|
||||
<Skeleton variant="text" className="w-1/3 h-7" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter bar skeleton */}
|
||||
<div className="flex gap-3">
|
||||
<Skeleton variant="rect" className="h-10 flex-1 max-w-xs" />
|
||||
<Skeleton variant="rect" className="h-10 w-32" />
|
||||
<Skeleton variant="rect" className="h-10 w-24" />
|
||||
</div>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white overflow-hidden">
|
||||
<div className="p-4 border-b border-[var(--admin-border)]">
|
||||
<div className="flex gap-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} variant="text" className="h-4 w-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--admin-border)]">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="p-4 flex gap-4">
|
||||
<Skeleton variant="text" className="w-20 h-4" />
|
||||
<Skeleton variant="text" className="w-32 h-4" />
|
||||
<Skeleton variant="text" className="w-24 h-4 flex-1" />
|
||||
<Skeleton variant="text" className="w-16 h-4" />
|
||||
<Skeleton variant="text" className="w-16 h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination skeleton */}
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<Skeleton variant="text" className="w-32 h-4" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton variant="rect" className="h-9 w-9" />
|
||||
<Skeleton variant="rect" className="h-9 w-12" />
|
||||
<Skeleton variant="rect" className="h-9 w-9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form skeleton
|
||||
export function FormSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton variant="text" className="w-24 h-3" />
|
||||
<Skeleton variant="rect" className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Toast notification system - re-exports for design-system
|
||||
// The actual implementations are in the parent admin directory
|
||||
// Use: import { ToastProvider, useToast, useToastActions, ToastContainer } from "@/components/admin/Toast";
|
||||
|
||||
export { ToastProvider, useToast, useToastActions } from "@/components/admin/Toast";
|
||||
export { ToastContainer, InlineToast } from "@/components/admin/ToastContainer";
|
||||
@@ -20,6 +20,14 @@ export { default as AdminFilterTabs, AdminStatusFilterTabs, AdminViewModeTabs }
|
||||
|
||||
// Form elements
|
||||
export { AdminInput, AdminTextInput, AdminTextarea, AdminSelect, AdminCheckbox, AdminSpinner, AdminLoadingOverlay } from "./AdminFormElements";
|
||||
export { AdminToggle, AdminToggleCompact } from "./AdminToggle";
|
||||
|
||||
// Skeleton loading components
|
||||
export { Skeleton, SkeletonTable, SkeletonCard, SkeletonStats, PageSkeleton, FormSkeleton } from "./Skeleton";
|
||||
|
||||
// Toast notification system
|
||||
export { ToastProvider, useToast, useToastActions } from "@/components/admin/Toast";
|
||||
export { ToastContainer, InlineToast } from "@/components/admin/ToastContainer";
|
||||
|
||||
// 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";
|
||||
Reference in New Issue
Block a user