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
+24
View File
@@ -0,0 +1,24 @@
"use client";
import Header, { Footer, LandingPageWrapper, Section } from "@/components/landing/LandingPageWrapper";
import HeroSection from "@/components/landing/HeroSection";
import FeaturesAndStats from "@/components/landing/FeaturesAndStats";
import TestimonialsAndCTA from "@/components/landing/TestimonialsAndCTA";
export default function LandingPageClient() {
return (
<LandingPageWrapper>
<Section id="hero" aria-label="Hero section">
<HeroSection />
</Section>
<Section id="features" aria-label="Features section">
<FeaturesAndStats />
</Section>
<Section id="reviews" aria-label="Reviews and call to action section">
<TestimonialsAndCTA />
</Section>
</LandingPageWrapper>
);
}
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { getAdminUser } from "@/lib/admin-permissions";
import { getBrandSettings } from "@/actions/brand-settings";
import AbandonedCartDashboard from "@/components/admin/AbandonedCartDashboard";
@@ -5,6 +6,11 @@ import AbandonedCartDashboard from "@/components/admin/AbandonedCartDashboard";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
export const metadata: Metadata = {
title: "Abandoned Cart Recovery - Harvest Reach",
description: "Recover abandoned carts with automated email sequences.",
};
export default async function AbandonedCartsPage() {
const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID;
@@ -13,7 +19,7 @@ export default async function AbandonedCartsPage() {
const brandName = settingsResult?.success ? (settingsResult.settings?.brand_name ?? "Farm") : "Farm";
return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<main className="min-h-screen px-4 sm:px-6 md:px-8 py-6 sm:py-8" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-5xl space-y-6">
{/* Header */}
<div>
@@ -24,12 +30,21 @@ export default async function AbandonedCartsPage() {
<span>/</span>
<span className="text-stone-600">Abandoned Cart Recovery</span>
</nav>
<div className="flex items-end justify-between">
<div>
<h1 className="text-2xl font-bold text-stone-950">Abandoned Cart Recovery</h1>
<p className="mt-1 text-sm text-stone-500">{brandName} 3-email sequence (1h, 24h, 48h)</p>
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<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-amber-500 to-amber-600 shadow-lg shadow-amber-500/20">
<svg className="h-6 w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="21" r="1"/>
<circle cx="20" cy="21" r="1"/>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
</svg>
</div>
<div className="flex items-center gap-3 text-xs text-stone-500">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-stone-900">Abandoned Cart Recovery</h1>
<p className="text-sm text-stone-500">{brandName} 3-email sequence (1h, 24h, 48h)</p>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-stone-500">
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-600" />
Active
@@ -1,5 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Analytics - Harvest Reach",
description: "Track campaign performance and email engagement metrics.",
};
export default function AnalyticsPage() {
redirect("/admin/communications");
redirect("/admin/communications?tab=analytics");
}
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationCampaigns, getCampaignById } from "@/actions/communications/campaigns";
@@ -5,11 +6,16 @@ import { getCommunicationTemplates } from "@/actions/communications/templates";
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function CampaignEditPage({
params,
}: {
export const metadata: Metadata = {
title: "Edit Campaign - Harvest Reach",
description: "Edit an existing email campaign.",
};
interface PageProps {
params: Promise<{ id: string }>;
}) {
}
export default async function CampaignEditPage({ params }: PageProps) {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_messages) {
redirect("/admin/pickup");
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
@@ -5,6 +6,11 @@ import { getCommunicationTemplates } from "@/actions/communications/templates";
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export const metadata: Metadata = {
title: "Compose Campaign - Harvest Reach",
description: "Create and send email campaigns to your customers.",
};
export default async function ComposePage() {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_messages) {
@@ -1,5 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Contacts - Harvest Reach",
description: "Manage your email marketing contacts and subscriber list.",
};
export default function ContactsPage() {
redirect("/admin/communications");
redirect("/admin/communications?tab=contacts");
}
+5
View File
@@ -0,0 +1,5 @@
import CommunicationsLoading from "@/components/admin/CommunicationsLoading";
export default function Loading() {
return <CommunicationsLoading activeTab="campaigns" />;
}
+7 -1
View File
@@ -1,5 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Message Logs - Harvest Reach",
description: "View delivery logs and engagement tracking for sent messages.",
};
export default function LogsPage() {
redirect("/admin/communications");
redirect("/admin/communications?tab=logs");
}
+6
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
@@ -6,6 +7,11 @@ import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
import { getCampaignAnalytics } from "@/actions/harvest-reach/campaigns";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export const metadata: Metadata = {
title: "Harvest Reach - Communications Hub",
description: "Manage email campaigns, templates, contacts, and marketing automation for your brand.",
};
export default async function CommunicationsRootPage() {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_messages) {
@@ -1,5 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Segments - Harvest Reach",
description: "Create and manage audience segments for targeted campaigns.",
};
export default function SegmentsPage() {
redirect("/admin/communications");
redirect("/admin/communications?tab=segments");
}
+13 -7
View File
@@ -1,8 +1,14 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationSettings } from "@/actions/communications/settings";
import CommunicationSettingsForm from "@/components/admin/CommunicationSettingsForm";
export const metadata: Metadata = {
title: "Settings - Harvest Reach",
description: "Configure email and SMS integration settings for Harvest Reach.",
};
export default async function SettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) redirect("/admin");
@@ -17,7 +23,7 @@ export default async function SettingsPage() {
{/* Back button */}
<a
href="/admin/communications"
className="inline-flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700 mb-4"
className="inline-flex items-center gap-2 text-sm text-stone-500 hover:text-stone-700 mb-4 transition-colors"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6"/>
@@ -27,20 +33,20 @@ export default async function SettingsPage() {
{/* 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">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<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">
<svg className="h-6 w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</div>
<div>
<h1 className="text-xl sm:text-2xl font-black text-[var(--admin-text-primary)] tracking-tight">Harvest Reach Settings</h1>
<p className="text-xs text-[var(--admin-text-muted)]">Configure email and SMS integration</p>
<h1 className="text-xl sm:text-2xl font-bold text-stone-900">Harvest Reach Settings</h1>
<p className="text-sm text-stone-500">Configure email and SMS integration</p>
</div>
</div>
{/* Settings Form */}
<div className="rounded-2xl border border-[var(--admin-border)] bg-white p-4 sm:p-6">
<div className="rounded-2xl border border-stone-200 bg-white p-4 sm:p-6 shadow-sm">
<CommunicationSettingsForm settings={settingsResult} brandId={brandId} />
</div>
</div>
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getAdminUser } from "@/lib/admin-permissions";
import { getCommunicationTemplates, getTemplateById } from "@/actions/communications/templates";
@@ -5,11 +6,16 @@ import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
import CommunicationsPage from "@/components/admin/CommunicationsPage";
export default async function TemplateEditPage({
params,
}: {
export const metadata: Metadata = {
title: "Edit Template - Harvest Reach",
description: "Edit an email template for your marketing campaigns.",
};
interface PageProps {
params: Promise<{ id: string }>;
}) {
}
export default async function TemplateEditPage({ params }: PageProps) {
const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_messages) {
redirect("/admin/pickup");
@@ -1,5 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Templates - Harvest Reach",
description: "Manage email templates for your marketing campaigns.",
};
export default function TemplatesPage() {
redirect("/admin/communications");
redirect("/admin/communications?tab=templates");
}
@@ -1,9 +1,15 @@
import type { Metadata } from "next";
import { getAdminUser } from "@/lib/admin-permissions";
import { getBrandSettings } from "@/actions/brand-settings";
import WelcomeSequenceDashboard from "@/components/admin/WelcomeSequenceDashboard";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export const metadata: Metadata = {
title: "Welcome Sequence - Harvest Reach",
description: "Configure the automated welcome email sequence for new subscribers.",
};
export default async function WelcomeSequencePage() {
const adminUser = await getAdminUser();
const brandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID;
@@ -12,7 +18,7 @@ export default async function WelcomeSequencePage() {
const brandName = settingsResult?.success ? (settingsResult.settings?.brand_name ?? "Farm") : "Farm";
return (
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
<main className="min-h-screen px-4 sm:px-6 md:px-8 py-6 sm:py-8" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="mx-auto max-w-5xl space-y-6">
{/* Header */}
<div>
@@ -23,10 +29,20 @@ export default async function WelcomeSequencePage() {
<span>/</span>
<span className="text-stone-600">Welcome Sequence</span>
</nav>
<div className="flex items-end justify-between">
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<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-purple-500 to-purple-600 shadow-lg shadow-purple-500/20">
<svg className="h-6 w-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
</div>
<div>
<h1 className="text-2xl font-bold text-stone-950">Welcome Email Sequence</h1>
<p className="mt-1 text-sm text-stone-500">{brandName} 4-email onboarding series</p>
<h1 className="text-xl sm:text-2xl font-bold text-stone-900">Welcome Email Sequence</h1>
<p className="text-sm text-stone-500">{brandName} 4-email onboarding series</p>
</div>
</div>
<div className="text-xs text-stone-400">
Auto-enrolls new subscribers (email opt-in)
+53 -15
View File
@@ -10,37 +10,75 @@ export default function AdminErrorPage({
reset: () => void;
}) {
return (
<div className="min-h-screen flex items-center justify-center px-6 relative">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-red-500/5 rounded-full blur-3xl" />
<div
className="min-h-screen flex items-center justify-center px-6 relative"
style={{ backgroundColor: "var(--admin-bg)" }}
>
{/* Background pattern */}
<div className="fixed inset-0 opacity-5 pointer-events-none">
<div className="absolute inset-0" style={{ backgroundImage: "radial-gradient(circle at 1px 1px, var(--admin-border) 1px, transparent 0)" }} />
</div>
<div className="text-center max-w-md mx-auto relative">
<div className="glass-card p-10">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-red-500/10 border border-red-500/20 mb-6">
<svg className="w-8 h-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<div className="text-center max-w-md mx-auto relative z-10">
<div
className="rounded-2xl border p-8"
style={{
backgroundColor: "var(--admin-card-bg)",
borderColor: "var(--admin-border)",
boxShadow: "var(--admin-shadow-lg)"
}}
>
<div
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6"
style={{ backgroundColor: "var(--admin-danger-light)" }}
>
<svg className="w-8 h-8" style={{ color: "var(--admin-danger)" }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h1 className="text-3xl font-semibold text-white tracking-tight">Admin Error</h1>
<p className="mt-3 text-zinc-400 text-sm">
<h1
className="text-2xl font-semibold tracking-tight mb-2"
style={{ color: "var(--admin-text-primary)" }}
>
Admin Error
</h1>
<p
className="text-sm mb-4"
style={{ color: "var(--admin-text-secondary)" }}
>
{error.message || "An unexpected error occurred in the admin panel."}
</p>
{error.digest && (
<p className="mt-2 text-xs text-zinc-600 font-mono">Digest: {error.digest}</p>
<p
className="text-xs font-mono px-2 py-1 rounded mb-4 inline-block"
style={{
backgroundColor: "var(--admin-bg)",
color: "var(--admin-text-muted)"
}}
>
ID: {error.digest}
</p>
)}
<div className="mt-8 flex items-center justify-center gap-4">
<div className="flex items-center justify-center gap-3 mt-6">
<button
onClick={reset}
className="rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 px-5 py-2.5 text-sm font-medium text-white transition-all backdrop-blur-sm"
className="rounded-xl px-5 py-2.5 text-sm font-medium border transition-all hover:-translate-y-0.5"
style={{
borderColor: "var(--admin-border)",
color: "var(--admin-text-secondary)",
backgroundColor: "var(--admin-card-bg)"
}}
>
Try again
</button>
<Link
href="/admin"
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-lg shadow-emerald-500/20"
className="rounded-xl px-5 py-2.5 text-sm font-semibold text-white transition-all hover:-translate-y-0.5"
style={{
backgroundColor: "var(--admin-accent)"
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = "var(--admin-accent-hover)"}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "var(--admin-accent)"}
>
Back to Admin
</Link>
+40 -5
View File
@@ -3,31 +3,66 @@ import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import { getAdminUser } from "@/lib/admin-permissions";
import { redirect } from "next/navigation";
import "@/styles/admin-design-system.css";
import { ToastProvider } from "@/components/admin/Toast";
import { ToastContainer } from "@/components/admin/ToastContainer";
// Toast provider wrapper component
function ToastProviderWrapper({ children }: { children: React.ReactNode }) {
return (
<ToastProvider>
{children}
<ToastContainer />
</ToastProvider>
);
}
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const adminUser = await getAdminUser();
let adminUser = null;
let authError: string | null = null;
// Robust auth with try-catch to prevent crashes
try {
adminUser = await getAdminUser();
} catch (error) {
console.error("Admin auth error:", error);
authError = "Failed to verify authentication. Please try again.";
}
// Auth verification failed
if (authError) {
return (
<ToastProviderWrapper>
<AdminSidebar userRole={null} />
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
<AdminAccessDenied message={authError} />
</div>
</ToastProviderWrapper>
);
}
// Not authenticated
if (!adminUser) {
return (
<>
<ToastProviderWrapper>
<AdminSidebar userRole={null} />
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
<AdminAccessDenied message="Your account does not have admin access." />
</div>
</>
</ToastProviderWrapper>
);
}
// Must change password
if (adminUser.must_change_password) {
redirect("/change-password");
}
return (
<>
<ToastProviderWrapper>
<AdminSidebar userRole={adminUser.role} />
<div className="min-h-screen lg:pl-60 admin-section" style={{ backgroundColor: "var(--admin-bg)" }}>
{children}
</div>
</>
</ToastProviderWrapper>
);
}
+26 -7
View File
@@ -1,9 +1,9 @@
import LoadingSkeleton, { SkeletonTable } from "@/components/shared/LoadingSkeleton";
import LoadingSkeleton, { SkeletonCard, SkeletonTable } from "@/components/shared/LoadingSkeleton";
export default function AdminLoading() {
return (
<main className="min-h-screen px-6 py-10">
<div className="mx-auto max-w-6xl space-y-6">
<main className="min-h-screen px-4 sm:px-6 md:px-8 py-8" style={{ backgroundColor: "var(--admin-bg)" }}>
<div className="max-w-7xl mx-auto space-y-6">
{/* Header skeleton */}
<div className="flex items-center justify-between">
<div className="space-y-2">
@@ -13,11 +13,30 @@ export default function AdminLoading() {
<LoadingSkeleton variant="button" />
</div>
{/* Cards grid skeleton */}
{/* Stats cards skeleton */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="rounded-xl border bg-white p-5"
style={{ borderColor: "var(--admin-border)" }}
>
<div className="flex items-center gap-3">
<div className="animate-pulse rounded-xl h-10 w-10" style={{ backgroundColor: "var(--admin-bg)" }} />
<div className="flex-1 space-y-2">
<div className="animate-pulse h-3 w-16 rounded" style={{ backgroundColor: "var(--admin-bg)" }} />
<div className="animate-pulse h-5 w-12 rounded" style={{ backgroundColor: "var(--admin-bg)" }} />
</div>
</div>
</div>
))}
</div>
{/* Content cards skeleton */}
<div className="grid gap-4 lg:grid-cols-3">
<LoadingSkeleton variant="card" />
<LoadingSkeleton variant="card" />
<LoadingSkeleton variant="card" />
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
{/* Table skeleton */}
@@ -10,25 +10,44 @@ type Props = {
export default function AddAddonButton({ brandId, addonKey }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleClick() {
setLoading(true);
setError(null);
const result = await createAddonCheckoutSession(brandId, addonKey);
setLoading(false);
if (result.success && result.url) {
window.location.href = result.url;
} else {
alert(result.error ?? "Failed to start add-on checkout");
setError(result.error ?? "Failed to start checkout");
}
}
return (
<div className="flex items-center gap-2">
<button
onClick={handleClick}
disabled={loading}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-3 py-1.5 text-xs font-medium text-zinc-400 hover:bg-zinc-800 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--admin-accent)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] transition-colors disabled:opacity-50"
>
{loading ? "..." : "+ Add"}
{loading ? (
<>
<span className="h-3 w-3 rounded-full border-2 border-current border-t-transparent animate-spin" />
Loading...
</>
) : (
<>
<svg className="w-3.5 h-3.5" 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>
Add
</>
)}
</button>
{error && (
<span className="text-xs text-[var(--admin-danger)]">{error}</span>
)}
</div>
);
}
@@ -8,6 +8,7 @@ import RemoveAddonButton from "./RemoveAddonButton";
import BillingCycleToggle from "./BillingCycleToggle";
import StripePortalButton from "./StripePortalButton";
import { PLAN_TIERS, ADDONS } from "@/lib/pricing";
import { AdminButton } from "@/components/admin/design-system";
type BillingCycle = "monthly" | "annual";
@@ -63,41 +64,59 @@ export default function BillingClientPage({
(billingCycle === "annual" ? (currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0) : 0)
);
const getStatusBadgeClass = (status: string | null) => {
if (!status) return "bg-stone-100 text-stone-600";
switch (status) {
case "active":
return "bg-[var(--admin-success)]/10 text-[var(--admin-success)]";
case "past_due":
return "bg-amber-100 text-amber-700";
case "canceled":
return "bg-red-100 text-red-600";
case "trialing":
return "bg-blue-100 text-blue-600";
default:
return "bg-stone-100 text-stone-600";
}
};
return (
<div className="space-y-4">
<div className="space-y-6">
{/* ── 1. Header summary bar ───────────────────────────────────────────── */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] px-6 py-5">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] px-6 py-6">
<div className="flex items-center justify-between flex-wrap gap-6">
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${currentPlan.color}`}>
{currentPlan.label}
</span>
{subscriptionStatus && (
<span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${
subscriptionStatus === "active" ? "bg-[var(--admin-success-light)] text-[var(--admin-success-accent)]" :
subscriptionStatus === "past_due" ? "bg-amber-100 text-amber-700" :
subscriptionStatus === "canceled" ? "bg-red-100 text-red-600" :
subscriptionStatus === "trialing" ? "bg-blue-100 text-blue-600" :
"bg-[var(--admin-bg)] text-[var(--admin-text-muted)]"
}`}>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${getStatusBadgeClass(subscriptionStatus)}`}>
{subscriptionStatus.replace("_", " ")}
</span>
)}
<span className="text-sm text-[var(--admin-text-muted)]">
</div>
<div className="flex items-baseline gap-2">
<p className="text-3xl font-bold text-[var(--admin-text-primary)]">
${totalMonthly}
<span className="text-base font-normal text-[var(--admin-text-muted)]">/mo</span>
</p>
{billingCycle === "annual" && (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--admin-success)]">
<svg className="w-3 h-3" 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>
Save 25%
</span>
)}
</div>
<p className="text-sm text-[var(--admin-text-muted)] mt-1">
{currentPeriodEnd ? (
<>Next billing: <span className="font-medium text-[var(--admin-text-primary)]">{new Date(currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</span></>
) : (
"No active subscription"
)}
</span>
</div>
<p className="text-2xl font-bold text-[var(--admin-text-primary)]">
${totalMonthly}<span className="text-sm font-normal text-[var(--admin-text-muted)]">/mo</span>
<span className="ml-2 text-sm font-medium text-[var(--admin-success-accent)]">
{billingCycle === "annual" ? "Annual (saves 25%)" : ""}
</span>
</p>
</div>
</div>
@@ -108,48 +127,57 @@ export default function BillingClientPage({
{/* ── 2. Current plan + Add-ons (two-column) ───────────────────────────── */}
<div className="grid gap-4 lg:grid-cols-5">
{/* Current plan card */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-5 lg:col-span-2">
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6 lg:col-span-2">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Your Plan</h3>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${currentPlan.color}`}>
{currentPlan.label}
</span>
</div>
<p className="text-lg font-bold text-[var(--admin-text-primary)] mb-1">
<p className="text-xl font-bold text-[var(--admin-text-primary)] mb-1">
{billingCycle === "annual" ? `$${currentPlan.annualPrice}/yr` : `$${currentPlan.monthlyPrice}/mo`}
</p>
<p className="text-xs text-[var(--admin-text-muted)] mb-4">
<p className="text-xs text-[var(--admin-text-muted)] mb-5">
{billingCycle === "annual" && currentPlan.annualPrice
? `$${Math.round(currentPlan.annualPrice / 12)}/mo equivalent`
? `$${Math.round(currentPlan.annualPrice / 12)}/mo equivalent — saves $${Math.round(((currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0)))}/yr`
: "billed monthly"}
</p>
<ul className="space-y-1 mb-4">
{(currentPlan.features as readonly string[]).slice(0, 5).map((f, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-[var(--admin-text-secondary)]">
<span className="text-[var(--admin-success)] shrink-0"></span> {f}
<ul className="space-y-2 mb-5">
{(currentPlan.features as readonly string[]).slice(0, 6).map((f, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-[var(--admin-text-secondary)]">
<svg className="w-4 h-4 text-[var(--admin-success)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{f}
</li>
))}
{(currentPlan.features as readonly string[]).length > 5 && (
<li className="text-xs text-[var(--admin-text-muted)] pl-5">+ {(currentPlan.features as readonly string[]).length - 5} more</li>
{(currentPlan.features as readonly string[]).length > 6 && (
<li className="text-xs text-[var(--admin-text-muted)] pl-6">+ {(currentPlan.features as readonly string[]).length - 6} more features</li>
)}
</ul>
{isPlatformAdmin && (
<button
onClick={() => setCompareOpen(!compareOpen)}
className="text-xs text-[var(--admin-accent)] hover:underline font-medium"
className="inline-flex items-center gap-1.5 text-sm font-medium text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] transition-colors"
>
{compareOpen ? "Hide plan comparison" : "Compare plans →"}
{compareOpen ? "Hide" : "Compare"} plans
<svg className={`w-4 h-4 transition-transform ${compareOpen ? "rotate-180" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
</div>
{/* Add-ons */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-5 lg:col-span-3">
<div className="flex items-center justify-between mb-4">
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6 lg:col-span-3">
<div className="flex items-center justify-between mb-5">
<div>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Add-ons</h3>
<span className="text-xs text-[var(--admin-text-muted)]">+{addonsMonthlyTotal}/mo</span>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">Extend your plan with optional features</p>
</div>
<div className="space-y-2.5">
<span className="text-sm font-semibold text-[var(--admin-text-secondary)]">+${addonsMonthlyTotal}/mo</span>
</div>
<div className="space-y-3">
{allAddonKeys.map((key) => {
const addon = ADDONS[key];
const isEnabled = !!enabledAddons[key];
@@ -157,9 +185,9 @@ export default function BillingClientPage({
? Math.round(addon.annualPrice / 12)
: addon.monthlyPrice;
return (
<div key={key} className="flex items-center justify-between rounded-lg border border-[var(--admin-border)] bg-[var(--admin-bg)] px-3 py-2.5">
<div className="flex items-center gap-2.5">
<span className="text-base leading-none">{addon.icon}</span>
<div key={key} className="flex items-center justify-between rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg)] px-4 py-3">
<div className="flex items-center gap-3">
<span className="text-xl leading-none">{addon.icon}</span>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">{addon.label}</p>
<p className="text-xs text-[var(--admin-text-muted)]">{addon.description}</p>
@@ -168,8 +196,11 @@ export default function BillingClientPage({
<div className="flex items-center gap-3 shrink-0">
<span className="text-sm font-semibold text-[var(--admin-text-secondary)]">+${displayPrice}/mo</span>
{isEnabled ? (
<div className="flex items-center gap-1.5">
<span className="rounded-full bg-[var(--admin-success-light)] text-[var(--admin-success-accent)] text-xs px-2 py-0.5 font-medium">Active</span>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 text-[var(--admin-success)] text-xs px-2.5 py-1 font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" />
Active
</span>
{isPlatformAdmin && (
<RemoveAddonButton brandId={brandId} addonKey={key} onRemoved={() => window.location.reload()} />
)}
@@ -186,32 +217,38 @@ export default function BillingClientPage({
</div>
{/* ── 3. Compare plans (collapsible) ───────────────────────────────────── */}
{compareOpen && (
{compareOpen && isPlatformAdmin && (
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] overflow-hidden">
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-bg)] px-5 py-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Plan Comparison</h3>
<button onClick={() => setCompareOpen(false)} className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
Close
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-bg)] px-6 py-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-[var(--admin-text-primary)]">Plan Comparison</h3>
<button
onClick={() => setCompareOpen(false)}
className="inline-flex items-center gap-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] 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="M6 18L18 6M6 6l12 12" />
</svg>
Close
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--admin-border)]">
<th className="pb-3 pr-4 text-left font-semibold text-[var(--admin-text-muted)] w-2/5" />
<th className="pb-4 pr-6 text-left font-semibold text-[var(--admin-text-muted)] w-2/5">Feature</th>
{(["starter", "farm", "enterprise"] as const).map((tier) => {
const plan = PLAN_TIERS[tier];
const price = billingCycle === "annual" ? plan.annualPrice : plan.monthlyPrice;
return (
<th key={tier} className="pb-3 px-3 text-center">
<span className={`rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide ${plan.color}`}>
<th key={tier} className="pb-4 px-4 text-center">
<span className={`rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${plan.color}`}>
{plan.label}
</span>
<div className="mt-1">
<span className="text-lg font-bold text-[var(--admin-text-primary)]">
<div className="mt-2">
<span className="text-2xl font-bold text-[var(--admin-text-primary)]">
{price !== null ? `$${price}` : "$399"}
</span>
<span className="text-[var(--admin-text-muted)] text-xs">/{billingCycle === "annual" ? "yr" : "mo"}</span>
<span className="text-xs text-[var(--admin-text-muted)]">/{billingCycle === "annual" ? "yr" : "mo"}</span>
</div>
{tier === "enterprise" && billingCycle === "annual" && (
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">or $399/mo</p>
@@ -224,10 +261,10 @@ export default function BillingClientPage({
<tbody>
{[
["Products catalog", { starter: true, farm: true, enterprise: true }],
["Stops management", { starter: false, farm: true, enterprise: true }],
["Stops management", { starter: "10/mo", farm: "Unlimited", enterprise: "Unlimited" }],
["Orders processing", { starter: true, farm: true, enterprise: true }],
["Pickup & fulfillment", { starter: true, farm: true, enterprise: true }],
["Multi-user", { starter: false, farm: true, enterprise: true }],
["Multi-user", { starter: "1 user", farm: "5 users", enterprise: "Unlimited" }],
["Reporting", { starter: false, farm: true, enterprise: true }],
["Wholesale Portal", { starter: false, farm: true, enterprise: true }],
["Harvest Reach", { starter: false, farm: true, enterprise: true }],
@@ -239,30 +276,49 @@ export default function BillingClientPage({
["Custom development", { starter: false, farm: false, enterprise: true }],
["Dedicated support", { starter: false, farm: false, enterprise: true }],
].map(([feature, tiers]) => {
const t = tiers as Record<string, boolean>;
const t = tiers as Record<string, boolean | string>;
return (
<tr key={feature as string} className="border-t border-[var(--admin-border)]">
<td className="py-2 pr-4 text-[var(--admin-text-secondary)]">{feature as string}</td>
<td className="py-3 pr-6 text-[var(--admin-text-secondary)]">{feature as string}</td>
{(["starter", "farm", "enterprise"] as const).map((tier) => (
<td key={tier} className="py-2 px-3 text-center">
{t[tier]
? <span className="text-[var(--admin-success)]"></span>
: <span className="text-[var(--admin-text-muted)]"></span>}
<td key={tier} className="py-3 px-4 text-center">
{typeof t[tier] === "boolean" ? (
t[tier] ? (
<svg className="w-5 h-5 text-[var(--admin-success)] mx-auto" 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>
) : (
<span className="text-[var(--admin-text-muted)]"></span>
)
) : (
<span className="text-sm font-medium text-[var(--admin-text-secondary)]">{t[tier]}</span>
)}
</td>
))}
</tr>
);
})}
<tr className="border-t border-[var(--admin-border)]">
<td className="pt-3 pr-4" />
<tr className="border-t-2 border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<td className="pt-4 pr-6" />
{(["starter", "farm", "enterprise"] as const).map((tier) => {
const isCurrent = tier === planTier;
return (
<td key={tier} className="pt-3 px-3 text-center">
<td key={tier} className="pt-4 px-4 text-center">
{isCurrent ? (
<span className="inline-block rounded-lg bg-[var(--admin-success-light)] px-2 py-1 text-xs font-medium text-[var(--admin-success-accent)]">Current</span>
<span className="inline-flex items-center gap-1.5 rounded-lg bg-[var(--admin-success)]/10 px-3 py-1.5 text-sm font-medium text-[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="M4.5 12.75l6 6 9-13.5" />
</svg>
Current
</span>
) : tier === "enterprise" ? (
<a href="mailto:team@cielohermosa.com?subject=Enterprise+Plan" className="inline-block rounded-lg border border-[var(--admin-accent)] bg-[var(--admin-accent-light)] px-2 py-1 text-xs font-medium text-[var(--admin-accent-text)] hover:opacity-80">
<a
href="mailto:team@cielohermosa.com?subject=Enterprise+Plan"
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--admin-accent)] bg-white px-3 py-1.5 text-sm font-medium text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] 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="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5H4.5A2.25 2.25 0 002.25 6.75m19.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>
Contact
</a>
) : (
@@ -281,38 +337,49 @@ export default function BillingClientPage({
{/* ── 4. Payment + Invoices (two-column) ──────────────────────────────── */}
<div className="grid gap-4 lg:grid-cols-2">
{/* Payment method */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-5">
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-4">Payment Method</h3>
{hasStripeCustomer ? (
<div className="space-y-3">
<div className="flex items-center gap-3 rounded-xl border border-[var(--admin-border)] p-3.5">
<div className="flex items-center justify-center h-9 w-12 rounded-lg bg-gradient-to-br from635bff to-purple-600 shrink-0">
<svg className="h-4 w-7 text-white" viewBox="0 0 32 20" fill="currentColor">
<path d="M13.193 7.448c-.114-.272-.379-.358-.65-.358-.27 0-.535.09-.65.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .534-.09.648-.357.13-.28.115-.561-.115-.837-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.27 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.536-.357.806-.357.271 0 .536.09.65.357l.001-.001c.115-.272.38-.358.65-.358.271 0 .536.09.65.357l.001.001c.122.28.122.56-.008.838zm8.697-.001c-.122-.276-.379-.357-.65-.357-.27 0-.535.09-.649.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .535-.09.65-.357.13-.28.114-.561-.115-.837-.238-.286-.536-.357-.807-.357-.27 0-.535.09-.65.357l-.001-.001-.001.001c-.114-.267-.379-.357-.65-.357-.27 0-.536.09-.649.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .535-.09.649-.357.13-.277.114-.558-.115-.838-.236-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.271 0-.536-.09-.65-.357-.114-.276-.379-.357-.65-.357-.271 0-.536.09-.649.357-.13.277-.114.558.115.838.236.286.536.357.806.357.271 0 .536-.09.65-.357.13-.277.114-.558-.115-.838-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001-.001c-.114.267-.379.357-.65.357-.271 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.535.357.806-.357.27 0 .535.09.65.357l.001.001c.122.28.122.56-.008.838z"/>
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-xl border border-[var(--admin-border)] p-4">
<div className="flex items-center justify-center h-10 w-12 rounded-lg bg-gradient-to-br from-blue-600 to-violet-600 shrink-0">
<svg className="h-5 w-8 text-white" viewBox="0 0 32 20" fill="currentColor">
<path d="M13.193 7.448c-.114-.272-.379-.358-.65-.358-.27 0-.535.09-.65.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .534-.09.648-.357.13-.28.115-.561-.115-.837-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.27 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.536-.357.806-.357.271 0 .536.09.65.357l.001-.001c.115-.272.38-.358.65-.358.271 0 .536.09.65.357l.001.001c.122.28.122.56-.008.838zm8.697-.001c-.122-.276-.379-.357-.65-.357-.27 0-.535.09-.649.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .535-.09.65-.357.13-.28.114-.561-.115-.837-.238-.286-.536-.357-.807-.357-.27 0-.535.09-.65.357l-.001-.001-.001.001c-.114-.267-.379-.357-.65-.357-.27 0-.536.09-.649.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .535-.09.649-.357.13-.277.114-.558-.115-.838-.236-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.271 0-.536-.09-.65-.357-.13-.276-.379-.357-.65-.357-.271 0-.536.09-.649.357-.13.277-.114.558.115.838.236.286.536.357.806.357.271 0 .536-.09.65-.357.13-.277.114-.558-.115-.838-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001-.001c-.114.267-.379.357-.65.357-.271 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.535.357.806-.357.27 0 .535.09.65.357l.001.001c.122.28.122.56-.008.838z"/>
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Visa ending in 4242</p>
<p className="text-xs text-[var(--admin-text-muted)]">Expires 12/26</p>
</div>
<span className="rounded-full bg-[var(--admin-success-light)] text-[var(--admin-success-accent)] text-xs px-2 py-0.5 font-medium shrink-0">Active</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--admin-success)]/10 text-[var(--admin-success)] text-xs px-2.5 py-1 font-medium shrink-0">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" />
Active
</span>
</div>
<StripePortalButton brandId={brandId} variant="secondary" label="Manage in Stripe Portal" />
<p className="text-xs text-[var(--admin-text-muted)] text-center">
<StripePortalButton brandId={brandId} variant="secondary" label="Manage in Stripe Portal" />
<p className="text-xs text-[var(--admin-text-muted)] text-center pt-2">
Payment powered by Stripe · Invoiced by Cielo Hermosa, LLC
</p>
</div>
) : (
<div className="space-y-3">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5">
<p className="text-sm text-amber-700">
<strong>No payment method on file.</strong> Add a card to activate your subscription.
<div className="space-y-4">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 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>
<div>
<p className="text-sm font-medium text-amber-800">
<strong>No payment method on file</strong>
</p>
<p className="text-xs text-amber-700 mt-0.5">Add a card to activate your subscription.</p>
</div>
</div>
</div>
<AddPaymentMethodButton brandId={brandId} />
<p className="text-xs text-[var(--admin-text-muted)] text-center">
Set up in{" "}
<a href="/admin/settings/payments" className="underline hover:text-[var(--admin-text-primary)]">Payments settings</a>
<a href="/admin/settings" className="underline hover:text-[var(--admin-accent)]">Payments settings</a>
{" "}to enable billing.
</p>
</div>
@@ -320,17 +387,17 @@ export default function BillingClientPage({
</div>
{/* Invoice history */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-5">
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-4">Invoice History</h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--admin-border)]">
<th className="pb-2 text-left font-semibold text-[var(--admin-text-muted)]">Invoice</th>
<th className="pb-2 text-left font-semibold text-[var(--admin-text-muted)]">Date</th>
<th className="pb-2 text-right font-semibold text-[var(--admin-text-muted)]">Amount</th>
<th className="pb-2 text-right font-semibold text-[var(--admin-text-muted)]">Status</th>
<th className="pb-2"></th>
<th className="pb-3 text-left font-semibold text-[var(--admin-text-muted)]">Invoice</th>
<th className="pb-3 text-left font-semibold text-[var(--admin-text-muted)]">Date</th>
<th className="pb-3 text-right font-semibold text-[var(--admin-text-muted)]">Amount</th>
<th className="pb-3 text-right font-semibold text-[var(--admin-text-muted)]">Status</th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border)]">
@@ -340,22 +407,27 @@ export default function BillingClientPage({
{ id: "INV-2026-002", date: "Mar 1, 2026", amount: totalMonthly, status: "paid" },
].map((inv) => (
<tr key={inv.id}>
<td className="py-2 font-medium text-[var(--admin-text-primary)]">{inv.id}</td>
<td className="py-2 text-[var(--admin-text-muted)]">{inv.date}</td>
<td className="py-2 text-right font-semibold text-[var(--admin-text-primary)]">${inv.amount}</td>
<td className="py-2 text-right">
<span className="rounded-full bg-[var(--admin-success-light)] text-[var(--admin-success-accent)] px-1.5 py-0.5 text-xs font-medium capitalize">{inv.status}</span>
<td className="py-3 font-medium text-[var(--admin-text-primary)]">{inv.id}</td>
<td className="py-3 text-[var(--admin-text-muted)]">{inv.date}</td>
<td className="py-3 text-right font-semibold text-[var(--admin-text-primary)]">${inv.amount}</td>
<td className="py-3 text-right">
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 text-[var(--admin-success)] px-2 py-0.5 text-xs font-medium capitalize">
<svg className="w-3 h-3" 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>
{inv.status}
</span>
</td>
<td className="py-2 text-right">
<button className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)]">PDF</button>
<td className="py-3 text-right">
<button className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)] transition-colors">PDF</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-[var(--admin-text-muted)] text-center">
Invoiced by Cielo Hermosa, LLC · <a href="mailto:billing@cielohermosa.com" className="underline">billing@cielohermosa.com</a>
<p className="mt-4 text-xs text-[var(--admin-text-muted)] text-center">
Invoiced by Cielo Hermosa, LLC · <a href="mailto:billing@cielohermosa.com" className="underline hover:text-[var(--admin-accent)]">billing@cielohermosa.com</a>
</p>
</div>
</div>
@@ -13,6 +13,7 @@ const TIER_ORDER = ["starter", "farm", "enterprise"];
export default function PlanUpgradeButton({ brandId, targetTier, currentTier }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentIndex = TIER_ORDER.indexOf(currentTier);
const targetIndex = TIER_ORDER.indexOf(targetTier);
const isDowngrade = targetIndex < currentIndex;
@@ -21,18 +22,22 @@ export default function PlanUpgradeButton({ brandId, targetTier, currentTier }:
async function handleClick() {
if (isCurrent || isDowngrade) return;
setLoading(true);
setError(null);
const result = await createPlanUpgradeCheckout(brandId, targetTier);
setLoading(false);
if (result.success && result.url) {
window.location.href = result.url;
} else {
alert(result.error ?? "Failed to start upgrade");
setError(result.error ?? "Failed to start upgrade");
}
}
if (isCurrent) {
return (
<span className="inline-block rounded-xl bg-green-900/40 px-4 py-2 text-sm font-medium text-green-400">
<span className="inline-flex items-center gap-1.5 rounded-xl bg-[var(--admin-success)]/10 px-4 py-2 text-sm font-medium text-[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="M4.5 12.75l6 6 9-13.5" />
</svg>
Current Plan
</span>
);
@@ -42,20 +47,40 @@ export default function PlanUpgradeButton({ brandId, targetTier, currentTier }:
return (
<a
href="mailto:team@cielohermosa.com?subject=Downgrade+Request"
className="inline-block rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-500 hover:bg-zinc-800"
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--admin-border)] bg-white px-4 py-2 text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-bg)] 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="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75" />
</svg>
Downgrade
</a>
);
}
return (
<div className="flex flex-col gap-1">
<button
onClick={handleClick}
disabled={loading}
className="inline-block rounded-xl bg-green-600 px-4 py-2 text-sm font-bold text-white hover:bg-green-700 disabled:opacity-50"
className="inline-flex items-center gap-2 rounded-xl bg-[var(--admin-accent)] px-4 py-2 text-sm font-bold text-white hover:bg-[var(--admin-accent-hover)] disabled:opacity-50 transition-colors"
>
{loading ? "Redirecting..." : "Upgrade"}
{loading ? (
<>
<span className="h-4 w-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
Redirecting...
</>
) : (
<>
<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 10.5L12 3m0 0l7.5 7.5M12 3v18" />
</svg>
Upgrade
</>
)}
</button>
{error && (
<span className="text-xs text-[var(--admin-danger)]">{error}</span>
)}
</div>
);
}
@@ -0,0 +1,467 @@
"use client";
import { useState, useEffect } from "react";
import AIProviderPanel from "@/components/admin/AIProviderPanel";
import { savePaymentSettings } from "@/actions/payments";
import { saveResendCredentials, saveTwilioCredentials, testResendConnection, testTwilioConnection, getResendCredentials, getTwilioCredentials } from "@/actions/integrations/credentials";
import { AdminInput, AdminTextInput, AdminSelect, AdminButton } from "@/components/admin/design-system";
import { AdminToggle } from "@/components/admin/design-system/AdminToggle";
type Props = {
brandId: string;
brands: { id: string; name: string }[];
isPlatformAdmin: boolean;
};
type CredentialField = {
key: string;
label: string;
placeholder: string;
isSecret?: boolean;
};
type Integration = {
id: string;
name: string;
icon: string;
description: string;
accentColor: string;
credentials: CredentialField[];
connected?: boolean;
};
// Simplified integration cards for display (AI is handled separately)
const COMMUNICATION_INTEGRATIONS: { id: string; name: string; icon: string; description: string; accentColor: string }[] = [
{
id: "resend",
name: "Resend",
icon: "📧",
description: "Send transactional and marketing emails via Harvest Reach.",
accentColor: "border-amber-200 bg-amber-50/50",
},
{
id: "stripe",
name: "Stripe",
icon: "💳",
description: "Process online payments for orders.",
accentColor: "border-stone-200 bg-stone-50/50",
},
{
id: "twilio",
name: "Twilio",
icon: "📱",
description: "Send SMS campaigns and alerts via Harvest Reach.",
accentColor: "border-blue-200 bg-blue-50/50",
},
];
const INTEGRATIONS: Integration[] = [
{
id: "openai",
name: "OpenAI",
icon: "🤖",
description: "Power AI tools like Campaign Writer, Report Explainer, and Pricing Advisor.",
accentColor: "border-violet-200 bg-violet-50/50",
credentials: [],
},
{
id: "resend",
name: "Resend",
icon: "📧",
description: "Send transactional and marketing emails via Harvest Reach.",
accentColor: "border-amber-200 bg-amber-50/50",
credentials: [
{ key: "RESEND_API_KEY", label: "API Key", placeholder: "re_...", isSecret: true },
{ key: "RESEND_FROM_EMAIL", label: "From Email Address", placeholder: "orders@yourbrand.com" },
{ key: "RESEND_FROM_NAME", label: "From Name", placeholder: "Your Brand Name" },
],
},
{
id: "stripe",
name: "Stripe",
icon: "💳",
description: "Process online payments for orders.",
accentColor: "border-stone-200 bg-stone-50/50",
credentials: [
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", placeholder: "pk_live_..." },
{ key: "STRIPE_SECRET_KEY", label: "Secret Key", placeholder: "sk_live_...", isSecret: true },
],
},
{
id: "twilio",
name: "Twilio",
icon: "📱",
description: "Send SMS campaigns and alerts via Harvest Reach.",
accentColor: "border-blue-200 bg-blue-50/50",
credentials: [
{ key: "TWILIO_ACCOUNT_SID", label: "Account SID", placeholder: "AC..." },
{ key: "TWILIO_AUTH_TOKEN", label: "Auth Token", placeholder: "Your Twilio auth token", isSecret: true },
{ key: "TWILIO_PHONE_NUMBER", label: "Phone Number", placeholder: "+1234567890" },
],
},
];
function IntegrationCard({
integration,
initialCredentials,
brandId,
}: {
integration: Integration;
initialCredentials?: Record<string, string>;
brandId: string;
}) {
const [credentials, setCredentials] = useState<Record<string, string>>(
initialCredentials ?? {}
);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
// Track dirty state
useEffect(() => {
const hasValues = Object.values(credentials).some((v) => v.trim().length > 0);
setDirty(hasValues && JSON.stringify(initialCredentials) !== JSON.stringify(credentials));
}, [credentials, initialCredentials]);
async function handleTest() {
setTestResult(null);
setError(null);
if (integration.id === "resend") {
const apiKey = credentials["RESEND_API_KEY"];
if (!apiKey?.trim()) {
setTestResult({ ok: false, message: "API key is required" });
return;
}
const result = await testResendConnection(apiKey);
setTestResult(result);
} else if (integration.id === "twilio") {
const accountSid = credentials["TWILIO_ACCOUNT_SID"];
const authToken = credentials["TWILIO_AUTH_TOKEN"];
if (!accountSid?.trim() || !authToken?.trim()) {
setTestResult({ ok: false, message: "Account SID and Auth Token are required" });
return;
}
const result = await testTwilioConnection(accountSid, authToken);
setTestResult(result);
} else {
// Default test (placeholder)
await new Promise((r) => setTimeout(r, 500));
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
setTestResult(
hasKey
? { ok: true, message: `Successfully connected to ${integration.name}` }
: { ok: false, message: `No API key configured for ${integration.name}` }
);
}
}
async function handleSave() {
setSaving(true);
setError(null);
setTestResult(null);
try {
if (integration.id === "resend") {
const result = await saveResendCredentials(brandId, {
api_key: credentials["RESEND_API_KEY"]?.trim() || null,
from_email: credentials["RESEND_FROM_EMAIL"]?.trim() || null,
from_name: credentials["RESEND_FROM_NAME"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Resend credentials saved successfully" });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} else if (integration.id === "twilio") {
const result = await saveTwilioCredentials(brandId, {
account_sid: credentials["TWILIO_ACCOUNT_SID"]?.trim() || null,
auth_token: credentials["TWILIO_AUTH_TOKEN"]?.trim() || null,
phone_number: credentials["TWILIO_PHONE_NUMBER"]?.trim() || null,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Twilio credentials saved successfully" });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} else if (integration.id === "stripe") {
const result = await savePaymentSettings({
brandId,
provider: "stripe",
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
});
if (!result.success) {
setError(result.error ?? "Failed to save");
return;
}
setTestResult({ ok: true, message: "Stripe credentials saved successfully" });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} else {
// Other integrations - just show success for now
setTestResult({ ok: true, message: `${integration.name} settings saved` });
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
} finally {
setSaving(false);
}
}
function toggleSecret(key: string) {
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
}
function updateCredential(key: string, value: string) {
setCredentials((prev) => ({ ...prev, [key]: value }));
}
return (
<div className={`rounded-2xl border p-6 bg-white ${integration.accentColor}`}>
<div className="flex items-start justify-between mb-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-bg)] text-lg">
{integration.icon}
</div>
<div>
<h3 className="text-base font-semibold text-[var(--admin-text-primary)]">{integration.name}</h3>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">{integration.description}</p>
</div>
</div>
{testResult?.ok && (
<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>
{/* Messages */}
{error && (
<div className="mb-4 rounded-xl p-4 text-sm border flex items-start gap-3 bg-[var(--admin-danger)]/10 border-[var(--admin-danger)]/30 text-[var(--admin-danger)]">
<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="mb-4 rounded-xl p-4 text-sm border flex items-center gap-3 bg-[var(--admin-success)]/10 border-[var(--admin-success)]/30 text-[var(--admin-success)]">
<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>
)}
{testResult && (
<div className={`mb-4 rounded-xl p-4 text-sm border flex items-start gap-3 ${
testResult.ok
? "bg-[var(--admin-success)]/10 border-[var(--admin-success)]/30 text-[var(--admin-success)]"
: "bg-[var(--admin-danger)]/10 border-[var(--admin-danger)]/30 text-[var(--admin-danger)]"
}`}>
{testResult.ok ? (
<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>
)}
{/* Credentials form */}
<div className="space-y-4 mb-5">
{integration.credentials.map((field) => (
<AdminInput
key={field.key}
label={field.label}
helpText={field.isSecret ? "Click Show/Hide to reveal" : undefined}
>
<div className="relative">
<AdminTextInput
type={showSecrets[field.key] ? "text" : "password"}
value={credentials[field.key] ?? ""}
onChange={(e) => updateCredential(field.key, e.target.value)}
placeholder={field.placeholder}
/>
{field.isSecret && (
<button
type="button"
onClick={() => toggleSecret(field.key)}
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)" }}
>
{showSecrets[field.key] ? "Hide" : "Show"}
</button>
)}
</div>
</AdminInput>
))}
</div>
{/* Action buttons */}
<div className="flex items-center gap-3 pt-4 border-t border-[var(--admin-border)]">
<AdminButton
variant="secondary"
size="sm"
onClick={handleTest}
disabled={saving}
>
{testResult?.ok ? "Re-test" : "Test Connection"}
</AdminButton>
<AdminButton
variant="primary"
size="sm"
onClick={handleSave}
disabled={saving || !dirty}
>
{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
</>
)}
</AdminButton>
{dirty && !saving && (
<span className="text-xs text-[var(--admin-text-muted)]">Unsaved changes</span>
)}
</div>
</div>
);
}
export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmin }: Props) {
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
const [loading, setLoading] = useState(true);
// Fetch initial credentials for each integration
useEffect(() => {
async function fetchCredentials() {
setLoading(true);
try {
const [resendCreds, twilioCreds] = await Promise.all([
getResendCredentials(selectedBrandId),
getTwilioCredentials(selectedBrandId),
]);
setInitialCredentials({
resend: {
RESEND_API_KEY: resendCreds.api_key ?? "",
RESEND_FROM_EMAIL: resendCreds.from_email ?? "",
RESEND_FROM_NAME: resendCreds.from_name ?? "",
},
twilio: {
TWILIO_ACCOUNT_SID: twilioCreds.account_sid ?? "",
TWILIO_AUTH_TOKEN: twilioCreds.auth_token ?? "",
TWILIO_PHONE_NUMBER: twilioCreds.phone_number ?? "",
},
});
} finally {
setLoading(false);
}
}
fetchCredentials();
}, [selectedBrandId]);
return (
<div className="space-y-8 max-w-4xl">
{/* Brand selector for platform admins */}
{isPlatformAdmin && brands.length > 0 && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-5">
<AdminInput label="Select Brand" helpText="Choose a brand to configure integrations for:">
<AdminSelect
value={selectedBrandId}
onChange={(e) => setSelectedBrandId(e.target.value)}
options={brands.map((b) => ({ value: b.id, label: b.name }))}
/>
</AdminInput>
</div>
)}
{/* AI Provider Section */}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-100 text-violet-700 text-sm">🤖</div>
<div>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">AI Provider</h3>
<p className="text-xs text-[var(--admin-text-muted)]">Configure AI models for intelligent features</p>
</div>
</div>
<a
href="/admin/settings/ai"
className="inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)]"
>
Configure AI
<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 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
</a>
</div>
<div className="p-5">
<AIProviderPanel brandId={selectedBrandId} />
</div>
</div>
{/* Payment & Email integrations grid */}
<div>
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Communication & Payments</h2>
<div className="space-y-4">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3">
<div className="h-5 w-5 rounded-full border-2 border-[var(--admin-accent)] border-t-transparent animate-spin" />
<span className="text-sm text-[var(--admin-text-muted)]">Loading integrations...</span>
</div>
</div>
) : (
INTEGRATIONS.filter(i => i.id !== "openai").map((integration) => (
<IntegrationCard
key={integration.id}
integration={integration}
initialCredentials={initialCredentials[integration.id]}
brandId={selectedBrandId}
/>
))
)}
</div>
</div>
{/* Help text */}
<div className="rounded-xl bg-[var(--admin-bg-subtle)] border border-[var(--admin-border)] p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-[var(--admin-text-muted)] shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
<div>
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Need help?</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-1">
Contact us at <a href="mailto:support@cielohermosa.com" className="underline hover:text-[var(--admin-accent)]">support@cielohermosa.com</a> for assistance setting up integrations.
</p>
</div>
</div>
</div>
</div>
);
}
+58 -2
View File
@@ -1,5 +1,61 @@
import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import IntegrationsClientPage from "./IntegrationsClientPage";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
export default function IntegrationsRedirect() {
redirect("/admin/settings#integrations");
export const metadata = {
title: "Integrations - Route Commerce Admin",
description: "Configure integrations for AI, email, SMS, and payments",
};
export default async function IntegrationsPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const isPlatformAdmin = adminUser.role === "platform_admin";
const brandId = adminUser.brand_id ?? "";
// Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin
? (await supabase.from("brands").select("id, name").order("name")).data ?? []
: [];
return (
<main className="min-h-screen bg-[var(--admin-bg)]">
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6 pb-10">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
<span>/</span>
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
<span>/</span>
<span className="text-[var(--admin-text-primary)]">Integrations</span>
</nav>
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)] text-white">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)]">Integrations</h1>
<p className="text-sm text-[var(--admin-text-muted)]">
Connect AI, email, SMS, and payment providers to power your operations.
</p>
</div>
</div>
</div>
<IntegrationsClientPage
brandId={brandId}
brands={brands}
isPlatformAdmin={isPlatformAdmin}
/>
</div>
</main>
);
}
@@ -4,6 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { syncSquareNow, getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
import { savePaymentSettings } from "@/actions/payments";
import { AdminToggle, AdminButton } from "@/components/admin/design-system";
type InventoryMode = "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
@@ -21,6 +22,10 @@ type Props = {
brandId: string;
};
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString();
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
@@ -31,10 +36,6 @@ function timeAgo(iso: string): string {
return `${Math.floor(hrs / 24)}d ago`;
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString();
}
export default function SquareSyncSettingsClient({ settings, logs, brandId }: Props) {
const [squareSyncEnabled, setSquareSyncEnabled] = useState(
settings?.square_sync_enabled ?? false
@@ -43,11 +44,20 @@ export default function SquareSyncSettingsClient({ settings, logs, brandId }: Pr
settings?.square_inventory_mode ?? "none"
);
const [syncing, setSyncing] = useState(false);
const [syncingType, setSyncingType] = useState<string | null>(null);
const [syncMsg, setSyncMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [displayLogs, setDisplayLogs] = useState<SyncLogEntry[]>(logs);
const [dirty, setDirty] = useState(false);
const hasToken = !!settings?.square_access_token;
const lastSyncAt = settings?.square_last_sync_at
? formatDateTime(settings.square_last_sync_at)
: "Never";
const lastError = settings?.square_last_sync_error;
const isEnabled = settings?.square_sync_enabled && hasToken;
async function handleSaveSettings() {
setSaving(true);
@@ -62,6 +72,7 @@ export default function SquareSyncSettingsClient({ settings, logs, brandId }: Pr
setSaving(false);
if (result.success) {
setSaved(true);
setDirty(false);
setTimeout(() => setSaved(false), 3000);
} else {
setError(result.error ?? "Failed to save settings");
@@ -70,6 +81,7 @@ export default function SquareSyncSettingsClient({ settings, logs, brandId }: Pr
async function handleSyncNow(type: "products" | "orders" | "all") {
setSyncing(true);
setSyncingType(type);
setSyncMsg(null);
const result = await syncSquareNow(brandId, type);
setSyncMsg({
@@ -79,269 +91,414 @@ export default function SquareSyncSettingsClient({ settings, logs, brandId }: Pr
: `Sync failed: ${result.errors[0] ?? "Unknown error"}`,
});
setSyncing(false);
setSyncingType(null);
const logResult = await getSyncLog(brandId);
if (logResult.success) setDisplayLogs(logResult.logs);
}
const hasToken = !!settings?.square_access_token;
const lastSyncAt = settings?.square_last_sync_at
? formatDateTime(settings.square_last_sync_at)
: "Never";
const lastError = settings?.square_last_sync_error;
const isEnabled = settings?.square_sync_enabled && hasToken;
return (
<div className="min-h-screen bg-zinc-950">
{/* Top nav */}
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
<div className="mx-auto max-w-4xl flex items-center gap-3">
<Link href="/admin" className="text-sm text-zinc-500 hover:text-zinc-300">Admin</Link>
<span className="text-slate-400">/</span>
<Link href="/admin/settings" className="text-sm text-zinc-500 hover:text-zinc-300">Settings</Link>
<span className="text-slate-400">/</span>
<span className="text-sm font-medium text-zinc-100">Square Sync</span>
</div>
</div>
<div className="mx-auto max-w-4xl px-6 py-8 space-y-6">
{/* Header */}
<main className="min-h-screen bg-[var(--admin-bg)]">
{/* Page Header */}
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold text-zinc-100">Square Sync</h1>
<p className="mt-2 text-zinc-400">
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)] text-white">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)]">Square Sync</h1>
</div>
<p className="text-sm text-[var(--admin-text-muted)]">
Sync products, orders, and inventory between Route Commerce and Square.
</p>
</div>
{hasToken && (
<div className="flex items-center gap-2">
<AdminButton
variant="secondary"
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>
) : (
<>
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Sync All Now
</>
)}
</AdminButton>
</div>
)}
</div>
</div>
<div className="px-4 sm:px-6 md:px-8 pb-10 space-y-6 max-w-4xl">
{/* Status messages */}
{error && (
<div className="rounded-xl border border-red-200 bg-red-900/30 px-4 py-3 text-sm text-red-400">
{error}
<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>
<div>
<p className="text-sm font-medium text-[var(--admin-danger)]">Error</p>
<p className="text-sm text-[var(--admin-danger)]/80">{error}</p>
</div>
</div>
)}
{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>
<p className="text-sm font-medium text-[var(--admin-success)]">Settings saved successfully!</p>
</div>
)}
{syncMsg && (
<div className={`rounded-xl border px-4 py-3 text-sm ${
<div className={`rounded-xl border px-4 py-3 flex items-start gap-3 ${
syncMsg.kind === "success"
? "border-green-200 bg-green-900/30 text-green-400"
: "border-red-200 bg-red-900/30 text-red-400"
? "border-[var(--admin-success)]/30 bg-[var(--admin-success)]/10"
: "border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/10"
}`}>
{syncMsg.text}
{syncMsg.kind === "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>
)}
<p className={`text-sm font-medium ${
syncMsg.kind === "success" ? "text-[var(--admin-success)]" : "text-[var(--admin-danger)]"
}`}>{syncMsg.text}</p>
</div>
)}
{/* Connection status */}
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
<h2 className="text-lg font-semibold text-zinc-100 mb-4">Connection Status</h2>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-zinc-500">Provider</p>
<p className="font-medium text-zinc-100">{settings?.provider ?? "Not set"}</p>
{/* Connection Status Card */}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<h2 className="text-base font-semibold text-[var(--admin-text-primary)]">Connection Status</h2>
</div>
<div>
<p className="text-zinc-500">Square Account</p>
<p className={`font-medium ${hasToken ? "text-green-400" : "text-slate-400"}`}>
<div className="p-5">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="p-3 rounded-xl bg-[var(--admin-bg)] border border-[var(--admin-border)]">
<p className="text-xs font-medium text-[var(--admin-text-muted)] mb-1">Provider</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{settings?.provider ?? "Not set"}</p>
</div>
<div className="p-3 rounded-xl bg-[var(--admin-bg)] border border-[var(--admin-border)]">
<p className="text-xs font-medium text-[var(--admin-text-muted)] mb-1">Square Account</p>
<div className="flex items-center gap-1.5">
<span className={`inline-block w-2 h-2 rounded-full ${hasToken ? "bg-[var(--admin-success)]" : "bg-[var(--admin-text-muted)]"}`} />
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">
{hasToken ? "Connected" : "Not connected"}
</p>
</div>
<div>
<p className="text-zinc-500">Last Sync</p>
<p className="font-medium text-zinc-100">{lastSyncAt}</p>
</div>
<div>
<p className="text-zinc-500">Sync Status</p>
<div className="p-3 rounded-xl bg-[var(--admin-bg)] border border-[var(--admin-border)]">
<p className="text-xs font-medium text-[var(--admin-text-muted)] mb-1">Last Sync</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{lastSyncAt}</p>
</div>
<div className="p-3 rounded-xl bg-[var(--admin-bg)] border border-[var(--admin-border)]">
<p className="text-xs font-medium text-[var(--admin-text-muted)] mb-1">Sync Status</p>
{lastError ? (
<p className="font-medium text-red-400">Error {lastError}</p>
<p className="text-sm font-semibold text-[var(--admin-danger)]">Error</p>
) : isEnabled ? (
<p className="font-medium text-green-400">Active</p>
<div className="flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-[var(--admin-success)]" />
<p className="text-sm font-semibold text-[var(--admin-success)]">Active</p>
</div>
) : hasToken ? (
<p className="font-medium text-yellow-600">Disabled</p>
<p className="text-sm font-semibold text-amber-600">Disabled</p>
) : (
<p className="font-medium text-slate-400">Not connected</p>
<p className="text-sm font-semibold text-[var(--admin-text-muted)]">N/A</p>
)}
</div>
</div>
<div className="mt-4 flex gap-3">
<div className="mt-4 flex flex-wrap gap-3">
<Link
href="/admin/settings/payments"
className="rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
href="/admin/settings"
className="inline-flex items-center gap-2 rounded-xl border border-[var(--admin-border)] bg-white px-4 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)] transition-colors"
>
Manage Connection
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
Back to Settings
</Link>
{hasToken && (
<button
onClick={() => handleSyncNow("all")}
disabled={syncing}
className="rounded-xl bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-50"
>
{syncing ? "Syncing..." : "Sync All Now"}
</button>
)}
</div>
</div>
</div>
{/* Sync settings */}
{/* Sync Settings Card */}
{hasToken && (
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
<h2 className="text-lg font-semibold text-zinc-100 mb-5">Sync Settings</h2>
<div className="space-y-6">
<div className="rounded-2xl bg-white border border-[var(--admin-border)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<h2 className="text-base font-semibold text-[var(--admin-text-primary)]">Sync Settings</h2>
</div>
<div className="p-5 space-y-6">
{/* Enable toggle */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-zinc-100">Enable Square Sync</p>
<p className="text-sm text-zinc-500">Automatically sync products and orders with Square.</p>
<div className="flex items-center justify-between p-4 rounded-xl bg-[var(--admin-bg)] border border-[var(--admin-border)]">
<div className="flex-1">
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Enable Square Sync</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">
Automatically sync products and orders with Square.
</p>
</div>
<button
type="button"
onClick={() => setSquareSyncEnabled(!squareSyncEnabled)}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${
squareSyncEnabled ? "bg-green-600" : "bg-slate-300"
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-zinc-900 shadow transition-transform ${
squareSyncEnabled ? "translate-x-6" : "translate-x-1"
}`}
<AdminToggle
checked={squareSyncEnabled}
onChange={(checked) => {
setSquareSyncEnabled(checked);
setDirty(true);
}}
/>
</button>
</div>
{squareSyncEnabled && (
<>
{/* Inventory mode */}
<div className="space-y-3">
<div>
<p className="mb-2 font-medium text-zinc-100">Inventory Direction</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Inventory Direction</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">
Choose how inventory data flows between Route Commerce and Square.
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
{ value: "none", label: "None", desc: "No inventory sync" },
{ value: "rc_to_square", label: "RC → Square", desc: "RC inventory to Square" },
{ value: "square_to_rc", label: "Square → RC", desc: "Square inventory to RC" },
{ value: "rc_to_square", label: "RC → Square", desc: "Push RC inventory" },
{ value: "square_to_rc", label: "Square → RC", desc: "Pull from Square" },
{ value: "bidirectional", label: "Bidirectional", desc: "Sync both ways" },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSquareInventoryMode(opt.value as InventoryMode)}
className={`rounded-xl border p-3 text-left text-sm ${
onClick={() => {
setSquareInventoryMode(opt.value as InventoryMode);
setDirty(true);
}}
className={`rounded-xl border p-3 text-left transition-all ${
squareInventoryMode === opt.value
? "border-green-600 bg-green-900/30 text-green-900"
: "border-zinc-800 text-zinc-400 hover:bg-zinc-800"
? "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"
}`}
>
<p className="font-semibold">{opt.label}</p>
<p className="text-xs mt-0.5 opacity-70">{opt.desc}</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">{opt.label}</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">{opt.desc}</p>
</button>
))}
</div>
</div>
{/* Manual sync buttons */}
<div className="space-y-3">
<div>
<p className="mb-2 font-medium text-zinc-100">Manual Sync</p>
<p className="text-sm font-semibold text-[var(--admin-text-primary)]">Manual Sync</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">
Manually trigger a sync for products or orders.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
<AdminButton
variant="secondary"
size="sm"
onClick={() => handleSyncNow("products")}
disabled={syncing}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{syncing ? "..." : "Sync Products"}
</button>
<button
{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>
) : (
<>
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
Sync Products
</>
)}
</AdminButton>
<AdminButton
variant="secondary"
size="sm"
onClick={() => handleSyncNow("orders")}
disabled={syncing}
className="rounded-lg border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
>
{syncing ? "..." : "Sync Orders"}
</button>
{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>
) : (
<>
<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="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.8 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>
Sync Orders
</>
)}
</AdminButton>
</div>
</div>
</>
)}
<button
{/* Save button */}
<div className="flex items-center gap-4 pt-4 border-t border-[var(--admin-border)]">
<AdminButton
variant="primary"
size="md"
onClick={handleSaveSettings}
disabled={saving}
className="rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-bold text-white disabled:opacity-50"
disabled={saving || !dirty}
>
{saving ? "Saving..." : "Save Settings"}
</button>
{saved && (
<span className="ml-3 text-sm text-green-600">Settings saved.</span>
{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>
) : (
"Save Settings"
)}
</AdminButton>
{dirty && !saving && (
<span className="text-xs text-[var(--admin-text-muted)]">You have unsaved changes</span>
)}
</div>
</div>
</div>
)}
{/* Sync log */}
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
<h2 className="text-lg font-semibold text-zinc-100 mb-4">Sync Log</h2>
{/* Not connected state */}
{!hasToken && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-[var(--admin-bg)] mx-auto mb-4">
<svg className="w-7 h-7 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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>
</div>
<h3 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-2">Square Not Connected</h3>
<p className="text-sm text-[var(--admin-text-muted)] max-w-md mx-auto mb-6">
Connect your Square account to enable product and inventory sync.
</p>
<Link
href="/admin/settings"
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"
>
Configure in Payment Settings
</Link>
</div>
)}
{/* Sync Log Card */}
{hasToken && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] flex items-center justify-between">
<h2 className="text-base font-semibold text-[var(--admin-text-primary)]">Sync Log</h2>
<span className="text-xs text-[var(--admin-text-muted)]">
Last 50 entries
</span>
</div>
<div className="p-5">
{displayLogs.length === 0 ? (
<p className="text-sm text-slate-400 py-4 text-center">No sync activity yet.</p>
<div className="text-center py-8">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--admin-bg)] mx-auto mb-3">
<svg className="w-6 h-6 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
</div>
<p className="text-sm text-[var(--admin-text-muted)]">No sync activity yet</p>
<p className="text-xs text-[var(--admin-text-muted)] mt-1">Sync logs will appear here after your first sync</p>
</div>
) : (
<div className="space-y-2">
<div className="space-y-2 max-h-96 overflow-y-auto">
{displayLogs.map((log) => (
<div
key={log.id}
className={`flex items-start justify-between rounded-xl border px-4 py-3 text-sm ${
className={`flex items-start justify-between rounded-xl border px-4 py-3 ${
log.status === "success"
? "border-green-200 bg-green-900/30"
? "border-[var(--admin-success)]/30 bg-[var(--admin-success)]/5"
: log.status === "partial"
? "border-yellow-200 bg-amber-900/30"
: "border-red-200 bg-red-900/30"
? "border-amber-200 bg-amber-50"
: "border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/5"
}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`inline-block rounded px-1.5 py-0.5 text-xs font-semibold ${
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${
log.status === "success"
? "bg-green-900/40 text-green-400"
? "bg-[var(--admin-success)]/20 text-[var(--admin-success)]"
: log.status === "partial"
? "bg-yellow-100 text-yellow-700"
: "bg-red-900/40 text-red-400"
? "bg-amber-100 text-amber-700"
: "bg-[var(--admin-danger)]/20 text-[var(--admin-danger)]"
}`}>
{log.status}
</span>
<span className="font-medium text-zinc-300">{log.event_type}</span>
<span className="text-sm font-medium text-[var(--admin-text-primary)]">{log.event_type}</span>
{log.direction && (
<span className="text-xs text-slate-400">({log.direction})</span>
<span className="text-xs text-[var(--admin-text-muted)]">({log.direction})</span>
)}
</div>
{log.message && (
<p className="mt-1 text-xs text-zinc-500">{log.message}</p>
<p className="mt-1 text-xs text-[var(--admin-text-secondary)]">{log.message}</p>
)}
</div>
<span className="ml-3 text-xs text-slate-400 whitespace-nowrap">
{formatDateTime(log.created_at)}
<span className="ml-3 text-xs text-[var(--admin-text-muted)] whitespace-nowrap">
{timeAgo(log.created_at)}
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Quick links */}
<div className="rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
<h2 className="text-lg font-semibold text-zinc-100 mb-4">Quick Links</h2>
<div className="flex flex-wrap gap-3">
{/* Quick Links Card */}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)]">
<h2 className="text-base font-semibold text-[var(--admin-text-primary)]">Quick Links</h2>
</div>
<div className="p-5 flex flex-wrap gap-3">
<Link
href="/admin/orders?square=1"
className="rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
className="inline-flex items-center gap-2 rounded-xl border border-[var(--admin-border)] bg-white px-4 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)] 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="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.8 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>
Square Orders
</Link>
<Link
href="/admin/products?sync=square"
className="rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
className="inline-flex items-center gap-2 rounded-xl border border-[var(--admin-border)] bg-white px-4 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)] 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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
Products with Square
</Link>
<Link
href="/admin/settings/payments"
className="rounded-xl border border-zinc-600 bg-zinc-900 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800"
href="/admin/settings"
className="inline-flex items-center gap-2 rounded-xl border border-[var(--admin-border)] bg-white px-4 py-2 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)] 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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.622.932.214.159.466.27.732.413.322.168.624-.139.748-.555.048-.401.096-.83.163-1.249.073-.418.306-.802.637-.972.326-.166.681-.182 1.011-.103.066.032.146.066.198.04a1.5 1.5 0 01.497 1.15c.039.247.015.497-.077.726-.085.21-.219.4-.383.595-.157.186-.356.333-.576.464-.26.148-.568.197-.924.12-.336-.07-.664-.214-.972-.398-.326-.195-.637-.464-.872-.732-.25-.283-.453-.597-.628-.947-.172-.342-.22-.718-.146-1.097.074-.39.33-.726.663-.986.311-.24.697-.36 1.103-.267.4.086.764.331 1.019.605.269.287.452.655.517.972.056.293.012.596-.017.884-.028.294-.09.576-.184.845-.093.263-.216.505-.366.737-.147.224-.307.416-.485.58-.172.153-.369.26-.576.336l-.123.003z" />
</svg>
Payment Settings
</Link>
</div>
</div>
</div>
</div>
</main>
);
}
+45
View File
@@ -8,6 +8,51 @@ const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
export const dynamic = "force-dynamic";
// ── Skeleton Loading ──────────────────────────────────────────────────────────
function WaterLogLoadingSkeleton() {
return (
<div className="space-y-6 px-6 pb-8">
{/* Navigation skeleton */}
<div className="flex gap-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-10 w-28 rounded-xl bg-slate-200 animate-pulse" />
))}
</div>
{/* Summary skeleton */}
<div className="rounded-xl border border-slate-200 bg-white p-5 animate-pulse">
<div className="flex items-start justify-between mb-4">
<div className="h-5 w-32 bg-slate-200 rounded" />
<div className="h-8 w-48 bg-slate-100 rounded" />
</div>
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="space-y-1">
<div className="h-3 w-20 bg-slate-100 rounded" />
<div className="h-6 w-16 bg-slate-200 rounded" />
</div>
))}
</div>
</div>
{/* Table skeletons */}
<div className="rounded-xl border border-slate-200 bg-white p-4 animate-pulse">
<div className="h-5 w-28 bg-slate-200 rounded mb-4" />
<div className="space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-4 py-3 border-b border-slate-50 last:border-0">
<div className="h-4 w-20 bg-slate-100 rounded" />
<div className="h-4 w-16 bg-slate-100 rounded" />
<div className="h-4 w-14 bg-slate-100 rounded ml-auto" />
<div className="h-4 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
</div>
);
}
export default async function AdminWaterLogPage() {
const adminUser = await getAdminUser();
+86 -6
View File
@@ -95,8 +95,8 @@ export default function WholesaleClient({ brandId }: { brandId: string }) {
if (loading) {
return (
<div className="min-h-screen bg-[var(--admin-bg)] flex items-center justify-center">
<p className="text-[var(--admin-text-muted)]">Loading wholesale data...</p>
<div className="min-h-screen bg-[var(--admin-bg)]">
<WholesaleLoadingSkeleton />
</div>
);
}
@@ -243,7 +243,15 @@ function DashboardTab({ stats, recentOrders, brandId, onMsg, webhookActivity }:
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)] mb-4">Recent Orders</h2>
{recentOrders.length === 0 ? (
<p className="text-sm text-[var(--admin-text-muted)] py-8 text-center">No wholesale orders yet.</p>
<div className="text-center py-12">
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-7 h-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</div>
<p className="text-base font-semibold text-slate-600 mb-1">No wholesale orders yet</p>
<p className="text-sm text-slate-400">Wholesale orders placed by your customers will appear here.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -702,7 +710,19 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [],
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{customers.length === 0 ? (
<tr><td colSpan={8} className="py-8 text-center text-[var(--admin-text-muted)]">No customers yet.</td></tr>
<tr>
<td colSpan={8} className="py-12 text-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<p className="text-sm font-semibold text-slate-600 mb-1">No customers yet</p>
<p className="text-xs text-slate-400">Wholesale customers will appear here once registered.</p>
</div>
</td>
</tr>
) : customers.map(c => (
<tr key={c.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3">
@@ -885,7 +905,7 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: {
{loading ? (
<p className="text-[var(--admin-text-muted)] text-sm">Loading...</p>
) : products.length === 0 ? (
<p className="text-[var(--admin-text-muted)] text-sm">No products available.</p>
<p className="text-[var(--admin-text-muted)] text-sm">No products available. Add wholesale products to see them here.</p>
) : (
<table className="w-full text-sm">
<thead>
@@ -1218,7 +1238,19 @@ function ProductsTab({ products, brandId, onMsg, onRefresh }: {
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{products.length === 0 ? (
<tr><td colSpan={6} className="py-8 text-center text-[var(--admin-text-muted)]">No products yet.</td></tr>
<tr>
<td colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<p className="text-sm font-semibold text-slate-600 mb-1">No wholesale products yet</p>
<p className="text-xs text-slate-400">Add products to your wholesale catalog to get started.</p>
</div>
</td>
</tr>
) : products.map(p => (
<tr key={p.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{p.name}</td>
@@ -2281,6 +2313,54 @@ function AddRecipientForm({ onAdd }: { onAdd: (email: string, name: string) => v
// ── Shared Components ─────────────────────────────────────────────────────────
// ── Loading Skeleton ──────────────────────────────────────────────────────────
function WholesaleLoadingSkeleton() {
return (
<div className="space-y-6 px-6 py-6">
{/* Header skeleton */}
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-slate-200 animate-pulse" />
<div className="space-y-2">
<div className="h-6 w-40 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-64 bg-slate-100 rounded animate-pulse" />
</div>
</div>
{/* Tab bar skeleton */}
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className={`h-10 rounded-xl bg-slate-200 animate-pulse ${i === 1 ? "w-24" : "w-20"}`} />
))}
</div>
{/* Stat cards skeleton */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="rounded-xl border border-slate-200 bg-white p-4 animate-pulse">
<div className="h-3 w-20 bg-slate-100 rounded mb-2" />
<div className="h-7 w-16 bg-slate-200 rounded" />
</div>
))}
</div>
{/* Recent orders skeleton */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="h-5 w-28 bg-slate-200 rounded mb-4 animate-pulse" />
<div className="space-y-3">
{[1, 2, 3, 4].map(i => (
<div key={i} className="flex items-center gap-4 py-3 border-b border-slate-100 last:border-0 animate-pulse">
<div className="h-4 w-20 bg-slate-100 rounded" />
<div className="h-4 w-32 bg-slate-100 rounded flex-1" />
<div className="h-4 w-24 bg-slate-100 rounded hidden md:block" />
<div className="h-5 w-12 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, "default" | "success" | "warning" | "danger" | "info"> = {
pending: "warning",
+417
View File
@@ -0,0 +1,417 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useCart } from "@/context/CartContext";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
};
export default function CartClient() {
const {
cart,
subtotal,
selectedStop,
cartBrandId,
setSelectedStop,
increaseQuantity,
decreaseQuantity,
removeFromCart,
} = useCart();
const [stops, setStops] = useState<Stop[]>([]);
const [loadingStops, setLoadingStops] = useState(false);
const [showStopPicker, setShowStopPicker] = useState(false);
const [incompatibleItems, setIncompatibleItems] = useState<string[]>([]);
const [stopBrandMismatch, setStopBrandMismatch] = useState(false);
const [availabilityError, setAvailabilityError] = useState(false);
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed");
// Check brand mismatch
useEffect(() => {
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
setStopBrandMismatch(true);
} else {
setStopBrandMismatch(false);
}
}, [selectedStop, cartBrandId]);
// Fetch stops when picker is open
useEffect(() => {
if (hasPickupItems && showStopPicker && cartBrandId) {
setLoadingStops(true);
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
)
.then((r) => r.json())
.then((data) => setStops(data ?? []))
.catch(() => setStops([]))
.finally(() => setLoadingStops(false));
}
}, [hasPickupItems, showStopPicker, cartBrandId]);
const handleStopSelect = useCallback((stop: Stop) => {
setSelectedStop(stop);
setStopBrandMismatch(false);
setShowStopPicker(false);
setAvailabilityError(false);
setIncompatibleItems([]);
if (hasPickupItems) {
const pickupProductIds = cart.filter((i) => i.fulfillment === "pickup").map((i) => i.id);
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/check_stop_product_availability`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stop.id, p_product_ids: pickupProductIds }),
}
)
.then((r) => r.json())
.then((data: { product_id: string; is_available: boolean }[]) => {
const unavailable = (data ?? [])
.filter((row) => row.is_available === false)
.map((row) => row.product_id);
setIncompatibleItems(unavailable);
})
.catch(() => {
setAvailabilityError(true);
setIncompatibleItems([]);
});
}
}, [cart, hasPickupItems, setSelectedStop]);
const handleCheckoutClick = useCallback(() => {
if (stopBrandMismatch) { setSelectedStop(null); return; }
if (hasStopPickupItems && !selectedStop) { setShowStopPicker(true); return; }
if (incompatibleItems.length > 0) { return; }
window.location.href = "/checkout";
}, [stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems]);
return (
<div className="min-h-screen relative">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" aria-hidden="true" />
<div className="fixed inset-0 pointer-events-none" aria-hidden="true">
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
</div>
<StorefrontHeader brandName="Your Cart" brandSlug="tuxedo" />
<main className="px-6 py-12 relative">
<div className="mx-auto grid max-w-6xl gap-10 lg:grid-cols-[1fr_380px]">
<div>
<h1 className="text-4xl font-semibold tracking-tight text-white">Shopping Cart</h1>
<p className="mt-3 text-zinc-400">Review your products before checkout.</p>
{/* Brand mismatch alert */}
{stopBrandMismatch && (
<div className="mt-5 glass-card p-5 border border-red-500/20" role="alert">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 border border-red-500/20">
<svg className="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Pickup stop is from a different store</p>
<p className="mt-1 text-sm text-zinc-400">Your cart was updated. Please select a new pickup stop.</p>
<button
onClick={() => { setSelectedStop(null); setStopBrandMismatch(false); setShowStopPicker(true); }}
className="mt-3 rounded-xl bg-red-500 hover:bg-red-400 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-red-500/20"
>
Choose Correct Stop
</button>
</div>
</div>
</div>
)}
{/* Shed pickup info */}
{hasShedPickupItems && (
<div className="mt-5 glass-card p-5 border border-emerald-500/20" role="status">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Shed Pickup</p>
<p className="mt-1 text-sm text-zinc-400">
{cart.filter((i) => i.fulfillment === "pickup" && i.pickup_type === "shed").map((i) => i.description || i.name).join(", ")}
</p>
</div>
</div>
</div>
)}
{/* Stop picker prompt */}
{hasStopPickupItems && !selectedStop && !stopBrandMismatch && cartBrandId && (
<div className="mt-5 glass-card p-5 border border-amber-500/20" role="status">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Pickup stop needed</p>
<p className="mt-1 text-sm text-zinc-400">Your cart has pickup items. Select a stop before checkout.</p>
<button
onClick={() => setShowStopPicker(true)}
className="mt-3 rounded-xl bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-amber-500/20"
aria-label="Choose pickup stop"
>
Choose Pickup Stop
</button>
</div>
</div>
</div>
)}
{/* Incompatible items warning */}
{incompatibleItems.length > 0 && (
<div className="mt-4 glass-card p-5 border border-red-500/20" role="alert">
<p className="font-semibold text-white">Some items are not available at this stop:</p>
<ul className="mt-2 space-y-1" aria-label="Unavailable items">
{incompatibleItems.map((id) => {
const item = cart.find((i) => i.id === id);
return <li key={id} className="text-sm text-zinc-400"> {item?.name}</li>;
})}
</ul>
<p className="mt-2 text-sm text-zinc-500">Please remove these items or choose a different stop.</p>
</div>
)}
{/* Availability error */}
{availabilityError && (
<div className="mt-4 glass-card p-5" role="status">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Could not verify product availability</p>
<p className="mt-1 text-sm text-zinc-400">Stop was selected. Checkout will validate product-stop assignment.</p>
</div>
</div>
</div>
)}
{/* Stop picker modal */}
{showStopPicker && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 pointer-events-none"
role="dialog"
aria-modal="true"
aria-labelledby="stop-picker-title"
>
<div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10">
<h3 id="stop-picker-title" className="text-xl font-semibold text-white">Choose Pickup Stop</h3>
<p className="mt-2 text-sm text-zinc-400">Select a stop for your pickup items.</p>
{loadingStops ? (
<div className="mt-4 flex items-center justify-center gap-2 py-4" role="status" aria-live="polite">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-600 border-t-white" aria-hidden="true" />
<span className="text-sm text-zinc-400">Loading stops...</span>
</div>
) : stops.length === 0 ? (
<p className="mt-4 text-sm text-zinc-400 text-center">No stops available.</p>
) : (
<div className="mt-4 space-y-2">
{stops.map((stop) => (
<button
key={stop.id}
onClick={() => handleStopSelect(stop)}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-left hover:bg-white/10 transition-all text-white"
>
<p className="font-medium">{stop.city}, {stop.state}</p>
<p className="mt-0.5 text-sm text-zinc-400">{stop.date} · {stop.time} · {stop.location}</p>
</button>
))}
</div>
)}
<button
onClick={() => setShowStopPicker(false)}
className="mt-5 w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Cart items */}
<div className="mt-10 space-y-4">
{cart.length === 0 ? (
<div className="glass-card p-8 text-center">
<p className="text-zinc-500">Your cart is empty.</p>
<Link href="/" className="mt-4 inline-block text-emerald-400 hover:text-emerald-300 transition-colors"> Continue shopping</Link>
</div>
) : (
cart.map((item) => (
<article key={item.id} className="glass-card p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-white">{item.name}</h2>
<p className="mt-1 text-sm text-zinc-400">
{item.fulfillment === "pickup" ? (
<span className="inline-flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Pickup
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
Shipping
</span>
)}
</p>
</div>
<div className="flex items-center gap-3" role="group" aria-label={`Quantity controls for ${item.name}`}>
<button
onClick={() => decreaseQuantity(item.id)}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/10 text-white hover:bg-white/20 active:scale-95 transition-all text-xl font-medium"
aria-label={`Decrease quantity of ${item.name}`}
></button>
<span className="w-10 text-center font-semibold text-white" aria-label={`Quantity: ${item.quantity}`}>{item.quantity}</span>
<button
onClick={() => increaseQuantity(item.id)}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500 text-white hover:bg-emerald-400 active:scale-95 transition-all text-xl font-medium shadow-lg shadow-emerald-500/20"
aria-label={`Increase quantity of ${item.name}`}
>+</button>
</div>
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
<p className="text-sm font-semibold text-white" aria-label={`Item total: $${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}`}>
${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}
</p>
<button
onClick={() => removeFromCart(item.id)}
className="text-sm text-zinc-500 hover:text-red-400 transition-colors"
aria-label={`Remove ${item.name} from cart`}
>
Remove
</button>
</div>
</article>
))
)}
</div>
</div>
{/* Order summary sidebar */}
<aside aria-label="Order summary">
<div className="glass-card p-6 sticky top-6">
<h2 className="text-xl font-semibold text-white">Order Summary</h2>
{hasPickupItems && (
<div className="mt-5">
{hasShedPickupItems && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
<p className="text-sm font-medium text-emerald-400">
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Shed Pickup
</p>
<p className="mt-1.5 text-xs text-zinc-400">
{cart.filter((i) => i.fulfillment === "pickup" && i.pickup_type === "shed").map((i) => i.description || i.name).join(", ")}
</p>
</div>
)}
{selectedStop && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
<p className="text-sm font-medium text-emerald-400">
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Pickup: {selectedStop.city}, {selectedStop.state}
</p>
<p className="mt-1.5 text-xs text-zinc-400">{selectedStop.date} · {selectedStop.time}</p>
<p className="mt-0.5 text-xs text-zinc-400">{selectedStop.location}</p>
<button
onClick={() => setShowStopPicker(true)}
className="mt-2.5 text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
aria-label="Change pickup stop"
>
Change stop
</button>
</div>
)}
{!selectedStop && hasStopPickupItems && (
<button
onClick={() => setShowStopPicker(true)}
className="w-full rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-left text-sm text-amber-400 hover:bg-amber-500/20 transition-all"
aria-label="Select pickup stop"
>
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /></svg>
No pickup stop selected click to choose
</button>
)}
</div>
)}
<div className="mt-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-400">Subtotal</span>
<span className="text-sm font-semibold text-white">${subtotal.toFixed(2)}</span>
</div>
<div className="border-t border-white/5 pt-3">
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">Total</span>
<span className="text-2xl font-bold text-white">${subtotal.toFixed(2)}</span>
</div>
</div>
</div>
<button
onClick={handleCheckoutClick}
disabled={hasStopPickupItems && (!selectedStop || incompatibleItems.length > 0)}
className="mt-6 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-6 py-3.5 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/20 hover:-translate-y-0.5"
aria-label={
incompatibleItems.length > 0
? "Remove incompatible items first to proceed to checkout"
: !selectedStop && hasStopPickupItems
? "Select pickup stop first to proceed to checkout"
: "Continue to checkout"
}
>
{incompatibleItems.length > 0
? "Remove Incompatible Items First"
: !selectedStop && hasStopPickupItems
? "Select Pickup Stop First"
: "Continue to Checkout"}
</button>
<Link
href="/"
className="mt-4 block w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
aria-label="Continue shopping"
>
Continue Shopping
</Link>
</div>
</aside>
</div>
</main>
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
</div>
);
}
+69 -7
View File
@@ -1,14 +1,76 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Your Cart — Route Commerce",
description: "Review and manage your shopping cart. Select pickup stops, adjust quantities, and proceed to checkout.",
keywords: ["cart", "shopping cart", "produce order", "checkout", "pickup"],
robots: {
index: false,
follow: false,
},
};
export default function Loading() {
return (
<main className="min-h-screen bg-slate-50 px-6 py-12">
<div className="mx-auto max-w-4xl">
<div className="animate-pulse space-y-6">
<div className="h-8 w-48 rounded bg-slate-200" />
<div className="h-12 w-80 rounded bg-slate-200" />
<div className="h-4 w-full max-w-xl rounded bg-slate-200" />
<div className="mt-8 rounded-2xl bg-white p-8 shadow-sm" />
<div className="min-h-screen relative">
{/* Dark background matching cart page */}
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" aria-hidden="true" />
</div>
{/* Minimal header skeleton */}
<div className="h-20 border-b border-white/5" />
{/* Content skeleton */}
<main className="px-6 py-12 relative">
<div className="mx-auto grid max-w-6xl gap-10 lg:grid-cols-[1fr_380px]">
<div>
{/* Title skeleton */}
<div className="h-10 w-56 rounded-lg bg-white/5 animate-pulse" />
<div className="h-5 w-80 rounded mt-3 bg-white/5 animate-pulse" />
{/* Cart items skeleton */}
<div className="mt-10 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="glass-card p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-5 w-40 rounded bg-white/5 animate-pulse" />
<div className="h-4 w-24 rounded bg-white/5 animate-pulse" />
</div>
<div className="flex items-center gap-3">
<div className="h-11 w-11 rounded-xl bg-white/5 animate-pulse" />
<div className="h-11 w-10 rounded bg-white/5 animate-pulse" />
<div className="h-11 w-11 rounded-xl bg-white/5 animate-pulse" />
</div>
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
<div className="h-5 w-20 rounded bg-white/5 animate-pulse" />
<div className="h-4 w-16 rounded bg-white/5 animate-pulse" />
</div>
</div>
))}
</div>
</div>
{/* Sidebar skeleton */}
<aside>
<div className="glass-card p-6 sticky top-6">
<div className="h-6 w-32 rounded bg-white/5 animate-pulse" />
<div className="mt-5 space-y-3">
<div className="h-4 w-full rounded bg-white/5 animate-pulse" />
<div className="h-4 w-3/4 rounded bg-white/5 animate-pulse" />
</div>
<div className="mt-5 h-14 w-full rounded-xl bg-white/5 animate-pulse" />
<div className="mt-4 h-4 w-32 mx-auto rounded bg-white/5 animate-pulse" />
</div>
</aside>
</div>
</main>
{/* Status for accessibility */}
<span role="status" className="sr-only">Loading your cart...</span>
</div>
);
}
+11 -365
View File
@@ -1,370 +1,16 @@
"use client";
import type { Metadata } from "next";
import CartClient from "./CartClient";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useCart } from "@/context/CartContext";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
type Stop = {
id: string;
city: string;
state: string;
date: string;
time: string;
location: string;
brand_id: string;
export const metadata: Metadata = {
title: "Your Cart — Route Commerce",
description: "Review and manage your shopping cart. Select pickup stops, adjust quantities, and proceed to checkout.",
keywords: ["cart", "shopping cart", "produce order", "checkout", "pickup"],
robots: {
index: false,
follow: false,
},
};
export default function CartPage() {
const {
cart,
subtotal,
selectedStop,
cartBrandId,
setSelectedStop,
increaseQuantity,
decreaseQuantity,
removeFromCart,
} = useCart();
const [stops, setStops] = useState<Stop[]>([]);
const [loadingStops, setLoadingStops] = useState(false);
const [showStopPicker, setShowStopPicker] = useState(false);
const [incompatibleItems, setIncompatibleItems] = useState<string[]>([]);
const [stopBrandMismatch, setStopBrandMismatch] = useState(false);
const [availabilityError, setAvailabilityError] = useState(false);
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed");
useEffect(() => {
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
setStopBrandMismatch(true);
} else {
setStopBrandMismatch(false);
}
}, [selectedStop, cartBrandId]);
useEffect(() => {
if (hasPickupItems && showStopPicker && cartBrandId) {
setLoadingStops(true);
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,time,location,brand_id&order=date`,
{ headers: { apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! } }
)
.then((r) => r.json())
.then((data) => setStops(data ?? []))
.finally(() => setLoadingStops(false));
}
}, [hasPickupItems, showStopPicker, cartBrandId]);
function handleStopSelect(stop: Stop) {
setSelectedStop(stop);
setStopBrandMismatch(false);
setShowStopPicker(false);
setAvailabilityError(false);
setIncompatibleItems([]);
if (hasPickupItems) {
const pickupProductIds = cart.filter((i) => i.fulfillment === "pickup").map((i) => i.id);
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/check_stop_product_availability`,
{
method: "POST",
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ p_stop_id: stop.id, p_product_ids: pickupProductIds }),
}
)
.then((r) => r.json())
.then((data: { product_id: string; is_available: boolean }[]) => {
const unavailable = (data ?? [])
.filter((row) => row.is_available === false)
.map((row) => row.product_id);
setIncompatibleItems(unavailable);
})
.catch(() => {
setAvailabilityError(true);
setIncompatibleItems([]);
});
}
}
function handleCheckoutClick() {
if (stopBrandMismatch) { setSelectedStop(null); return; }
if (hasStopPickupItems && !selectedStop) { setShowStopPicker(true); return; }
if (incompatibleItems.length > 0) { return; }
window.location.href = "/checkout";
}
return (
<div className="min-h-screen relative">
{/* Background */}
<div className="fixed inset-0 bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950" />
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[120px]" />
</div>
<StorefrontHeader brandName="Your Cart" brandSlug="tuxedo" />
<main className="px-6 py-12 relative">
<div className="mx-auto grid max-w-6xl gap-10 lg:grid-cols-[1fr_380px]">
<div>
<h1 className="text-4xl font-semibold tracking-tight text-white">Shopping Cart</h1>
<p className="mt-3 text-zinc-400">Review your products before checkout.</p>
{stopBrandMismatch && (
<div className="mt-5 glass-card p-5 border border-red-500/20">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-500/10 border border-red-500/20">
<svg className="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Pickup stop is from a different store</p>
<p className="mt-1 text-sm text-zinc-400">Your cart was updated. Please select a new pickup stop.</p>
<button
onClick={() => { setSelectedStop(null); setStopBrandMismatch(false); setShowStopPicker(true); }}
className="mt-3 rounded-xl bg-red-500 hover:bg-red-400 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-red-500/20"
>
Choose Correct Stop
</button>
</div>
</div>
</div>
)}
{hasShedPickupItems && (
<div className="mt-5 glass-card p-5 border border-emerald-500/20">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Shed Pickup</p>
<p className="mt-1 text-sm text-zinc-400">
{cart.filter((i) => i.fulfillment === "pickup" && i.pickup_type === "shed").map((i) => i.description || i.name).join(", ")}
</p>
</div>
</div>
</div>
)}
{hasStopPickupItems && !selectedStop && !stopBrandMismatch && cartBrandId && (
<div className="mt-5 glass-card p-5 border border-amber-500/20">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Pickup stop needed</p>
<p className="mt-1 text-sm text-zinc-400">Your cart has pickup items. Select a stop before checkout.</p>
<button
onClick={() => setShowStopPicker(true)}
className="mt-3 rounded-xl bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 px-4 py-2 text-sm font-semibold text-white transition-all shadow-lg shadow-amber-500/20"
>
Choose Pickup Stop
</button>
</div>
</div>
</div>
)}
{incompatibleItems.length > 0 && (
<div className="mt-4 glass-card p-5 border border-red-500/20">
<p className="font-semibold text-white">Some items are not available at this stop:</p>
<ul className="mt-2 space-y-1">
{incompatibleItems.map((id) => {
const item = cart.find((i) => i.id === id);
return <li key={id} className="text-sm text-zinc-400"> {item?.name}</li>;
})}
</ul>
<p className="mt-2 text-sm text-zinc-500">Please remove these items or choose a different stop.</p>
</div>
)}
{availabilityError && (
<div className="mt-4 glass-card p-5">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 border border-amber-500/20">
<svg className="h-5 w-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<p className="font-semibold text-white">Could not verify product availability</p>
<p className="mt-1 text-sm text-zinc-400">Stop was selected. Checkout will validate product-stop assignment.</p>
</div>
</div>
</div>
)}
{showStopPicker && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 pointer-events-none">
<div className="w-full max-w-sm rounded-2xl glass-strong p-6 shadow-2xl pointer-events-auto border border-white/10">
<h3 className="text-xl font-semibold text-white">Choose Pickup Stop</h3>
<p className="mt-2 text-sm text-zinc-400">Select a stop for your pickup items.</p>
{loadingStops ? (
<div className="mt-4 flex items-center justify-center gap-2 py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-600 border-t-white" />
<span className="text-sm text-zinc-400">Loading stops...</span>
</div>
) : (
<div className="mt-4 space-y-2">
{stops.map((stop) => (
<button
key={stop.id}
onClick={() => handleStopSelect(stop)}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-left hover:bg-white/10 transition-all text-white"
>
<p className="font-medium">{stop.city}, {stop.state}</p>
<p className="mt-0.5 text-sm text-zinc-400">{stop.date} · {stop.time} · {stop.location}</p>
</button>
))}
</div>
)}
<button
onClick={() => setShowStopPicker(false)}
className="mt-5 w-full text-center text-sm text-zinc-400 hover:text-white transition-colors"
>
Cancel
</button>
</div>
</div>
)}
<div className="mt-10 space-y-4">
{cart.length === 0 ? (
<div className="glass-card p-8 text-center">
<p className="text-zinc-500">Your cart is empty.</p>
<Link href="/" className="mt-4 inline-block text-emerald-400 hover:text-emerald-300 transition-colors"> Continue shopping</Link>
</div>
) : (
cart.map((item) => (
<div key={item.id} className="glass-card p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-white">{item.name}</h2>
<p className="mt-1 text-sm text-zinc-400">
{item.fulfillment === "pickup" ? (
<span className="inline-flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Pickup
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
Shipping
</span>
)}
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => decreaseQuantity(item.id)}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-white/10 text-white hover:bg-white/20 active:scale-95 transition-all text-xl font-medium"
></button>
<span className="w-10 text-center font-semibold text-white">{item.quantity}</span>
<button
onClick={() => increaseQuantity(item.id)}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-500 text-white hover:bg-emerald-400 active:scale-95 transition-all text-xl font-medium shadow-lg shadow-emerald-500/20"
>+</button>
</div>
</div>
<div className="mt-4 flex items-center justify-between pt-3 border-t border-white/5">
<p className="text-sm font-semibold text-white">${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}</p>
<button onClick={() => removeFromCart(item.id)} className="text-sm text-zinc-500 hover:text-red-400 transition-colors">Remove</button>
</div>
</div>
))
)}
</div>
</div>
<aside>
<div className="glass-card p-6 sticky top-6">
<h2 className="text-xl font-semibold text-white">Order Summary</h2>
{hasPickupItems && (
<div className="mt-5">
{hasShedPickupItems && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
<p className="text-sm font-medium text-emerald-400">
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Shed Pickup
</p>
<p className="mt-1.5 text-xs text-zinc-400">
{cart.filter((i) => i.fulfillment === "pickup" && i.pickup_type === "shed").map((i) => i.description || i.name).join(", ")}
</p>
</div>
)}
{selectedStop && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4">
<p className="text-sm font-medium text-emerald-400">
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
Pickup: {selectedStop.city}, {selectedStop.state}
</p>
<p className="mt-1.5 text-xs text-zinc-400">{selectedStop.date} · {selectedStop.time}</p>
<p className="mt-0.5 text-xs text-zinc-400">{selectedStop.location}</p>
<button onClick={() => setShowStopPicker(true)} className="mt-2.5 text-xs text-emerald-400 hover:text-emerald-300 transition-colors">Change stop</button>
</div>
)}
{!selectedStop && hasStopPickupItems && (
<button
onClick={() => setShowStopPicker(true)}
className="w-full rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-left text-sm text-amber-400 hover:bg-amber-500/20 transition-all"
>
<svg className="inline h-3.5 w-3.5 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /></svg>
No pickup stop selected click to choose
</button>
)}
</div>
)}
<div className="mt-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-400">Subtotal</span>
<span className="text-sm font-semibold text-white">${subtotal.toFixed(2)}</span>
</div>
<div className="border-t border-white/5 pt-3">
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">Total</span>
<span className="text-2xl font-bold text-white">${subtotal.toFixed(2)}</span>
</div>
</div>
</div>
<button
onClick={handleCheckoutClick}
disabled={hasStopPickupItems && (!selectedStop || incompatibleItems.length > 0)}
className="mt-6 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-6 py-3.5 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/20 hover:-translate-y-0.5"
>
{incompatibleItems.length > 0
? "Remove Incompatible Items First"
: !selectedStop && hasStopPickupItems
? "Select Pickup Stop First"
: "Continue to Checkout"}
</button>
<Link href="/" className="mt-4 block w-full text-center text-sm text-zinc-400 hover:text-white transition-colors">
Continue Shopping
</Link>
</div>
</aside>
</div>
</main>
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
</div>
);
return <CartClient />;
}
+437
View File
@@ -0,0 +1,437 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useCart } from "@/context/CartContext";
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
type Stop = {
id: string;
city: string;
state: string;
date: string;
location: string;
brand_id: string;
};
export default function CheckoutClient() {
const { cart, subtotal, selectedStop, setSelectedStop, clearCart, cartBrandId } = useCart();
const router = useRouter();
const [stops, setStops] = useState<Stop[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Stable idempotency key per checkout session — survives retries
const idempotencyKeyRef = useRef<string | null>(null);
if (typeof window !== "undefined" && !idempotencyKeyRef.current) {
idempotencyKeyRef.current = crypto.randomUUID();
}
useEffect(() => {
if (!cartBrandId) return;
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,location,brand_id&order=date`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
}
)
.then((r) => r.json())
.then((data) => setStops(data ?? []))
.catch(() => setStops([]));
}, [cartBrandId]);
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed");
const hasShipItems = cart.some((i) => i.fulfillment === "ship");
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (cart.length === 0) return;
// Guard: if selected stop brand mismatches cart brand, block checkout
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
setSelectedStop(null);
router.replace("/cart");
return;
}
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const customerName = formData.get("customer_name") as string;
const customerEmail = formData.get("customer_email") as string;
const customerPhone = formData.get("customer_phone") as string;
const stopId = (selectedStop?.id ?? formData.get("stop_id") as string | null) as string | null;
// Shipping address for tax calculation (ship items only)
let shippingAddress;
if (hasShipItems) {
const shippingPostal = formData.get("shipping_postal_code") as string;
const shippingState = formData.get("shipping_state") as string;
const shippingCity = formData.get("shipping_city") as string;
if (shippingState) {
shippingAddress = { state: shippingState, postal_code: shippingPostal, city: shippingCity };
}
}
if (hasStopPickupItems && !stopId) {
setError("Please select a pickup location.");
setLoading(false);
return;
}
const items = cart.map((item) => ({
id: item.id,
name: item.name,
price: Number(item.price.replace("$", "")),
quantity: item.quantity,
fulfillment: item.fulfillment ?? "pickup",
}));
// Build return URLs for Stripe
const siteUrl = typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${siteUrl}/checkout?status=cancelled`;
// Store customer info for order creation on success page
if (typeof sessionStorage !== "undefined") {
sessionStorage.setItem("pending_checkout", JSON.stringify({
customerName,
customerEmail,
customerPhone,
stopId,
items: items.map((item) => ({ ...item, fulfillment: item.fulfillment as "pickup" | "ship" })),
cartBrandId,
shippingAddress,
idempotencyKey: idempotencyKeyRef.current,
}));
}
// Create Stripe Checkout session first
const stripeResult = await createRetailStripeCheckoutSession(items, "", cartBrandId ?? "unknown", successUrl, cancelUrl);
if (!stripeResult.success || !stripeResult.url) {
setError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
setLoading(false);
return;
}
// Redirect to Stripe Checkout
window.location.href = stripeResult.url;
}, [cart, selectedStop, cartBrandId, hasShipItems, hasStopPickupItems, router, setSelectedStop]);
if (cart.length === 0) {
return (
<div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-stone-900">
Your cart is empty
</h1>
<a
href="/"
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
>
Back to storefront
</a>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12">
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
<div>
<h1 className="text-4xl font-bold text-slate-900">
Checkout
</h1>
<p className="mt-3 text-slate-600">
Complete your order details.
</p>
{/* Error alert */}
{error && (
<div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert">
<div className="flex items-start gap-3">
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{error}</span>
</div>
</div>
)}
{/* Loading indicator */}
{loading && (
<div className="mt-6 flex items-center gap-3 rounded-xl bg-emerald-50 border border-emerald-200 p-4" role="status" aria-live="polite">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-300 border-t-emerald-600" aria-hidden="true" />
<span className="text-sm text-emerald-700">Placing your order...</span>
</div>
)}
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
{/* Customer Information */}
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
<legend className="text-xl font-bold text-slate-900">Customer Information</legend>
<div className="mt-4 space-y-4">
<div>
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
Full Name <span className="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="customer_name"
name="customer_name"
required
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="Jane Smith"
aria-required="true"
/>
</div>
<div>
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
Email <span className="text-slate-400 text-xs">(optional)</span>
</label>
<input
id="customer_email"
name="customer_email"
type="email"
autoComplete="email"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="jane@example.com"
/>
</div>
<div>
<label htmlFor="customer_phone" className="block text-sm font-medium text-slate-700">
Phone <span className="text-slate-400 text-xs">(optional)</span>
</label>
<input
id="customer_phone"
name="customer_phone"
type="tel"
autoComplete="tel"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="(555) 555-5555"
/>
</div>
</div>
</fieldset>
{/* Shed Pickup */}
{hasShedPickupItems && (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
<p className="mt-2 text-sm text-slate-600">
{cart.filter((i) => i.fulfillment === "pickup" && i.pickup_type === "shed").map((i) => i.description || i.name).join(", ")}
</p>
<p className="mt-1 text-xs text-slate-500">Pickup location details are included in your order confirmation.</p>
</fieldset>
)}
{/* Stop Pickup */}
{hasStopPickupItems && (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
<legend className="text-xl font-bold text-slate-900">
Pickup Location <span className="text-red-500" aria-hidden="true">*</span>
</legend>
{selectedStop ? (
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<p className="font-medium text-emerald-900">
<svg className="inline h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
{selectedStop.city}, {selectedStop.state}
</p>
<p className="mt-1 text-sm text-emerald-700">
{selectedStop.date} · {selectedStop.location}
</p>
<button
type="button"
onClick={() => setSelectedStop(null)}
className="mt-2 text-xs text-emerald-600 hover:text-emerald-800 underline transition-colors"
aria-label="Change pickup stop"
>
Change pickup stop
</button>
</div>
) : (
<div className="mt-4">
<label htmlFor="stop_id" className="block text-sm font-medium text-slate-700 mb-2">
Select pickup stop <span className="text-red-500" aria-hidden="true">*</span>
</label>
<select
id="stop_id"
name="stop_id"
required
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white"
aria-required="true"
>
<option value="">Select a stop...</option>
{stops.map((stop) => (
<option key={stop.id} value={stop.id}>
{stop.city}, {stop.state} {stop.date} @ {stop.location}
</option>
))}
</select>
</div>
)}
</fieldset>
)}
{/* Shipping Address */}
{hasShipItems && (
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
<legend className="text-xl font-bold text-slate-900">Shipping Address</legend>
<p className="mt-1 text-sm text-slate-500">
Enter your shipping details for delivery.
</p>
<div className="mt-4 space-y-4">
<div>
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
Street Address
</label>
<input
id="shipping_address"
name="shipping_address"
autoComplete="street-address"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="123 Main St"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
City
</label>
<input
id="shipping_city"
name="shipping_city"
autoComplete="address-level2"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
/>
</div>
<div>
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
State
</label>
<input
id="shipping_state"
name="shipping_state"
autoComplete="address-level1"
maxLength={2}
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
placeholder="NC"
/>
</div>
<div>
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
ZIP Code
</label>
<input
id="shipping_postal_code"
name="shipping_postal_code"
autoComplete="postal-code"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
placeholder="28147"
/>
</div>
</div>
</div>
</fieldset>
)}
{/* Submit button */}
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 hover:from-emerald-500 hover:to-emerald-400 px-6 py-4 text-lg font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/30 hover:-translate-y-0.5 flex items-center justify-center gap-2"
>
{loading ? (
<>
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Redirecting to payment...
</>
) : (
<>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Pay Now ${subtotal.toFixed(2)}
</>
)}
</button>
</form>
</div>
{/* Order Summary */}
<aside aria-label="Order summary">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
<h2 className="text-xl font-bold text-slate-900">
Order Summary
</h2>
<div className="mt-4 space-y-3">
{cart.map((item) => (
<div key={item.id} className="flex justify-between text-sm">
<span className="text-slate-600">
{item.name} × {item.quantity}
{item.fulfillment === "ship" && (
<span className="ml-1 text-xs text-blue-500">(ship)</span>
)}
</span>
<span className="font-medium text-slate-900">
${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}
</span>
</div>
))}
</div>
<div className="mt-4 border-t border-slate-200 pt-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-600">Subtotal</span>
<span className="text-slate-900">${subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-600">Estimated Tax</span>
<span className="text-slate-900 text-xs italic">Calculated at payment</span>
</div>
<div className="flex justify-between text-xl font-bold text-slate-900 pt-2 border-t border-slate-200">
<span>Total</span>
<span>${subtotal.toFixed(2)}</span>
</div>
</div>
{/* Secure payment badge */}
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-slate-500">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Secured by Stripe</span>
</div>
</div>
</aside>
</div>
</main>
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
</div>
);
}
+58 -9
View File
@@ -1,16 +1,65 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Processing...",
description: "Loading checkout...",
robots: {
index: false,
follow: false,
},
};
export default function Loading() {
return (
<main className="min-h-screen bg-slate-50 px-6 py-12">
<div className="mx-auto max-w-4xl">
<div className="animate-pulse space-y-6">
<div className="h-8 w-48 rounded bg-slate-200" />
<div className="h-12 w-80 rounded bg-slate-200" />
<div className="h-4 w-full max-w-xl rounded bg-slate-200" />
<div className="mt-8 rounded-2xl bg-white p-8 shadow-sm" />
<div className="mt-8 rounded-2xl bg-white p-8 shadow-sm" />
<div className="mt-8 rounded-2xl bg-white p-8 shadow-sm" />
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
<div className="h-20 border-b border-slate-100/50" />
<main className="px-6 py-12">
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
<div>
{/* Title skeleton */}
<div className="h-10 w-32 rounded-lg bg-slate-200 animate-pulse" />
<div className="h-5 w-64 rounded mt-3 bg-slate-100 animate-pulse" />
{/* Form skeleton */}
<div className="mt-8 space-y-6">
<div className="rounded-2xl bg-white p-6 shadow-sm border border-slate-100">
<div className="h-6 w-40 rounded bg-slate-100 animate-pulse" />
<div className="mt-4 space-y-4">
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-slate-100 animate-pulse" />
<div className="h-12 w-full rounded-xl bg-slate-100 animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
<div className="h-12 w-full rounded-xl bg-slate-100 animate-pulse" />
</div>
</div>
</div>
{/* Button skeleton */}
<div className="h-14 w-full rounded-xl bg-slate-100 animate-pulse" />
</div>
</div>
{/* Sidebar skeleton */}
<aside>
<div className="rounded-2xl bg-white p-6 shadow-sm border border-slate-100 sticky top-6">
<div className="h-6 w-32 rounded bg-slate-100 animate-pulse" />
<div className="mt-4 space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex justify-between">
<div className="h-4 w-32 rounded bg-slate-100 animate-pulse" />
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
</div>
))}
</div>
</div>
</aside>
</div>
</main>
<span role="status" className="sr-only">Loading checkout...</span>
</div>
);
}
+20 -384
View File
@@ -1,390 +1,26 @@
"use client";
import type { Metadata } from "next";
import CheckoutClient from "./CheckoutClient";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useCart } from "@/context/CartContext";
import { createOrder } from "@/actions/checkout";
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
type Stop = {
id: string;
city: string;
state: string;
date: string;
location: string;
brand_id: string;
export const metadata: Metadata = {
title: "Checkout — Route Commerce",
description: "Complete your order. Enter customer details, select pickup location, and proceed to secure payment via Stripe.",
keywords: ["checkout", "payment", "order", "pickup", "shipping"],
openGraph: {
title: "Checkout — Route Commerce",
description: "Complete your order. Enter customer details, select pickup location, and proceed to secure payment.",
url: `${BASE_URL}/checkout`,
siteName: "Route Commerce",
locale: "en_US",
type: "website",
},
robots: {
index: false,
follow: false,
},
};
export default function CheckoutPage() {
const { cart, subtotal, selectedStop, setSelectedStop, clearCart, cartBrandId } = useCart();
const router = useRouter();
const [stops, setStops] = useState<Stop[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Stable idempotency key per checkout session — survives retries
const idempotencyKeyRef = useRef<string | null>(null);
if (!idempotencyKeyRef.current) {
idempotencyKeyRef.current = crypto.randomUUID();
}
useEffect(() => {
if (!cartBrandId) return;
fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/stops?active=eq.true&brand_id=eq.${cartBrandId}&select=id,city,state,date,location,brand_id&order=date`,
{
headers: {
apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
},
}
)
.then((r) => r.json())
.then((data) => setStops(data));
}, [cartBrandId]);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (cart.length === 0) return;
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
const hasShipItems = cart.some((i) => i.fulfillment === "ship");
// Guard: if selected stop brand mismatches cart brand, block checkout
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
setSelectedStop(null);
router.replace("/cart");
return;
}
setLoading(true);
setError(null);
const formData = new FormData(e.currentTarget);
const customerName = formData.get("customer_name") as string;
const customerEmail = formData.get("customer_email") as string;
const customerPhone = formData.get("customer_phone") as string;
const stopId = (selectedStop?.id ?? formData.get("stop_id") as string | null) as string | null;
// Shipping address for tax calculation (ship items only)
let shippingAddress;
if (hasShipItems) {
const shippingPostal = formData.get("shipping_postal_code") as string;
const shippingState = formData.get("shipping_state") as string;
const shippingCity = formData.get("shipping_city") as string;
if (shippingState) {
shippingAddress = { state: shippingState, postal_code: shippingPostal, city: shippingCity };
}
}
if (hasStopPickupItems && !stopId) {
setError("Please select a pickup location.");
setLoading(false);
return;
}
const items = cart.map((item) => ({
id: item.id,
name: item.name,
price: Number(item.price.replace("$", "")),
quantity: item.quantity,
fulfillment: item.fulfillment ?? "pickup",
}));
setLoading(true);
// Build return URLs for Stripe
const siteUrl = typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${siteUrl}/checkout?status=cancelled`;
// Store customer info for order creation on success page
sessionStorage.setItem("pending_checkout", JSON.stringify({
customerName,
customerEmail,
customerPhone,
stopId,
items: items.map((item) => ({ ...item, fulfillment: item.fulfillment as "pickup" | "ship" })),
cartBrandId,
shippingAddress,
idempotencyKey: idempotencyKeyRef.current,
}));
// Create Stripe Checkout session first
const stripeResult = await createRetailStripeCheckoutSession(items, "", cartBrandId ?? "unknown", successUrl, cancelUrl);
if (!stripeResult.success || !stripeResult.url) {
setError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
setLoading(false);
return;
}
// Redirect to Stripe Checkout
window.location.href = stripeResult.url;
}
if (cart.length === 0) {
return (
<div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12">
<div className="mx-auto max-w-4xl">
<h1 className="text-3xl font-bold text-stone-900">
Your cart is empty
</h1>
<a
href="/"
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
>
Back to storefront
</a>
</div>
</main>
</div>
);
}
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
const hasStopPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type !== "shed");
const hasShipItems = cart.some((i) => i.fulfillment === "ship");
return (
<div className="min-h-screen bg-stone-50">
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
<main className="px-6 py-12">
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
<div>
<h1 className="text-4xl font-bold text-slate-900">
Checkout
</h1>
<p className="mt-3 text-slate-600">
Complete your order details.
</p>
{error && (
<div className="mt-6 rounded-xl bg-red-50 p-4 text-red-700 text-sm">
{error}
</div>
)}
{loading && (
<div className="mt-6 flex items-center gap-3 rounded-xl bg-slate-100 p-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-slate-300 border-t-slate-900" />
<span className="text-sm text-slate-600">Placing your order...</span>
</div>
)}
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
<h2 className="text-xl font-bold text-slate-900">
Customer Information
</h2>
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700">
Full Name *
</label>
<input
name="customer_name"
required
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
placeholder="Jane Smith"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700">
Email
</label>
<input
name="customer_email"
type="email"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
placeholder="jane@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700">
Phone
</label>
<input
name="customer_phone"
type="tel"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
placeholder="(555) 555-5555"
/>
</div>
</div>
</div>
{hasShedPickupItems && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200">
<h2 className="text-xl font-bold text-slate-900">
Shed Pickup
</h2>
<p className="mt-2 text-sm text-slate-600">
{cart.filter((i) => i.fulfillment === "pickup" && i.pickup_type === "shed").map((i) => i.description || i.name).join(", ")}
</p>
<p className="mt-1 text-xs text-slate-500">Pickup location details are included in your order confirmation.</p>
</div>
)}
{hasStopPickupItems && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
<h2 className="text-xl font-bold text-slate-900">
Pickup Location
</h2>
{selectedStop ? (
<div className="mt-4 rounded-xl border border-green-200 bg-green-50 p-4">
<p className="font-medium text-green-900">
📦 {selectedStop.city}, {selectedStop.state}
</p>
<p className="mt-1 text-sm text-green-700">
{selectedStop.date} · {selectedStop.time} · {selectedStop.location}
</p>
<button
type="button"
onClick={() => setSelectedStop(null)}
className="mt-2 text-xs text-green-600 underline hover:text-green-800"
>
Change pickup stop
</button>
</div>
) : (
<div className="mt-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Select pickup stop *
</label>
<select
name="stop_id"
required
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
>
<option value="">Select a stop...</option>
{stops.map((stop) => (
<option key={stop.id} value={stop.id}>
{stop.city}, {stop.state} {stop.date} @ {stop.location}
</option>
))}
</select>
</div>
)}
</div>
)}
{hasShipItems && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
<h2 className="text-xl font-bold text-slate-900">
Shipping Address
</h2>
<p className="mt-1 text-sm text-slate-500">
Enter your shipping details.
</p>
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700">
Street Address
</label>
<input
name="shipping_address"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
placeholder="123 Main St"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700">
City
</label>
<input
name="shipping_city"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700">
State
</label>
<input
name="shipping_state"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
placeholder="NC"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700">
ZIP Code
</label>
<input
name="shipping_postal_code"
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-slate-900"
placeholder="28147"
/>
</div>
</div>
</div>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-slate-900 px-6 py-4 text-lg font-medium text-white disabled:opacity-50"
>
{loading ? "Redirecting to payment..." : `Pay Now — $${subtotal.toFixed(2)}`}
</button>
</form>
</div>
{/* Order Summary */}
<aside>
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
<h2 className="text-xl font-bold text-slate-900">
Order Summary
</h2>
<div className="mt-4 space-y-3">
{cart.map((item) => (
<div key={item.id} className="flex justify-between text-sm">
<span className="text-slate-600">
{item.name} × {item.quantity}
{item.fulfillment === "ship" && (
<span className="ml-1 text-xs text-slate-400">(ship)</span>
)}
</span>
<span className="font-medium text-slate-900">
${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}
</span>
</div>
))}
</div>
<div className="mt-4 border-t border-slate-200 pt-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-600">Subtotal</span>
<span className="text-slate-900">${subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-600">Estimated Tax</span>
<span className="text-slate-900 text-xs italic">Taxes will be calculated before payment integration.</span>
</div>
<div className="flex justify-between text-xl font-bold text-slate-900 pt-2 border-t border-slate-200">
<span>Total</span>
<span>${subtotal.toFixed(2)}</span>
</div>
</div>
</div>
</aside>
</div>
</main>
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
</div>
);
return <CheckoutClient />;
}
+67 -34
View File
@@ -14,6 +14,7 @@ export default function ContactClientPage() {
const [submitted, setSubmitted] = useState(false);
const [form, setForm] = useState<FormState>({ name: "", email: "", topic: "general", message: "" });
const [isSubmitting, setIsSubmitting] = useState(false);
const [focusedField, setFocusedField] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -29,8 +30,8 @@ export default function ContactClientPage() {
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-xl border-b border-[#e5e5e5]">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="flex items-center gap-3">
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center shadow-lg shadow-[#1a4d2e]/20">
<Link href="/" className="flex items-center gap-3" aria-label="Route Commerce home">
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center shadow-lg shadow-[#1a4d2e]/20" aria-hidden="true">
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
@@ -57,8 +58,8 @@ export default function ContactClientPage() {
{/* Contact info cards */}
<div className="grid gap-6 md:grid-cols-3 mb-16">
<div className="rounded-3xl bg-white border border-[#e5e5e5] p-8 text-center shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#f0fdf4] mb-5 shadow-md">
<article className="rounded-3xl bg-white border border-[#e5e5e5] p-8 text-center shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#f0fdf4] mb-5 shadow-md" aria-hidden="true">
<svg className="h-6 w-6 text-[#1a4d2e]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<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" />
@@ -66,27 +67,27 @@ export default function ContactClientPage() {
</div>
<h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Address</h3>
<p className="text-[#666] leading-relaxed">Serving farms and trucking routes across the nation</p>
</div>
</article>
<div className="rounded-3xl bg-white border border-[#e5e5e5] p-8 text-center shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#f0fdf4] mb-5 shadow-md">
<article className="rounded-3xl bg-white border border-[#e5e5e5] p-8 text-center shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#f0fdf4] mb-5 shadow-md" aria-hidden="true">
<svg className="h-6 w-6 text-[#1a4d2e]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.184-.046-.379-.041-.545.114L18 10.48a2.25 2.25 0 00-.545-.114l-4.423 1.106c-.5.119-.852.575-.852 1.091v1.372a2.25 2.25 0 01-2.25 2.25h-2.25a15 15 0 01-15-15z" />
</svg>
</div>
<h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Phone</h3>
<p className="text-[#666] leading-relaxed">support@routecommerce.com</p>
</div>
</article>
<div className="rounded-3xl bg-white border border-[#e5e5e5] p-8 text-center shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#f0fdf4] mb-5 shadow-md">
<article className="rounded-3xl bg-white border border-[#e5e5e5] p-8 text-center shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[#f0fdf4] mb-5 shadow-md" aria-hidden="true">
<svg className="h-6 w-6 text-[#1a4d2e]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-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>
<h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Email</h3>
<p className="text-[#666] leading-relaxed">hello@routecommerce.com</p>
</div>
</article>
</div>
{/* Contact form */}
@@ -96,12 +97,12 @@ export default function ContactClientPage() {
<h2 className="text-3xl font-black text-[#0a0a0a] tracking-tight leading-tight">
Get in Touch
</h2>
<div className="mt-4 h-1 w-12 bg-[#1a4d2e]" />
<div className="mt-4 h-1 w-12 bg-[#1a4d2e]" aria-hidden="true" />
</div>
{submitted ? (
<div className="rounded-2xl bg-[#f0fdf4] border border-[#bbf7d0] p-10 text-center">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-[#dcfce7] mb-4 shadow-md">
<div className="rounded-2xl bg-[#f0fdf4] border border-[#bbf7d0] p-10 text-center" role="status" aria-live="polite">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-[#dcfce7] mb-4 shadow-md" aria-hidden="true">
<svg className="h-7 w-7 text-[#1a4d2e]" 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>
@@ -113,43 +114,67 @@ export default function ContactClientPage() {
setSubmitted(false);
setForm({ name: "", email: "", topic: "general", message: "" });
}}
className="mt-5 text-sm font-semibold text-[#1a4d2e] underline hover:text-[#2d6a4f]"
className="mt-5 text-sm font-semibold text-[#1a4d2e] underline hover:text-[#2d6a4f] transition-colors"
>
Send another message
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-6" aria-label="Contact form">
<div className="grid gap-6 md:grid-cols-2">
<div>
<label className="block text-sm font-semibold text-[#333] mb-2">Your Name</label>
<label htmlFor="contact-name" className="block text-sm font-semibold text-[#333] mb-2">
Your Name
</label>
<input
id="contact-name"
type="text"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-xl border-2 border-[#e5e5e5] bg-white px-5 py-4 text-base text-[#1a1a1a] placeholder:text-[#999] outline-none focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/10 transition-colors"
onFocus={() => setFocusedField("name")}
onBlur={() => setFocusedField(null)}
className={`w-full rounded-xl border-2 px-5 py-4 text-base text-[#1a1a1a] placeholder:text-[#999] outline-none transition-colors ${
focusedField === "name" ? "border-[#1a4d2e] ring-4 ring-[#1a4d2e]/10" : "border-[#e5e5e5] focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/10"
}`}
placeholder="Jane Smith"
aria-required="true"
/>
</div>
<div>
<label className="block text-sm font-semibold text-[#333] mb-2">Email</label>
<label htmlFor="contact-email" className="block text-sm font-semibold text-[#333] mb-2">
Email
</label>
<input
required
id="contact-email"
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full rounded-xl border-2 border-[#e5e5e5] bg-white px-5 py-4 text-base text-[#1a1a1a] placeholder:text-[#999] outline-none focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/10 transition-colors"
onFocus={() => setFocusedField("email")}
onBlur={() => setFocusedField(null)}
className={`w-full rounded-xl border-2 px-5 py-4 text-base text-[#1a1a1a] placeholder:text-[#999] outline-none transition-colors ${
focusedField === "email" ? "border-[#1a4d2e] ring-4 ring-[#1a4d2e]/10" : "border-[#e5e5e5] focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/10"
}`}
placeholder="jane@example.com"
aria-required="true"
/>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-[#333] mb-2">Topic</label>
<label htmlFor="contact-topic" className="block text-sm font-semibold text-[#333] mb-2">
Topic
</label>
<select
id="contact-topic"
value={form.topic}
onChange={(e) => setForm({ ...form, topic: e.target.value })}
className="w-full rounded-xl border-2 border-[#e5e5e5] bg-white px-5 py-4 text-base text-[#1a1a1a] outline-none focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/10 transition-colors appearance-none"
onFocus={() => setFocusedField("topic")}
onBlur={() => setFocusedField(null)}
className={`w-full rounded-xl border-2 px-5 py-4 text-base text-[#1a1a1a] outline-none transition-colors appearance-none cursor-pointer ${
focusedField === "topic" ? "border-[#1a4d2e] ring-4 ring-[#1a4d2e]/10" : "border-[#e5e5e5] focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/10"
}`}
>
<option value="general">General Question</option>
<option value="orders">Orders & Pickup</option>
@@ -160,14 +185,22 @@ export default function ContactClientPage() {
</div>
<div>
<label className="block text-sm font-semibold text-[#333] mb-2">Message</label>
<label htmlFor="contact-message" className="block text-sm font-semibold text-[#333] mb-2">
Message
</label>
<textarea
id="contact-message"
required
rows={5}
value={form.message}
onChange={(e) => setForm({ ...form, message: e.target.value })}
className="w-full rounded-xl border-2 border-[#e5e5e5] bg-white px-5 py-4 text-base text-[#1a1a1a] placeholder:text-[#999] outline-none focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/10 transition-colors resize-none"
onFocus={() => setFocusedField("message")}
onBlur={() => setFocusedField(null)}
className={`w-full rounded-xl border-2 px-5 py-4 text-base text-[#1a1a1a] placeholder:text-[#999] outline-none transition-colors resize-none ${
focusedField === "message" ? "border-[#1a4d2e] ring-4 ring-[#1a4d2e]/10" : "border-[#e5e5e5] focus:border-[#1a4d2e] focus:ring-4 focus:ring-[#1a4d2e]/10"
}`}
placeholder="How can we help you?"
aria-required="true"
/>
</div>
@@ -178,7 +211,7 @@ export default function ContactClientPage() {
>
{isSubmitting ? (
<>
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
@@ -187,7 +220,7 @@ export default function ContactClientPage() {
) : (
<>
Send Message
<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="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
</svg>
</>
@@ -198,13 +231,13 @@ export default function ContactClientPage() {
</div>
{/* Business hours */}
<div className="mt-10 rounded-3xl bg-white border border-[#e5e5e5] p-10 shadow-sm">
<section className="mt-10 rounded-3xl bg-white border border-[#e5e5e5] p-10 shadow-sm" aria-labelledby="hours-heading">
<div className="mb-6">
<p className="text-sm font-bold tracking-[0.15em] uppercase text-[#1a4d2e] mb-3">Availability</p>
<h2 className="text-2xl font-black text-[#0a0a0a] tracking-tight leading-tight">
<h2 id="hours-heading" className="text-2xl font-black text-[#0a0a0a] tracking-tight leading-tight">
When to Reach Us
</h2>
<div className="mt-4 h-1 w-12 bg-[#1a4d2e]" />
<div className="mt-4 h-1 w-12 bg-[#1a4d2e]" aria-hidden="true" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between py-4 px-6 rounded-2xl bg-[#fafafa]">
@@ -219,7 +252,7 @@ export default function ContactClientPage() {
<p className="mt-5 text-sm text-[#999] leading-relaxed">
For urgent matters, please call our support line.
</p>
</div>
</section>
</main>
{/* Footer */}
@@ -227,17 +260,17 @@ export default function ContactClientPage() {
<div className="max-w-5xl mx-auto px-6 py-8">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#1a4d2e] to-[#2d6a4f] flex items-center justify-center" aria-hidden="true">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span className="text-sm text-[#666]">2024 Route Commerce. All rights reserved.</span>
</div>
<div className="flex items-center gap-6 text-sm text-[#888]">
<nav className="flex items-center gap-6 text-sm text-[#888]" aria-label="Footer navigation">
<Link href="/privacy-policy" className="hover:text-[#1a4d2e] transition-colors">Privacy</Link>
<Link href="/terms-and-conditions" className="hover:text-[#1a4d2e] transition-colors">Terms</Link>
</div>
</nav>
</div>
</div>
</footer>
+78
View File
@@ -0,0 +1,78 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Loading contact...",
description: "Loading...",
robots: {
index: false,
follow: false,
},
};
export default function Loading() {
return (
<div className="min-h-screen bg-white">
{/* Header skeleton */}
<header className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-xl border-b border-[#e5e5e5]">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-[#e5e5e5] to-[#f5f5f5] animate-pulse" />
<div className="h-6 w-36 rounded bg-[#e5e5e5] animate-pulse" />
</div>
<div className="h-5 w-24 rounded bg-[#e5e5e5] animate-pulse" />
</div>
</header>
<main className="max-w-5xl mx-auto px-6 py-32">
{/* Page header skeleton */}
<div className="text-center mb-16">
<div className="h-4 w-20 rounded-full bg-[#e5e5e5] mx-auto mb-4 animate-pulse" />
<div className="h-12 w-64 rounded-lg bg-[#e5e5e5] mx-auto mb-4 animate-pulse" />
<div className="h-5 w-96 rounded bg-[#e5e5e5] mx-auto animate-pulse" />
</div>
{/* Contact cards skeleton */}
<div className="grid gap-6 md:grid-cols-3 mb-16">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-3xl bg-white border border-[#e5e5e5] p-8">
<div className="h-14 w-14 rounded-2xl bg-[#e5e5e5] mx-auto mb-5 animate-pulse" />
<div className="h-5 w-24 rounded bg-[#e5e5e5] mx-auto mb-3 animate-pulse" />
<div className="h-4 w-40 rounded bg-[#e5e5e5] mx-auto animate-pulse" />
</div>
))}
</div>
{/* Form skeleton */}
<div className="rounded-3xl bg-white border border-[#e5e5e5] p-10">
<div className="h-5 w-20 rounded-full bg-[#e5e5e5] mb-3 animate-pulse" />
<div className="h-8 w-40 rounded bg-[#e5e5e5] mb-4 animate-pulse" />
<div className="h-1 w-12 bg-[#e5e5e5] mb-8 animate-pulse" />
<div className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-[#e5e5e5] animate-pulse" />
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-[#e5e5e5] animate-pulse" />
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
</div>
</div>
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-[#e5e5e5] animate-pulse" />
<div className="h-12 rounded-xl bg-[#e5e5e5] animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-[#e5e5e5] animate-pulse" />
<div className="h-32 rounded-xl bg-[#e5e5e5] animate-pulse" />
</div>
<div className="h-12 w-32 rounded-xl bg-[#e5e5e5] animate-pulse" />
</div>
</div>
</main>
<span role="status" className="sr-only">Loading contact page...</span>
</div>
);
}
@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 via-blue-700 to-blue-900 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-lg w-full text-center relative"
>
<div className="absolute inset-0 opacity-20 pointer-events-none">
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full -translate-y-1/2 translate-x-1/4" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full translate-y-1/2 -translate-x-1/4" />
</div>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
>
<svg className="h-12 w-12 text-white" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<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>
</motion.div>
<h1 className="text-4xl font-black text-white mb-4">Our Story Unavailable</h1>
<p className="text-blue-100 mb-8 leading-relaxed">
We couldn't load our story page. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
<div className="mb-8 rounded-2xl bg-white/10 border border-white/20 p-5 text-left backdrop-blur-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-blue-200 mb-2">Error Details</p>
<p className="text-sm text-white/80 font-mono leading-relaxed break-all">{error.message}</p>
</div>
)}
<div className="relative flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="rounded-2xl bg-white text-blue-700 hover:bg-blue-50 px-8 py-4 text-sm font-bold transition-all hover:shadow-xl hover:-translate-y-0.5 active:scale-95 shadow-lg"
>
Try Again
</button>
<Link
href="/indian-river-direct"
className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 text-white hover:bg-blue-600/80 px-8 py-4 text-sm font-bold transition-all hover:-translate-y-0.5 active:scale-95"
>
Back to Homepage
</Link>
</div>
</motion.div>
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { motion } from "framer-motion";
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-4xl px-6 py-20">
{/* Hero skeleton */}
<div className="mb-20 animate-pulse text-center">
<div className="h-3 w-24 rounded bg-blue-100 mx-auto mb-5" />
<div className="h-20 w-full max-w-xl mx-auto rounded-lg bg-blue-50 mb-5" />
<div className="h-8 w-full max-w-2xl mx-auto rounded bg-blue-50" />
</div>
{/* Content sections skeleton */}
<div className="space-y-20">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="grid md:grid-cols-2 gap-12 items-center"
>
<div className="animate-pulse space-y-4">
<div className="h-4 w-20 rounded bg-blue-100" />
<div className="h-8 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
</div>
<div className="h-64 rounded-3xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100" />
</motion.div>
))}
</div>
</div>
</div>
);
}
@@ -156,7 +156,7 @@ export default function IndianRiverContactPage() {
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-6" aria-label="Contact form">
<div className="grid gap-6 md:grid-cols-2">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-2">Your Name</label>
@@ -166,6 +166,7 @@ export default function IndianRiverContactPage() {
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-5 py-4 text-base text-stone-900 placeholder:text-stone-400 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-colors"
placeholder="Jane Smith"
autoComplete="name"
/>
</div>
<div>
@@ -177,6 +178,7 @@ export default function IndianRiverContactPage() {
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full rounded-xl border-2 border-stone-200 bg-white px-5 py-4 text-base text-stone-900 placeholder:text-stone-400 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-colors"
placeholder="jane@example.com"
autoComplete="email"
/>
</div>
</div>
@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 via-blue-700 to-blue-900 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-lg w-full text-center relative"
>
<div className="absolute inset-0 opacity-20 pointer-events-none">
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full -translate-y-1/2 translate-x-1/4" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full translate-y-1/2 -translate-x-1/4" />
</div>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
>
<svg className="h-12 w-12 text-white" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<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>
</motion.div>
<h1 className="text-4xl font-black text-white mb-4">Contact Unavailable</h1>
<p className="text-blue-100 mb-8 leading-relaxed">
We couldn't load the contact page. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
<div className="mb-8 rounded-2xl bg-white/10 border border-white/20 p-5 text-left backdrop-blur-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-blue-200 mb-2">Error Details</p>
<p className="text-sm text-white/80 font-mono leading-relaxed break-all">{error.message}</p>
</div>
)}
<div className="relative flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="rounded-2xl bg-white text-blue-700 hover:bg-blue-50 px-8 py-4 text-sm font-bold transition-all hover:shadow-xl hover:-translate-y-0.5 active:scale-95 shadow-lg"
>
Try Again
</button>
<Link
href="/indian-river-direct"
className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 text-white hover:bg-blue-600/80 px-8 py-4 text-sm font-bold transition-all hover:-translate-y-0.5 active:scale-95"
>
Back to Homepage
</Link>
</div>
</motion.div>
</div>
);
}
@@ -0,0 +1,62 @@
"use client";
import { motion } from "framer-motion";
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-4xl px-6 py-20">
{/* Header skeleton */}
<div className="mb-16 animate-pulse text-center">
<div className="h-4 w-20 rounded bg-blue-100 mx-auto mb-5" />
<div className="h-16 w-64 mx-auto rounded-lg bg-stone-200 mb-5" />
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
</div>
{/* Contact cards skeleton */}
<div className="grid gap-6 md:grid-cols-3 mb-16">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white border-2 border-stone-200 p-8 shadow-xl text-center"
>
<div className="h-14 w-14 rounded-2xl bg-blue-100 mx-auto mb-5" />
<div className="h-5 w-24 mx-auto rounded bg-stone-200 mb-3" />
<div className="h-4 w-full max-w-[160px] mx-auto rounded bg-stone-100" />
</motion.div>
))}
</div>
{/* Form skeleton */}
<div className="rounded-3xl bg-white border-2 border-stone-200 p-10 shadow-xl">
<div className="animate-pulse space-y-6">
<div className="h-6 w-32 rounded bg-stone-200" />
<div className="h-8 w-48 rounded bg-stone-200" />
<div className="grid gap-6 md:grid-cols-2 mt-8">
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-14 rounded-xl bg-stone-100" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-14 rounded-xl bg-stone-100" />
</div>
</div>
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-stone-100" />
<div className="h-14 rounded-xl bg-stone-100" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-32 rounded-xl bg-stone-100" />
</div>
<div className="h-14 w-40 rounded-xl bg-blue-100 mt-8" />
</div>
</div>
</div>
</div>
);
}
+56 -8
View File
@@ -1,5 +1,8 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function ErrorPage({
error,
reset,
@@ -8,20 +11,65 @@ export default function ErrorPage({
reset: () => void;
}) {
return (
<main className="min-h-screen bg-red-50 px-6 py-12">
<div className="mx-auto max-w-2xl">
<h1 className="text-3xl font-bold text-red-900">Page Error</h1>
<p className="mt-4 text-red-700">{error.message}</p>
{error.digest && (
<p className="mt-2 text-sm text-red-500">Digest: {error.digest}</p>
<main className="min-h-screen bg-gradient-to-br from-blue-600 via-blue-700 to-blue-900 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-lg w-full text-center relative"
>
{/* Decorative background */}
<div className="absolute inset-0 opacity-20 pointer-events-none">
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full -translate-y-1/2 translate-x-1/4" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full translate-y-1/2 -translate-x-1/4" />
</div>
{/* Error icon */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
>
<svg className="h-12 w-12 text-white" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<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>
</motion.div>
{/* Error message */}
<h1 className="text-4xl font-black text-white mb-4">
Something went wrong
</h1>
<p className="text-blue-100 mb-8 leading-relaxed">
We encountered an unexpected error. Please try again or return to the homepage.
</p>
{/* Error details (development only) */}
{process.env.NODE_ENV === "development" && (
<div className="mb-8 rounded-2xl bg-white/10 border border-white/20 p-5 text-left backdrop-blur-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-blue-200 mb-2">Error Details</p>
<p className="text-sm text-white/80 font-mono leading-relaxed break-all">
{error.message}
</p>
</div>
)}
{/* Actions */}
<div className="relative flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="mt-6 rounded-xl bg-red-600 px-4 py-2 text-white"
className="rounded-2xl bg-white text-blue-700 hover:bg-blue-50 px-8 py-4 text-sm font-bold transition-all hover:shadow-xl hover:-translate-y-0.5 active:scale-95 shadow-lg"
>
Try again
Try Again
</button>
<Link
href="/indian-river-direct"
className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 text-white hover:bg-blue-600/80 px-8 py-4 text-sm font-bold transition-all hover:-translate-y-0.5 active:scale-95"
>
Back to Homepage
</Link>
</div>
</motion.div>
</main>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 via-blue-700 to-blue-900 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-lg w-full text-center relative"
>
<div className="absolute inset-0 opacity-20 pointer-events-none">
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full -translate-y-1/2 translate-x-1/4" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full translate-y-1/2 -translate-x-1/4" />
</div>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
>
<svg className="h-12 w-12 text-white" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<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>
</motion.div>
<h1 className="text-4xl font-black text-white mb-4">FAQ Unavailable</h1>
<p className="text-blue-100 mb-8 leading-relaxed">
We couldn't load the FAQ page. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
<div className="mb-8 rounded-2xl bg-white/10 border border-white/20 p-5 text-left backdrop-blur-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-blue-200 mb-2">Error Details</p>
<p className="text-sm text-white/80 font-mono leading-relaxed break-all">{error.message}</p>
</div>
)}
<div className="relative flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="rounded-2xl bg-white text-blue-700 hover:bg-blue-50 px-8 py-4 text-sm font-bold transition-all hover:shadow-xl hover:-translate-y-0.5 active:scale-95 shadow-lg"
>
Try Again
</button>
<Link
href="/indian-river-direct"
className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 text-white hover:bg-blue-600/80 px-8 py-4 text-sm font-bold transition-all hover:-translate-y-0.5 active:scale-95"
>
Back to Homepage
</Link>
</div>
</motion.div>
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import { motion } from "framer-motion";
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-3xl px-6 py-20">
{/* Header skeleton */}
<div className="mb-14 text-center animate-pulse">
<div className="h-4 w-16 rounded bg-blue-100 mx-auto mb-4" />
<div className="h-16 w-48 mx-auto rounded-lg bg-stone-200 mb-5" />
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
</div>
{/* Search skeleton */}
<div className="mb-12">
<div className="h-14 rounded-2xl bg-white border-2 border-stone-200 shadow-lg" />
</div>
{/* FAQ items skeleton */}
<div className="space-y-4">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="rounded-2xl bg-white border-2 border-stone-200 overflow-hidden"
>
<div className="flex items-center justify-between px-6 py-5">
<div className="h-5 w-3/4 rounded bg-stone-200" />
<div className="h-8 w-8 rounded-full bg-blue-100" />
</div>
</motion.div>
))}
</div>
</div>
</div>
);
}
+69 -10
View File
@@ -1,15 +1,74 @@
"use client";
import { motion } from "framer-motion";
export default function Loading() {
return (
<main className="min-h-screen bg-stone-50 px-6 py-12">
<div className="mx-auto max-w-5xl">
<div className="animate-pulse space-y-6">
<div className="h-8 w-56 rounded bg-slate-200" />
<div className="h-12 w-96 rounded bg-slate-200" />
<div className="h-4 w-full max-w-xl rounded bg-slate-200" />
<div className="mt-8 rounded-2xl bg-white p-6 shadow-sm" />
<div className="mt-10 grid gap-6 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-48 rounded-2xl bg-slate-200" />
<main className="min-h-screen bg-white/50 backdrop-blur-xl">
<div className="mx-auto max-w-6xl px-6 py-16">
{/* Header skeleton with blue accent */}
<div className="mb-16 animate-pulse">
<div className="h-3 w-20 rounded bg-blue-100 mb-4" />
<div className="h-16 w-80 rounded-lg bg-blue-50 mb-4" />
<div className="h-6 w-96 max-w-full rounded bg-blue-50" />
</div>
{/* Product cards skeleton */}
<div className="grid gap-6 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white border-2 border-stone-200 overflow-hidden shadow-lg"
>
{/* Image skeleton */}
<div className="h-48 bg-gradient-to-br from-blue-50 to-white relative overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-blue-100/60 to-transparent" />
</div>
{/* Content skeleton */}
<div className="p-6 space-y-4">
<div className="h-6 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
<div className="pt-4 flex items-center justify-between">
<div className="h-8 w-20 rounded-lg bg-blue-50" />
<div className="h-11 w-28 rounded-xl bg-blue-100" />
</div>
</div>
</motion.div>
))}
</div>
{/* Stops section skeleton */}
<div className="mt-20">
<div className="animate-pulse mb-10">
<div className="h-3 w-24 rounded bg-blue-100 mb-4" />
<div className="h-10 w-64 rounded-lg bg-blue-50 mb-3" />
<div className="h-4 w-48 rounded bg-blue-50" />
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2, 3, 4, 5].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="rounded-3xl bg-gradient-to-br from-blue-50 to-white border-2 border-blue-100 p-6"
>
<div className="flex items-start justify-between mb-3">
<div>
<div className="h-6 w-32 rounded bg-stone-200 mb-2" />
<div className="h-4 w-full rounded bg-stone-100" />
</div>
<div className="h-6 w-16 rounded-full bg-blue-100" />
</div>
<div className="space-y-1">
<div className="h-4 w-28 rounded bg-stone-100" />
<div className="h-4 w-20 rounded bg-stone-100" />
</div>
</motion.div>
))}
</div>
</div>
@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-600 via-blue-700 to-blue-900 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-lg w-full text-center relative"
>
<div className="absolute inset-0 opacity-20 pointer-events-none">
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full -translate-y-1/2 translate-x-1/4" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full translate-y-1/2 -translate-x-1/4" />
</div>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="relative inline-flex h-24 w-24 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm mb-8"
>
<svg className="h-12 w-12 text-white" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<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>
</motion.div>
<h1 className="text-4xl font-black text-white mb-4">Stop Not Available</h1>
<p className="text-blue-100 mb-8 leading-relaxed">
We couldn't load this stop. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
<div className="mb-8 rounded-2xl bg-white/10 border border-white/20 p-5 text-left backdrop-blur-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-blue-200 mb-2">Error Details</p>
<p className="text-sm text-white/80 font-mono leading-relaxed break-all">{error.message}</p>
</div>
)}
<div className="relative flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="rounded-2xl bg-white text-blue-700 hover:bg-blue-50 px-8 py-4 text-sm font-bold transition-all hover:shadow-xl hover:-translate-y-0.5 active:scale-95 shadow-lg"
>
Try Again
</button>
<Link
href="/indian-river-direct"
className="rounded-2xl bg-blue-700/50 backdrop-blur-sm border border-white/30 text-white hover:bg-blue-600/80 px-8 py-4 text-sm font-bold transition-all hover:-translate-y-0.5 active:scale-95"
>
Back to Homepage
</Link>
</div>
</motion.div>
</div>
);
}
@@ -1,18 +1,70 @@
"use client";
import { motion } from "framer-motion";
export default function Loading() {
return (
<main className="min-h-screen bg-blue-50 px-6 py-12">
<div className="mx-auto max-w-5xl">
<div className="animate-pulse space-y-6">
<div className="h-6 w-40 rounded bg-slate-200" />
<div className="h-12 w-80 rounded bg-slate-200" />
<div className="mt-8 rounded-2xl bg-white p-8 shadow-sm" />
<div className="mt-10 grid gap-6 md:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-64 rounded-2xl bg-slate-200" />
<div className="min-h-screen bg-stone-50/80 backdrop-blur-xl">
<div className="mx-auto max-w-5xl px-6 py-20">
{/* Back navigation skeleton */}
<div className="mb-10 animate-pulse">
<div className="h-5 w-32 rounded bg-stone-200" />
</div>
{/* Stop header skeleton */}
<div className="mb-12 animate-pulse rounded-3xl bg-white/60 border border-white/50 p-8">
<div className="h-3 w-32 rounded bg-blue-100 mb-4" />
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
<div className="h-5 w-full max-w-md rounded bg-stone-200 mb-4" />
<div className="h-px w-12 bg-blue-100" />
</div>
{/* Stop info skeleton */}
<div className="mb-14 rounded-3xl bg-white/80 border border-white/50 p-8 shadow-xl">
<div className="grid gap-4 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
>
<div className="h-10 w-10 rounded-xl bg-blue-50" />
<div className="flex-1">
<div className="h-3 w-12 rounded bg-stone-200 mb-2" />
<div className="h-5 w-24 rounded bg-stone-200" />
</div>
</motion.div>
))}
</div>
</div>
{/* Products section skeleton */}
<div className="animate-pulse">
<div className="h-4 w-20 rounded bg-blue-100 mb-4" />
<div className="h-10 w-48 rounded bg-stone-200 mb-4" />
<div className="h-4 w-64 rounded bg-stone-200 mb-10" />
<div className="grid gap-8 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white overflow-hidden shadow-lg"
>
<div className="h-48 bg-gradient-to-br from-blue-50 to-white" />
<div className="p-6 space-y-3">
<div className="h-6 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
</div>
</motion.div>
))}
</div>
</div>
</div>
</main>
</div>
);
}
@@ -101,7 +101,12 @@ export default function StopPage() {
<div className="max-w-5xl mx-auto">
{/* Back navigation */}
<div className="mb-10 flex items-center gap-2">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4 }}
className="mb-10 flex items-center gap-2"
>
<Link
href={`/${brandSlug}#stops`}
className="flex items-center gap-2 text-sm font-medium text-stone-500 hover:text-stone-800 transition-colors group"
@@ -119,13 +124,13 @@ export default function StopPage() {
</Link>
<span className="text-stone-300">/</span>
<span className="text-sm text-stone-700 font-medium">{stop.city}, {stop.state}</span>
</div>
</motion.div>
{/* Stop header with animation */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
transition={{ duration: 0.6, ease: [0.22, 0.61, 0.36, 1] }}
className="backdrop-blur-sm bg-white/60 border border-white/50 rounded-3xl p-8 mb-12"
>
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">
@@ -135,16 +140,28 @@ export default function StopPage() {
{stop.city},<br className="hidden md:block" /> {stop.state}
</h1>
<p className="mt-5 max-w-xl text-lg text-stone-500 leading-relaxed">
Order fresh Florida citrus for pickup at this stop.
{isBlue
? "Order fresh Florida citrus for pickup at this stop."
: "Order fresh Olathe Sweet™ sweet corn for pickup at this stop."}
</p>
<div className="mt-6 h-px w-12 bg-blue-600" />
</motion.div>
{/* Stop info */}
<div className="backdrop-blur-md bg-white/80 border border-white/50 rounded-3xl p-8 mb-14 shadow-xl shadow-stone-200/50">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1, ease: [0.22, 0.61, 0.36, 1] }}
className="backdrop-blur-md bg-white/80 border border-white/50 rounded-3xl p-8 mb-14 shadow-xl shadow-stone-200/50"
>
<div className="grid gap-4 md:grid-cols-3">
{/* Date */}
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
>
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
@@ -154,9 +171,14 @@ export default function StopPage() {
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Date</p>
<p className="font-bold text-stone-950">{formatDate(stop.date)}</p>
</div>
</div>
</motion.div>
{/* Time */}
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
>
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -166,9 +188,14 @@ export default function StopPage() {
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Time</p>
<p className="font-bold text-stone-950">{stop.time}</p>
</div>
</div>
</motion.div>
{/* Location */}
<div className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4 }}
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
>
<div className={`flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center ${isBlue ? "bg-blue-50" : "bg-emerald-50"}`}>
<svg className={`h-5 w-5 ${isBlue ? "text-blue-500" : "text-emerald-600"}`} 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" />
@@ -179,12 +206,16 @@ export default function StopPage() {
<p className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">Location</p>
<p className="font-bold text-stone-950 leading-tight">{stop.location}</p>
</div>
</motion.div>
</div>
</div>
</div>
</motion.div>
{/* Available Products — editorial header */}
<section>
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2, ease: [0.22, 0.61, 0.36, 1] }}
>
<div className="mb-10">
<p className="text-[11px] font-semibold uppercase tracking-widest text-blue-600 mb-4">Farm-Direct</p>
<h2 className="text-4xl md:text-5xl font-black tracking-tight text-stone-950 leading-tight">
@@ -220,7 +251,7 @@ export default function StopPage() {
))}
</div>
)}
</section>
</motion.section>
</div>
</LayoutContainer>
</main>
+53
View File
@@ -0,0 +1,53 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Loading Route Commerce...",
description: "Loading...",
robots: {
index: false,
follow: false,
},
};
export default function Loading() {
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden" style={{ backgroundColor: "#faf8f5" }}>
{/* Subtle loading animation */}
<div className="flex flex-col items-center gap-6">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center animate-pulse"
style={{
background: "linear-gradient(135deg, #1a4d2e 0%, #166534 100%)",
boxShadow: "0 8px 32px rgba(26, 77, 46, 0.3)"
}}
aria-label="Loading"
role="status"
>
<svg className="w-8 h-8 text-white animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" />
</svg>
</div>
<div className="text-center">
<p className="text-lg font-semibold text-stone-700" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif" }}>
Loading
</p>
<p className="text-sm text-stone-500 mt-1">Please wait...</p>
</div>
</div>
{/* Decorative elements */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div
className="absolute -top-32 -right-32 w-96 h-96 rounded-full opacity-10"
style={{ background: "radial-gradient(circle at 30% 30%, #c97a3e20 0%, transparent 70%)", filter: "blur(40px)" }}
aria-hidden="true"
/>
<div
className="absolute -bottom-48 -left-48 w-[600px] h-[600px] rounded-full opacity-10"
style={{ background: "radial-gradient(circle at 70% 70%, #6b8f7130 0%, transparent 70%)", filter: "blur(60px)" }}
aria-hidden="true"
/>
</div>
</div>
);
}
+408
View File
@@ -0,0 +1,408 @@
"use client";
import { useState, useEffect, useRef, useCallback, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [globalError, setGlobalError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [forgotPassword, setForgotPassword] = useState(false);
const [forgotEmail, setForgotEmail] = useState("");
const [forgotSent, setForgotSent] = useState(false);
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState<string | null>(null);
const [focusedField, setFocusedField] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [tilt, setTilt] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const { clientX, clientY } = e;
const innerWidth = window.innerWidth;
const innerHeight = window.innerHeight;
const x = (clientX / innerWidth - 0.5) * 2;
const y = (clientY / innerHeight - 0.5) * 2;
setMousePos({ x: clientX, y: clientY });
setTilt({ x: y * 8, y: -x * 8 });
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setGlobalError(null);
if (!email.trim()) { setGlobalError("Please enter your email address."); return; }
if (!password.trim()) { setGlobalError("Please enter your password."); return; }
setLoading(true);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), password }),
});
const data = await res.json().catch(() => ({ error: "Login failed" }));
if (res.ok && data?.ok) {
window.location.replace("/admin");
} else {
setGlobalError(data?.error || `Login failed (${res.status})`);
}
} catch {
setGlobalError("Network error. Please try again.");
} finally {
setLoading(false);
}
}, [email, password]);
const handleForgotPassword = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!forgotEmail.trim()) return;
setForgotLoading(true);
setForgotError(null);
const fd = new FormData();
fd.set("email", forgotEmail.trim());
const result = await fetch("/api/forgot-password", {
method: "POST",
body: fd,
}).then(r => r.json()).catch(() => ({ error: "Network error" }));
setForgotLoading(false);
if (result.error) {
setForgotError(result.error);
} else {
setForgotSent(true);
}
}, [forgotEmail]);
return (
<main ref={containerRef} className="min-h-screen flex items-center justify-center px-6 py-12 relative overflow-hidden" role="main">
{/* Animated gradient mesh background */}
<div
className="fixed inset-0 transition-transform duration-1000 ease-out"
style={{
background: `
radial-gradient(circle at 20% 80%, rgba(34, 197, 94, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(250, 204, 21, 0.12) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(22, 163, 74, 0.08) 0%, transparent 40%),
linear-gradient(135deg, #fafaf9 0%, #f5f5f4 50%, #fefce8 100%)
`,
transform: `perspective(1000px) rotateX(${tilt.x * 0.1}deg) rotateY(${tilt.y * 0.1}deg)`
}}
aria-hidden="true"
/>
{/* Floating orbs with parallax */}
<div
className="fixed w-96 h-96 rounded-full transition-transform duration-500 ease-out pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(34, 197, 94, 0.2) 0%, transparent 70%)',
top: `${10 + tilt.y * 0.5}%`,
left: `${10 + tilt.x * 0.3}%`,
transform: `translate(${mousePos.x * 0.02}px, ${mousePos.y * 0.02}px) scale(1.2)`,
filter: 'blur(60px)'
}}
aria-hidden="true"
/>
<div
className="fixed w-[500px] h-[500px] rounded-full transition-transform duration-700 ease-out pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(250, 204, 21, 0.15) 0%, transparent 70%)',
bottom: `${5 - tilt.y * 0.3}%`,
right: `${5 - tilt.x * 0.2}%`,
transform: `translate(${-mousePos.x * 0.03}px, ${-mousePos.y * 0.03}px)`,
filter: 'blur(80px)'
}}
aria-hidden="true"
/>
<div
className="fixed w-64 h-64 rounded-full transition-transform duration-500 ease-out pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(22, 163, 74, 0.1) 0%, transparent 70%)',
top: '50%',
left: '50%',
transform: `translate(${mousePos.x * 0.015}px, ${mousePos.y * 0.015}px)`,
filter: 'blur(50px)'
}}
aria-hidden="true"
/>
{/* 3D Card Container */}
<div
className="w-full max-w-sm relative z-10"
style={{
transform: `perspective(1000px) rotateX(${tilt.x}deg) rotateY(${tilt.y}deg)`,
transition: 'transform 0.1s ease-out'
}}
>
{/* Glow effect behind card */}
<div
className="absolute -inset-4 rounded-[2rem] opacity-50 pointer-events-none"
style={{
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.3), rgba(250, 204, 21, 0.2), rgba(34, 197, 94, 0.3))',
filter: 'blur(30px)',
transform: 'translateZ(-50px)'
}}
aria-hidden="true"
/>
{/* Main card with glassmorphism */}
<div className="relative bg-white/70 backdrop-blur-2xl rounded-[2rem] shadow-[0_8px_32px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] ring-1 ring-white/50 overflow-hidden">
{/* Top gradient border */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-400 via-emerald-500 to-amber-400" aria-hidden="true" />
<div className="p-8">
{/* Logo */}
<div className="text-center mb-10">
<div
className="inline-flex h-20 w-20 items-center justify-center rounded-3xl mb-6"
style={{
background: 'linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)',
boxShadow: '0 20px 40px rgba(34, 197, 94, 0.3), 0 8px 16px rgba(34, 197, 94, 0.2), inset 0 -2px 8px rgba(0,0,0,0.1)',
transform: `translateZ(20px) rotate(${tilt.y * 0.3}deg)`,
transition: 'transform 0.3s ease-out'
}}
aria-hidden="true"
>
<svg className="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-stone-900 tracking-tight" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
Welcome back
</h1>
<p className="mt-2 text-stone-500">Sign in to continue</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5" aria-label="Sign in form">
{globalError && (
<div
role="alert"
className="rounded-2xl bg-red-50/80 backdrop-blur-sm p-4 text-sm text-red-600 border border-red-100 shadow-lg"
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-red-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{globalError}
</div>
</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="block text-sm font-medium text-stone-700">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onFocus={() => setFocusedField("email")}
onBlur={() => setFocusedField(null)}
required
autoComplete="username"
disabled={loading}
className={`w-full rounded-xl border px-4 py-4 text-stone-900 shadow-sm outline-none transition-all disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400 ${
focusedField === "email"
? "border-emerald-400 ring-4 ring-emerald-500/10"
: "border-stone-200/80 focus:border-emerald-400 focus:ring-4 focus:ring-emerald-500/10"
}`}
placeholder="you@company.com"
aria-required="true"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="block text-sm font-medium text-stone-700">Password</label>
<div className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
onFocus={() => setFocusedField("password")}
onBlur={() => setFocusedField(null)}
required
autoComplete="current-password"
disabled={loading}
className={`w-full rounded-xl border px-4 py-4 pr-12 text-stone-900 shadow-sm outline-none transition-all disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400 ${
focusedField === "password"
? "border-emerald-400 ring-4 ring-emerald-500/10"
: "border-stone-200/80 focus:border-emerald-400 focus:ring-4 focus:ring-emerald-500/10"
}`}
placeholder="••••••••"
aria-required="true"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600 p-1 transition-colors"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-gradient-to-r from-emerald-500 via-emerald-600 to-emerald-500 bg-size-200 px-6 py-4 text-base font-semibold text-white hover:shadow-xl hover:shadow-emerald-500/30 disabled:opacity-50 flex items-center justify-center gap-2 transition-all active:scale-[0.98]"
style={{ backgroundPosition: '0% 50%' }}
>
{loading ? (
<>
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in...
</>
) : "Sign in"}
</button>
{!forgotPassword && !forgotSent && (
<button
type="button"
onClick={() => { setForgotPassword(true); setGlobalError(null); }}
className="w-full text-center text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Forgot password?
</button>
)}
</form>
{forgotPassword && !forgotSent && (
<form onSubmit={handleForgotPassword} className="mt-6 space-y-4 border-t border-stone-200/50 pt-6" aria-label="Password reset form">
<p className="text-sm text-stone-600">Enter your email and we&apos;ll send you a reset link.</p>
{forgotError && (
<div role="alert" className="rounded-xl bg-red-50/80 p-3 text-sm text-red-600 border border-red-100">
{forgotError}
</div>
)}
<input
type="email"
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
required
className="w-full rounded-xl border border-stone-200/80 bg-white/90 px-4 py-4 text-stone-900 outline-none focus:border-emerald-400 focus:ring-4 focus:ring-emerald-500/10 placeholder:text-stone-400 transition-all"
placeholder="you@company.com"
aria-required="true"
/>
<button
type="submit"
disabled={forgotLoading}
className="w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-600 px-6 py-4 text-sm font-semibold text-white hover:shadow-lg hover:shadow-emerald-500/20 disabled:opacity-50 flex items-center justify-center gap-2 transition-all"
>
{forgotLoading ? "Sending..." : "Send Reset Link"}
</button>
<button
type="button"
onClick={() => { setForgotPassword(false); setForgotEmail(""); setForgotError(null); }}
className="w-full text-center text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Back to sign in
</button>
</form>
)}
{forgotSent && (
<div className="mt-6 border-t border-stone-200/50 pt-6" role="status" aria-live="polite">
<div className="rounded-xl bg-emerald-50/80 p-4 text-sm text-emerald-700 border border-emerald-100 shadow-sm">
<strong>Check your inbox.</strong> If an account exists for <span className="font-medium">{forgotEmail}</span>, a reset link has been sent.
</div>
<button
type="button"
onClick={() => { setForgotPassword(false); setForgotSent(false); setForgotEmail(""); }}
className="mt-4 w-full text-center text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Back to sign in
</button>
</div>
)}
</div>
</div>
</div>
{/* Back link */}
<Link
href="/brands"
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-sm text-stone-500 hover:text-stone-700 transition-colors z-10 flex items-center gap-1"
aria-label="View farms and brands"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
View Farms
</Link>
</main>
);
}
// Demo mode wrapper
function DemoMode() {
return (
<div className="min-h-screen flex items-center justify-center px-6 py-12 bg-gradient-to-br from-emerald-50 to-amber-50">
<div className="text-center">
<h1 className="text-4xl font-bold text-stone-900 mb-4">Demo Mode</h1>
<p className="text-stone-600 mb-8">Select a role to explore the platform</p>
<div className="flex flex-col gap-4">
<button
onClick={() => { document.cookie = "dev_session=platform_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="px-8 py-4 bg-gradient-to-r from-emerald-600 to-emerald-500 text-white rounded-xl font-semibold hover:from-emerald-500 hover:to-emerald-400 transition-all shadow-lg"
>
Platform Admin
</button>
<button
onClick={() => { document.cookie = "dev_session=brand_admin; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="px-8 py-4 bg-gradient-to-r from-amber-500 to-amber-400 text-white rounded-xl font-semibold hover:from-amber-400 hover:to-amber-300 transition-all shadow-lg"
>
Brand Admin
</button>
<button
onClick={() => { document.cookie = "dev_session=store_employee; path=/; max-age=86400"; window.location.replace("/admin"); }}
className="px-8 py-4 bg-gradient-to-r from-stone-600 to-stone-500 text-white rounded-xl font-semibold hover:from-stone-500 hover:to-stone-400 transition-all shadow-lg"
>
Store Employee
</button>
</div>
</div>
</div>
);
}
// Inner component that uses useSearchParams - must be wrapped in Suspense
function LoginPageInner() {
const searchParams = useSearchParams();
const isDemo = searchParams.get("demo") === "1";
if (isDemo) return <DemoMode />;
return <LoginForm />;
}
export default function LoginClient() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-16 w-16 rounded-2xl bg-gradient-to-br from-emerald-600 to-emerald-500 animate-pulse" />
<p className="text-stone-500">Loading...</p>
</div>
</div>
}>
<LoginPageInner />
</Suspense>
);
}
+59
View File
@@ -0,0 +1,59 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Loading...",
description: "Loading...",
robots: {
index: false,
follow: false,
},
};
export default function Loading() {
return (
<div className="min-h-screen flex items-center justify-center px-6 py-12 bg-gradient-to-br from-stone-50 to-amber-50/30">
{/* Background orbs */}
<div
className="fixed w-96 h-96 rounded-full pointer-events-none animate-pulse"
style={{
background: 'radial-gradient(circle, rgba(34, 197, 94, 0.15) 0%, transparent 70%)',
top: '10%',
left: '10%',
filter: 'blur(60px)'
}}
aria-hidden="true"
/>
{/* Loading card */}
<div className="w-full max-w-sm relative">
<div className="bg-white/70 backdrop-blur-2xl rounded-[2rem] shadow-lg p-8">
{/* Logo skeleton */}
<div className="flex justify-center mb-10">
<div className="h-20 w-20 rounded-3xl bg-slate-200 animate-pulse" />
</div>
{/* Text skeleton */}
<div className="space-y-3">
<div className="h-8 w-32 rounded bg-slate-200 mx-auto animate-pulse" />
<div className="h-4 w-24 rounded bg-slate-100 mx-auto animate-pulse" />
</div>
{/* Form skeleton */}
<div className="mt-8 space-y-4">
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-slate-100 animate-pulse" />
<div className="h-12 rounded-xl bg-slate-100 animate-pulse" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-slate-100 animate-pulse" />
<div className="h-12 rounded-xl bg-slate-100 animate-pulse" />
</div>
<div className="h-12 rounded-xl bg-slate-200 animate-pulse" />
</div>
</div>
</div>
<span role="status" className="sr-only">Loading login page...</span>
</div>
);
}
+22 -377
View File
@@ -1,381 +1,26 @@
"use client";
import type { Metadata } from "next";
import LoginClient from "./LoginClient";
import { useState, useEffect, useRef, Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [globalError, setGlobalError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [forgotPassword, setForgotPassword] = useState(false);
const [forgotEmail, setForgotEmail] = useState("");
const [forgotSent, setForgotSent] = useState(false);
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState<string | null>(null);
export const metadata: Metadata = {
title: "Sign In — Route Commerce",
description: "Sign in to your Route Commerce account. Access your admin dashboard, manage orders, stops, and communications.",
keywords: ["sign in", "login", "admin", "account", "Route Commerce"],
openGraph: {
title: "Sign In — Route Commerce",
description: "Sign in to your Route Commerce account.",
url: `${BASE_URL}/login`,
siteName: "Route Commerce",
locale: "en_US",
type: "website",
},
robots: {
index: false,
follow: false,
},
};
const containerRef = useRef<HTMLDivElement>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [tilt, setTilt] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const { clientX, clientY } = e;
const innerWidth = window.innerWidth;
const innerHeight = window.innerHeight;
const x = (clientX / innerWidth - 0.5) * 2;
const y = (clientY / innerHeight - 0.5) * 2;
setMousePos({ x: clientX, y: clientY });
setTilt({ x: y * 8, y: -x * 8 });
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setGlobalError(null);
if (!email.trim()) { setGlobalError("Please enter your email address."); return; }
if (!password.trim()) { setGlobalError("Please enter your password."); return; }
setLoading(true);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), password }),
});
const data = await res.json().catch(() => ({ error: "Login failed" }));
if (res.ok && data?.ok) {
window.location.replace("/admin");
} else {
setGlobalError(data?.error || `Login failed (${res.status})`);
}
} catch {
setGlobalError("Network error. Please try again.");
} finally {
setLoading(false);
}
}
async function handleForgotPassword(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!forgotEmail.trim()) return;
setForgotLoading(true);
setForgotError(null);
const fd = new FormData();
fd.set("email", forgotEmail.trim());
const result = await fetch("/api/forgot-password", {
method: "POST",
body: fd,
}).then(r => r.json()).catch(() => ({ error: "Network error" }));
setForgotLoading(false);
if (result.error) {
setForgotError(result.error);
} else {
setForgotSent(true);
}
}
return (
<main ref={containerRef} className="min-h-screen flex items-center justify-center px-6 py-12 relative overflow-hidden">
{/* Animated gradient mesh background */}
<div
className="fixed inset-0 transition-transform duration-1000 ease-out"
style={{
background: `
radial-gradient(circle at 20% 80%, rgba(16, 185, 129, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(245, 158, 11, 0.12) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(5, 150, 105, 0.08) 0%, transparent 40%),
linear-gradient(135deg, #fafaf9 0%, #f5f5f4 50%, #fefce8 100%)
`,
transform: `perspective(1000px) rotateX(${tilt.x * 0.1}deg) rotateY(${tilt.y * 0.1}deg)`
}}
/>
{/* Floating orbs with parallax */}
<div
className="fixed w-96 h-96 rounded-full transition-transform duration-500 ease-out pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(16, 185, 129, 0.2) 0%, transparent 70%)',
top: `${10 + tilt.y * 0.5}%`,
left: `${10 + tilt.x * 0.3}%`,
transform: `translate(${mousePos.x * 0.02}px, ${mousePos.y * 0.02}px) scale(1.2)`,
filter: 'blur(60px)'
}}
/>
<div
className="fixed w-[500px] h-[500px] rounded-full transition-transform duration-700 ease-out pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(245, 158, 11, 0.15) 0%, transparent 70%)',
bottom: `${5 - tilt.y * 0.3}%`,
right: `${5 - tilt.x * 0.2}%`,
transform: `translate(${-mousePos.x * 0.03}px, ${-mousePos.y * 0.03}px)`,
filter: 'blur(80px)'
}}
/>
<div
className="fixed w-64 h-64 rounded-full transition-transform duration-500 ease-out pointer-events-none"
style={{
background: 'radial-gradient(circle, rgba(5, 150, 105, 0.1) 0%, transparent 70%)',
top: '50%',
left: '50%',
transform: `translate(${mousePos.x * 0.015}px, ${mousePos.y * 0.015}px)`,
filter: 'blur(50px)'
}}
/>
{/* 3D Card Container */}
<div
className="w-full max-w-sm relative z-10"
style={{
transform: `perspective(1000px) rotateX(${tilt.x}deg) rotateY(${tilt.y}deg)`,
transition: 'transform 0.1s ease-out'
}}
>
{/* Glow effect behind card */}
<div
className="absolute -inset-4 rounded-[2rem] opacity-50 pointer-events-none"
style={{
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.3), rgba(245, 158, 11, 0.2), rgba(16, 185, 129, 0.3))',
filter: 'blur(30px)',
transform: 'translateZ(-50px)'
}}
/>
{/* Main card with glassmorphism */}
<div className="relative bg-white/70 backdrop-blur-2xl rounded-[2rem] shadow-[0_8px_32px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] ring-1 ring-white/50 overflow-hidden">
{/* Top gradient border */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-400 via-emerald-500 to-amber-400" />
<div className="p-8">
{/* Logo */}
<div className="text-center mb-10">
<div
className="inline-flex h-20 w-20 items-center justify-center rounded-3xl mb-6 transform"
style={{
background: 'linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%)',
boxShadow: '0 20px 40px rgba(16, 185, 129, 0.3), 0 8px 16px rgba(16, 185, 129, 0.2), inset 0 -2px 8px rgba(0,0,0,0.1)',
transform: `translateZ(20px) rotate(${tilt.y * 0.3}deg)`,
transition: 'transform 0.3s ease-out'
}}
>
<svg className="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-stone-900 tracking-tight" style={{ textShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
Welcome back
</h1>
<p className="mt-2 text-stone-500">Sign in to continue</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{globalError && (
<div
role="alert"
className="rounded-2xl bg-red-50/80 backdrop-blur-sm p-4 text-sm text-red-600 border border-red-100 shadow-lg transform"
style={{ transform: `translateZ(5px)` }}
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{globalError}
</div>
</div>
)}
<div className="space-y-2" style={{ transform: 'translateZ(10px)' }}>
<label htmlFor="email" className="block text-sm font-medium text-stone-700">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="username"
disabled={loading}
className="w-full rounded-xl border border-stone-200/80 bg-white/90 px-4 py-4 text-stone-900 shadow-sm outline-none focus:border-emerald-400 focus:ring-4 focus:ring-emerald-500/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400 transition-all"
placeholder="you@company.com"
/>
</div>
<div className="space-y-2" style={{ transform: 'translateZ(10px)' }}>
<label htmlFor="password" className="block text-sm font-medium text-stone-700">Password</label>
<div className="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
disabled={loading}
className="w-full rounded-xl border border-stone-200/80 bg-white/90 px-4 py-4 pr-12 text-stone-900 shadow-sm outline-none focus:border-emerald-400 focus:ring-4 focus:ring-emerald-500/10 disabled:bg-stone-100 disabled:cursor-not-allowed placeholder:text-stone-400 transition-all"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-600 p-1 transition-colors"
>
{showPassword ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-xl bg-gradient-to-r from-emerald-500 via-emerald-600 to-emerald-500 bg-size-200 px-6 py-4 text-base font-semibold text-white hover:shadow-xl hover:shadow-emerald-500/30 disabled:opacity-50 flex items-center justify-center gap-2 transition-all active:scale-[0.98]"
style={{
transform: 'translateZ(15px)',
backgroundPosition: '0% 50%'
}}
>
{loading ? (
<>
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in...
</>
) : "Sign in"}
</button>
{!forgotPassword && !forgotSent && (
<button
type="button"
onClick={() => { setForgotPassword(true); setGlobalError(null); }}
className="w-full text-center text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Forgot password?
</button>
)}
</form>
{forgotPassword && !forgotSent && (
<form onSubmit={handleForgotPassword} className="mt-6 space-y-4 border-t border-stone-200/50 pt-6">
<p className="text-sm text-stone-600">Enter your email and we&apos;ll send you a reset link.</p>
{forgotError && (
<div className="rounded-xl bg-red-50/80 p-3 text-sm text-red-600 border border-red-100">
{forgotError}
</div>
)}
<input
type="email"
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
required
className="w-full rounded-xl border border-stone-200/80 bg-white/90 px-4 py-4 text-stone-900 outline-none focus:border-emerald-400 focus:ring-4 focus:ring-emerald-500/10 placeholder:text-stone-400 transition-all"
placeholder="you@company.com"
/>
<button
type="submit"
disabled={forgotLoading}
className="w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-600 px-6 py-4 text-sm font-semibold text-white hover:shadow-lg hover:shadow-emerald-500/20 disabled:opacity-50 flex items-center justify-center gap-2 transition-all"
>
{forgotLoading ? "Sending..." : "Send Reset Link"}
</button>
<button
type="button"
onClick={() => { setForgotPassword(false); setForgotEmail(""); setForgotError(null); }}
className="w-full text-center text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Back to sign in
</button>
</form>
)}
{forgotSent && (
<div className="mt-6 border-t border-stone-200/50 pt-6">
<div className="rounded-xl bg-emerald-50/80 p-4 text-sm text-emerald-700 border border-emerald-100 shadow-sm">
<strong>Check your inbox.</strong> If an account exists for <span className="font-medium">{forgotEmail}</span>, a reset link has been sent.
</div>
<button
type="button"
onClick={() => { setForgotPassword(false); setForgotSent(false); setForgotEmail(""); }}
className="mt-4 w-full text-center text-sm text-stone-500 hover:text-stone-700 transition-colors"
>
Back to sign in
</button>
</div>
)}
</div>
</div>
</div>
{/* Back link */}
<Link
href="/brands"
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-sm text-[#888] hover:text-[#1a4d2e] transition-colors z-10 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
View Farms
</Link>
{/* Custom styles for animated gradient */}
<style jsx>{`
.bg-size-200 {
background-size: 200% 100%;
}
`}</style>
</main>
);
export default function LoginPage() {
return <LoginClient />;
}
// Demo mode wrapper
function DemoMode() {
return (
<div className="min-h-screen flex items-center justify-center px-6 py-12 bg-gradient-to-br from-emerald-50 to-amber-50">
<div className="text-center">
<h1 className="text-4xl font-bold text-stone-900 mb-4">Demo Mode</h1>
<p className="text-stone-600 mb-8">Select a role to explore the platform</p>
<div className="flex flex-col gap-4">
<button onClick={() => { document.cookie = "dev_session=platform_admin; path=/; max-age=86400"; window.location.replace("/admin"); }} className="px-8 py-4 bg-emerald-600 text-white rounded-xl font-semibold hover:bg-emerald-700 transition-colors shadow-lg">
Platform Admin
</button>
<button onClick={() => { document.cookie = "dev_session=brand_admin; path=/; max-age=86400"; window.location.replace("/admin"); }} className="px-8 py-4 bg-amber-600 text-white rounded-xl font-semibold hover:bg-amber-700 transition-colors shadow-lg">
Brand Admin
</button>
<button onClick={() => { document.cookie = "dev_session=store_employee; path=/; max-age=86400"; window.location.replace("/admin"); }} className="px-8 py-4 bg-stone-600 text-white rounded-xl font-semibold hover:bg-stone-700 transition-colors shadow-lg">
Store Employee
</button>
</div>
</div>
</div>
);
}
// Inner component that uses useSearchParams - must be wrapped in Suspense
function LoginPageInner() {
const searchParams = useSearchParams();
const isDemo = searchParams.get("demo") === "1";
if (isDemo) return <DemoMode />;
return <LoginForm />;
}
function LoginPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="animate-spin h-8 w-8 border-4 border-emerald-500 border-t-transparent rounded-full" /></div>}>
<LoginPageInner />
</Suspense>
);
}
export default LoginPage;
+40 -20
View File
@@ -1,24 +1,44 @@
"use client";
import type { Metadata } from "next";
import LandingPageClient from "./LandingPageClient";
import Header, { Footer, LandingPageWrapper, Section } from "@/components/landing/LandingPageWrapper";
import HeroSection from "@/components/landing/HeroSection";
import FeaturesAndStats from "@/components/landing/FeaturesAndStats";
import TestimonialsAndCTA from "@/components/landing/TestimonialsAndCTA";
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com";
export const metadata: Metadata = {
title: "Route Commerce — Fresh Produce Wholesale Platform",
description: "The all-in-one platform for produce wholesale distribution. Manage orders, stops, routes, and customer communications. Built for farms, Co-ops, and distributors.",
keywords: ["produce wholesale", "farm management", "route commerce", "agricultural platform", "order management", "stop scheduling"],
openGraph: {
title: "Route Commerce — Fresh Produce Wholesale Platform",
description: "The all-in-one platform for produce wholesale distribution. Manage orders, stops, routes, and customer communications.",
url: BASE_URL,
siteName: "Route Commerce",
locale: "en_US",
type: "website",
images: [
{
url: "/og-default.jpg",
width: 1200,
height: 630,
alt: "Route Commerce Platform",
},
],
},
twitter: {
card: "summary_large_image",
title: "Route Commerce — Fresh Produce Wholesale Platform",
description: "The all-in-one platform for produce wholesale distribution.",
site: "@RouteCommerce",
images: ["/og-default.jpg"],
},
alternates: {
canonical: BASE_URL,
},
robots: {
index: true,
follow: true,
},
};
export default function LandingPage() {
return (
<LandingPageWrapper>
<Section id="hero">
<HeroSection />
</Section>
<Section id="features">
<FeaturesAndStats />
</Section>
<Section id="reviews">
<TestimonialsAndCTA />
</Section>
</LandingPageWrapper>
);
return <LandingPageClient />;
}
+116 -62
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useCallback } from "react";
import Link from "next/link";
import { PLAN_TIERS, ADDONS } from "@/lib/pricing";
@@ -55,19 +55,28 @@ export default function PricingClientPage() {
const [compareOpen, setCompareOpen] = useState(false);
const [openFaq, setOpenFaq] = useState<number | null>(null);
const toggleFaq = useCallback((index: number) => {
setOpenFaq((prev) => (prev === index ? null : index));
}, []);
return (
<div className="min-h-screen bg-white">
{/* ── Nav bar ─────────────────────────────────────────────────────────── */}
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-green-600 text-white font-bold text-sm">RC</div>
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-600 to-emerald-500 text-white font-bold text-sm" aria-hidden="true">
RC
</div>
<span className="text-lg font-bold text-slate-900">Route Commerce</span>
</div>
<nav className="flex items-center gap-6">
<Link href="/about" className="text-sm text-slate-500 hover:text-slate-900">About</Link>
<Link href="/pricing" className="text-sm font-medium text-slate-900">Pricing</Link>
<Link href="/admin" className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
<nav className="flex items-center gap-6" aria-label="Main navigation">
<Link href="/about" className="text-sm text-slate-500 hover:text-slate-900 transition-colors">About</Link>
<Link href="/pricing" className="text-sm font-medium text-slate-900" aria-current="page">Pricing</Link>
<Link
href="/admin"
className="rounded-lg bg-gradient-to-r from-emerald-600 to-emerald-500 px-4 py-2 text-sm font-medium text-white hover:from-emerald-500 hover:to-emerald-400 transition-all shadow-md"
>
Get Started
</Link>
</nav>
@@ -75,13 +84,13 @@ export default function PricingClientPage() {
</header>
{/* ── Hero ────────────────────────────────────────────────────────────── */}
<section className="bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-center">
<section className="bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-center" aria-labelledby="pricing-heading">
<div className="mx-auto max-w-3xl">
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-green-50 px-4 py-1.5 text-sm font-medium text-green-700">
<span className="text-xs"></span>
<div className="mb-4 inline-flex items-center gap-2 rounded-full bg-emerald-50 px-4 py-1.5 text-sm font-medium text-emerald-700">
<span className="text-xs" aria-hidden="true"></span>
Built for produce wholesale operations
</div>
<h1 className="text-5xl font-bold tracking-tight text-slate-900 sm:text-6xl">
<h1 id="pricing-heading" className="text-5xl font-bold tracking-tight text-slate-900 sm:text-6xl">
Pricing that scales<br className="hidden sm:block" /> with your operation
</h1>
<p className="mt-6 text-xl text-slate-500">
@@ -89,13 +98,14 @@ export default function PricingClientPage() {
</p>
<div className="mt-8 flex items-center justify-center gap-4">
<BillingToggle cycle={billingCycle} onChange={setBillingCycle} />
<span className="text-sm text-green-600 font-medium">Save 25% with annual</span>
<span className="text-sm text-emerald-600 font-medium">Save 25% with annual</span>
</div>
</div>
</section>
{/* ── Plan cards ───────────────────────────────────────────────────────── */}
<section className="mx-auto max-w-6xl px-6 py-6">
<section className="mx-auto max-w-6xl px-6 py-6" aria-labelledby="plans-heading">
<h2 id="plans-heading" className="sr-only">Available Plans</h2>
<div className="grid gap-6 lg:grid-cols-3">
{(Object.entries(PLAN_TIERS) as [keyof typeof PLAN_TIERS, typeof PLAN_TIERS[keyof typeof PLAN_TIERS]][]).map(([key, plan]) => {
const price = billingCycle === "annual" ? plan.annualPrice : plan.monthlyPrice;
@@ -103,9 +113,14 @@ export default function PricingClientPage() {
const isMostPopular = plan.highlighted;
return (
<div key={key} className={`relative flex flex-col rounded-2xl border-2 p-6 ${isMostPopular ? "border-green-500 shadow-lg shadow-green-100" : "border-slate-200 shadow-sm"}`}>
<article
key={key}
className={`relative flex flex-col rounded-2xl border-2 p-6 transition-transform hover:-translate-y-1 ${
isMostPopular ? "border-emerald-500 shadow-lg shadow-emerald-100" : "border-slate-200 shadow-sm"
}`}
>
{isMostPopular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-green-600 px-4 py-1 text-xs font-bold text-white uppercase tracking-wide">
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-emerald-600 to-emerald-500 px-4 py-1 text-xs font-bold text-white uppercase tracking-wide shadow-md">
Most Popular
</div>
)}
@@ -122,19 +137,25 @@ export default function PricingClientPage() {
)}
<Link
href="/admin"
className={`mt-auto rounded-xl px-4 py-2.5 text-center text-sm font-medium transition-colors ${isMostPopular ? "bg-green-600 text-white hover:bg-green-700" : "border border-slate-300 text-slate-700 hover:bg-slate-50"}`}
className={`mt-auto rounded-xl px-4 py-2.5 text-center text-sm font-medium transition-all ${
isMostPopular
? "bg-gradient-to-r from-emerald-600 to-emerald-500 text-white hover:from-emerald-500 hover:to-emerald-400 shadow-md"
: "border border-slate-300 text-slate-700 hover:bg-slate-50"
}`}
>
{key === "enterprise" ? "Contact Sales" : "Get Started"}
</Link>
<ul className="mt-6 space-y-2.5">
<ul className="mt-6 space-y-2.5" aria-label={`${plan.label} features`}>
{(plan.features as readonly string[]).map((f, i) => (
<li key={i} className="flex items-start gap-2.5 text-sm text-slate-600">
<span className="mt-0.5 text-green-500 text-xs"></span>
<svg className="mt-0.5 h-4 w-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{f}
</li>
))}
</ul>
</div>
</article>
);
})}
</div>
@@ -142,7 +163,9 @@ export default function PricingClientPage() {
<div className="mt-6 flex items-center justify-center">
<button
onClick={() => setCompareOpen(!compareOpen)}
className="text-sm text-violet-600 hover:underline font-medium"
className="text-sm text-emerald-600 hover:underline font-medium transition-colors"
aria-expanded={compareOpen}
aria-controls="compare-table"
>
{compareOpen ? "Hide" : "Compare"} all features
</button>
@@ -150,20 +173,26 @@ export default function PricingClientPage() {
{/* Compare table */}
{compareOpen && (
<div className="mt-6 rounded-2xl border border-slate-200 overflow-hidden">
<div id="compare-table" className="mt-6 rounded-2xl border border-slate-200 overflow-hidden shadow-sm">
<div className="border-b border-slate-100 bg-slate-50 px-6 py-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-900">Full Feature Comparison</h3>
<button onClick={() => setCompareOpen(false)} className="text-xs text-slate-400 hover:text-slate-600"> Close</button>
<button
onClick={() => setCompareOpen(false)}
className="text-xs text-slate-400 hover:text-slate-600 transition-colors"
aria-label="Close comparison table"
>
Close
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100">
<th className="py-3 pr-6 text-left font-semibold text-slate-500 w-2/5" />
<th className="py-3 pr-6 text-left font-semibold text-slate-500 w-2/5" scope="col">Feature</th>
{(["starter", "farm", "enterprise"] as const).map((tier) => {
const p = PLAN_TIERS[tier];
return (
<th key={tier} className="py-3 px-4 text-center">
<th key={tier} className="py-3 px-4 text-center" scope="col">
<span className={`rounded-full px-3 py-0.5 text-xs font-bold uppercase ${p.color}`}>{p.label}</span>
</th>
);
@@ -189,19 +218,21 @@ export default function PricingClientPage() {
["Unlimited brands", { starter: false, farm: false, enterprise: true }],
["Custom development", { starter: false, farm: false, enterprise: true }],
["SLA guarantee", { starter: false, farm: false, enterprise: true }],
].map(([feature, tiers]) => {
].map(([feature, tiers], rowIndex) => {
const t = tiers as Record<string, boolean | string>;
return (
<tr key={feature as string} className="border-t border-slate-50">
<tr key={feature as string} className={`border-t ${rowIndex % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
<td className="py-2.5 pr-6 text-slate-600">{feature as string}</td>
{(["starter", "farm", "enterprise"] as const).map((tier) => {
const val = t[tier];
return (
<td key={tier} className="py-2.5 px-4 text-center text-sm">
{val === true ? (
<span className="text-green-500"></span>
<svg className="h-4 w-4 text-emerald-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-label="Included">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : val === false ? (
<span className="text-slate-300"></span>
<span className="text-slate-300" aria-label="Not included"></span>
) : (
<span className="text-slate-700">{val}</span>
)}
@@ -219,12 +250,10 @@ export default function PricingClientPage() {
</section>
{/* ── Add-ons ───────────────────────────────────────────────────────────── */}
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16">
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16" aria-labelledby="addons-heading">
<div className="mx-auto max-w-6xl">
<div className="text-center mb-10">
<h2 className="text-3xl font-bold text-slate-900">Power-Up with Add-ons</h2>
<p className="mt-2 text-slate-500">Add capabilities à la carte on any plan. No bundles required.</p>
</div>
<h2 id="addons-heading" className="text-center text-3xl font-bold text-slate-900 mb-2">Power-Up with Add-ons</h2>
<p className="text-center text-slate-500 mb-10">Add capabilities à la carte on any plan. No bundles required.</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{ADDON_LIST.map(({ key, label, icon, description }) => {
const addon = ADDONS[key as keyof typeof ADDONS];
@@ -232,8 +261,8 @@ export default function PricingClientPage() {
const price = billingCycle === "annual" ? addon.annualPrice : addon.monthlyPrice;
const monthlyEquiv = billingCycle === "annual" ? Math.round(addon.annualPrice / 12) : null;
return (
<div key={key} className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5">
<span className="text-2xl leading-none">{icon}</span>
<article key={key} className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 transition-all hover:border-emerald-200 hover:shadow-md">
<span className="text-2xl leading-none" aria-hidden="true">{icon}</span>
<div className="flex-1">
<h3 className="font-semibold text-slate-900">{label}</h3>
<p className="mt-1 text-xs text-slate-500">{description}</p>
@@ -245,7 +274,7 @@ export default function PricingClientPage() {
)}
</div>
</div>
</div>
</article>
);
})}
</div>
@@ -253,46 +282,55 @@ export default function PricingClientPage() {
</section>
{/* ── Social proof ─────────────────────────────────────────────────────── */}
<section className="border-t border-slate-100 px-6 py-16">
<section className="border-t border-slate-100 px-6 py-16" aria-labelledby="testimonials-heading">
<div className="mx-auto max-w-6xl">
<h2 className="text-center text-2xl font-bold text-slate-900 mb-8">Trusted by produce operations across the US</h2>
<h2 id="testimonials-heading" className="text-center text-2xl font-bold text-slate-900 mb-8">Trusted by produce operations across the US</h2>
<div className="grid gap-6 sm:grid-cols-3">
{[
{ quote: "We went from managing 12 spreadsheets to one platform. Route Commerce cut our order chaos by 80%.", name: "Marcus T., Fresh Fields Farm", location: "California" },
{ quote: "Harvest Reach alone paid for the subscription. Our pickup rate went from 70% to 94% in two months.", name: "Sandra K., Pacific Produce Co-op", location: "Oregon" },
{ quote: "The wholesale portal saved us 6 hours a week on order entry. Buyers love the self-service.", name: "James R., Gulf Coast Distribution", location: "Florida" },
].map((item, i) => (
<div key={i} className="rounded-2xl border border-slate-200 p-6">
<div className="flex gap-1 mb-3">
<article key={i} className="rounded-2xl border border-slate-200 p-6 transition-all hover:shadow-md">
<div className="flex gap-1 mb-3" aria-label="5 star rating">
{["★", "★", "★", "★", "★"].map((s, j) => (
<span key={j} className="text-amber-400 text-sm">{s}</span>
<span key={j} className="text-amber-400 text-sm" aria-hidden="true">{s}</span>
))}
</div>
<blockquote>
<p className="text-slate-700 text-sm leading-relaxed">&ldquo;{item.quote}&rdquo;</p>
</blockquote>
<p className="mt-3 text-xs font-semibold text-slate-900">{item.name}</p>
<p className="text-xs text-slate-400">{item.location}</p>
</div>
</article>
))}
</div>
</div>
</section>
{/* ── FAQ ─────────────────────────────────────────────────────────────── */}
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16">
<section className="border-t border-slate-100 bg-slate-50 px-6 py-16" aria-labelledby="faq-heading">
<div className="mx-auto max-w-3xl">
<h2 className="text-center text-3xl font-bold text-slate-900 mb-10">Frequently Asked Questions</h2>
<h2 id="faq-heading" className="text-center text-3xl font-bold text-slate-900 mb-10">Frequently Asked Questions</h2>
<div className="space-y-3">
{FAQ_ITEMS.map((item, i) => (
<div key={i} className="rounded-xl border border-slate-200 bg-white">
<button
onClick={() => setOpenFaq(openFaq === i ? null : i)}
className="flex w-full items-center justify-between px-5 py-4 text-left"
onClick={() => toggleFaq(i)}
className="flex w-full items-center justify-between px-5 py-4 text-left transition-colors hover:bg-slate-50"
aria-expanded={openFaq === i}
aria-controls={`faq-answer-${i}`}
>
<span className="text-sm font-medium text-slate-900">{item.q}</span>
<span className={`ml-3 text-slate-400 text-lg transition-transform ${openFaq === i ? "rotate-45" : ""}`}>+</span>
<span
className={`ml-3 text-slate-400 text-lg transition-transform ${openFaq === i ? "rotate-45" : ""}`}
aria-hidden="true"
>
+
</span>
</button>
{openFaq === i && (
<div className="border-t border-slate-50 px-5 pb-4">
<div id={`faq-answer-${i}`} className="border-t border-slate-50 px-5 pb-4">
<p className="text-sm text-slate-600 leading-relaxed">{item.a}</p>
</div>
)}
@@ -303,20 +341,26 @@ export default function PricingClientPage() {
</section>
{/* ── CTA ──────────────────────────────────────────────────────────────── */}
<section className="bg-slate-900 px-6 py-20 text-center">
<section className="bg-gradient-to-br from-slate-900 to-slate-800 px-6 py-20 text-center" aria-labelledby="cta-heading">
<div className="mx-auto max-w-2xl">
<h2 className="text-4xl font-bold text-white">Ready to grow your operation?</h2>
<h2 id="cta-heading" className="text-4xl font-bold text-white">Ready to grow your operation?</h2>
<p className="mt-4 text-lg text-slate-400">Start free on Starter. No credit card required. Upgrade when you&apos;re ready.</p>
<div className="mt-8 flex items-center justify-center gap-4 flex-wrap">
<Link href="/admin" className="rounded-xl bg-green-500 px-8 py-3 text-base font-bold text-white hover:bg-green-600 transition-colors">
<Link
href="/admin"
className="rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 px-8 py-3 text-base font-bold text-white hover:from-emerald-400 hover:to-emerald-300 transition-all shadow-lg shadow-emerald-500/25"
>
Start for Free
</Link>
<Link href="/about" className="rounded-xl border border-slate-600 px-8 py-3 text-base font-medium text-slate-300 hover:border-slate-500 hover:text-white transition-colors">
<Link
href="/contact"
className="rounded-xl border border-slate-600 px-8 py-3 text-base font-medium text-slate-300 hover:border-slate-500 hover:text-white transition-all"
>
Talk to Us
</Link>
</div>
<p className="mt-6 text-xs text-slate-500">
Invoiced by Cielo Hermosa, LLC · <a href="mailto:billing@cielohermosa.com" className="underline hover:text-slate-400">billing@routecommerce.com</a>
Invoiced by Cielo Hermosa, LLC · <a href="mailto:billing@cielohermosa.com" className="underline hover:text-slate-400 transition-colors">billing@routecommerce.com</a>
</p>
</div>
</section>
@@ -325,14 +369,14 @@ export default function PricingClientPage() {
<footer className="border-t border-slate-200 bg-white px-6 py-8">
<div className="mx-auto flex max-w-6xl items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-green-600 text-white font-bold text-xs">RC</div>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-600 to-emerald-500 text-white font-bold text-xs" aria-hidden="true">RC</div>
<span className="text-sm font-bold text-slate-700">Route Commerce</span>
</div>
<div className="flex items-center gap-6">
<Link href="/privacy-policy" className="text-xs text-slate-400 hover:text-slate-600">Privacy</Link>
<Link href="/terms-and-conditions" className="text-xs text-slate-400 hover:text-slate-600">Terms</Link>
<a href="mailto:team@cielohermosa.com" className="text-xs text-slate-400 hover:text-slate-600">Contact</a>
</div>
<nav className="flex items-center gap-6" aria-label="Footer navigation">
<Link href="/privacy-policy" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Privacy</Link>
<Link href="/terms-and-conditions" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Terms</Link>
<a href="mailto:team@cielohermosa.com" className="text-xs text-slate-400 hover:text-slate-600 transition-colors">Contact</a>
</nav>
</div>
</footer>
</div>
@@ -341,19 +385,29 @@ export default function PricingClientPage() {
function BillingToggle({ cycle, onChange }: { cycle: BillingCycle; onChange: (c: BillingCycle) => void }) {
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-3" role="group" aria-label="Billing cycle selection">
<button
onClick={() => onChange("monthly")}
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-colors ${cycle === "monthly" ? "border-slate-300 bg-white text-slate-900" : "border-slate-200 bg-slate-50 text-slate-400"}`}
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-all ${
cycle === "monthly"
? "border-emerald-300 bg-white text-slate-900 shadow-sm"
: "border-slate-200 bg-slate-50 text-slate-400 hover:border-slate-300"
}`}
aria-pressed={cycle === "monthly"}
>
Monthly
</button>
<button
onClick={() => onChange("annual")}
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-colors flex items-center gap-1.5 ${cycle === "annual" ? "border-2 border-green-600 bg-green-50 text-green-700" : "border border-slate-200 bg-slate-50 text-slate-400"}`}
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-all flex items-center gap-1.5 ${
cycle === "annual"
? "border-2 border-emerald-600 bg-emerald-50 text-emerald-700 shadow-sm"
: "border border-slate-200 bg-slate-50 text-slate-400 hover:border-slate-300"
}`}
aria-pressed={cycle === "annual"}
>
Annual
<span className="rounded-full bg-green-100 text-green-700 text-xs px-1.5 py-0.5 font-bold">-25%</span>
<span className="rounded-full bg-emerald-100 text-emerald-700 text-xs px-1.5 py-0.5 font-bold">-25%</span>
</button>
</div>
);
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Loading pricing...",
description: "Loading...",
robots: {
index: false,
follow: false,
},
};
export default function Loading() {
return (
<div className="min-h-screen bg-white">
{/* Nav skeleton */}
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-xl bg-slate-200 animate-pulse" />
<div className="h-6 w-36 rounded bg-slate-200 animate-pulse" />
</div>
<div className="flex items-center gap-6">
<div className="h-4 w-16 rounded bg-slate-200 animate-pulse" />
<div className="h-4 w-20 rounded bg-slate-200 animate-pulse" />
<div className="h-9 w-24 rounded-xl bg-slate-200 animate-pulse" />
</div>
</div>
</header>
{/* Hero skeleton */}
<section className="bg-gradient-to-b from-slate-50 to-white px-6 py-20 text-center">
<div className="mx-auto max-w-3xl">
<div className="h-6 w-40 rounded-full bg-slate-200 mx-auto mb-4 animate-pulse" />
<div className="h-16 w-80 rounded-lg bg-slate-200 mx-auto mb-6 animate-pulse" />
<div className="h-6 w-96 rounded bg-slate-200 mx-auto animate-pulse" />
</div>
</section>
{/* Plan cards skeleton */}
<section className="mx-auto max-w-6xl px-6 py-6">
<div className="grid gap-6 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-2xl border-2 border-slate-200 p-6">
<div className="h-6 w-24 rounded bg-slate-200 mb-3 animate-pulse" />
<div className="h-4 w-32 rounded bg-slate-200 mb-1 animate-pulse" />
<div className="h-10 w-24 rounded bg-slate-200 mb-4 animate-pulse" />
<div className="h-12 w-full rounded-xl bg-slate-200 animate-pulse" />
<div className="mt-6 space-y-2.5">
{[1, 2, 3, 4].map((j) => (
<div key={j} className="h-4 w-full rounded bg-slate-100 animate-pulse" />
))}
</div>
</div>
))}
</div>
</section>
<span role="status" className="sr-only">Loading pricing page...</span>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-stone-950 via-stone-900 to-emerald-950 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-lg w-full text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
>
<svg className="h-12 w-12 text-emerald-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<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>
</motion.div>
<h1 className="text-4xl font-black text-white mb-4">Our Story Unavailable</h1>
<p className="text-stone-400 mb-8 leading-relaxed">
We couldn't load our story page. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
<div className="mb-8 rounded-2xl bg-white/5 border border-white/10 p-5 text-left">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-500 mb-2">Error Details</p>
<p className="text-sm text-emerald-400/80 font-mono leading-relaxed break-all">{error.message}</p>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-500 px-8 py-4 text-sm font-bold text-white transition-all hover:shadow-lg hover:shadow-emerald-900/30 hover:-translate-y-0.5 active:scale-95"
>
Try Again
</button>
<Link
href="/tuxedo"
className="rounded-2xl bg-white/10 hover:bg-white/20 border border-white/20 px-8 py-4 text-sm font-bold text-white transition-all hover:-translate-y-0.5 active:scale-95"
>
Back to Homepage
</Link>
</div>
</motion.div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { motion } from "framer-motion";
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-4xl px-6 py-20">
{/* Hero skeleton */}
<div className="mb-20 animate-pulse text-center">
<div className="h-3 w-24 rounded bg-stone-200 mx-auto mb-5" />
<div className="h-20 w-full max-w-xl mx-auto rounded-lg bg-stone-200 mb-5" />
<div className="h-8 w-full max-w-2xl mx-auto rounded bg-stone-200" />
</div>
{/* Content sections skeleton */}
<div className="space-y-20">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="grid md:grid-cols-2 gap-12 items-center"
>
<div className="animate-pulse space-y-4">
<div className="h-4 w-20 rounded bg-stone-200" />
<div className="h-8 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
</div>
<div className="h-64 rounded-3xl bg-stone-200" />
</motion.div>
))}
</div>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-stone-950 via-stone-900 to-emerald-950 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-lg w-full text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
>
<svg className="h-12 w-12 text-emerald-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<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>
</motion.div>
<h1 className="text-4xl font-black text-white mb-4">Contact Unavailable</h1>
<p className="text-stone-400 mb-8 leading-relaxed">
We couldn't load the contact page. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
<div className="mb-8 rounded-2xl bg-white/5 border border-white/10 p-5 text-left">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-500 mb-2">Error Details</p>
<p className="text-sm text-emerald-400/80 font-mono leading-relaxed break-all">{error.message}</p>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-500 px-8 py-4 text-sm font-bold text-white transition-all hover:shadow-lg hover:shadow-emerald-900/30 hover:-translate-y-0.5 active:scale-95"
>
Try Again
</button>
<Link
href="/tuxedo"
className="rounded-2xl bg-white/10 hover:bg-white/20 border border-white/20 px-8 py-4 text-sm font-bold text-white transition-all hover:-translate-y-0.5 active:scale-95"
>
Back to Homepage
</Link>
</div>
</motion.div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import { motion } from "framer-motion";
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-4xl px-6 py-20">
{/* Header skeleton */}
<div className="mb-16 animate-pulse text-center">
<div className="h-3 w-20 rounded bg-emerald-100 mx-auto mb-5" />
<div className="h-16 w-64 mx-auto rounded-lg bg-stone-200 mb-5" />
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
</div>
{/* Contact cards skeleton */}
<div className="grid gap-6 md:grid-cols-3 mb-16">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center"
>
<div className="h-12 w-12 rounded-2xl bg-emerald-50 mx-auto mb-5" />
<div className="h-5 w-24 mx-auto rounded bg-stone-200 mb-3" />
<div className="h-4 w-full max-w-[160px] mx-auto rounded bg-stone-100" />
</motion.div>
))}
</div>
{/* Form skeleton */}
<div className="rounded-3xl bg-white p-10 shadow-sm ring-1 ring-stone-200/60">
<div className="animate-pulse space-y-6">
<div className="h-6 w-32 rounded bg-stone-200" />
<div className="h-8 w-48 rounded bg-stone-200" />
<div className="grid gap-6 md:grid-cols-2 mt-8">
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-12 rounded-xl bg-stone-100" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-12 rounded-xl bg-stone-100" />
</div>
</div>
<div className="space-y-2">
<div className="h-4 w-16 rounded bg-stone-100" />
<div className="h-12 rounded-xl bg-stone-100" />
</div>
<div className="space-y-2">
<div className="h-4 w-20 rounded bg-stone-100" />
<div className="h-32 rounded-xl bg-stone-100" />
</div>
<div className="h-14 w-40 rounded-xl bg-emerald-100 mt-8" />
</div>
</div>
</div>
</div>
);
}
+56 -8
View File
@@ -1,5 +1,8 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function ErrorPage({
error,
reset,
@@ -8,20 +11,65 @@ export default function ErrorPage({
reset: () => void;
}) {
return (
<main className="min-h-screen bg-red-50 px-6 py-12">
<div className="mx-auto max-w-2xl">
<h1 className="text-3xl font-bold text-red-900">Page Error</h1>
<p className="mt-4 text-red-700">{error.message}</p>
{error.digest && (
<p className="mt-2 text-sm text-red-500">Digest: {error.digest}</p>
<main className="min-h-screen bg-gradient-to-br from-stone-950 via-stone-900 to-emerald-950 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-lg w-full text-center"
>
{/* Error icon */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
>
<svg className="h-12 w-12 text-emerald-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<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>
</motion.div>
{/* Error message */}
<h1 className="text-4xl font-black text-white mb-4">
Something went wrong
</h1>
<p className="text-stone-400 mb-8 leading-relaxed">
We encountered an unexpected error. Please try again or return to the homepage.
</p>
{/* Error details (development only hint) */}
{process.env.NODE_ENV === "development" && (
<div className="mb-8 rounded-2xl bg-white/5 border border-white/10 p-5 text-left">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-500 mb-2">Error Details</p>
<p className="text-sm text-emerald-400/80 font-mono leading-relaxed break-all">
{error.message}
</p>
</div>
)}
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="mt-6 rounded-xl bg-red-600 px-4 py-2 text-white"
className="rounded-2xl bg-emerald-600 hover:bg-emerald-500 px-8 py-4 text-sm font-bold text-white transition-all hover:shadow-lg hover:shadow-emerald-900/30 hover:-translate-y-0.5 active:scale-95"
>
Try again
Try Again
</button>
<Link
href="/tuxedo"
className="rounded-2xl bg-white/10 hover:bg-white/20 border border-white/20 px-8 py-4 text-sm font-bold text-white transition-all hover:-translate-y-0.5 active:scale-95"
>
Back to Homepage
</Link>
</div>
{/* Decorative elements */}
<div className="absolute top-0 left-0 w-full h-full pointer-events-none overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-emerald-500/10 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-48 h-48 bg-emerald-600/10 rounded-full blur-2xl" />
</div>
</motion.div>
</main>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-stone-950 via-stone-900 to-emerald-950 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-lg w-full text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
>
<svg className="h-12 w-12 text-emerald-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<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>
</motion.div>
<h1 className="text-4xl font-black text-white mb-4">FAQ Unavailable</h1>
<p className="text-stone-400 mb-8 leading-relaxed">
We couldn't load the FAQ page. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
<div className="mb-8 rounded-2xl bg-white/5 border border-white/10 p-5 text-left">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-500 mb-2">Error Details</p>
<p className="text-sm text-emerald-400/80 font-mono leading-relaxed break-all">{error.message}</p>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-500 px-8 py-4 text-sm font-bold text-white transition-all hover:shadow-lg hover:shadow-emerald-900/30 hover:-translate-y-0.5 active:scale-95"
>
Try Again
</button>
<Link
href="/tuxedo"
className="rounded-2xl bg-white/10 hover:bg-white/20 border border-white/20 px-8 py-4 text-sm font-bold text-white transition-all hover:-translate-y-0.5 active:scale-95"
>
Back to Homepage
</Link>
</div>
</motion.div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { motion } from "framer-motion";
export default function Loading() {
return (
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-3xl px-6 py-20">
{/* Header skeleton */}
<div className="mb-14 text-center animate-pulse">
<div className="h-3 w-16 rounded bg-emerald-100 mx-auto mb-4" />
<div className="h-16 w-48 mx-auto rounded-lg bg-stone-200 mb-5" />
<div className="h-5 w-96 max-w-full mx-auto rounded bg-stone-200" />
</div>
{/* Search skeleton */}
<div className="mb-12">
<div className="h-14 rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60" />
</div>
{/* FAQ items skeleton */}
<div className="space-y-4">
{[0, 1, 2, 3, 4].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="rounded-2xl bg-white shadow-sm ring-1 ring-stone-200/60 overflow-hidden"
>
<div className="flex items-center justify-between px-6 py-5">
<div className="h-5 w-3/4 rounded bg-stone-200" />
<div className="h-7 w-7 rounded-full bg-stone-100" />
</div>
</motion.div>
))}
</div>
</div>
</div>
);
}
+68 -9
View File
@@ -1,14 +1,73 @@
"use client";
import { motion } from "framer-motion";
export default function Loading() {
return (
<main className="min-h-screen bg-stone-50 px-6 py-12">
<div className="mx-auto max-w-5xl">
<div className="animate-pulse space-y-6">
<div className="h-6 w-32 rounded bg-stone-200" />
<div className="h-12 w-72 rounded bg-stone-200" />
<div className="h-4 w-96 rounded bg-stone-200" />
<div className="mt-10 grid gap-6 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-48 rounded-2xl bg-stone-200" />
<main className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-6xl px-6 py-16">
{/* Header skeleton */}
<div className="mb-16 animate-pulse">
<div className="h-4 w-32 rounded bg-stone-200 mb-4" />
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
<div className="h-6 w-96 max-w-full rounded bg-stone-200" />
</div>
{/* Cards skeleton */}
<div className="grid gap-6 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white ring-1 ring-stone-200/60 overflow-hidden"
>
{/* Image skeleton */}
<div className="h-48 bg-gradient-to-br from-stone-100 to-stone-50 relative overflow-hidden">
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-stone-200/60 to-transparent" />
</div>
{/* Content skeleton */}
<div className="p-7 space-y-4">
<div className="h-6 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
<div className="pt-4 flex items-center justify-between">
<div className="h-8 w-20 rounded-lg bg-stone-200" />
<div className="h-11 w-28 rounded-xl bg-stone-200" />
</div>
</div>
</motion.div>
))}
</div>
{/* Stops section skeleton */}
<div className="mt-20">
<div className="animate-pulse mb-10">
<div className="h-3 w-24 rounded bg-stone-200 mb-4" />
<div className="h-10 w-64 rounded-lg bg-stone-200 mb-3" />
<div className="h-4 w-48 rounded bg-stone-200" />
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2, 3, 4, 5].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="rounded-3xl bg-white p-7 ring-1 ring-stone-200/60"
>
<div className="flex items-start gap-4 mb-5">
<div className="h-10 w-10 rounded-xl bg-stone-100" />
<div className="flex-1">
<div className="h-8 w-32 rounded bg-stone-200 mb-2" />
<div className="h-4 w-full rounded bg-stone-100" />
</div>
</div>
<div className="flex gap-2 pl-[2.75rem]">
<div className="h-4 w-28 rounded bg-stone-100" />
</div>
</motion.div>
))}
</div>
</div>
+61
View File
@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-stone-950 via-stone-900 to-emerald-950 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-lg w-full text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-emerald-900/40 mb-8"
>
<svg className="h-12 w-12 text-emerald-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<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>
</motion.div>
<h1 className="text-4xl font-black text-white mb-4">Stop Not Available</h1>
<p className="text-stone-400 mb-8 leading-relaxed">
We couldn't load this stop. Please try again.
</p>
{process.env.NODE_ENV === "development" && (
<div className="mb-8 rounded-2xl bg-white/5 border border-white/10 p-5 text-left">
<p className="text-xs font-semibold uppercase tracking-wider text-stone-500 mb-2">Error Details</p>
<p className="text-sm text-emerald-400/80 font-mono leading-relaxed break-all">{error.message}</p>
</div>
)}
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="rounded-2xl bg-emerald-600 hover:bg-emerald-500 px-8 py-4 text-sm font-bold text-white transition-all hover:shadow-lg hover:shadow-emerald-900/30 hover:-translate-y-0.5 active:scale-95"
>
Try Again
</button>
<Link
href="/tuxedo"
className="rounded-2xl bg-white/10 hover:bg-white/20 border border-white/20 px-8 py-4 text-sm font-bold text-white transition-all hover:-translate-y-0.5 active:scale-95"
>
Back to Homepage
</Link>
</div>
</motion.div>
</div>
);
}
+62 -10
View File
@@ -1,18 +1,70 @@
"use client";
import { motion } from "framer-motion";
export default function Loading() {
return (
<main className="min-h-screen bg-yellow-50 px-6 py-12">
<div className="mx-auto max-w-5xl">
<div className="animate-pulse space-y-6">
<div className="h-6 w-32 rounded bg-slate-200" />
<div className="h-12 w-80 rounded bg-slate-200" />
<div className="mt-8 rounded-2xl bg-white p-8 shadow-sm" />
<div className="mt-10 grid gap-6 md:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="h-64 rounded-2xl bg-slate-200" />
<div className="min-h-screen bg-stone-50">
<div className="mx-auto max-w-5xl px-6 py-20">
{/* Back navigation skeleton */}
<div className="mb-10 animate-pulse">
<div className="h-5 w-32 rounded bg-stone-200" />
</div>
{/* Stop header skeleton */}
<div className="mb-12 animate-pulse rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
<div className="h-3 w-32 rounded bg-emerald-100 mb-4" />
<div className="h-16 w-80 rounded-lg bg-stone-200 mb-4" />
<div className="h-5 w-full max-w-md rounded bg-stone-200 mb-4" />
<div className="h-px w-12 bg-emerald-600" />
</div>
{/* Stop info skeleton */}
<div className="mb-14 rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60">
<div className="grid gap-4 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="flex items-start gap-4 py-4 px-5 rounded-2xl bg-stone-50"
>
<div className="h-10 w-10 rounded-xl bg-emerald-50" />
<div className="flex-1">
<div className="h-3 w-12 rounded bg-stone-200 mb-2" />
<div className="h-5 w-24 rounded bg-stone-200" />
</div>
</motion.div>
))}
</div>
</div>
{/* Products section skeleton */}
<div className="animate-pulse">
<div className="h-4 w-20 rounded bg-emerald-100 mb-4" />
<div className="h-10 w-48 rounded bg-stone-200 mb-4" />
<div className="h-4 w-64 rounded bg-stone-200 mb-10" />
<div className="grid gap-8 md:grid-cols-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="rounded-3xl bg-white overflow-hidden shadow-lg"
>
<div className="h-48 bg-gradient-to-br from-stone-100 to-stone-50" />
<div className="p-6 space-y-3">
<div className="h-6 w-3/4 rounded bg-stone-200" />
<div className="h-4 w-full rounded bg-stone-100" />
<div className="h-4 w-2/3 rounded bg-stone-100" />
</div>
</motion.div>
))}
</div>
</div>
</div>
</main>
</div>
);
}
+92 -35
View File
@@ -134,27 +134,23 @@ export default function EmployeePortalPage() {
}
if (loading) {
return (
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
<p className="text-slate-500">Loading pickup queue...</p>
</div>
);
return <EmployeePortalSkeleton />;
}
return (
<div className="min-h-screen bg-slate-100">
{/* Header */}
<div className="bg-white border-b border-slate-200 px-6 py-4">
<div className="mx-auto max-w-5xl flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Pickup Portal</h1>
<p className="mt-0.5 text-sm text-slate-500">{brandName}</p>
<div className="bg-white border-b border-slate-200 px-4 py-3 sm:px-6 sm:py-4">
<div className="mx-auto max-w-5xl flex items-center justify-between gap-4">
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl font-bold text-slate-900 truncate">Pickup Portal</h1>
<p className="mt-0.5 text-xs sm:text-sm text-slate-500 truncate">{brandName}</p>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-500">{employeeName}</span>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs sm:text-sm text-slate-500 hidden sm:inline">{employeeName}</span>
<button
onClick={handleSignOut}
className="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
className="rounded-xl border border-slate-300 px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors min-h-[40px]"
>
Sign Out
</button>
@@ -163,9 +159,9 @@ export default function EmployeePortalPage() {
</div>
{/* Queue tabs */}
<div className="bg-white border-b border-slate-200 px-6">
<div className="bg-white border-b border-slate-200 px-4 sm:px-6">
<div className="mx-auto max-w-5xl">
<nav className="flex gap-1 -mb-px">
<nav className="flex gap-1 -mb-px overflow-x-auto">
{(["past_due", "today", "upcoming"] as Queue[]).map((tab) => {
const data = tabData[tab];
const isActive = activeTab === tab;
@@ -206,13 +202,16 @@ export default function EmployeePortalPage() {
)}
{currentTab.orders.length === 0 ? (
<div className="rounded-2xl bg-white p-12 text-center shadow-sm ring-1 ring-slate-200">
<p className="text-slate-400 text-sm">
{activeTab === "past_due" ? "No past due orders." :
activeTab === "today" ? "No pickups scheduled for today." :
"No upcoming pickups."}
</p>
</div>
<EmptyQueueState
label={currentTab.label === "Past Due" ? "No past due orders" :
currentTab.label === "Today" ? "No pickups scheduled today" :
"No upcoming pickups"}
description={currentTab.label === "Past Due" ?
"All orders are on schedule. Great work!" :
currentTab.label === "Today" ?
"There are no wholesale orders scheduled for pickup today." :
"You have no upcoming pickups scheduled."}
/>
) : (
<div className="space-y-3">
{currentTab.orders.map((order) => (
@@ -299,6 +298,64 @@ export default function EmployeePortalPage() {
);
}
// ── Loading Skeleton ──────────────────────────────────────────────────────────
function EmployeePortalSkeleton() {
return (
<div className="min-h-screen bg-slate-100">
<div className="bg-white border-b border-slate-200 px-6 py-4">
<div className="mx-auto max-w-5xl flex items-center justify-between">
<div className="space-y-2 animate-pulse">
<div className="h-7 w-40 bg-slate-200 rounded" />
<div className="h-4 w-32 bg-slate-100 rounded" />
</div>
<div className="h-10 w-24 bg-slate-200 rounded-xl" />
</div>
</div>
<div className="bg-white border-b border-slate-200 px-6">
<div className="mx-auto max-w-5xl">
<div className="flex gap-1 -mb-px py-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-9 w-20 bg-slate-200 rounded-xl animate-pulse" />
))}
</div>
</div>
</div>
<div className="mx-auto max-w-5xl px-6 py-6 space-y-3">
{[1, 2, 3, 4].map(i => (
<div key={i} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 animate-pulse">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2 flex-1">
<div className="h-5 w-40 bg-slate-200 rounded" />
<div className="h-4 w-56 bg-slate-100 rounded" />
<div className="h-3 w-32 bg-slate-100 rounded" />
</div>
<div className="flex gap-2">
<div className="h-9 w-16 bg-slate-200 rounded-xl" />
<div className="h-9 w-16 bg-slate-200 rounded-xl" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
// ── Empty State ──────────────────────────────────────────────────────────────
function EmptyQueueState({ label, description }: { label: string; description: string }) {
return (
<div className="rounded-2xl bg-white p-12 text-center shadow-sm ring-1 ring-slate-200">
<div className="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</div>
<p className="text-lg font-semibold text-slate-700 mb-2">{label}</p>
<p className="text-sm text-slate-500 max-w-xs mx-auto">{description}</p>
</div>
);
}
// ── Order Card ────────────────────────────────────────────────────────────────
function OrderCard({
@@ -325,23 +382,23 @@ function OrderCard({
const hasPhone = Boolean(order.customer_phone);
return (
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 overflow-hidden">
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 overflow-hidden hover:ring-slate-300 transition-shadow">
{/* Card header */}
<div className="px-5 py-4 flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="px-4 py-4 sm:px-5 sm:py-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex-1 min-w-0 w-full">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-slate-900">{order.company_name}</span>
<span className="font-semibold text-slate-900 text-sm sm:text-base">{order.company_name}</span>
<StatusBadge status={order.status} />
<span className={`text-xs font-medium ${order.payment_status === "paid" ? "text-green-600" : "text-orange-600"}`}>
{order.payment_status === "paid" ? "Paid" : `$${Number(order.balance_due).toFixed(2)} due`}
</span>
</div>
<p className="text-sm text-slate-500 mt-0.5">
{order.contact_name ?? ""} ·{" "}
<p className="text-xs sm:text-sm text-slate-500 mt-0.5">
{order.contact_name ?? ""}
{hasPhone ? (
<a href={`tel:${order.customer_phone}`} className="hover:underline">{order.customer_phone}</a>
<> · <a href={`tel:${order.customer_phone}`} className="hover:underline">{order.customer_phone}</a></>
) : (
<a href={`mailto:${order.customer_email}`} className="hover:underline">{order.customer_email}</a>
<> · <a href={`mailto:${order.customer_email}`} className="hover:underline">{order.customer_email}</a></>
)}
</p>
<p className="text-xs text-slate-400 mt-0.5">
@@ -350,7 +407,7 @@ function OrderCard({
</div>
{/* Inline action buttons */}
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center gap-2 shrink-0 w-full sm:w-auto justify-end">
{order.fulfillment_status !== "fulfilled" && (
<button
onClick={() => !fulfilling && onFulfill(order.id)}
@@ -418,7 +475,7 @@ function OrderCard({
{/* Line items summary */}
{order.items && order.items.length > 0 && (
<div className="px-5 pb-4">
<div className="px-4 pb-4 sm:px-5 sm:pb-4">
<div className="rounded-xl bg-slate-50 border border-slate-100 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-100 text-slate-500">
@@ -431,9 +488,9 @@ function OrderCard({
<tbody className="divide-y divide-slate-100">
{order.items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-2 text-slate-700">{item.product_name}</td>
<td className="px-4 py-2 text-right text-slate-600">{item.quantity}</td>
<td className="px-4 py-2 text-right font-medium text-slate-900">${Number(item.line_total).toFixed(2)}</td>
<td className="px-4 py-2 text-slate-700 text-xs">{item.product_name}</td>
<td className="px-4 py-2 text-right text-slate-600 text-xs">{item.quantity}</td>
<td className="px-4 py-2 text-right font-medium text-slate-900 text-xs">${Number(item.line_total).toFixed(2)}</td>
</tr>
))}
</tbody>
+117 -38
View File
@@ -4,17 +4,54 @@ import { useState, useEffect } from "react";
import { wholesaleLoginAction } from "@/actions/wholesale-auth";
import { useRouter } from "next/navigation";
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
import Link from "next/link";
const BRANDS = [
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct", slug: "indian-river-direct" },
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn", slug: "tuxedo" },
];
function BrandLogo({ name }: { name: string }) {
return (
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-900/60 border border-emerald-700/50">
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
</div>
);
}
// Input field with validation state
function FormField({ label, id, error, children }: {
label: string;
id: string;
error?: string | null;
children: React.ReactNode;
}) {
return (
<div>
<label htmlFor={id} className="block text-sm font-semibold text-zinc-400 mb-1.5">{label}</label>
<div className="relative">
{children}
{error && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<svg className="w-4 h-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
)}
</div>
{error && <p className="mt-1 text-xs text-red-400">{error}</p>}
</div>
);
}
export default function WholesaleLoginPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "", brandId: BRANDS[1].id });
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
const [selectedBrand, setSelectedBrand] = useState(BRANDS[1]);
useEffect(() => {
@@ -27,6 +64,10 @@ export default function WholesaleLoginPage() {
const err = params.get("error");
if (err === "portal_disabled") {
setError("The wholesale portal is currently disabled. Contact us for assistance.");
} else if (err === "account_not_active") {
setError("Your account is not active. Please contact support or register for a new account.");
} else if (err === "invalid_credentials") {
setError("Invalid email or password. Please try again.");
}
}, []);
@@ -34,6 +75,26 @@ export default function WholesaleLoginPage() {
e.preventDefault();
setSubmitting(true);
setError(null);
setFieldErrors({});
// Client-side validation
const errors: { email?: string; password?: string } = {};
if (!form.email) {
errors.email = "Email is required";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = "Enter a valid email address";
}
if (!form.password) {
errors.password = "Password is required";
} else if (form.password.length < 6) {
errors.password = "Password must be at least 6 characters";
}
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
setSubmitting(false);
return;
}
const fd = new FormData(e.currentTarget as HTMLFormElement);
fd.set("brand_id", form.brandId);
const result = await wholesaleLoginAction(fd);
@@ -42,7 +103,7 @@ export default function WholesaleLoginPage() {
router.push("/wholesale/portal");
router.refresh();
} else {
setError(result.error ?? "Login failed.");
setError(result.error ?? "Login failed. Please check your credentials.");
}
}
@@ -52,26 +113,27 @@ export default function WholesaleLoginPage() {
<div className="bg-zinc-900 border-b border-zinc-800">
<div className="mx-auto max-w-5xl px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-900/60 border border-emerald-700/50">
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
</div>
<BrandLogo name={selectedBrand.name} />
<div>
<p className="font-bold text-base text-zinc-100 leading-none">{selectedBrand.name}</p>
<p className="text-xs text-zinc-500 mt-0.5">Wholesale Portal</p>
</div>
</div>
<a href="/" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"> Back to site</a>
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Back to site
</Link>
</div>
</div>
<div className="mx-auto max-w-5xl px-6 py-12">
<div className="grid gap-10 lg:grid-cols-[1fr_420px] lg:items-start">
<div className="grid gap-12 lg:grid-cols-[1fr_420px] lg:items-start">
{/* Benefits column */}
<div className="lg:pt-4">
<h2 className="text-3xl font-black text-zinc-100 tracking-tight mb-2">
<h2 className="text-3xl font-black text-zinc-100 tracking-tight mb-3">
Grow your business with wholesale pricing
</h2>
<p className="text-zinc-500 mb-8 leading-relaxed">
@@ -83,79 +145,96 @@ export default function WholesaleLoginPage() {
{/* Form column */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 shadow-xl shadow-black/20">
<div className="mb-6">
<h1 className="text-2xl font-bold text-zinc-100">Sign In</h1>
<p className="mt-1 text-sm text-zinc-500">Access your wholesale account.</p>
<h1 className="text-2xl font-bold text-zinc-100">Welcome back</h1>
<p className="mt-1 text-sm text-zinc-500">Sign in to your wholesale account.</p>
</div>
{error && (
<div className="mb-4 rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">
<div className="mb-5 rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 shrink-0" 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>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Company</label>
<form onSubmit={handleSubmit} className="space-y-5">
<FormField label="Company" id="brand_id" error={null}>
<select
id="brand_id"
name="brand_id"
value={form.brandId}
onChange={e => setForm(f => ({ ...f, brandId: e.target.value }))}
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors"
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors appearance-none cursor-pointer"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center", backgroundSize: "16px" }}
>
{BRANDS.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Email</label>
</FormField>
<FormField label="Email" id="email" error={fieldErrors.email}>
<input
type="email"
id="email"
name="email"
value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
required
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors placeholder:text-zinc-600"
onChange={e => { setForm(f => ({ ...f, email: e.target.value })); setFieldErrors(f => ({ ...f, email: undefined })); }}
autoComplete="email"
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.email ? "border-red-600 focus:border-red-500 focus:ring-1 focus:ring-red-500" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="buyer@company.com"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Password</label>
</FormField>
<FormField label="Password" id="password" error={fieldErrors.password}>
<input
type="password"
id="password"
name="password"
value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
required
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors placeholder:text-zinc-600"
onChange={e => { setForm(f => ({ ...f, password: e.target.value })); setFieldErrors(f => ({ ...f, password: undefined })); }}
autoComplete="current-password"
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.password ? "border-red-600 focus:border-red-500 focus:ring-1 focus:ring-red-500" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="••••••••"
/>
</div>
</FormField>
<button
type="submit"
disabled={submitting}
className="w-full rounded-xl bg-emerald-600 py-3 text-sm font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-emerald-900/30 mt-2"
className="w-full rounded-xl bg-emerald-600 py-3.5 text-sm font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-emerald-900/30 mt-2 flex items-center justify-center gap-2"
>
{submitting ? (
<span className="flex items-center justify-center gap-2">
<>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in...
</span>
) : "Sign In"}
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
</svg>
Sign In
</>
)}
</button>
</form>
<div className="mt-6 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
<div className="mt-5 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
<p className="text-sm text-zinc-400 font-medium">Don&apos;t have an account?</p>
<a
<Link
href="/wholesale/register"
className="mt-1 inline-block text-sm text-emerald-400 hover:text-emerald-300 hover:underline"
className="mt-1 inline-flex items-center gap-1 text-sm text-emerald-400 hover:text-emerald-300 hover:underline"
>
Apply for a wholesale account &rarr;
</a>
Apply for a wholesale account
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
</Link>
</div>
</div>
+80 -12
View File
@@ -1,33 +1,101 @@
"use client";
import { Suspense } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
export default function PaymentCancelPage() {
return (
<div className="min-h-screen bg-slate-100 flex items-center justify-center px-4">
<div className="bg-white rounded-2xl shadow-sm ring-1 ring-slate-200 p-8 max-w-md w-full text-center">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<Suspense fallback={
<div className="min-h-screen bg-gradient-to-br from-slate-100 to-stone-100 flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
<p className="text-stone-500">Processing...</p>
</div>
</div>
}>
<PaymentCancelContent />
</Suspense>
);
}
function PaymentCancelContent() {
const searchParams = useSearchParams();
const reason = searchParams.get("reason");
const sessionId = searchParams.get("session_id");
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-stone-50 flex items-center justify-center px-4 py-8">
<div className="bg-white rounded-3xl shadow-xl ring-1 ring-slate-200 p-8 sm:p-10 max-w-md w-full text-center">
{/* Cancel icon */}
<div className="w-20 h-20 bg-stone-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-2xl font-bold text-slate-900 mb-2">Payment Cancelled</h1>
<p className="text-sm text-slate-500 mb-6">
Your payment was not completed. No charges have been made.
<h1 className="text-2xl sm:text-3xl font-bold text-slate-900 mb-3">
Payment Cancelled
</h1>
<div className="bg-stone-50 rounded-xl px-4 py-3 mb-6">
<p className="text-sm text-stone-600 font-medium">
No charges were made
</p>
<p className="text-xs text-stone-500 mt-1">
Your order is still pending and you can return to complete payment anytime.
</p>
</div>
{sessionId && (
<p className="text-xs text-slate-400 mb-4 font-mono">
Session: {sessionId.slice(0, 20)}...
</p>
)}
{reason && (
<p className="text-xs text-amber-600 bg-amber-50 rounded-lg px-3 py-2 mb-6">
{reason === "aborted" ? "Payment was aborted by user." : reason}
</p>
)}
<div className="space-y-3">
<Link
href="/wholesale/portal?tab=orders"
className="block w-full rounded-xl bg-slate-800 py-3 text-sm font-bold text-white hover:bg-slate-700"
className="flex items-center justify-center gap-2 w-full rounded-xl bg-slate-800 py-3.5 text-sm font-bold text-white hover:bg-slate-700 active:bg-slate-800 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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
Back to Orders
</Link>
<Link
href="/wholesale/portal?tab=products"
className="block w-full rounded-xl border border-slate-300 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50"
href="/wholesale/portal?tab=cart"
className="flex items-center justify-center gap-2 w-full rounded-xl border border-slate-300 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50 active:bg-slate-100 transition-colors"
>
Continue Shopping
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
Return to Cart
</Link>
<Link
href="/wholesale/portal?tab=products"
className="flex items-center justify-center gap-2 w-full rounded-xl bg-stone-100 py-3 text-sm font-semibold text-stone-600 hover:bg-stone-200 active:bg-stone-100 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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
Browse Products
</Link>
</div>
<div className="mt-8 pt-6 border-t border-slate-100">
<p className="text-xs text-stone-400">
Need help?{" "}
<Link href="/wholesale/login" className="text-slate-500 hover:text-slate-700 hover:underline">
Contact support
</Link>
</p>
</div>
</div>
</div>
+82 -33
View File
@@ -3,13 +3,15 @@
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { enqueueWholesaleNotification } from "@/actions/wholesale";
export default function PaymentSuccessPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
<p className="text-slate-500">Loading...</p>
<div className="min-h-screen bg-gradient-to-br from-slate-100 to-stone-100 flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
<p className="text-stone-500">Processing payment...</p>
</div>
</div>
}>
<PaymentSuccessContent />
@@ -20,55 +22,102 @@ export default function PaymentSuccessPage() {
function PaymentSuccessContent() {
const searchParams = useSearchParams();
const sessionId = searchParams.get("session_id");
const [loading, setLoading] = useState(true);
const [notified, setNotified] = useState(false);
const [showContent, setShowContent] = useState(false);
const [successStep, setSuccessStep] = useState(0);
useEffect(() => {
setLoading(false);
// Stagger the animation
const timer1 = setTimeout(() => setShowContent(true), 200);
const timer2 = setTimeout(() => setSuccessStep(1), 500);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, []);
// After render, enqueue deposit received notification
// We don't have order details here (webhook handled the DB update),
// so we defer to the admin fulfill notification for now.
// The webhook already processed the payment — a follow-up cron can
// detect deposit_paid increases and send the deposit notification.
return (
<div className="min-h-screen bg-slate-100 flex items-center justify-center px-4">
<div className="bg-white rounded-2xl shadow-sm ring-1 ring-slate-200 p-8 max-w-md w-full text-center">
{loading ? (
<p className="text-slate-500">Confirming payment...</p>
) : (
<>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-stone-50 flex items-center justify-center px-4 py-8">
<div
className={`bg-white rounded-3xl shadow-xl ring-1 ring-slate-200 p-8 sm:p-10 max-w-md w-full text-center transition-all duration-500 ${
showContent ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
}`}
>
{/* Success animation */}
<div
className={`relative w-20 h-20 mx-auto mb-6 transition-all duration-500 ${
successStep >= 1 ? "scale-100" : "scale-50"
}`}
>
<div className={`absolute inset-0 rounded-full bg-green-100 transition-all duration-500 ${successStep >= 1 ? "opacity-100 scale-100" : "opacity-0 scale-50"}`} />
<div className="absolute inset-0 flex items-center justify-center">
<svg
className={`w-10 h-10 text-green-600 transition-all duration-300 ${
successStep >= 1 ? "opacity-100 scale-100" : "opacity-0 scale-50"
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold text-slate-900 mb-2">Payment Successful</h1>
<p className="text-sm text-slate-500 mb-6">
Your payment has been received. Your order will be updated shortly.
{sessionId && <span className="block mt-1 text-xs text-slate-400">Session: {sessionId.slice(0, 20)}...</span>}
{/* Ripple effect */}
<div className="absolute inset-0 rounded-full border-2 border-green-400 animate-ping opacity-20" />
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-slate-900 mb-3">
Payment Successful
</h1>
<div className="bg-green-50 rounded-xl px-4 py-3 mb-6">
<p className="text-sm text-green-700 font-medium">
Your payment has been received
</p>
<p className="text-xs text-green-600 mt-1">
Your order will be updated shortly. A confirmation email will be sent once your payment is processed.
</p>
</div>
{sessionId && (
<p className="text-xs text-slate-400 mb-6 font-mono">
Reference: {sessionId.slice(0, 20)}...
</p>
)}
<div className="space-y-3">
<Link
href="/wholesale/portal?tab=orders"
className="block w-full rounded-xl bg-green-600 py-3 text-sm font-bold text-white hover:bg-green-700"
className="flex items-center justify-center gap-2 w-full rounded-xl bg-green-600 py-3.5 text-sm font-bold text-white hover:bg-green-700 active:bg-green-800 transition-colors shadow-lg shadow-green-900/20"
>
View Orders
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
View My Orders
</Link>
<Link
href="/wholesale/portal?tab=products"
className="block w-full rounded-xl border border-slate-300 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50"
className="flex items-center justify-center gap-2 w-full rounded-xl border border-slate-300 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50 active:bg-slate-100 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="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
Continue Shopping
</Link>
</div>
<p className="mt-4 text-xs text-slate-400">
A confirmation email will be sent once your payment is processed.
</p>
</>
)}
<div className="mt-8 pt-6 border-t border-slate-100">
<Link
href="/wholesale/portal"
className="text-xs text-slate-400 hover:text-slate-600 transition-colors inline-flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Back to Portal
</Link>
</div>
</div>
</div>
);
+532 -74
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { formatDate } from "@/lib/format-date";
import { useRouter, useSearchParams } from "next/navigation";
import { getWholesaleCustomerByUser, submitWholesaleOrder, getWholesaleProducts, getWholesaleCustomerOrders, getWholesaleCustomerPricing, getWholesaleCustomer, type WholesaleProduct, type WholesaleCustomerOrder, type WholesalePricingOverride } from "@/actions/wholesale-register";
@@ -12,6 +12,214 @@ type CartItem = {
unitPrice: number;
};
// ── Loading Skeleton ─────────────────────────────────────────────────────────
function ProductSkeleton() {
return (
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 animate-pulse">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-2">
<div className="h-5 w-32 bg-slate-200 rounded" />
<div className="h-3 w-48 bg-slate-100 rounded" />
<div className="h-3 w-24 bg-slate-100 rounded" />
<div className="h-3 w-40 bg-slate-100 rounded" />
</div>
<div className="h-10 w-20 bg-slate-200 rounded-xl" />
</div>
</div>
);
}
function CartSkeleton() {
return (
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 p-5 animate-pulse">
<div className="h-6 w-40 bg-slate-200 rounded mb-4" />
<div className="space-y-3">
{[1, 2, 3].map(i => (
<div key={i} className="flex gap-4">
<div className="h-4 w-32 bg-slate-100 rounded" />
<div className="h-4 w-16 bg-slate-100 rounded ml-auto" />
<div className="h-4 w-12 bg-slate-100 rounded" />
<div className="h-4 w-16 bg-slate-100 rounded" />
</div>
))}
</div>
</div>
);
}
function OrdersSkeleton() {
return (
<div className="space-y-3">
{[1, 2, 3].map(i => (
<div key={i} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 animate-pulse">
<div className="flex items-start justify-between mb-3">
<div className="space-y-2">
<div className="h-5 w-32 bg-slate-200 rounded" />
<div className="h-3 w-48 bg-slate-100 rounded" />
</div>
<div className="space-y-2 text-right">
<div className="h-6 w-20 bg-slate-200 rounded" />
<div className="h-4 w-16 bg-slate-100 rounded" />
</div>
</div>
</div>
))}
</div>
);
}
// ── Quantity Stepper ──────────────────────────────────────────────────────────
function QuantityStepper({
maxQty,
onAdd,
}: {
maxQty: number;
onAdd: (qty: number) => void;
}) {
const [open, setOpen] = useState(false);
const [qty, setQty] = useState(1);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const presets = maxQty >= 10 ? [1, 5, 10] : maxQty >= 5 ? [1, 5] : [1];
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(!open)}
className="rounded-xl bg-green-600 px-4 py-2 text-xs font-semibold text-white hover:bg-green-700 shrink-0 transition-colors shadow-sm"
>
+ Add
</button>
{open && (
<div className="absolute right-0 bottom-full mb-2 z-20 bg-white rounded-xl shadow-xl ring-1 ring-slate-200 p-4 w-56">
<p className="text-xs font-semibold text-slate-500 mb-2">Qty for this item</p>
<div className="flex items-center gap-2 mb-3">
<button
type="button"
onClick={() => setQty(q => Math.max(1, q - 1))}
className="w-9 h-9 rounded-lg border border-slate-300 flex items-center justify-center text-slate-600 hover:bg-slate-50 disabled:opacity-30"
disabled={qty <= 1}
>
</button>
<input
type="number"
min="1"
max={maxQty}
value={qty}
onChange={e => setQty(Math.min(maxQty, Math.max(1, Number(e.target.value) || 1)))}
className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-center font-semibold text-slate-900 outline-none focus:border-green-500 min-w-0"
/>
<button
type="button"
onClick={() => setQty(q => Math.min(maxQty, q + 1))}
className="w-9 h-9 rounded-lg border border-slate-300 flex items-center justify-center text-slate-600 hover:bg-slate-50 disabled:opacity-30"
disabled={qty >= maxQty}
>
+
</button>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{presets.map(p => (
<button
key={p}
type="button"
onClick={() => setQty(Math.min(maxQty, p))}
className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition-colors ${
qty === p ? "bg-green-600 text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
{p}
</button>
))}
{maxQty >= 20 && (
<button
type="button"
onClick={() => setQty(Math.min(maxQty, 20))}
className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition-colors ${
qty === 20 ? "bg-green-600 text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
20
</button>
)}
</div>
<p className="text-xs text-slate-400 mb-3">Max: {maxQty}</p>
<button
type="button"
onClick={() => { onAdd(qty); setOpen(false); setQty(1); }}
className="w-full rounded-xl bg-green-600 py-2.5 text-sm font-bold text-white hover:bg-green-700"
>
Add {qty} to Cart
</button>
</div>
)}
</div>
);
}
// ── Search & Filter Bar ───────────────────────────────────────────────────────
function SearchBar({ value, onChange, placeholder }: {
value: string;
onChange: (v: string) => void;
placeholder: string;
}) {
return (
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input
type="search"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-slate-300 pl-10 pr-4 py-3 text-sm outline-none focus:border-green-500 focus:ring-1 focus:ring-green-500 placeholder:text-slate-400"
/>
{value && (
<button
type="button"
onClick={() => onChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
<svg className="w-4 h-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>
);
}
// ── Empty States ──────────────────────────────────────────────────────────────
function EmptyState({ icon, title, description }: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="rounded-2xl bg-white p-12 text-center shadow-sm ring-1 ring-slate-200">
<div className="w-14 h-14 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
{icon}
</div>
<h3 className="text-lg font-semibold text-slate-700 mb-2">{title}</h3>
<p className="text-sm text-slate-500 max-w-xs mx-auto">{description}</p>
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function WholesalePortalPage() {
const router = useRouter();
const [customer, setCustomer] = useState<{
@@ -28,6 +236,7 @@ export default function WholesalePortalPage() {
const [previewMode, setPreviewMode] = useState(false);
const [tab, setTab] = useState<"products" | "cart" | "orders">("products");
const [products, setProducts] = useState<WholesaleProduct[]>([]);
const [allProducts, setAllProducts] = useState<WholesaleProduct[]>([]);
const [cart, setCart] = useState<CartItem[]>([]);
const [orders, setOrders] = useState<WholesaleCustomerOrder[]>([]);
const [pricingOverrides, setPricingOverrides] = useState<Record<string, number>>({});
@@ -36,6 +245,26 @@ export default function WholesalePortalPage() {
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
const [onlinePaymentEnabled, setOnlinePaymentEnabled] = useState(false);
const [processingPayment, setProcessingPayment] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filterAvailability, setFilterAvailability] = useState<"all" | "available" | "limited">("all");
// Product filtering
const filteredProducts = useMemo(() => {
let filtered = allProducts;
if (searchQuery) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(p =>
p.name.toLowerCase().includes(q) ||
(p.description ?? "").toLowerCase().includes(q)
);
}
if (filterAvailability !== "all") {
filtered = filtered.filter(p =>
filterAvailability === "available" ? p.availability === "available" : p.availability === "limited"
);
}
return filtered;
}, [allProducts, searchQuery, filterAvailability]);
useEffect(() => {
// ── Admin preview mode ──────────────────────────────────────────────────
@@ -133,6 +362,7 @@ export default function WholesalePortalPage() {
const { getWholesaleProducts } = await import("@/actions/wholesale");
const prods = await getWholesaleProducts(brandId);
setProducts(prods);
setAllProducts(prods);
}
async function loadOrders(customerId: string) {
@@ -171,11 +401,8 @@ export default function WholesalePortalPage() {
}
}
function addToCart(product: WholesaleProduct) {
const qty = prompt(`Quantity for ${product.name} (${product.unit_type}):`);
if (!qty) return;
const q = Number(qty);
if (isNaN(q) || q <= 0) return;
function addToCart(product: WholesaleProduct, quantity: number) {
if (quantity <= 0 || quantity > product.qty_available) return;
// Determine unit price: customer override first, then price tiers
let unitPrice = 0;
@@ -184,7 +411,7 @@ export default function WholesalePortalPage() {
unitPrice = overridePrice;
} else if (product.price_tiers && product.price_tiers.length > 0) {
const tier = product.price_tiers.find((t: { min_qty: number; max_qty: number; price: number }) =>
q >= t.min_qty && (t.max_qty === 0 || q <= t.max_qty)
quantity >= t.min_qty && (t.max_qty === 0 || quantity <= t.max_qty)
);
unitPrice = tier ? tier.price : product.price_tiers[product.price_tiers.length - 1].price;
}
@@ -192,11 +419,23 @@ export default function WholesalePortalPage() {
setCart(prev => {
const existing = prev.find(i => i.product.id === product.id);
if (existing) {
return prev.map(i => i.product.id === product.id ? { ...i, quantity: i.quantity + q } : i);
const newQty = existing.quantity + quantity;
// Recalculate unit price if quantity changed tier
let newUnitPrice = unitPrice;
if (overridePrice === undefined && product.price_tiers && product.price_tiers.length > 0) {
const tier = product.price_tiers.find((t: { min_qty: number; max_qty: number; price: number }) =>
newQty >= t.min_qty && (t.max_qty === 0 || newQty <= t.max_qty)
);
newUnitPrice = tier ? tier.price : product.price_tiers[product.price_tiers.length - 1].price;
}
return [...prev, { product, quantity: q, unitPrice }];
return prev.map(i => i.product.id === product.id ? { ...i, quantity: newQty, unitPrice: newUnitPrice } : i);
}
return [...prev, { product, quantity, unitPrice }];
});
setTab("cart");
// Brief success feedback
setMsg({ kind: "success", text: `${quantity} × ${product.name} added to cart` });
setTimeout(() => setMsg(null), 3000);
}
async function handlePlaceOrder() {
@@ -283,8 +522,37 @@ export default function WholesalePortalPage() {
if (loading) {
return (
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
<p className="text-slate-500">Loading...</p>
<div className="min-h-screen bg-slate-100">
{previewMode && (
<div className="bg-yellow-400 text-yellow-900 text-center py-2 text-sm font-medium">
Admin Preview viewing portal as {customer?.company_name}
</div>
)}
{/* Skeleton header */}
<div className="bg-white border-b border-slate-200 px-6 py-4">
<div className="mx-auto max-w-5xl flex items-center justify-between">
<div className="animate-pulse space-y-2">
<div className="h-6 w-40 bg-slate-200 rounded" />
<div className="h-4 w-56 bg-slate-100 rounded" />
</div>
<div className="h-10 w-24 bg-slate-200 rounded-xl" />
</div>
</div>
{/* Skeleton tab bar */}
<div className="bg-white border-b border-slate-200 px-6">
<div className="mx-auto max-w-5xl">
<div className="flex gap-1 -mb-px py-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-9 w-20 bg-slate-200 rounded-xl animate-pulse" />
))}
</div>
</div>
</div>
<div className="mx-auto max-w-5xl px-6 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2, 3, 4, 5, 6].map(i => <ProductSkeleton key={i} />)}
</div>
</div>
</div>
);
}
@@ -341,55 +609,147 @@ export default function WholesalePortalPage() {
{tab === "products" && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-slate-900">Product Catalog</h2>
{products.length === 0 ? (
<p className="text-sm text-slate-400 py-8 text-center">No products available.</p>
{/* Search + filter bar */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search products..."
/>
</div>
<select
value={filterAvailability}
onChange={e => setFilterAvailability(e.target.value as typeof filterAvailability)}
className="rounded-xl border border-slate-300 px-3 py-3 text-sm outline-none focus:border-green-500 min-h-[48px]"
>
<option value="all">All ({allProducts.length})</option>
<option value="available">Available ({allProducts.filter(p => p.availability === "available").length})</option>
<option value="limited">Limited Stock</option>
</select>
</div>
{filteredProducts.length === 0 && !loading ? (
allProducts.length === 0 ? (
<EmptyState
icon={
<svg className="w-7 h-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
}
title="No products available"
description="This wholesale portal doesn't have any products listed yet. Check back soon or contact support."
/>
) : (
<EmptyState
icon={
<svg className="w-7 h-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
}
title="No products match your search"
description={`No products found for "${searchQuery}". Try adjusting your search or filters.`}
/>
)
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{products.map(p => (
{filteredProducts.map(p => (
<div key={p.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
<div className="flex items-start justify-between">
<div>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-slate-900">{p.name}</h3>
<p className="text-xs text-slate-500 mt-0.5">{p.description ?? ""}</p>
<p className="text-xs text-slate-400 mt-1">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${p.availability === "available" ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-500"}`}>
{p.availability}
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${
p.availability === "available" ? "bg-green-100 text-green-700" :
p.availability === "limited" ? "bg-amber-100 text-amber-700" :
"bg-slate-100 text-slate-500"
}`}>
{p.availability === "available" ? "In Stock" :
p.availability === "limited" ? `Limited (${p.qty_available})` :
"Out of Stock"}
</span>
{" "}{p.qty_available} {p.unit_type} available
</p>
<span className="text-xs text-slate-400">{p.unit_type}</span>
</div>
{p.price_tiers && p.price_tiers.length > 0 && (
<p className="text-xs text-slate-500 mt-1">
<div className="mt-2 space-y-0.5">
{pricingOverrides[p.id] !== undefined ? (
<span className="text-green-700 font-medium">Your price: ${pricingOverrides[p.id]!.toFixed(2)}</span>
) : (
p.price_tiers.map((t: { min_qty: number; max_qty: number; price: number }, i: number) => (
<span key={i} className="mr-2">
{t.min_qty}{t.max_qty ? `-${t.max_qty}` : "+"}: ${t.price}
<div className="flex items-center gap-2">
<span className="text-xs text-green-600 font-semibold bg-green-50 px-2 py-0.5 rounded">
Your price: ${pricingOverrides[p.id]!.toFixed(2)} / {p.unit_type}
</span>
))
)}
</p>
</div>
) : (
<div className="flex flex-wrap gap-x-3 gap-y-1">
{p.price_tiers.slice(0, 3).map((t: { min_qty: number; max_qty: number; price: number }, i: number) => (
<span key={i} className="text-xs text-slate-500">
<span className="font-medium text-slate-700">${t.price.toFixed(2)}</span>
<span className="text-slate-400 ml-1">
{t.max_qty === 0 ? `${t.min_qty}+` : `${t.min_qty}${t.max_qty}`}
</span>
</span>
))}
</div>
)}
</div>
{p.availability === "available" && (
<button onClick={() => addToCart(p)}
className="rounded-xl bg-green-600 px-4 py-2 text-xs font-semibold text-white hover:bg-green-700 shrink-0">
+ Add
</button>
)}
</div>
{p.availability === "available" && p.qty_available > 0 ? (
<QuantityStepper
maxQty={p.qty_available}
onAdd={(qty) => addToCart(p, qty)}
/>
) : p.availability === "limited" && p.qty_available > 0 ? (
<QuantityStepper
maxQty={p.qty_available}
onAdd={(qty) => addToCart(p, qty)}
/>
) : (
<span className="rounded-xl bg-slate-100 px-3 py-2 text-xs font-medium text-slate-400 shrink-0">
Unavailable
</span>
)}
</div>
</div>
))}
</div>
)}
{searchQuery && (
<p className="text-sm text-slate-500 text-center">
Showing {filteredProducts.length} of {allProducts.length} products
</p>
)}
</div>
)}
{tab === "cart" && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-slate-900">Your Order ({cart.length} items)</h2>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900">
Your Order
{cart.length > 0 && (
<span className="ml-2 text-sm font-normal text-slate-500">({cart.length} item{cart.length !== 1 ? "s" : ""})</span>
)}
</h2>
{cart.length > 0 && (
<button
onClick={() => setCart([])}
className="text-sm text-red-500 hover:text-red-700 hover:underline"
>
Clear cart
</button>
)}
</div>
{cart.length === 0 ? (
<p className="text-sm text-slate-400 py-8 text-center">Cart is empty.</p>
<EmptyState
icon={
<svg className="w-7 h-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
}
title="Your cart is empty"
description="Browse our product catalog and add items to get started with your wholesale order."
/>
) : (
<>
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 overflow-hidden">
@@ -397,7 +757,7 @@ export default function WholesalePortalPage() {
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="px-5 py-3 font-semibold text-left">Product</th>
<th className="px-5 py-3 font-semibold text-right">Qty</th>
<th className="px-5 py-3 font-semibold text-center">Qty</th>
<th className="px-5 py-3 font-semibold text-right">Unit Price</th>
<th className="px-5 py-3 font-semibold text-right">Total</th>
<th className="px-5 py-3"></th>
@@ -405,14 +765,56 @@ export default function WholesalePortalPage() {
</thead>
<tbody className="divide-y divide-slate-100">
{cart.map(item => (
<tr key={item.product.id}>
<td className="px-5 py-3 font-medium text-slate-900">{item.product.name}</td>
<td className="px-5 py-3 text-right">{item.quantity} {item.product.unit_type}</td>
<td className="px-5 py-3 text-right">${item.unitPrice.toFixed(2)}</td>
<td className="px-5 py-3 text-right font-semibold">${(item.quantity * item.unitPrice).toFixed(2)}</td>
<td className="px-5 py-3">
<button onClick={() => setCart(prev => prev.filter(i => i.product.id !== item.product.id))}
className="text-xs text-red-500 hover:underline">Remove</button>
<tr key={item.product.id} className="hover:bg-slate-50/50">
<td className="px-5 py-4">
<span className="font-medium text-slate-900">{item.product.name}</span>
<span className="ml-1 text-xs text-slate-400">/ {item.product.unit_type}</span>
</td>
<td className="px-5 py-4 text-center">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => {
if (item.quantity > 1) {
setCart(prev => prev.map(i =>
i.product.id === item.product.id
? { ...i, quantity: i.quantity - 1 }
: i
));
} else {
setCart(prev => prev.filter(i => i.product.id !== item.product.id));
}
}}
className="w-7 h-7 rounded-lg border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 disabled:opacity-30"
>
</button>
<span className="w-10 text-center font-semibold text-slate-900">{item.quantity}</span>
<button
onClick={() => {
if (item.quantity < item.product.qty_available) {
setCart(prev => prev.map(i =>
i.product.id === item.product.id
? { ...i, quantity: i.quantity + 1 }
: i
));
}
}}
disabled={item.quantity >= item.product.qty_available}
className="w-7 h-7 rounded-lg border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 disabled:opacity-30"
>
+
</button>
</div>
</td>
<td className="px-5 py-4 text-right text-slate-600">${item.unitPrice.toFixed(2)}</td>
<td className="px-5 py-4 text-right font-semibold text-slate-900">${(item.quantity * item.unitPrice).toFixed(2)}</td>
<td className="px-5 py-4">
<button
onClick={() => setCart(prev => prev.filter(i => i.product.id !== item.product.id))}
className="text-xs text-red-500 hover:text-red-700 hover:underline"
>
Remove
</button>
</td>
</tr>
))}
@@ -420,22 +822,46 @@ export default function WholesalePortalPage() {
</table>
</div>
<div className="flex gap-4 items-end">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Pickup Date</label>
<input type="date" value={pickupDate} onChange={e => setPickupDate(e.target.value)}
className="rounded-xl border border-slate-300 px-3 py-2 text-sm outline-none focus:border-green-600" />
<div className="rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 p-5">
<div className="flex flex-col sm:flex-row gap-4 sm:items-end">
<div className="flex-1">
<label className="block text-sm font-semibold text-slate-700 mb-1.5">
Preferred Pickup Date
<span className="text-slate-400 font-normal ml-1">(optional)</span>
</label>
<input
type="date"
value={pickupDate}
onChange={e => setPickupDate(e.target.value)}
min={new Date().toISOString().split("T")[0]}
className="w-full rounded-xl border border-slate-300 px-4 py-3 text-sm outline-none focus:border-green-500 focus:ring-1 focus:ring-green-500"
/>
</div>
<div className="ml-auto">
<p className="text-sm text-slate-500 mb-1">
Total: <span className="font-bold text-slate-900">${cart.reduce((s, i) => s + i.quantity * i.unitPrice, 0).toFixed(2)}</span>
<div className="flex items-center justify-between sm:flex-col sm:items-end gap-3">
<div className="text-right">
<p className="text-sm text-slate-500">Order Total</p>
<p className="text-2xl font-bold text-slate-900">
${cart.reduce((s, i) => s + i.quantity * i.unitPrice, 0).toFixed(2)}
</p>
<button onClick={handlePlaceOrder} disabled={submitting || !pickupDate}
className="rounded-xl bg-green-600 px-6 py-3 text-sm font-bold text-white hover:bg-green-700 disabled:opacity-50">
{submitting ? "Placing Order..." : "Place Order"}
</div>
<button
onClick={handlePlaceOrder}
disabled={submitting || cart.length === 0}
className="rounded-xl bg-green-600 px-8 py-3 text-base font-bold text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-green-900/20 min-w-[160px]"
>
{submitting ? (
<span className="flex items-center justify-center gap-2">
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Placing Order...
</span>
) : "Place Order"}
</button>
</div>
</div>
</div>
</>
)}
</div>
@@ -443,65 +869,97 @@ export default function WholesalePortalPage() {
{tab === "orders" && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-slate-900">Order History</h2>
<h2 className="text-lg font-semibold text-slate-900">
Order History
{orders.length > 0 && (
<span className="ml-2 text-sm font-normal text-slate-500">({orders.length} order{orders.length !== 1 ? "s" : ""})</span>
)}
</h2>
{orders.length === 0 ? (
<p className="text-sm text-slate-400 py-8 text-center">No orders yet.</p>
<EmptyState
icon={
<svg className="w-7 h-7 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
}
title="No orders yet"
description="Once you place your first wholesale order, it will appear here with full order history and tracking."
/>
) : (
<div className="space-y-3">
{orders.map(o => (
<div key={o.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200">
<div className="flex items-start justify-between mb-3">
<div key={o.id} className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 hover:ring-slate-300 transition-shadow">
<div className="flex items-start justify-between mb-4">
<div>
<p className="font-semibold text-slate-900">{o.invoice_number ?? o.id.slice(0, 8)}</p>
<p className="text-xs text-slate-500 mt-0.5">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-semibold text-slate-900">{o.invoice_number ?? "—"}</p>
{o.invoice_number && (
<span className="text-xs text-slate-400 font-mono">{o.id.slice(0, 8)}</span>
)}
</div>
<p className="text-xs text-slate-500 mt-1">
{formatDate(new Date(o.created_at))}
{o.anticipated_pickup_date && ` · Pickup: ${o.anticipated_pickup_date}`}
{o.anticipated_pickup_date && (
<span className="ml-2 text-green-600 font-medium"> Pickup: {o.anticipated_pickup_date}</span>
)}
</p>
</div>
<div className="text-right">
<p className="font-bold text-slate-900">${Number(o.subtotal).toFixed(2)}</p>
<p className={`text-xs font-medium ${
<p className="text-xl font-bold text-slate-900">${Number(o.subtotal).toFixed(2)}</p>
<p className={`text-sm font-medium ${
o.payment_status === "paid" ? "text-green-600" : "text-orange-500"
}`}>
{o.payment_status === "paid" ? "Paid" : `$${Number(o.balance_due).toFixed(2)} due`}
{o.payment_status === "paid" ? "Paid in full" : `$${Number(o.balance_due).toFixed(2)} due`}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${
o.status === "fulfilled" ? "bg-green-100 text-green-700" :
o.status === "pending" ? "bg-yellow-100 text-yellow-700" :
o.status === "awaiting_deposit" ? "bg-purple-100 text-purple-700" :
"bg-blue-100 text-blue-700"
o.status === "confirmed" ? "bg-blue-100 text-blue-700" :
"bg-slate-100 text-slate-700"
}`}>
{o.status.replace("_", " ")}
{o.status.replace(/_/g, " ")}
</span>
{o.items && o.items.length > 0 && (
<span className="text-xs text-slate-500">
<span className="text-xs text-slate-500 flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
{o.items.length} item{o.items.length !== 1 ? "s" : ""}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{o.invoice_number && o.invoice_token && (
<a
href={`/api/wholesale/invoice/${o.id}/pdf?token=${o.invoice_token}`}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white hover:bg-slate-700"
className="rounded-xl bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white hover:bg-slate-700 flex items-center gap-1"
>
Download Invoice
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Invoice
</a>
)}
{onlinePaymentEnabled && Number(o.balance_due) > 0 && o.payment_status !== "paid" && (
<button
onClick={() => handlePayNow(o)}
disabled={processingPayment === o.id}
className="ml-auto rounded-xl bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50"
className="rounded-xl bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/>
</svg>
{processingPayment === o.id ? "Loading..." : "Pay Now"}
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
+154 -43
View File
@@ -4,15 +4,45 @@ import { useState, useEffect } from "react";
import { registerWholesaleCustomer } from "@/actions/wholesale-register";
import { useRouter } from "next/navigation";
import WholesaleBenefits from "@/components/wholesale/WholesaleBenefits";
import Link from "next/link";
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
function FormField({ label, id, error, hint, children }: {
label: string;
id: string;
error?: string | null;
hint?: string | null;
children: React.ReactNode;
}) {
return (
<div>
<label htmlFor={id} className="block text-sm font-semibold text-zinc-400 mb-1.5">{label}</label>
{children}
{error && <p className="mt-1 text-xs text-red-400 flex items-center gap-1">
<svg className="w-3.5 h-3.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>
{error}
</p>}
{hint && !error && <p className="mt-1 text-xs text-zinc-500">{hint}</p>}
</div>
);
}
export default function WholesaleRegisterPage() {
const router = useRouter();
const [checkingEnabled, setCheckingEnabled] = useState(true);
const [portalDisabled, setPortalDisabled] = useState(false);
const [form, setForm] = useState({ companyName: "", contactName: "", email: "", phone: "", brandId: TUXEDO_BRAND_ID });
const [form, setForm] = useState({
companyName: "",
contactName: "",
email: "",
phone: "",
brandId: TUXEDO_BRAND_ID,
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);
@@ -32,10 +62,34 @@ export default function WholesaleRegisterPage() {
checkEnabled(form.brandId);
}, [form.brandId]);
function validateForm() {
const errors: Record<string, string> = {};
if (!form.companyName.trim()) {
errors.companyName = "Company name is required";
}
if (!form.email) {
errors.email = "Email is required";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = "Enter a valid email address";
}
if (form.phone && !/^[\d\s\-\+\(\)]+$/.test(form.phone)) {
errors.phone = "Enter a valid phone number";
}
return errors;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const errors = validateForm();
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
return;
}
setSubmitting(true);
setResult(null);
setFieldErrors({});
const res = await registerWholesaleCustomer({
brandId: form.brandId,
companyName: form.companyName,
@@ -52,15 +106,18 @@ export default function WholesaleRegisterPage() {
: "Account created — you can now log in.",
});
} else {
setResult({ success: false, message: res.error ?? "Registration failed." });
setResult({ success: false, message: res.error ?? "Registration failed. Please try again." });
}
}
if (checkingEnabled) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="text-center">
<div className="w-10 h-10 rounded-full border-2 border-emerald-500/30 border-t-emerald-500 animate-spin mx-auto mb-4" />
<p className="text-zinc-500 text-sm">Loading...</p>
</div>
</div>
);
}
@@ -70,7 +127,7 @@ export default function WholesaleRegisterPage() {
<div className="bg-zinc-900 border-b border-zinc-800">
<div className="mx-auto max-w-5xl px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-900/60 border border-emerald-700/50">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-900/60 border border-emerald-700/50">
<svg className="h-5 w-5 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
@@ -80,16 +137,21 @@ export default function WholesaleRegisterPage() {
<p className="text-xs text-zinc-500 mt-0.5">Fresh produce at wholesale prices</p>
</div>
</div>
<a href="/" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"> Back to site</a>
<Link href="/" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Back to site
</Link>
</div>
</div>
<div className="mx-auto max-w-5xl px-6 py-12">
<div className="grid gap-10 lg:grid-cols-[1fr_420px] lg:items-start">
<div className="grid gap-12 lg:grid-cols-[1fr_420px] lg:items-start">
{/* Benefits column */}
<div className="lg:pt-4">
<h2 className="text-3xl font-black text-zinc-100 tracking-tight mb-2">
<h2 className="text-3xl font-black text-zinc-100 tracking-tight mb-3">
Ready to get wholesale pricing?
</h2>
<p className="text-zinc-500 mb-8 leading-relaxed">
@@ -101,103 +163,152 @@ export default function WholesaleRegisterPage() {
{/* Form column */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-8 shadow-xl shadow-black/20">
{portalDisabled && (
<div className="mb-4 rounded-xl border border-amber-900/50 bg-amber-950/50 px-4 py-3 text-sm text-amber-400">
<div className="mb-5 rounded-xl border border-amber-900/50 bg-amber-950/50 px-4 py-3 text-sm text-amber-400 flex items-start gap-2">
<svg className="w-4 h-4 mt-0.5 shrink-0" 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>
The wholesale portal is currently disabled for this brand. You may still apply an admin will activate your account.
</div>
)}
<div className="mb-6">
<h1 className="text-2xl font-bold text-zinc-100">Apply for an Account</h1>
<h1 className="text-2xl font-bold text-zinc-100">Create an account</h1>
<p className="mt-1 text-sm text-zinc-500">We&apos;ll review and respond within 12 business days.</p>
</div>
{result && (
<div className={`mb-4 rounded-xl border px-4 py-3 text-sm ${
<div className={`mb-5 rounded-xl border px-4 py-4 text-sm flex items-start gap-2 ${
result.success
? "border-emerald-900/50 bg-emerald-950/50 text-emerald-400"
: "border-red-900/50 bg-red-950/50 text-red-400"
}`}>
{result.success ? (
<svg className="w-4 h-4 mt-0.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>
) : (
<svg className="w-4 h-4 mt-0.5 shrink-0" 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>
)}
{result.message}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Company Name *</label>
<input
value={form.companyName}
onChange={e => setForm(f => ({ ...f, companyName: e.target.value }))}
required
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors placeholder:text-zinc-600"
placeholder="Farm or business name"
/>
{result?.success ? (
<div className="text-center py-4">
<div className="w-16 h-16 bg-emerald-900/40 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Contact Name</label>
<Link
href="/wholesale/login"
className="inline-flex items-center gap-2 rounded-xl bg-emerald-600 px-6 py-3 text-sm font-bold text-white hover:bg-emerald-500"
>
Go to Sign In
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<FormField label="Company Name" id="companyName" error={fieldErrors.companyName} hint={null}>
<input
id="companyName"
value={form.companyName}
onChange={e => { setForm(f => ({ ...f, companyName: e.target.value })); setFieldErrors(f => ({ ...f, companyName: "" })); }}
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.companyName ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="Farm or business name"
autoComplete="organization"
/>
</FormField>
<FormField label="Contact Name" id="contactName" error={null} hint="Optional — your name">
<input
id="contactName"
value={form.contactName}
onChange={e => setForm(f => ({ ...f, contactName: e.target.value }))}
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors placeholder:text-zinc-600"
placeholder="Your name"
autoComplete="name"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Email *</label>
</FormField>
<FormField label="Email" id="email" error={fieldErrors.email} hint={null}>
<input
type="email"
id="email"
value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
onChange={e => { setForm(f => ({ ...f, email: e.target.value })); setFieldErrors(f => ({ ...f, email: "" })); }}
required
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors placeholder:text-zinc-600"
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.email ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="order@company.com"
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Phone</label>
</FormField>
<FormField label="Phone" id="phone" error={fieldErrors.phone} hint="Optional — for order coordination">
<input
type="tel"
id="phone"
value={form.phone}
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors placeholder:text-zinc-600"
onChange={e => { setForm(f => ({ ...f, phone: e.target.value })); setFieldErrors(f => ({ ...f, phone: "" })); }}
className={`w-full rounded-xl border bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 ${fieldErrors.phone ? "border-red-600" : "border-zinc-700 focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600"}`}
placeholder="(555) 555-5555"
autoComplete="tel"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Brand</label>
</FormField>
<FormField label="Brand" id="brandId" error={null} hint={null}>
<select
id="brandId"
value={form.brandId}
onChange={e => setForm(f => ({ ...f, brandId: e.target.value }))}
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors"
className="w-full rounded-xl border border-zinc-700 bg-zinc-950 px-3 py-3 text-sm text-zinc-100 outline-none focus:border-emerald-600 focus:ring-1 focus:ring-emerald-600 transition-colors appearance-none cursor-pointer"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center", backgroundSize: "16px" }}
>
<option value={TUXEDO_BRAND_ID}>Tuxedo Corn Colorado Sweet Corn</option>
<option value={IRD_BRAND_ID}>Indian River Direct Florida Citrus</option>
</select>
</div>
</FormField>
<button
type="submit"
disabled={submitting}
className="w-full rounded-xl bg-emerald-600 py-3 text-sm font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-emerald-900/30 mt-2"
className="w-full rounded-xl bg-emerald-600 py-3.5 text-sm font-bold text-white hover:bg-emerald-500 active:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-lg shadow-emerald-900/30 mt-3 flex items-center justify-center gap-2"
>
{submitting ? (
<span className="flex items-center justify-center gap-2">
<>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Submitting...
</span>
) : "Submit Application"}
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
Submit Application
</>
)}
</button>
</form>
)}
<div className="mt-4 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
{!result?.success && (
<div className="mt-5 rounded-xl border border-zinc-800 bg-zinc-950 p-4">
<p className="text-sm text-zinc-400">
Already have an account?{" "}
<a href="/wholesale/login" className="text-emerald-400 hover:underline font-medium hover:text-emerald-300">
<Link href="/wholesale/login" className="text-emerald-400 hover:underline font-medium hover:text-emerald-300">
Sign in
</a>
</Link>
</p>
</div>
)}
</div>
</div>
+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>
)}
+210 -65
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>
</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={[
{/* 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 border-[var(--admin-sidebar-bg)]",
"flex flex-col transition-transform duration-300 lg:translate-x-0",
mobileOpen ? "translate-x-0" : "-translate-x-full"
"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)" }}>
{/* 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">
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">
{/* 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 (
<div key={`divider-${index}`} className="flex items-center gap-2 px-3 py-3 mt-2">
<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 (
<li key={item.href}>
<Link
key={item.href}
href={item.href!}
onClick={() => setMobileOpen(false)}
data-nav-index={navIndex}
onClick={() => closeMobileMenu()}
onKeyDown={(e) => handleNavKeyDown(e, item.href!, navIndex)}
className={[
"group flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 mb-1",
"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-2"
: "border-l-2 border-transparent hover:border-l-2",
? "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)"
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" style={{
<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>
{item.label}
<span className="flex-1">{item.label}</span>
{active && (
<span className="ml-auto w-1.5 h-1.5 rounded-full" style={{ backgroundColor: "var(--admin-accent)" }} />
<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>
);
})}
</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={{
<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-[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,
};
+187 -66
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">
<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")}
+406 -27
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>
<div className="h-2 rounded-full bg-stone-100 overflow-hidden">
<span className="text-sm font-semibold" style={{ color: "var(--admin-text-primary)" }}>{value}</span>
</div>
<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"
}`}>
{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="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-1.5 rounded-full ${color}`}
className={`h-2 rounded-full ${color} transition-all duration-500`}
style={{ width: `${Math.min(100, value)}%` }}
/>
</div>
);
}
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
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>
);
}
export default function AnalyticsDashboard({ analytics }: Props) {
const [period, setPeriod] = useState<Period>("30");
// 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" />
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);
<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>
);
}
const avgOpenRate = totalDelivered > 0 ? (totalOpened / totalDelivered) * 100 : 0;
const avgClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0;
// 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 (
<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">
<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"
/>
</svg>
);
}
// 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 (
<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");
// 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 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-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>
<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>
{/* 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="Delivered"
value={totalSent > 0 ? `${Math.round((totalDelivered / totalSent) * 100)}%` : "—"}
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} messages` : undefined}
/>
<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>
{/* 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) => (
{/* 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-1.5 text-xs font-medium rounded-md transition-colors ${
period === val ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-white"
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 === "30" ? "30 days" : val === "90" ? "90 days" : "All time"}
{val === "all" ? "All time" : `${val} days`}
</button>
))}
</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>
{/* 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={`${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")}
/>
</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>
{/* 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">
<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>
{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"}>
<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-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 ml-1">({a.bounce_rate}%)</span>
<span className="text-xs text-stone-400">({a.bounce_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 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>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useCallback } from "react";
import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns";
import { type Template } from "@/actions/communications/templates";
import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments";
@@ -16,30 +16,297 @@ type Props = {
type Step = 1 | 2 | 3 | 4;
const STEPS = ["Template", "Audience", "Preview", "Schedule"] as const;
const STATUS_COLORS: Record<string, string> = {
draft: "bg-zinc-950 text-zinc-400",
scheduled: "bg-blue-900/40 text-blue-700",
sending: "bg-amber-100 text-amber-700",
sent: "bg-green-900/40 text-green-400",
canceled: "bg-red-900/40 text-red-400",
};
const CAMPAIGN_TYPES: { value: CampaignType; label: string; desc: string }[] = [
{ value: "marketing", label: "Marketing", desc: "Promotions, newsletters" },
{ value: "operational", label: "Operational", desc: "Stop updates, reminders" },
{ value: "transactional", label: "Transactional", desc: "Receipts, confirmations" },
const STEPS = [
{ id: 1 as Step, label: "Template", description: "Choose or create" },
{ id: 2 as Step, label: "Content", description: "Write your message" },
{ id: 3 as Step, label: "Audience", description: "Select recipients" },
{ id: 4 as Step, label: "Schedule", description: "Send or schedule" },
];
function statusBadge(status: string) {
const STATUS_COLORS: Record<string, string> = {
draft: "bg-stone-100 text-stone-600",
scheduled: "bg-blue-100 text-blue-700",
sending: "bg-amber-100 text-amber-700",
sent: "bg-emerald-100 text-emerald-700",
canceled: "bg-red-100 text-red-600",
};
const CAMPAIGN_TYPES: { value: CampaignType; label: string; desc: string; icon: React.ReactNode }[] = [
{
value: "marketing",
label: "Marketing",
desc: "Promotions, newsletters",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
<path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
},
{
value: "operational",
label: "Operational",
desc: "Stop updates, reminders",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
},
{
value: "transactional",
label: "Transactional",
desc: "Receipts, confirmations",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
},
];
// Icons
const Icons = {
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>
),
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
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>
),
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>
),
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>
),
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>
),
arrowLeft: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
),
arrowRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
),
edit: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
),
sparkles: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v1m0 16v1m-9-9h1m16 0h1m-2.636-6.364l-.707.707M6.343 17.657l-.707.707m0-12.728l.707.707m11.314 11.314l.707.707M12 8a4 4 0 100 8 4 4 0 000-8z"/>
</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>
),
};
// Step indicator component
function StepIndicator({ currentStep, steps }: { currentStep: Step; steps: typeof STEPS }) {
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[status] ?? "bg-zinc-950 text-zinc-400"}`}>
<div className="flex items-center justify-center">
{steps.map((step, index) => {
const isCompleted = currentStep > step.id;
const isCurrent = currentStep === step.id;
const isUpcoming = currentStep < step.id;
return (
<div key={step.id} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-300 ${
isCurrent
? "bg-emerald-600 text-white ring-4 ring-emerald-100"
: isCompleted
? "bg-emerald-600 text-white"
: "bg-stone-200 text-stone-500"
}`}
>
{isCompleted ? (
Icons.check("w-5 h-5")
) : (
<span className="text-sm font-bold">{step.id}</span>
)}
</div>
<div className="mt-2 text-center">
<p className={`text-xs font-semibold ${isCurrent ? "text-emerald-600" : "text-stone-500"}`}>
{step.label}
</p>
</div>
</div>
{index < steps.length - 1 && (
<div className={`h-0.5 w-16 mx-2 transition-colors ${
isCompleted ? "bg-emerald-500" : "bg-stone-200"
}`} />
)}
</div>
);
})}
</div>
);
}
// Status badge
function StatusBadge({ status }: { status: string }) {
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${STATUS_COLORS[status] ?? "bg-stone-100 text-stone-600"}`}>
{status}
</span>
);
}
// Template card component
function TemplateCard({
template,
isSelected,
onSelect
}: {
template: Template;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<button
onClick={onSelect}
className={`group relative rounded-2xl border-2 p-5 text-left transition-all duration-200 ${
isSelected
? "border-emerald-500 bg-emerald-50 shadow-md shadow-emerald-500/10"
: "border-stone-200 hover:border-emerald-300 hover:bg-stone-50"
}`}
>
{/* Selection indicator */}
{isSelected && (
<div className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-emerald-500 flex items-center justify-center shadow-lg">
{Icons.check("w-4 h-4 text-white")}
</div>
)}
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-xl ${
isSelected ? "bg-emerald-500 text-white" : "bg-stone-100 text-stone-500 group-hover:bg-emerald-100 group-hover:text-emerald-600"
} transition-colors`}>
{Icons.mail("w-5 h-5")}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-stone-800 truncate group-hover:text-emerald-700">{template.name}</p>
<p className="text-xs text-stone-500 mt-0.5 truncate">{template.subject || "(no subject)"}</p>
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600 capitalize">
{template.template_type}
</span>
</div>
</div>
</div>
</button>
);
}
// Email preview component
function EmailPreview({ subject, body }: { subject: string; body: string }) {
return (
<div className="rounded-2xl border border-stone-200 bg-stone-50 overflow-hidden shadow-sm">
{/* Email header */}
<div className="px-4 py-3 bg-white border-b border-stone-200 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-500 to-emerald-600" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-stone-800">Your Brand</p>
<p className="text-xs text-stone-400">To: {`{{customer_name}}`}</p>
</div>
</div>
{/* Email body */}
<div className="p-5">
<h3 className="text-base font-bold text-stone-900 mb-2">
{subject || <span className="text-stone-400 italic">(no subject)</span>}
</h3>
<div className="text-sm text-stone-600 whitespace-pre-wrap leading-relaxed">
{body || <span className="text-stone-400 italic">(no body)</span>}
</div>
</div>
</div>
);
}
// Success state component
function SuccessState({ sendNow, scheduledAt, campaignName }: { sendNow: boolean; scheduledAt: string; campaignName: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
{/* Success icon */}
<div className="relative">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-xl shadow-emerald-500/30">
{sendNow ? Icons.send("w-10 h-10 text-white") : Icons.clock("w-10 h-10 text-white")}
</div>
<div className="absolute -bottom-1 -right-1 w-8 h-8 rounded-full bg-white border-4 border-emerald-500 flex items-center justify-center">
{Icons.check("w-5 h-5 text-emerald-600")}
</div>
</div>
{/* Message */}
<h2 className="text-2xl font-bold text-stone-900 mt-6">
{sendNow ? "Campaign Sent!" : "Campaign Scheduled!"}
</h2>
<p className="text-stone-500 mt-2 text-center max-w-sm">
{sendNow
? `Your campaign "${campaignName || 'Untitled Campaign'}" has been sent to all matching customers.`
: `Your campaign is scheduled for ${new Date(scheduledAt).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
})}.`
}
</p>
{/* Actions */}
<div className="flex items-center gap-3 mt-8">
<AdminButton variant="secondary" onClick={() => window.location.href = "/admin/communications?tab=analytics"}>
View Analytics
</AdminButton>
<AdminButton onClick={() => window.location.href = "/admin/communications"}>
Back to Campaigns
</AdminButton>
</div>
</div>
);
}
export default function CampaignComposerPage({ brandId, campaigns, templates, segments, editCampaignId }: Props) {
const editing = campaigns.find((c) => c.id === editCampaignId);
@@ -60,15 +327,15 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
const selectedSegment = segments.find((s) => s.id === selectedSegmentId);
function handleTemplateSelect(template: Template) {
const handleTemplateSelect = useCallback((template: Template) => {
setSelectedTemplateId(template.id);
setSubject(template.subject ?? "");
setBodyText(template.body_text ?? "");
setBodyHtml(template.body_html ?? "");
setStep(2);
}
}, []);
async function handleCreateOrSchedule() {
const handleCreateOrSchedule = useCallback(async () => {
setError("");
setSaving(true);
const rules = selectedSegment?.rules ?? { combinator: "AND", filters: [] };
@@ -106,28 +373,16 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
setSaved(true);
setSaving(false);
setTimeout(() => setSaved(false), 3000);
}
}, [name, subject, bodyText, bodyHtml, selectedTemplateId, campaignType, sendNow, scheduledAt, selectedSegment, editing, brandId]);
// Validation
const canProceedFromStep2 = subject.trim().length > 0;
const canProceedFromStep3 = name.trim().length > 0;
if (saved) {
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-10 text-center">
<div className="w-12 h-12 rounded-full bg-green-900/40 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold text-zinc-100 mb-2">
{sendNow ? "Campaign Sent!" : "Campaign Scheduled!"}
</h2>
<p className="text-sm text-zinc-500 mb-8">
{sendNow
? "Your campaign has been sent to all matching customers."
: `It will be sent on ${new Date(scheduledAt).toLocaleString()}.`}
</p>
<AdminButton onClick={() => window.location.href = "/admin/communications"}>
Back to Campaigns
</AdminButton>
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden">
<SuccessState sendNow={sendNow} scheduledAt={scheduledAt} campaignName={name} />
</div>
);
}
@@ -135,211 +390,213 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
return (
<div className="flex flex-col gap-6">
{/* Step indicator */}
<div className="flex items-center">
{STEPS.map((label, i) => {
const n = (i + 1) as Step;
const done = step > n;
return (
<div key={label} className="flex items-center">
<div className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
step === n
? "bg-[var(--admin-text-primary)] text-white"
: done
? "bg-[var(--admin-border)] text-[var(--admin-text-secondary)]"
: "bg-zinc-950 text-slate-400"
}`}>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs border ${
step === n ? "border-white" : done ? "border-[var(--admin-text-muted)]" : "border-zinc-600"
}`}>
{done ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : n}
</span>
<span className="hidden sm:inline">{label}</span>
</div>
{i < STEPS.length - 1 && (
<div className={`h-0.5 w-6 ${done ? "bg-[var(--admin-border)]" : "bg-slate-200"} mx-1`} />
)}
</div>
);
})}
</div>
<StepIndicator currentStep={step} steps={STEPS} />
{/* Step content card */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6">
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden shadow-sm">
{/* Step 1: Template */}
{step === 1 && (
<div className="flex flex-col gap-5">
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
{Icons.mail("w-5 h-5")}
</div>
<div>
<h2 className="text-base font-semibold text-zinc-100">Choose a Template</h2>
<p className="text-sm text-zinc-500 mt-0.5">Select a template or start from scratch.</p>
<h2 className="text-lg font-bold text-stone-900">Choose a Template</h2>
<p className="text-sm text-stone-500">Select a template or start from scratch to craft your message.</p>
</div>
</div>
{templates.length === 0 ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-zinc-500">No templates yet. Create your subject and body below.</p>
<div className="flex flex-col gap-3">
<input
type="text"
placeholder="Subject line"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
<textarea
placeholder="Email body text…"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={5}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm resize-none outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div className="flex justify-end">
<AdminButton onClick={() => setStep(2)}>
Continue
</AdminButton>
<div className="text-center py-12 px-4 bg-stone-50 rounded-2xl border-2 border-dashed border-stone-200">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-stone-100 flex items-center justify-center">
{Icons.plus("w-8 h-8 text-stone-400")}
</div>
<p className="text-sm text-stone-600 font-medium">No templates yet</p>
<p className="text-xs text-stone-400 mt-1 mb-4">Create your subject and body in the next step.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Start from scratch */}
<button
onClick={() => setStep(2)}
className="rounded-xl border-2 border-dashed border-zinc-600 p-5 text-left hover:border-[var(--admin-border)] hover:bg-zinc-950 transition-all group"
>
<span className="text-2xl block mb-2">+</span>
<p className="text-sm font-medium text-zinc-300 group-hover:text-zinc-100">Start from scratch</p>
<p className="text-xs text-slate-400 mt-0.5">Write your own subject and body</p>
</button>
{/* Template cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{templates.map((t) => (
<button
<TemplateCard
key={t.id}
onClick={() => handleTemplateSelect(t)}
className={`rounded-xl border p-4 text-left transition-all ${
selectedTemplateId === t.id
? "border-[var(--admin-text-primary)] bg-zinc-950"
: "border-zinc-800 hover:border-[var(--admin-border)] hover:bg-zinc-800"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{t.name}</p>
<p className="text-xs text-slate-400 mt-0.5 truncate">{t.subject}</p>
</div>
<span className="inline-flex items-center rounded-full bg-zinc-950 px-2 py-0.5 text-xs text-zinc-400 flex-shrink-0">
{t.template_type}
</span>
</div>
</button>
template={t}
isSelected={selectedTemplateId === t.id}
onSelect={() => handleTemplateSelect(t)}
/>
))}
</div>
)}
<div className="border-t border-stone-100 pt-6">
<p className="text-xs text-stone-400 mb-3">Or start fresh:</p>
<AdminButton onClick={() => setStep(2)} variant="secondary">
{Icons.edit("w-4 h-4")}
Start from scratch
</AdminButton>
</div>
</div>
)}
{/* Step 2: Audience */}
{/* Step 2: Content */}
{step === 2 && (
<div className="flex flex-col gap-5">
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
{Icons.edit("w-5 h-5")}
</div>
<div>
<h2 className="text-base font-semibold text-zinc-100">Audience</h2>
<p className="text-sm text-zinc-500 mt-0.5">Name your campaign and pick a segment.</p>
<h2 className="text-lg font-bold text-stone-900">Write Your Message</h2>
<p className="text-sm text-stone-500">Craft a compelling subject and body for your campaign.</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Editor */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Campaign Name</label>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Subject Line</label>
<input
type="text"
placeholder="Enter a compelling subject..."
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
/>
<p className="text-xs text-stone-400 mt-1">{subject.length}/60 characters recommended</p>
</div>
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Message Body</label>
<textarea
placeholder="Write your message here..."
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={8}
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm resize-none outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
/>
</div>
</div>
{/* Preview */}
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Preview</label>
<EmailPreview subject={subject} body={bodyText} />
</div>
</div>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-600">
{error}
</div>
)}
<div className="flex justify-between pt-6 mt-6 border-t border-stone-100">
<AdminButton variant="ghost" onClick={() => setStep(1)} icon={Icons.arrowLeft("w-4 h-4")} iconPosition="left">
Back
</AdminButton>
<AdminButton onClick={() => setStep(3)} disabled={!canProceedFromStep2} icon={Icons.arrowRight("w-4 h-4")}>
Continue to Audience
</AdminButton>
</div>
</div>
)}
{/* Step 3: Audience */}
{step === 3 && (
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
{Icons.users("w-5 h-5")}
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">Select Your Audience</h2>
<p className="text-sm text-stone-500">Name your campaign and choose who will receive it.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Campaign settings */}
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-stone-700 mb-1.5">
Campaign Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. May Sweet Corn Promotion"
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Saved Segment</label>
<div className="flex gap-2">
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Recipient Segment</label>
<select
value={selectedSegmentId}
onChange={(e) => {
setSelectedSegmentId(e.target.value);
}}
className="flex-1 border border-zinc-600 rounded-lg px-3 py-2.5 text-sm bg-zinc-900 outline-none focus:border-[var(--admin-accent)]"
onChange={(e) => setSelectedSegmentId(e.target.value)}
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
>
<option value=""> Choose a saved segment </option>
<option value="">All contacts</option>
{segments.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{selectedSegmentId && (
<AdminButton variant="secondary" size="sm" onClick={() => setSelectedSegmentId("")}>
Clear
</AdminButton>
<p className="text-xs text-stone-400 mt-1">
Using segment: {selectedSegment?.name}
</p>
)}
</div>
{/* Content preview */}
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-2">Content Summary</p>
<p className="text-sm font-medium text-stone-800">{subject || "(no subject)"}</p>
<p className="text-xs text-stone-500 mt-1 line-clamp-2">{bodyText || "(no body)"}</p>
</div>
</div>
{/* Visual segment picker */}
<div className="bg-stone-50 rounded-2xl border border-stone-200 p-5">
<h3 className="text-sm font-semibold text-stone-700 mb-4">Segment Overview</h3>
{selectedSegment ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-white rounded-xl border border-stone-200">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
<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">{selectedSegment.name}</p>
<p className="text-xs text-stone-500">{selectedSegment.description || "Custom segment"}</p>
</div>
</div>
<div className="text-xs text-stone-400">
{"filters" in selectedSegment.rules ? `${selectedSegment.rules.filters.length} filter${selectedSegment.rules.filters.length !== 1 ? "s" : ""} configured` : "Custom rules configured"}
</div>
</div>
) : (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-stone-100 flex items-center justify-center">
{Icons.users("w-6 h-6 text-stone-400")}
</div>
<p className="text-sm text-stone-600 font-medium">All Contacts</p>
<p className="text-xs text-stone-400 mt-1">This campaign will be sent to all your contacts</p>
</div>
)}
</div>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-between pt-2">
<AdminButton variant="ghost" onClick={() => setStep(1)}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
<div className="flex justify-between pt-6 mt-6 border-t border-stone-100">
<AdminButton variant="ghost" onClick={() => setStep(2)} icon={Icons.arrowLeft("w-4 h-4")} iconPosition="left">
Back
</AdminButton>
<AdminButton onClick={() => setStep(3)} disabled={!name}>
Continue to Preview
</AdminButton>
</div>
</div>
)}
{/* Step 3: Preview */}
{step === 3 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Preview & Edit</h2>
<p className="text-sm text-zinc-500 mt-0.5">Review how your message will look.</p>
</div>
{/* Mock email frame */}
<div className="rounded-xl border border-zinc-800 bg-slate-50 overflow-hidden">
<div className="px-4 py-3 bg-zinc-900 border-b border-zinc-800 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-[var(--admin-border)]" />
<div>
<p className="text-xs font-medium text-zinc-300">Your Brand</p>
<p className="text-xs text-slate-400">To: {"{{customer_name}}"}</p>
</div>
</div>
<div className="p-5">
<p className="text-base font-semibold text-zinc-100 mb-3">{subject || "(no subject)"}</p>
<div className="text-sm text-zinc-300 whitespace-pre-wrap">{bodyText || "(no body)"}</div>
</div>
</div>
{/* Editable subject */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-1.5">Subject Line</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
/>
</div>
<div className="flex justify-between pt-2">
<AdminButton variant="ghost" onClick={() => setStep(2)}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</AdminButton>
<AdminButton onClick={() => setStep(4)}>
<AdminButton onClick={() => setStep(4)} disabled={!canProceedFromStep3} icon={Icons.arrowRight("w-4 h-4")}>
Continue to Schedule
</AdminButton>
</div>
@@ -348,28 +605,43 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
{/* Step 4: Schedule */}
{step === 4 && (
<div className="flex flex-col gap-5">
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
{Icons.calendar("w-5 h-5")}
</div>
<div>
<h2 className="text-base font-semibold text-zinc-100">Schedule & Send</h2>
<p className="text-sm text-zinc-500 mt-0.5">Set campaign type and timing.</p>
<h2 className="text-lg font-bold text-stone-900">Schedule & Send</h2>
<p className="text-sm text-stone-500">Set your campaign type and choose when to send.</p>
</div>
</div>
{/* Campaign type */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Campaign type selection */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-2">Campaign Type</label>
<div className="grid grid-cols-3 gap-3">
<label className="block text-sm font-semibold text-stone-700 mb-3">Campaign Type</label>
<div className="space-y-3">
{CAMPAIGN_TYPES.map((ct) => (
<button
key={ct.value}
onClick={() => setCampaignType(ct.value)}
className={`rounded-xl border py-3 px-4 text-left transition-all ${
className={`w-full rounded-xl border-2 p-4 text-left transition-all ${
campaignType === ct.value
? "border-[var(--admin-text-primary)] bg-[var(--admin-text-primary)] text-white"
: "border-zinc-800 hover:border-[var(--admin-border)] hover:bg-zinc-800"
? "border-emerald-500 bg-emerald-50"
: "border-stone-200 hover:border-stone-300"
}`}
>
<p className={`text-sm font-medium ${campaignType === ct.value ? "text-white" : "text-zinc-300"}`}>{ct.label}</p>
<p className={`text-xs mt-0.5 ${campaignType === ct.value ? "text-[var(--admin-border)]" : "text-slate-400"}`}>{ct.desc}</p>
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-xl ${
campaignType === ct.value ? "bg-emerald-500 text-white" : "bg-stone-100 text-stone-500"
}`}>
{ct.icon}
</div>
<div>
<p className="text-sm font-semibold text-stone-800">{ct.label}</p>
<p className="text-xs text-stone-500">{ct.desc}</p>
</div>
</div>
</button>
))}
</div>
@@ -377,62 +649,90 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
{/* Send timing */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-2">Send</label>
<div className="flex gap-6">
<label className="flex items-center gap-2.5 cursor-pointer">
<label className="block text-sm font-semibold text-stone-700 mb-3">When to Send</label>
<div className="space-y-3">
<label className={`flex items-center gap-4 p-4 rounded-xl border-2 cursor-pointer transition-all ${
sendNow ? "border-emerald-500 bg-emerald-50" : "border-stone-200 hover:border-stone-300"
}`}>
<input
type="radio"
checked={sendNow}
onChange={() => setSendNow(true)}
className="accent-[var(--admin-accent)] w-4 h-4"
className="w-4 h-4 accent-emerald-600"
/>
<span className="text-sm font-medium text-zinc-300">Send now</span>
<div className="flex-1">
<div className="flex items-center gap-2">
{Icons.send("w-5 h-5 text-emerald-600")}
<span className="text-sm font-semibold text-stone-800">Send immediately</span>
</div>
<p className="text-xs text-stone-500 mt-0.5">Your campaign will be sent right away</p>
</div>
</label>
<label className="flex items-center gap-2.5 cursor-pointer">
<label className={`flex items-center gap-4 p-4 rounded-xl border-2 cursor-pointer transition-all ${
!sendNow ? "border-emerald-500 bg-emerald-50" : "border-stone-200 hover:border-stone-300"
}`}>
<input
type="radio"
checked={!sendNow}
onChange={() => setSendNow(false)}
className="accent-[var(--admin-accent)] w-4 h-4"
className="w-4 h-4 accent-emerald-600"
/>
<span className="text-sm font-medium text-zinc-300">Schedule for later</span>
</label>
<div className="flex-1">
<div className="flex items-center gap-2">
{Icons.clock("w-5 h-5 text-emerald-600")}
<span className="text-sm font-semibold text-stone-800">Schedule for later</span>
</div>
</div>
</label>
</div>
{!sendNow && (
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-1.5">Send Date & Time</label>
<div className="mt-4">
<label className="block text-sm font-semibold text-stone-700 mb-1.5">Send Date & Time</label>
<input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-[var(--admin-accent)]"
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
min={new Date().toISOString().slice(0, 16)}
/>
</div>
)}
</div>
</div>
{/* Summary */}
<div className="rounded-xl bg-slate-50 border border-zinc-800 p-4">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide mb-3">Summary</p>
<div className="grid grid-cols-2 gap-y-2 text-sm">
<span className="text-zinc-500">Campaign</span>
<span className="text-zinc-100 font-medium">{name || "—"}</span>
<span className="text-zinc-500">Template</span>
<span className="text-zinc-100">{selectedTemplate?.name ?? "Custom"}</span>
<span className="text-zinc-500">Audience</span>
<span className="text-zinc-100">{selectedSegment?.name ?? "—"}</span>
<span className="text-zinc-500">Type</span>
<span className="text-zinc-100 capitalize">{campaignType}</span>
<div className="mt-6 p-5 bg-stone-50 rounded-2xl border border-stone-200">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-3">Campaign Summary</p>
<div className="grid grid-cols-2 gap-y-3 text-sm">
<div>
<span className="text-stone-500">Campaign name</span>
<p className="font-semibold text-stone-800">{name || "—"}</p>
</div>
<div>
<span className="text-stone-500">Template</span>
<p className="font-semibold text-stone-800">{selectedTemplate?.name ?? "Custom"}</p>
</div>
<div>
<span className="text-stone-500">Audience</span>
<p className="font-semibold text-stone-800">{selectedSegment?.name ?? "All contacts"}</p>
</div>
<div>
<span className="text-stone-500">Type</span>
<p className="font-semibold text-stone-800 capitalize">{campaignType}</p>
</div>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-600">
{error}
</div>
)}
<div className="flex justify-between pt-2">
<AdminButton variant="ghost" onClick={() => setStep(3)}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
<div className="flex justify-between pt-6 mt-6 border-t border-stone-100">
<AdminButton variant="ghost" onClick={() => setStep(3)} icon={Icons.arrowLeft("w-4 h-4")} iconPosition="left">
Back
</AdminButton>
<AdminButton
@@ -440,7 +740,17 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
isLoading={saving}
disabled={(!sendNow && !scheduledAt)}
>
{saving ? "Processing…" : sendNow ? "Send Campaign" : "Schedule Campaign"}
{saving ? "Processing…" : sendNow ? (
<>
{Icons.send("w-4 h-4")}
Send Campaign
</>
) : (
<>
{Icons.calendar("w-4 h-4")}
Schedule Campaign
</>
)}
</AdminButton>
</div>
</div>
@@ -449,25 +759,28 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
{/* Recent campaigns */}
{campaigns.length > 0 && (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-800">
<h3 className="text-sm font-semibold text-slate-800">Recent Campaigns</h3>
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden shadow-sm">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Recent Campaigns</h3>
<span className="text-xs text-stone-400">{campaigns.length} total</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<thead className="bg-stone-50 border-b border-stone-100">
<tr>
{["Name", "Type", "Status", "Sent"].map((h) => (
<th key={h} className={`text-left px-6 py-3 font-medium text-zinc-400 ${h !== "Name" ? "hidden sm:table-cell" : ""}`}>{h}</th>
))}
<th className="text-left px-6 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider">Name</th>
<th className="text-left px-6 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider hidden sm:table-cell">Type</th>
<th className="text-left px-6 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider hidden sm:table-cell">Status</th>
<th className="text-left px-6 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider hidden sm:table-cell">Sent</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{campaigns.slice(0, 10).map((c) => (
<tr key={c.id} className="hover:bg-zinc-800 transition-colors">
<td className="px-6 py-3.5 font-medium text-slate-800">{c.name}</td>
<td className="px-6 py-3.5 hidden sm:table-cell text-zinc-500 capitalize">{c.campaign_type}</td>
<td className="px-6 py-3.5 hidden sm:table-cell">{statusBadge(c.status)}</td>
<td className="px-6 py-3.5 hidden sm:table-cell text-xs text-slate-400">
<tbody className="divide-y divide-stone-100">
{campaigns.slice(0, 5).map((c) => (
<tr key={c.id} className="hover:bg-stone-50 transition-colors">
<td className="px-6 py-3.5 font-semibold text-stone-800">{c.name}</td>
<td className="px-6 py-3.5 hidden sm:table-cell text-stone-600 capitalize">{c.campaign_type}</td>
<td className="px-6 py-3.5 hidden sm:table-cell"><StatusBadge status={c.status} /></td>
<td className="px-6 py-3.5 hidden sm:table-cell text-xs text-stone-400">
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
</td>
</tr>
@@ -475,6 +788,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
</tbody>
</table>
</div>
</div>
)}
</div>
);
@@ -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">
<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={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg transition-all ${
active
? "border-stone-900 text-stone-900"
: "border-transparent text-zinc-500 hover:text-zinc-300"
? "bg-white text-emerald-600 shadow-sm"
: "text-stone-500 hover:text-stone-700 hover:bg-white/50"
}`}
>
{tab.label}
{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,6 +153,14 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
subtitle="Build filters to define your audience, then save and reuse the segment."
/>
{/* Show empty state if no segments */}
{segments.length === 0 ? (
<SegmentsEmptyState onNew={handleNewSegment} />
) : (
<>
{/* Active segment indicator */}
<ActiveSegmentHeader segment={activeSegment} onClear={handleClearSelection} />
{/* Main layout */}
<div className="flex flex-col lg:flex-row gap-4">
{/* Left sidebar */}
@@ -110,6 +186,8 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
<MatchingCustomersPanel brandId={brandId} rules={currentRules} />
</div>
</div>
</>
)}
{showEditModal && (
<SegmentEditModal
+235 -136
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>;
})()}
<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-[100px]" title={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>
)}
</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>
)}
</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>
+217 -63
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>
<AdminInput label="State" required>
<AdminTextInput
value={state}
onChange={(e) => setState(e.target.value)}
placeholder="e.g. CO"
/>
</AdminInput>
{fieldErrors.city && <p className="mt-1 text-xs text-red-500">{fieldErrors.city}</p>}
</div>
<AdminInput label="Location Name" required>
<AdminTextInput
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Southwest Plaza Parking Lot"
<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="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>
<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);
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)]"
}`}
/>
{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>
<AdminInput label="Time" required>
<AdminTextInput
value={time}
onChange={(e) => setTime(e.target.value)}
placeholder="e.g. 8:00 AM 2:00 PM"
/>
</AdminInput>
{fieldErrors.date && <p className="mt-1 text-xs text-red-500">{fieldErrors.date}</p>}
</div>
<AdminInput label="Brand" required>
<AdminSelect
value={brandId}
onChange={(e) => setBrandId(e.target.value)}
options={[
{ value: "", label: "Select brand..." },
{ value: "64294306-5f42-463d-a5e8-2ad6c81a96de", label: "Tuxedo Corn" },
{ value: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", label: "Indian River Direct" },
]}
<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);
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="Active">
<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);
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>
<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>
+109 -31
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,10 +137,11 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
const toSave = items.filter((i) => !i.removed);
const toRemove = items.filter((i) => i.removed);
try {
for (const item of toRemove) {
const result = await deleteOrderItem(item.id);
if (!result.success) {
setError(result.error ?? "Failed to remove item");
showError("Failed to remove item", result.error ?? "Please try again");
setSaving(false);
return;
}
@@ -128,7 +159,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
price: Number(item.price),
});
if (!result.success) {
setError(result.error ?? "Failed to update item");
showError("Failed to update item", result.error ?? "Please try again");
setSaving(false);
return;
}
@@ -149,30 +180,32 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
});
if (!result.success) {
setError(result.error ?? "Failed to save");
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 (
<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,11 +242,14 @@ 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
</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"
@@ -220,8 +257,10 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) {
onChange={(e) =>
updateItem(item.id, "price", Number(e.target.value))
}
className="w-full rounded-xl border border-stone-200 bg-stone-50 pl-8 pr-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
/>
</AdminInput>
</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
<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)]"
}`}
/>
</AdminInput>
<AdminInput label="Discount Reason">
<AdminTextInput
</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>
);
}
+102 -36
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">
{/* 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">
<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>
)}
{/* Payment details */}
<form onSubmit={handleSavePayment} className="space-y-4">
{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,37 +257,48 @@ 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 */}
{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">
Refund recorded
<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)}
onChange={(e) => {
setRefundAmount(e.target.value);
setRefundError(null);
}}
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"
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
@@ -243,18 +306,21 @@ export default function OrderPaymentSection({
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"
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={!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"
isLoading={refunding}
variant="danger"
fullWidth
>
{refunding ? "Processing..." : "Record Refund"}
</button>
{refunding ? "Processing..." : `Record Refund (Max ${formatCurrency(remainingBalance)})`}
</AdminButton>
</form>
</div>
)}
</div>
);
}
+256 -102
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,53 +197,79 @@ 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">
<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">
<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" },
{ value: "stripe", label: "Stripe" },
{ value: "square", label: "Square" },
{ value: "", label: "None / Manual", icon: "—" },
{ value: "stripe", label: "Stripe", icon: "💳" },
{ value: "square", label: "Square", icon: "◼️" },
].map((opt) => (
<button
key={opt.value}
@@ -209,49 +278,60 @@ export default function PaymentSettingsForm({ settings, brandId, brands = [], is
setProvider(opt.value as PaymentProvider | "");
setShowStripe(opt.value === "stripe");
setShowSquare(opt.value === "square");
setDirty(true);
}}
className={`rounded-xl border px-4 py-3 text-sm font-medium transition-colors ${
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"
: "border-[var(--admin-border)] text-[var(--admin-text-secondary)] hover:bg-stone-50"
? "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">
<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">
<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)}
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"
}`}
<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"
/>
</button>
<span className="text-sm font-medium text-[var(--admin-text-primary)]">
{squareSyncEnabled ? "Enabled" : "Disabled"}
</span>
</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="space-y-3">
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Manual Sync</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
<AdminButton
variant="secondary"
size="sm"
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"
{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}
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"
{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}
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>
{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}
<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-red-600">— {settings.square_last_sync_error}</span>
<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
{/* 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}
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>
{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>
</>
+204 -69
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">
<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)"
}}
>
<span
className="inline-block h-4 w-4 rounded-full transition-transform"
style={{
backgroundColor: "white",
transform: fedexUseProduction ? "translateX(26px)" : "translateX(4px)"
<AdminToggle
checked={fedexUseProduction}
onChange={(checked) => {
setFedexUseProduction(checked);
setDirty(true);
}}
label="Use Production Mode"
description={fedexUseProduction ? "Live rates, real labels" : "Sandbox/test mode — safe for testing"}
/>
</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>
</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>
) : (
<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 ? "Saving..." : "Save Shipping Settings"}
{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>
);
+130 -44
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,88 +105,148 @@ 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>
)}
<div className="grid grid-cols-2 gap-4">
<AdminInput label="City">
<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="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)]"
}`}
/>
</AdminInput>
{fieldErrors.city && <p className="mt-1 text-xs text-red-500">{fieldErrors.city}</p>}
</div>
<AdminInput label="State">
<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="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)]"
}`}
/>
</AdminInput>
{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
<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">
<AdminTextInput
<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"
/>
</AdminInput>
</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
<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
<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>
@@ -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";
+112 -55
View File
@@ -3,6 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion";
import { useCart } from "@/context/CartContext";
type StorefrontHeaderProps = {
@@ -17,25 +18,31 @@ type StorefrontHeaderProps = {
const ACCENT_CONFIG = {
green: {
wholesaleBg: "bg-emerald-700 hover:bg-emerald-800",
wholesaleBg: "bg-emerald-700 hover:bg-emerald-800 active:bg-emerald-900",
wholesaleText: "text-white",
cartBg: "bg-stone-900 hover:bg-stone-800",
cartBg: "bg-stone-900 hover:bg-stone-800 active:bg-stone-950",
cartText: "text-white",
cartBadge: "bg-emerald-600",
navHover: "hover:text-emerald-600",
activeNav: "text-emerald-600",
},
orange: {
wholesaleBg: "bg-orange-500 hover:bg-orange-600",
wholesaleBg: "bg-orange-500 hover:bg-orange-600 active:bg-orange-700",
wholesaleText: "text-white",
cartBg: "bg-stone-800 hover:bg-stone-700",
cartBg: "bg-stone-800 hover:bg-stone-700 active:bg-stone-900",
cartText: "text-white",
cartBadge: "bg-orange-500",
navHover: "hover:text-orange-500",
activeNav: "text-orange-500",
},
blue: {
wholesaleBg: "bg-blue-600 hover:bg-blue-500",
wholesaleBg: "bg-blue-600 hover:bg-blue-500 active:bg-blue-700",
wholesaleText: "text-white",
cartBg: "bg-stone-800 hover:bg-stone-700",
cartBg: "bg-stone-800 hover:bg-stone-700 active:bg-stone-900",
cartText: "text-white",
cartBadge: "bg-blue-600",
navHover: "hover:text-blue-600",
activeNav: "text-blue-600",
},
};
@@ -51,8 +58,6 @@ export default function StorefrontHeader({
const { cart } = useCart();
const [menuOpen, setMenuOpen] = useState(false);
const accent = ACCENT_CONFIG[brandAccent] ?? ACCENT_CONFIG.green;
const isBlue = brandAccent === "blue";
const isOrange = brandAccent === "orange";
const cartCount = cart.reduce((sum, item) => sum + item.quantity, 0);
@@ -64,108 +69,160 @@ export default function StorefrontHeader({
{ label: "FAQ", href: `/${brandSlug}/faq` },
];
const headerBg = isBlue
? "bg-white/95 border-stone-200 shadow-sm"
: isOrange
? "bg-white/95 border-stone-200 shadow-sm"
: "bg-white/95 border-stone-200 shadow-sm";
const navColor = "text-stone-600";
const navHover = "hover:text-blue-600";
const headerBg = "bg-white/95 backdrop-blur-md border-b border-stone-200/60 shadow-sm";
return (
<header className={`sticky top-0 z-40 border-b backdrop-blur-md ${headerBg}`}>
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
{/* Brand */}
<header className={`sticky top-0 z-40 ${headerBg}`}>
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 sm:px-6 py-3 sm:py-4">
{/* Brand Logo */}
<Link href={`/${brandSlug}`} className="flex items-center gap-3 group">
{logoUrlDark ? (
<span className="relative h-10 w-[160px]">
<Image src={logoUrlDark} alt={brandName} fill className="object-contain object-left" />
<span className="relative h-9 sm:h-10 w-[140px] sm:w-[160px]">
<Image src={logoUrlDark} alt={brandName} fill className="object-contain object-left transition-transform duration-300 group-hover:scale-[1.02]" />
</span>
) : logoUrl ? (
<span className="relative h-10 w-[160px]">
<Image src={logoUrl} alt={brandName} fill className="object-contain object-left" />
<span className="relative h-9 sm:h-10 w-[140px] sm:w-[160px]">
<Image src={logoUrl} alt={brandName} fill className="object-contain object-left transition-transform duration-300 group-hover:scale-[1.02]" />
</span>
) : (
<span className="text-xl font-bold tracking-tight text-stone-800">{brandName}</span>
<span className="text-lg sm:text-xl font-bold tracking-tight text-stone-800 group-hover:text-stone-900 transition-colors">
{brandName}
</span>
)}
</Link>
{/* Desktop nav */}
<nav className={`hidden items-center gap-7 text-sm font-medium ${navColor} md:flex`}>
{/* Desktop Navigation */}
<nav className="hidden items-center gap-6 sm:gap-7 text-sm font-medium text-stone-600 md:flex">
{navLinks.map((link) => (
<Link key={link.href} href={link.href} className={`${navHover} transition-colors duration-150`}>
{link.label}
<Link
key={link.href}
href={link.href}
className={`relative py-1 ${accent.navHover} transition-all duration-200 group`}
>
<span className="relative z-10">{link.label}</span>
<span className={`absolute -bottom-0.5 left-0 w-0 h-0.5 ${accent.activeNav.replace('hover:', '')} transition-all duration-200 group-hover:w-full rounded-full`} />
</Link>
))}
{showWholesaleLink && (
<Link
href="/wholesale/login"
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors ${accent.wholesaleBg} ${accent.wholesaleText}`}
className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-all duration-200 ${accent.wholesaleBg} ${accent.wholesaleText} hover:shadow-md hover:shadow-black/10`}
>
Wholesale
</Link>
)}
</nav>
{/* Right side */}
<div className="flex items-center gap-2">
{/* Right Side - Cart & Mobile Menu */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Cart Button */}
<Link
href="/cart"
className={`relative rounded-full px-4 py-2 text-sm font-semibold transition-colors ${accent.cartBg} ${accent.cartText}`}
className={`relative rounded-full px-4 sm:px-5 py-2.5 sm:py-2 text-sm font-semibold transition-all duration-200 ${accent.cartBg} ${accent.cartText} hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 active:scale-95`}
>
Cart
<span className="hidden sm:inline">Cart</span>
<svg className="h-5 w-5 sm:hidden" fill="none" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
{cartCount > 0 && (
<span className={`absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full ${accent.cartBadge} text-[10px] font-bold text-white`}>
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className={`absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full ${accent.cartBadge} text-[10px] font-bold text-white shadow-sm`}
>
{cartCount > 9 ? "9+" : cartCount}
</span>
</motion.span>
)}
</Link>
{/* Mobile menu button */}
{/* Mobile Menu Button */}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-stone-200 text-stone-500 hover:border-stone-400 hover:text-stone-700 transition-colors md:hidden"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-stone-200 text-stone-500 transition-all duration-200 hover:border-stone-400 hover:text-stone-700 active:scale-95 md:hidden"
aria-expanded={menuOpen}
aria-label="Toggle menu"
>
{menuOpen ? (
<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>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
<motion.div
animate={menuOpen ? "open" : "closed"}
className="relative w-5 h-4"
>
<motion.span
animate={menuOpen ? { y: 8, rotate: 45 } : { y: 0, rotate: 0 }}
className="absolute top-0 left-0 w-full h-0.5 bg-current rounded-full"
transition={{ duration: 0.2 }}
/>
<motion.span
animate={menuOpen ? { opacity: 0 } : { opacity: 1 }}
className="absolute top-1/2 left-0 w-full h-0.5 bg-current -translate-y-1/2 rounded-full"
transition={{ duration: 0.2 }}
/>
<motion.span
animate={menuOpen ? { y: -8, rotate: -45 } : { y: 0, rotate: 0 }}
className="absolute bottom-0 left-0 w-full h-0.5 bg-current rounded-full"
transition={{ duration: 0.2 }}
/>
</motion.div>
</button>
</div>
</div>
{/* Mobile nav */}
{/* Mobile Navigation */}
<AnimatePresence>
{menuOpen && (
<div className="border-t border-stone-100 bg-white px-4 py-5 md:hidden">
<nav className="flex flex-col gap-1 text-sm font-medium text-stone-600">
{navLinks.map((link) => (
<Link
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-hidden border-t border-stone-100 bg-white md:hidden"
>
<motion.nav
initial={{ y: -10 }}
animate={{ y: 0 }}
transition={{ delay: 0.1 }}
className="flex flex-col gap-1 px-5 py-5"
>
{navLinks.map((link, index) => (
<motion.div
key={link.href}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
>
<Link
href={link.href}
onClick={() => setMenuOpen(false)}
className="py-2.5 transition-colors hover:text-stone-900"
className="flex items-center gap-3 py-3 px-4 rounded-xl text-stone-600 font-medium transition-all duration-200 hover:bg-stone-50 hover:text-stone-900"
>
{link.label}
<svg className="h-4 w-4 ml-auto text-stone-400" fill="none" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Link>
</motion.div>
))}
{showWholesaleLink && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: navLinks.length * 0.05 }}
>
<Link
href="/wholesale/login"
onClick={() => setMenuOpen(false)}
className="mt-2 py-2.5 font-semibold text-emerald-700 hover:text-emerald-800 transition-colors"
className="mt-3 flex items-center gap-3 py-3 px-4 rounded-xl bg-emerald-50 text-emerald-700 font-semibold transition-all duration-200 hover:bg-emerald-100"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Wholesale Portal
</Link>
</motion.div>
)}
</nav>
</div>
</motion.nav>
</motion.div>
)}
</AnimatePresence>
</header>
);
}
+77 -8
View File
@@ -123,6 +123,61 @@ const LABELS = {
},
};
function SummarySkeleton() {
return (
<div className="rounded-xl border border-stone-200 bg-white p-4 animate-pulse">
<div className="flex items-center justify-between mb-3">
<div className="h-5 w-32 bg-stone-200 rounded" />
<div className="h-4 w-16 bg-stone-100 rounded" />
</div>
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="space-y-1">
<div className="h-3 w-20 bg-stone-100 rounded" />
<div className="h-6 w-16 bg-stone-200 rounded" />
</div>
))}
</div>
</div>
);
}
function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="rounded-xl border border-stone-200 bg-white overflow-hidden animate-pulse">
<div className="h-12 bg-stone-50 border-b border-stone-100" />
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-4 py-3 border-b border-stone-50 last:border-0">
<div className="h-4 w-32 bg-stone-100 rounded" />
<div className="h-4 w-20 bg-stone-100 rounded" />
<div className="h-4 w-16 bg-stone-100 rounded ml-auto" />
<div className="h-4 w-12 bg-stone-100 rounded" />
</div>
))}
</div>
);
}
function CardsSkeleton({ count = 4 }: { count?: number }) {
return (
<div className="rounded-xl border border-stone-200 bg-white p-4 animate-pulse">
<div className="flex items-center justify-between mb-3">
<div className="h-4 w-24 bg-stone-200 rounded" />
<div className="h-8 w-8 bg-stone-100 rounded-lg" />
</div>
<div className="flex gap-2 overflow-hidden">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="rounded-lg border border-stone-100 p-3 w-36 shrink-0">
<div className="h-4 w-20 bg-stone-100 rounded mb-2" />
<div className="h-6 w-16 bg-stone-100 rounded mb-1" />
<div className="h-3 w-12 bg-stone-100 rounded" />
</div>
))}
</div>
</div>
);
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
@@ -247,6 +302,9 @@ export default function WaterAdminClient() {
const t = LABELS[lang];
// Initial loading state
const [initialLoading, setInitialLoading] = useState(true);
useEffect(() => {
const savedLang = document.cookie.match(/wl_lang=(en|es)/)?.[1] as "en" | "es" | undefined;
if (savedLang) setLang(savedLang);
@@ -266,6 +324,7 @@ export default function WaterAdminClient() {
setHeadgates(headgatesData);
setAlertLog(alertsData as AlertLogEntry[]);
setLoading(false);
setInitialLoading(false);
};
const loadDisplaySummary = useCallback(async () => {
@@ -373,8 +432,10 @@ export default function WaterAdminClient() {
{t.refreshIn}: {refreshCountdown}s
</span>
</div>
{displayLoading ? (
<div className="rounded-xl bg-white p-4 text-sm text-stone-400">Loading...</div>
{initialLoading ? (
<CardsSkeleton count={3} />
) : displayLoading ? (
<CardsSkeleton count={3} />
) : displaySummary ? (
<div className="space-y-3">
<div>
@@ -471,8 +532,8 @@ export default function WaterAdminClient() {
{showHeadgates && (
<div className="space-y-1">
{loading ? (
<p className="text-sm text-stone-400 py-2">Loading...</p>
{initialLoading ? (
<TableSkeleton rows={3} />
) : headgates.length === 0 ? (
<p className="text-sm text-stone-400 py-2">{t.noHeadgates}</p>
) : (
@@ -571,8 +632,8 @@ export default function WaterAdminClient() {
{showUsers && (
<div className="space-y-1">
{loading ? (
<p className="text-sm text-stone-400 py-2">Loading...</p>
{initialLoading ? (
<TableSkeleton rows={3} />
) : users.length === 0 ? (
<p className="text-sm text-stone-400 py-2">No users</p>
) : (
@@ -666,8 +727,16 @@ export default function WaterAdminClient() {
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={5} className="py-4 text-center text-stone-400">Loading...</td></tr>
{initialLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i}>
<td className="py-3"><div className="h-3 w-20 bg-stone-100 rounded animate-pulse" /></td>
<td className="py-3"><div className="h-3 w-16 bg-stone-100 rounded animate-pulse hidden sm:table-cell" /></td>
<td className="py-3"><div className="h-3 w-14 bg-stone-100 rounded animate-pulse" /></td>
<td className="py-3"><div className="h-3 w-10 bg-stone-100 rounded animate-pulse ml-auto" /></td>
<td className="py-3"><div className="h-5 w-8 bg-stone-100 rounded animate-pulse ml-auto" /></td>
</tr>
))
) : filteredEntries.length === 0 ? (
<tr><td colSpan={5} className="py-4 text-center text-stone-400">{allEntries.length === 0 ? t.noEntries : "No matches"}</td></tr>
) : (
+67 -14
View File
@@ -121,7 +121,7 @@ function WaterFieldInner() {
const qrHeadgateToken = searchParams.get("h");
const [lang, setLang] = useState<Language>("en");
const [step, setStep] = useState<"lang" | "role" | "pin" | "form">("lang");
const [step, setStep] = useState<"loading" | "lang" | "role" | "pin" | "form">("loading");
const [pin, setPin] = useState("");
const [irrigatorName, setIrrigatorName] = useState("");
const [selectedRole, setSelectedRole] = useState<"irrigator" | "water_admin" | null>(null);
@@ -146,18 +146,18 @@ function WaterFieldInner() {
const t = LABELS[lang];
// Detect saved language preference
// Detect saved language preference + check session
useEffect(() => {
const saved = document.cookie.match(/wl_lang=(en|es)/)?.[1] as Language | undefined;
if (saved) setLang(saved);
}, []);
// Check if already logged in (session cookie)
useEffect(() => {
const match = document.cookie.match(/wl_session=([^;]+)/);
if (match) {
setStep("form");
// Already logged in
loadHeadgates();
setStep("form");
} else {
setStep("lang");
}
}, []);
@@ -177,6 +177,9 @@ function WaterFieldInner() {
setHeadgates(hgs.filter((h) => h.active));
}
// Show loading spinner next to headgate dropdown
const isLoadingHeadgates = step === "form" && headgates.length === 0;
async function handleLangSelect(lang: Language) {
setLang(lang);
await setWaterLang(lang);
@@ -333,6 +336,18 @@ function WaterFieldInner() {
setHeadgateLocked(false);
}
// Language selection screen
if (step === "loading") {
return (
<div className="min-h-screen bg-stone-50 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-xs text-center">
<div className="w-12 h-12 rounded-full border-3 border-stone-300 border-t-stone-600 animate-spin mx-auto mb-4" />
<p className="text-stone-500 text-base">Loading...</p>
</div>
</div>
);
}
// Language selection screen
if (step === "lang") {
return (
@@ -448,13 +463,32 @@ function WaterFieldInner() {
</div>
<button
onClick={handleLogout}
className="rounded-lg bg-stone-700 px-3 py-2 text-sm font-medium text-white hover:bg-stone-600"
className="rounded-lg bg-stone-700 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-600 transition-colors min-h-[44px]"
>
{t.logout}
</button>
</div>
</div>
{/* Step progress indicator */}
<div className="bg-stone-100 border-b border-stone-200 px-4 py-2">
<div className="mx-auto max-w-lg">
<div className="flex items-center gap-1.5">
{["lang", "role", "pin", "form"].map((s, i) => {
const stepIndex = ["lang", "role", "pin", "form"].indexOf(step as string);
const isActive = step === s;
const isPast = stepIndex > i;
return (
<div key={s} className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${isActive ? "bg-green-600" : isPast ? "bg-green-400" : "bg-stone-300"}`} />
{i < 3 && <div className={`w-6 h-0.5 ${isPast ? "bg-green-400" : "bg-stone-300"}`} />}
</div>
);
})}
</div>
</div>
</div>
<div className="mx-auto max-w-lg px-4 py-6 space-y-5">
{/* Success banner */}
{success && (
@@ -478,6 +512,9 @@ function WaterFieldInner() {
{headgateLocked ? (
<div className="w-full rounded-xl border-2 border-amber-300 bg-amber-50 px-4 py-4 text-lg font-bold text-stone-900">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
{selectedHg?.name ?? selectedHeadgate}
<span className="ml-1 inline-flex items-center gap-0.5 rounded bg-amber-200 px-2 py-0.5 text-xs font-bold text-amber-800">
🔒 {t.locked}
@@ -487,14 +524,20 @@ function WaterFieldInner() {
<ThresholdBadge high={selectedHg.high_threshold} low={selectedHg.low_threshold} t={t} />
)}
</div>
) : isLoadingHeadgates ? (
<div className="w-full rounded-xl border-2 border-stone-300 bg-stone-100 px-4 py-5 flex items-center gap-3">
<div className="w-5 h-5 rounded-full border-2 border-stone-400 border-t-stone-700 animate-spin" />
<span className="text-stone-500 text-base font-medium">Loading headgates...</span>
</div>
) : (
<select
value={selectedHeadgate}
onChange={(e) => setSelectedHeadgate(e.target.value)}
required
className="w-full rounded-xl border-2 border-stone-300 bg-white px-4 py-4 text-lg outline-none focus:border-stone-900 min-h-[56px]"
className="w-full rounded-xl border-2 border-stone-300 bg-white px-4 py-4 text-lg outline-none focus:border-stone-900 min-h-[56px] appearance-none"
style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2371717b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E")`, backgroundRepeat: "no-repeat", backgroundPosition: "right 16px center", backgroundSize: "20px" }}
>
<option value="">{t.selectHeadgate}</option>
<option value="" disabled>{t.selectHeadgate}</option>
{headgates.length === 0 && <option disabled>{t.noHeadgates}</option>}
{headgates.map((hg) => (
<option key={hg.id} value={hg.id}>
@@ -619,15 +662,25 @@ function WaterFieldInner() {
<button
type="submit"
disabled={!selectedHeadgate || !measurement || loading}
className="w-full rounded-xl bg-green-600 px-6 py-5 text-xl font-bold text-white disabled:opacity-50 active:bg-green-700 min-h-[64px] shadow-lg"
className="w-full rounded-xl bg-green-600 px-6 py-5 text-xl font-bold text-white disabled:opacity-50 active:bg-green-700 min-h-[64px] shadow-lg shadow-green-900/20 flex items-center justify-center gap-2"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="w-5 h-5 border-2 border-white/50 border-t-white rounded-full animate-spin" />
<>
<span className="w-5 h-5 rounded-full border-2 border-white/40 border-t-white animate-spin" />
{t.submitting}
</span>
) : t.submit}
</>
) : (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{t.submit}
</>
)}
</button>
{loading && (
<p className="text-center text-xs text-stone-400 mt-1">Submitting your reading...</p>
)}
</div>
</div>
);
+39
View File
@@ -328,3 +328,42 @@
color: var(--admin-accent-text);
border: 1px solid var(--admin-accent);
}
/* === Shimmer Animation === */
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.shimmer {
animation: shimmer 1.5s ease-in-out infinite;
}
/* === Toast Animations === */
@keyframes slide-in-from-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-in {
animation: slide-in-from-right 0.3s ease-out, fade-in 0.3s ease-out;
}