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>
<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-3 text-xs text-stone-500">
<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
@@ -50,4 +65,4 @@ export default async function AbandonedCartsPage() {
</div>
</main>
);
}
}
@@ -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>
<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>
<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-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)
@@ -38,4 +54,4 @@ export default async function WelcomeSequencePage() {
</div>
</main>
);
}
}
+54 -16
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>
@@ -49,4 +87,4 @@ export default function AdminErrorPage({
</div>
</div>
);
}
}
+42 -7
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;
if (!adminUser) {
// 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="Your account does not have admin access." />
<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 (
<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"
>
{loading ? "..." : "+ Add"}
</button>
<div className="flex items-center gap-2">
<button
onClick={handleClick}
disabled={loading}
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 ? (
<>
<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)]">
{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>
<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"
)}
</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">
<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>
<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>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">Extend your plan with optional features</p>
</div>
<span className="text-sm font-semibold text-[var(--admin-text-secondary)]">+${addonsMonthlyTotal}/mo</span>
</div>
<div className="space-y-2.5">
<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.
</p>
<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 (
<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"
>
{loading ? "Redirecting..." : "Upgrade"}
</button>
<div className="flex flex-col gap-1">
<button
onClick={handleClick}
disabled={loading}
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 ? (
<>
<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>
);
}
+59 -3
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>
<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>
<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="mx-auto max-w-4xl px-6 py-8 space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-zinc-100">Square Sync</h1>
<p className="mt-2 text-zinc-400">
Sync products, orders, and inventory between Route Commerce and Square.
</p>
</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>
</div>
<div>
<p className="text-zinc-500">Square Account</p>
<p className={`font-medium ${hasToken ? "text-green-400" : "text-slate-400"}`}>
{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>
{lastError ? (
<p className="font-medium text-red-400">Error {lastError}</p>
) : isEnabled ? (
<p className="font-medium text-green-400">Active</p>
) : hasToken ? (
<p className="font-medium text-yellow-600">Disabled</p>
) : (
<p className="font-medium text-slate-400">Not connected</p>
)}
</div>
{/* 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 className="mt-4 flex 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"
>
Manage Connection
</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"
<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>
<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="text-sm font-semibold text-[var(--admin-danger)]">Error</p>
) : isEnabled ? (
<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="text-sm font-semibold text-amber-600">Disabled</p>
) : (
<p className="text-sm font-semibold text-[var(--admin-text-muted)]">N/A</p>
)}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<Link
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"
>
{syncing ? "Syncing..." : "Sync All Now"}
</button>
)}
<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>
</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"
}`}
/>
</button>
<AdminToggle
checked={squareSyncEnabled}
onChange={(checked) => {
setSquareSyncEnabled(checked);
setDirty(true);
}}
/>
</div>
{squareSyncEnabled && (
<>
{/* Inventory mode */}
<div>
<p className="mb-2 font-medium text-zinc-100">Inventory Direction</p>
<div className="space-y-3">
<div>
<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>
<p className="mb-2 font-medium text-zinc-100">Manual Sync</p>
<div className="space-y-3">
<div>
<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
onClick={handleSaveSettings}
disabled={saving}
className="rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-bold text-white disabled:opacity-50"
>
{saving ? "Saving..." : "Save Settings"}
</button>
{saved && (
<span className="ml-3 text-sm text-green-600">Settings saved.</span>
{/* 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 || !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>
) : (
"Save Settings"
)}
</AdminButton>
{dirty && !saving && (
<span className="text-xs text-[var(--admin-text-muted)]">You have unsaved changes</span>
)}
</div>
</div>
</div>
)}
{/* 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 ? (
<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 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 ${
log.status === "success"
? "border-[var(--admin-success)]/30 bg-[var(--admin-success)]/5"
: log.status === "partial"
? "border-amber-200 bg-amber-50"
: "border-[var(--admin-danger)]/30 bg-[var(--admin-danger)]/5"
}`}
>
<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-[var(--admin-success)]/20 text-[var(--admin-success)]"
: log.status === "partial"
? "bg-amber-100 text-amber-700"
: "bg-[var(--admin-danger)]/20 text-[var(--admin-danger)]"
}`}>
{log.status}
</span>
<span className="text-sm font-medium text-[var(--admin-text-primary)]">{log.event_type}</span>
{log.direction && (
<span className="text-xs text-[var(--admin-text-muted)]">({log.direction})</span>
)}
</div>
{log.message && (
<p className="mt-1 text-xs text-[var(--admin-text-secondary)]">{log.message}</p>
)}
</div>
<span className="ml-3 text-xs text-[var(--admin-text-muted)] whitespace-nowrap">
{timeAgo(log.created_at)}
</span>
</div>
))}
</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>
{displayLogs.length === 0 ? (
<p className="text-sm text-slate-400 py-4 text-center">No sync activity yet.</p>
) : (
<div className="space-y-2">
{displayLogs.map((log) => (
<div
key={log.id}
className={`flex items-start justify-between rounded-xl border px-4 py-3 text-sm ${
log.status === "success"
? "border-green-200 bg-green-900/30"
: log.status === "partial"
? "border-yellow-200 bg-amber-900/30"
: "border-red-200 bg-red-900/30"
}`}
>
<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 ${
log.status === "success"
? "bg-green-900/40 text-green-400"
: log.status === "partial"
? "bg-yellow-100 text-yellow-700"
: "bg-red-900/40 text-red-400"
}`}>
{log.status}
</span>
<span className="font-medium text-zinc-300">{log.event_type}</span>
{log.direction && (
<span className="text-xs text-slate-400">({log.direction})</span>
)}
</div>
{log.message && (
<p className="mt-1 text-xs text-zinc-500">{log.message}</p>
)}
</div>
<span className="ml-3 text-xs text-slate-400 whitespace-nowrap">
{formatDateTime(log.created_at)}
</span>
</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>
);
}
+71 -9
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>
<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>
</main>
{/* 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>
);
}
+60 -11
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>
</div>
</main>
</main>
<span role="status" className="sr-only">Loading checkout...</span>
</div>
);
}
+21 -385
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>
);
}
+61 -13
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>
)}
<button
onClick={reset}
className="mt-6 rounded-xl bg-red-600 px-4 py-2 text-white"
<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"
>
Try again
</button>
</div>
<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="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>
</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>
</div>
</motion.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>
);
}
+23 -378
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);
const containerRef = useRef<HTMLDivElement>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [tilt, setTilt] = useState({ x: 0, y: 0 });
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,
},
};
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>
);
}
// 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;
export default function LoginPage() {
return <LoginClient />;
}
+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 />;
}
+117 -63
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>
<p className="text-slate-700 text-sm leading-relaxed">&ldquo;{item.quote}&rdquo;</p>
<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>
);
}
+61 -13
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>
)}
<button
onClick={reset}
className="mt-6 rounded-xl bg-red-600 px-4 py-2 text-white"
<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"
>
Try again
</button>
</div>
<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="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>
{/* 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>
+82 -14
View File
@@ -1,35 +1,103 @@
"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.
</p>
<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>
);
}
}
+95 -46
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" />
</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>}
</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"
>
View 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"
>
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="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>
{/* 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="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"
>
<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="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>
<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>
);
+550 -92
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.map(i => i.product.id === product.id ? { ...i, quantity: newQty, unitPrice: newUnitPrice } : i);
}
return [...prev, { product, quantity: q, unitPrice }];
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>
))
</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>
)}
</p>
</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>
{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,20 +822,44 @@ 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>
<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>
</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"}
</button>
<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="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>
</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>
</>
@@ -443,63 +869,95 @@ 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>
)}
{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"
>
Download 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"
>
{processingPayment === o.id ? "Loading..." : "Pay Now"}
</button>
)}
<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 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="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="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>
))}
+195 -84
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,14 +106,17 @@ 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">
<p className="text-zinc-500 text-sm">Loading...</p>
<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"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Contact Name</label>
<input
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"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Email *</label>
<input
type="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"
placeholder="order@company.com"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Phone</label>
<input
type="tel"
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"
placeholder="(555) 555-5555"
/>
</div>
<div>
<label className="block text-sm font-semibold text-zinc-400 mb-1.5">Brand</label>
<select
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"
{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>
<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"
>
<option value={TUXEDO_BRAND_ID}>Tuxedo Corn Colorado Sweet Corn</option>
<option value={IRD_BRAND_ID}>Indian River Direct Florida Citrus</option>
</select>
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>
<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"
>
{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"}
</button>
</form>
) : (
<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>
<div className="mt-4 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">
Sign in
</a>
</p>
</div>
<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"
/>
</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 })); setFieldErrors(f => ({ ...f, email: "" })); }}
required
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"
/>
</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 })); 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"
/>
</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 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>
</FormField>
<button
type="submit"
disabled={submitting}
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 ? (
<>
<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...
</>
) : (
<>
<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>
)}
{!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?{" "}
<Link href="/wholesale/login" className="text-emerald-400 hover:underline font-medium hover:text-emerald-300">
Sign in
</Link>
</p>
</div>
)}
</div>
</div>