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
+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>