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:
2026-06-02 05:19:34 +00:00
parent fb25c5ee22
commit b845d69aba
97 changed files with 10698 additions and 3487 deletions
+159 -27
View File
@@ -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>
)}
+250 -105
View File
@@ -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,
};
+188 -67
View File
@@ -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")}
+409 -30
View File
@@ -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&apos;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&apos;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
+238 -139
View File
@@ -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>
+207 -53
View File
@@ -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>
+161 -83
View File
@@ -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>
);
}
+134 -68
View File
@@ -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>
);
}
+302 -148
View File
@@ -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&apos;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>
);
}
}
+45 -9
View File
@@ -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>
+50 -20
View File
@@ -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>
</>
+218 -83
View File
@@ -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>
);
+160 -74
View File
@@ -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>
);
}
+135 -6
View File
@@ -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}`}
+79
View File
@@ -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 };
}
+150
View File
@@ -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";