feat: comprehensive frontend polish - UI/UX improvements across all pages
Public Pages: - Landing page with server/client split and metadata export - Cart page with ARIA accessibility and loading states - Checkout page with form accessibility and proper design system - Contact page with SEO metadata and improved accessibility - Pricing page with enhanced FAQ accessibility - Login page with Suspense boundaries and secure patterns Brand Storefronts: - Premium loading skeletons for Tuxedo and Indian River Direct - Branded error boundaries with animations - Loading.tsx for about, contact, FAQ, stops pages - Error.tsx for all storefront subpages Admin Dashboard: - AdminSidebar: ARIA labels, keyboard navigation, mobile improvements - DashboardClient: Stats cards, quick actions, usage progress - Admin layout: Toast provider integration Admin Orders/Products/Stops: - Toast notification system with auto-dismiss - Skeleton loading components (Table, Card, Stats, Form) - Bulk actions (mark picked up, publish stops) - Form validation with error styling Admin Communications: - AnalyticsDashboard: sparklines, stat cards, engagement badges - CampaignComposerPage: step wizard, template selection, email preview - SegmentBuilderPage: empty state, active segment handling - ContactListPanel: loading skeletons, professional empty states - MessageLogPanel: stats cards, engagement indicators - HarvestReachNav: branded tab navigation - All pages: metadata exports and loading.tsx Admin Settings: - SquareSyncSettingsClient: design system colors, save/cancel UX - ShippingSettingsForm: validation, dirty state tracking - Integrations page: proper layout with AI and communications sections - Billing: improved plan comparison, add-on cards - PaymentSettings: toggle components, validation Wholesale Portal: - Portal: loading skeletons, quantity stepper, search/filter - Login/Register: FormField validation, success states - Success/Cancel pages: animated checkmarks - Employee portal: skeletons, empty states, mobile responsive Water Log: - FieldClient: loading step, progress indicator, spinner - AdminClient: loading skeletons - Admin pages: loading.tsx files New Components: - Toast.tsx/ToastContainer.tsx: comprehensive notification system - Skeleton.tsx: shimmer loading components - AdminToggle.tsx: consistent toggle/switch - CommunicationsLoading.tsx: loading skeleton for comms - ToastExport.ts: exports CSS Improvements: - Shimmer animation keyframes - Toast slide-in animation Accessibility: - ARIA labels throughout - Keyboard navigation - Focus states - Semantic HTML - Screen reader support
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { markPickupComplete } from "@/actions/pickup";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import AdminBadge from "./design-system/AdminBadge";
|
||||
import { AdminButton, AdminSearchInput, AdminFilterTabs } from "./design-system";
|
||||
import { AdminButton, AdminSearchInput, AdminFilterTabs, AdminIconButton, useToast } from "./design-system";
|
||||
import { Skeleton } from "./design-system";
|
||||
|
||||
type OrderItem = {
|
||||
id: string;
|
||||
@@ -104,6 +105,12 @@ const Icons = {
|
||||
<line x1="12" y1="22" x2="12" y2="12"/>
|
||||
</svg>
|
||||
),
|
||||
selectAll: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<polyline points="9 11 12 14 22 4"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function AdminOrdersPanel({
|
||||
@@ -111,6 +118,7 @@ export default function AdminOrdersPanel({
|
||||
initialStops,
|
||||
brandId,
|
||||
}: AdminOrdersPanelProps) {
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const [orders, setOrders] = useState<Order[]>(initialOrders);
|
||||
const [stops] = useState<Stop[]>(initialStops);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -119,9 +127,18 @@ export default function AdminOrdersPanel({
|
||||
const [showStopDropdown, setShowStopDropdown] = useState(false);
|
||||
const [pickingUp, setPickingUp] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [pickupToast, setPickupToast] = useState<string | null>(null);
|
||||
const [selectedOrders, setSelectedOrders] = useState<Set<string>>(new Set());
|
||||
const [bulkMarkingUp, setBulkMarkingUp] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// Simulate loading when orders change
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
const timer = setTimeout(() => setIsLoading(false), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [page, activeTab, search, selectedStops.length]);
|
||||
|
||||
const filteredOrders = useMemo(() => {
|
||||
return orders.filter((order) => {
|
||||
if (activeTab === "pending" && order.pickup_complete) return false;
|
||||
@@ -142,6 +159,27 @@ export default function AdminOrdersPanel({
|
||||
const pendingCount = orders.filter((o) => !o.pickup_complete).length;
|
||||
const pickedUpCount = orders.filter((o) => o.pickup_complete).length;
|
||||
|
||||
// Bulk selection
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (selectedOrders.size === paginatedOrders.length) {
|
||||
setSelectedOrders(new Set());
|
||||
} else {
|
||||
setSelectedOrders(new Set(paginatedOrders.map(o => o.id)));
|
||||
}
|
||||
}, [selectedOrders.size, paginatedOrders]);
|
||||
|
||||
const toggleOrderSelection = useCallback((orderId: string) => {
|
||||
setSelectedOrders(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(orderId)) {
|
||||
next.delete(orderId);
|
||||
} else {
|
||||
next.add(orderId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
function toggleStop(stopId: string) {
|
||||
setSelectedStops((prev) =>
|
||||
prev.includes(stopId) ? prev.filter((id) => id !== stopId) : [...prev, stopId]
|
||||
@@ -158,8 +196,7 @@ export default function AdminOrdersPanel({
|
||||
setPickingUp(orderId);
|
||||
const result = await markPickupComplete(orderId, brandId);
|
||||
if (result.success) {
|
||||
setPickupToast(orderId);
|
||||
setTimeout(() => setPickupToast(null), 3000);
|
||||
showSuccess("Pickup confirmed", `${result.pickup_completed_at ? 'Order marked as picked up' : 'Success'}`);
|
||||
setOrders((prev) =>
|
||||
prev.map((o) =>
|
||||
o.id === orderId
|
||||
@@ -167,10 +204,45 @@ export default function AdminOrdersPanel({
|
||||
: o
|
||||
)
|
||||
);
|
||||
} else {
|
||||
showError("Failed to mark pickup", result.error ?? "Please try again");
|
||||
}
|
||||
setPickingUp(null);
|
||||
}
|
||||
|
||||
async function handleBulkMarkPickup() {
|
||||
if (selectedOrders.size === 0) return;
|
||||
|
||||
setBulkMarkingUp(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const orderId of selectedOrders) {
|
||||
const result = await markPickupComplete(orderId, brandId);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
setOrders((prev) =>
|
||||
prev.map((o) =>
|
||||
o.id === orderId
|
||||
? { ...o, pickup_complete: true, pickup_completed_at: result.pickup_completed_at, pickup_completed_by: result.pickup_completed_by }
|
||||
: o
|
||||
)
|
||||
);
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
setBulkMarkingUp(false);
|
||||
setSelectedOrders(new Set());
|
||||
|
||||
if (failCount === 0) {
|
||||
showSuccess(`${successCount} order${successCount !== 1 ? 's' : ''} marked as picked up`);
|
||||
} else {
|
||||
showError("Some orders failed", `${successCount} succeeded, ${failCount} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
@@ -195,15 +267,15 @@ export default function AdminOrdersPanel({
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{orders.length}</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{isLoading ? <Skeleton variant="text" className="w-16 h-8" /> : orders.length}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-amber-600 mt-1">{pendingCount}</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-amber-600 mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pendingCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Picked Up</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-accent)] mt-1">{pickedUpCount}</p>
|
||||
<p className="text-lg sm:text-xl md:text-2xl font-bold text-[var(--admin-accent)] mt-1">{isLoading ? <Skeleton variant="text" className="w-12 h-8" /> : pickedUpCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,8 +368,47 @@ export default function AdminOrdersPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedOrders.size > 0 && (
|
||||
<div className="mb-4 flex items-center justify-between rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-[var(--admin-accent-text)]">
|
||||
{selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedOrders(new Set())}
|
||||
className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleBulkMarkPickup}
|
||||
isLoading={bulkMarkingUp}
|
||||
icon={Icons.check("h-4 w-4")}
|
||||
>
|
||||
Mark All as Picked Up
|
||||
</AdminButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Orders Table */}
|
||||
{paginatedOrders.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-6">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton variant="rect" className="h-5 w-5 rounded" />
|
||||
<Skeleton variant="text" className="flex-1" />
|
||||
<Skeleton variant="text" className="w-24" />
|
||||
<Skeleton variant="text" className="w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : paginatedOrders.length === 0 ? (
|
||||
<div className="text-center py-12 rounded-xl border border-[var(--admin-border)] bg-white">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.package("h-8 w-8 text-stone-400")}
|
||||
@@ -310,6 +421,14 @@ export default function AdminOrdersPanel({
|
||||
<table className="w-full text-sm min-w-[700px]">
|
||||
<thead className="bg-stone-50">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<th className="w-10 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Order</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Customer</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs hidden md:table-cell">Stop</th>
|
||||
@@ -322,6 +441,14 @@ export default function AdminOrdersPanel({
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{paginatedOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedOrders.has(order.id)}
|
||||
onChange={() => toggleOrderSelection(order.id)}
|
||||
className="h-4 w-4 rounded border-stone-300 text-[var(--admin-accent)] focus:ring-[var(--admin-accent)] cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/admin/orders/${order.id}`} className="font-mono text-xs text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]">
|
||||
{shortId(order.id)}
|
||||
@@ -357,6 +484,17 @@ export default function AdminOrdersPanel({
|
||||
{order.payment_processor === "square" && (
|
||||
<AdminBadge variant="info">Square</AdminBadge>
|
||||
)}
|
||||
{!order.pickup_complete && (
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMarkPickup(order.id)}
|
||||
disabled={pickingUp === order.id}
|
||||
isLoading={pickingUp === order.id}
|
||||
>
|
||||
{pickingUp === order.id ? "..." : "Pick Up"}
|
||||
</AdminButton>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -373,33 +511,27 @@ export default function AdminOrdersPanel({
|
||||
Showing {(page * PAGE_SIZE) + 1} to {Math.min((page + 1) * PAGE_SIZE, filteredOrders.length)} of {filteredOrders.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Previous page"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--admin-border)] text-stone-500 hover:bg-stone-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{Icons.chevronLeft("h-4 w-4")}
|
||||
</button>
|
||||
</AdminIconButton>
|
||||
<span className="px-3 text-sm font-medium text-stone-700">{page + 1} / {totalPages}</span>
|
||||
<button
|
||||
<AdminIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Next page"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--admin-border)] text-stone-500 hover:bg-stone-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="!rounded-lg"
|
||||
>
|
||||
{Icons.chevronRight("h-4 w-4")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{pickupToast && (
|
||||
<div className="fixed bottom-6 right-6 z-50 rounded-xl border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-5 py-3 shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--admin-accent)]">
|
||||
{Icons.check("h-4 w-4 text-white")}
|
||||
</div>
|
||||
<span className="font-medium text-[var(--admin-accent-text)]">Pickup confirmed!</span>
|
||||
</AdminIconButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user