diff --git a/src/app/LandingPageClient.tsx b/src/app/LandingPageClient.tsx new file mode 100644 index 0000000..9f6de08 --- /dev/null +++ b/src/app/LandingPageClient.tsx @@ -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 ( + +
+ +
+ +
+ +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/communications/abandoned-carts/page.tsx b/src/app/admin/communications/abandoned-carts/page.tsx index 2c364f4..889417a 100644 --- a/src/app/admin/communications/abandoned-carts/page.tsx +++ b/src/app/admin/communications/abandoned-carts/page.tsx @@ -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 ( -
+
{/* Header */}
@@ -24,12 +30,21 @@ export default async function AbandonedCartsPage() { / Abandoned Cart Recovery -
-
-

Abandoned Cart Recovery

-

{brandName} — 3-email sequence (1h, 24h, 48h)

+
+
+
+ + + + + +
+
+

Abandoned Cart Recovery

+

{brandName} — 3-email sequence (1h, 24h, 48h)

+
-
+
Active @@ -50,4 +65,4 @@ export default async function AbandonedCartsPage() {
); -} +} \ No newline at end of file diff --git a/src/app/admin/communications/analytics/page.tsx b/src/app/admin/communications/analytics/page.tsx index af8aba7..451eab6 100644 --- a/src/app/admin/communications/analytics/page.tsx +++ b/src/app/admin/communications/analytics/page.tsx @@ -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"); } \ No newline at end of file diff --git a/src/app/admin/communications/campaigns/[id]/page.tsx b/src/app/admin/communications/campaigns/[id]/page.tsx index 566960d..6c6ae7f 100644 --- a/src/app/admin/communications/campaigns/[id]/page.tsx +++ b/src/app/admin/communications/campaigns/[id]/page.tsx @@ -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"); diff --git a/src/app/admin/communications/compose/page.tsx b/src/app/admin/communications/compose/page.tsx index 1c0ad62..66fc3b9 100644 --- a/src/app/admin/communications/compose/page.tsx +++ b/src/app/admin/communications/compose/page.tsx @@ -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) { diff --git a/src/app/admin/communications/contacts/page.tsx b/src/app/admin/communications/contacts/page.tsx index 9ef6b8a..b459f86 100644 --- a/src/app/admin/communications/contacts/page.tsx +++ b/src/app/admin/communications/contacts/page.tsx @@ -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"); } \ No newline at end of file diff --git a/src/app/admin/communications/loading.tsx b/src/app/admin/communications/loading.tsx new file mode 100644 index 0000000..5d44232 --- /dev/null +++ b/src/app/admin/communications/loading.tsx @@ -0,0 +1,5 @@ +import CommunicationsLoading from "@/components/admin/CommunicationsLoading"; + +export default function Loading() { + return ; +} \ No newline at end of file diff --git a/src/app/admin/communications/logs/page.tsx b/src/app/admin/communications/logs/page.tsx index 6402e0b..44fde44 100644 --- a/src/app/admin/communications/logs/page.tsx +++ b/src/app/admin/communications/logs/page.tsx @@ -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"); } \ No newline at end of file diff --git a/src/app/admin/communications/page.tsx b/src/app/admin/communications/page.tsx index 9160d50..cefa5e2 100644 --- a/src/app/admin/communications/page.tsx +++ b/src/app/admin/communications/page.tsx @@ -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) { diff --git a/src/app/admin/communications/segments/page.tsx b/src/app/admin/communications/segments/page.tsx index e44ce72..1a435cf 100644 --- a/src/app/admin/communications/segments/page.tsx +++ b/src/app/admin/communications/segments/page.tsx @@ -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"); } \ No newline at end of file diff --git a/src/app/admin/communications/settings/page.tsx b/src/app/admin/communications/settings/page.tsx index d062e1e..7ed9011 100644 --- a/src/app/admin/communications/settings/page.tsx +++ b/src/app/admin/communications/settings/page.tsx @@ -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 */} @@ -27,20 +33,20 @@ export default async function SettingsPage() { {/* Header */}
-
- - +
+ +
-

Harvest Reach Settings

-

Configure email and SMS integration

+

Harvest Reach Settings

+

Configure email and SMS integration

{/* Settings Form */} -
+
diff --git a/src/app/admin/communications/templates/[id]/page.tsx b/src/app/admin/communications/templates/[id]/page.tsx index bef597f..3f25890 100644 --- a/src/app/admin/communications/templates/[id]/page.tsx +++ b/src/app/admin/communications/templates/[id]/page.tsx @@ -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"); diff --git a/src/app/admin/communications/templates/page.tsx b/src/app/admin/communications/templates/page.tsx index 4ad5d76..5dbf136 100644 --- a/src/app/admin/communications/templates/page.tsx +++ b/src/app/admin/communications/templates/page.tsx @@ -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"); } \ No newline at end of file diff --git a/src/app/admin/communications/welcome-sequence/page.tsx b/src/app/admin/communications/welcome-sequence/page.tsx index 23adabb..1e1da62 100644 --- a/src/app/admin/communications/welcome-sequence/page.tsx +++ b/src/app/admin/communications/welcome-sequence/page.tsx @@ -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 ( -
+
{/* Header */}
@@ -23,10 +29,20 @@ export default async function WelcomeSequencePage() { / Welcome Sequence -
-
-

Welcome Email Sequence

-

{brandName} — 4-email onboarding series

+
+
+
+ + + + + + +
+
+

Welcome Email Sequence

+

{brandName} — 4-email onboarding series

+
Auto-enrolls new subscribers (email opt-in) @@ -38,4 +54,4 @@ export default async function WelcomeSequencePage() {
); -} +} \ No newline at end of file diff --git a/src/app/admin/error.tsx b/src/app/admin/error.tsx index c3c819f..bf64edd 100644 --- a/src/app/admin/error.tsx +++ b/src/app/admin/error.tsx @@ -10,37 +10,75 @@ export default function AdminErrorPage({ reset: () => void; }) { return ( -
- {/* Background */} -
-
-
+
+ {/* Background pattern */} +
+
-
-
-
- +
+
+
+
-

Admin Error

-

+

+ Admin Error +

+

{error.message || "An unexpected error occurred in the admin panel."}

{error.digest && ( -

Digest: {error.digest}

+

+ ID: {error.digest} +

)} -
+
e.currentTarget.style.backgroundColor = "var(--admin-accent-hover)"} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "var(--admin-accent)"} > Back to Admin @@ -49,4 +87,4 @@ export default function AdminErrorPage({
); -} +} \ No newline at end of file diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 7f5ce5c..95b984d 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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 ( + + {children} + + + ); +} 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 ( - <> +
- +
- +
); } + // Not authenticated + if (!adminUser) { + return ( + + +
+ +
+
+ ); + } + + // Must change password if (adminUser.must_change_password) { redirect("/change-password"); } return ( - <> +
{children}
- +
); } \ No newline at end of file diff --git a/src/app/admin/loading.tsx b/src/app/admin/loading.tsx index af8a87f..5a4ffb2 100644 --- a/src/app/admin/loading.tsx +++ b/src/app/admin/loading.tsx @@ -1,9 +1,9 @@ -import LoadingSkeleton, { SkeletonTable } from "@/components/shared/LoadingSkeleton"; +import LoadingSkeleton, { SkeletonCard, SkeletonTable } from "@/components/shared/LoadingSkeleton"; export default function AdminLoading() { return ( -
-
+
+
{/* Header skeleton */}
@@ -13,11 +13,30 @@ export default function AdminLoading() {
- {/* Cards grid skeleton */} + {/* Stats cards skeleton */} +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ + {/* Content cards skeleton */}
- - - + + +
{/* Table skeleton */} diff --git a/src/app/admin/settings/billing/AddAddonButton.tsx b/src/app/admin/settings/billing/AddAddonButton.tsx index fef2646..e826e65 100644 --- a/src/app/admin/settings/billing/AddAddonButton.tsx +++ b/src/app/admin/settings/billing/AddAddonButton.tsx @@ -10,25 +10,44 @@ type Props = { export default function AddAddonButton({ brandId, addonKey }: Props) { const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( - +
+ + {error && ( + {error} + )} +
); } \ No newline at end of file diff --git a/src/app/admin/settings/billing/BillingClientPage.tsx b/src/app/admin/settings/billing/BillingClientPage.tsx index 1678968..24c744f 100644 --- a/src/app/admin/settings/billing/BillingClientPage.tsx +++ b/src/app/admin/settings/billing/BillingClientPage.tsx @@ -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 ( -
+
{/* ── 1. Header summary bar ───────────────────────────────────────────── */} -
-
+
+
-
+
{currentPlan.label} {subscriptionStatus && ( - + {subscriptionStatus.replace("_", " ")} )} - - {currentPeriodEnd ? ( - <>Next billing: {new Date(currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} - ) : ( - "No active subscription" - )} -
-

- ${totalMonthly}/mo - - {billingCycle === "annual" ? "Annual (saves 25%)" : ""} - +

+

+ ${totalMonthly} + /mo +

+ {billingCycle === "annual" && ( + + + + + Save 25% + + )} +
+

+ {currentPeriodEnd ? ( + <>Next billing: {new Date(currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} + ) : ( + "No active subscription" + )}

@@ -108,48 +127,57 @@ export default function BillingClientPage({ {/* ── 2. Current plan + Add-ons (two-column) ───────────────────────────── */}
{/* Current plan card */} -
+

Your Plan

{currentPlan.label}
-

+

{billingCycle === "annual" ? `$${currentPlan.annualPrice}/yr` : `$${currentPlan.monthlyPrice}/mo`}

-

+

{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"}

-
    - {(currentPlan.features as readonly string[]).slice(0, 5).map((f, i) => ( -
  • - {f} +
      + {(currentPlan.features as readonly string[]).slice(0, 6).map((f, i) => ( +
    • + + + + {f}
    • ))} - {(currentPlan.features as readonly string[]).length > 5 && ( -
    • + {(currentPlan.features as readonly string[]).length - 5} more
    • + {(currentPlan.features as readonly string[]).length > 6 && ( +
    • + {(currentPlan.features as readonly string[]).length - 6} more features
    • )}
    {isPlatformAdmin && ( )}
{/* Add-ons */} -
-
-

Add-ons

- +{addonsMonthlyTotal}/mo +
+
+
+

Add-ons

+

Extend your plan with optional features

+
+ +${addonsMonthlyTotal}/mo
-
+
{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 ( -
-
- {addon.icon} +
+
+ {addon.icon}

{addon.label}

{addon.description}

@@ -168,8 +196,11 @@ export default function BillingClientPage({
+${displayPrice}/mo {isEnabled ? ( -
- Active +
+ + + Active + {isPlatformAdmin && ( window.location.reload()} /> )} @@ -186,32 +217,38 @@ export default function BillingClientPage({
{/* ── 3. Compare plans (collapsible) ───────────────────────────────────── */} - {compareOpen && ( + {compareOpen && isPlatformAdmin && (
-
-

Plan Comparison

-
- +
- {(["starter", "farm", "enterprise"] as const).map((tier) => { const plan = PLAN_TIERS[tier]; const price = billingCycle === "annual" ? plan.annualPrice : plan.monthlyPrice; return ( - {[ ["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; + const t = tiers as Record; return ( - + {(["starter", "farm", "enterprise"] as const).map((tier) => ( - ))} ); })} - - +
+ Feature - + + {plan.label} -
- +
+ {price !== null ? `$${price}` : "$399"} - /{billingCycle === "annual" ? "yr" : "mo"} + /{billingCycle === "annual" ? "yr" : "mo"}
{tier === "enterprise" && billingCycle === "annual" && (

or $399/mo

@@ -224,10 +261,10 @@ export default function BillingClientPage({
{feature as string}{feature as string} - {t[tier] - ? - : } + + {typeof t[tier] === "boolean" ? ( + t[tier] ? ( + + + + ) : ( + + ) + ) : ( + {t[tier]} + )}
+
{(["starter", "farm", "enterprise"] as const).map((tier) => { const isCurrent = tier === planTier; return ( - + {isCurrent ? ( - Current + + + + + Current + ) : tier === "enterprise" ? ( - + + + + Contact ) : ( @@ -281,38 +337,49 @@ export default function BillingClientPage({ {/* ── 4. Payment + Invoices (two-column) ──────────────────────────────── */}
{/* Payment method */} -
+

Payment Method

{hasStripeCustomer ? ( -
-
-
- - +
+
+
+ +

Visa ending in 4242

Expires 12/26

- Active + + + Active +
- -

+ +

Payment powered by Stripe · Invoiced by Cielo Hermosa, LLC

) : ( -
-
-

- No payment method on file. Add a card to activate your subscription. -

+
+
+
+ + + +
+

+ No payment method on file +

+

Add a card to activate your subscription.

+
+

Set up in{" "} - Payments settings + Payments settings {" "}to enable billing.

@@ -320,17 +387,17 @@ export default function BillingClientPage({
{/* Invoice history */} -
+

Invoice History

- +
- - - - - + + + + + @@ -340,22 +407,27 @@ export default function BillingClientPage({ { id: "INV-2026-002", date: "Mar 1, 2026", amount: totalMonthly, status: "paid" }, ].map((inv) => ( - - - - + + + - ))}
InvoiceDateAmountStatusInvoiceDateAmountStatus
{inv.id}{inv.date}${inv.amount} - {inv.status} + {inv.id}{inv.date}${inv.amount} + + + + + {inv.status} + - + +
-

- Invoiced by Cielo Hermosa, LLC · billing@cielohermosa.com +

+ Invoiced by Cielo Hermosa, LLC · billing@cielohermosa.com

diff --git a/src/app/admin/settings/billing/PlanUpgradeButton.tsx b/src/app/admin/settings/billing/PlanUpgradeButton.tsx index 2395c26..1031d9f 100644 --- a/src/app/admin/settings/billing/PlanUpgradeButton.tsx +++ b/src/app/admin/settings/billing/PlanUpgradeButton.tsx @@ -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(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 ( - + + + + Current Plan ); @@ -42,20 +47,40 @@ export default function PlanUpgradeButton({ brandId, targetTier, currentTier }: return ( + + + Downgrade ); } return ( - +
+ + {error && ( + {error} + )} +
); } \ No newline at end of file diff --git a/src/app/admin/settings/integrations/IntegrationsClientPage.tsx b/src/app/admin/settings/integrations/IntegrationsClientPage.tsx new file mode 100644 index 0000000..d03dde4 --- /dev/null +++ b/src/app/admin/settings/integrations/IntegrationsClientPage.tsx @@ -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; + brandId: string; +}) { + const [credentials, setCredentials] = useState>( + initialCredentials ?? {} + ); + const [showSecrets, setShowSecrets] = useState>({}); + const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(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 ( +
+
+
+
+ {integration.icon} +
+
+

{integration.name}

+

{integration.description}

+
+
+ {testResult?.ok && ( + + + Connected + + )} +
+ + {/* Messages */} + {error && ( +
+ + + + {error} +
+ )} + {saved && ( +
+ + + + Settings saved successfully! +
+ )} + {testResult && ( +
+ {testResult.ok ? ( + + + + ) : ( + + + + )} + {testResult.message} +
+ )} + + {/* Credentials form */} +
+ {integration.credentials.map((field) => ( + +
+ updateCredential(field.key, e.target.value)} + placeholder={field.placeholder} + /> + {field.isSecret && ( + + )} +
+
+ ))} +
+ + {/* Action buttons */} +
+ + {testResult?.ok ? "Re-test" : "Test Connection"} + + + {saving ? ( + + + Saving... + + ) : ( + <> + + + + Save + + )} + + {dirty && !saving && ( + Unsaved changes + )} +
+
+ ); +} + +export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmin }: Props) { + const [selectedBrandId, setSelectedBrandId] = useState(brandId); + const [initialCredentials, setInitialCredentials] = useState>>({}); + 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 ( +
+ {/* Brand selector for platform admins */} + {isPlatformAdmin && brands.length > 0 && ( +
+ + setSelectedBrandId(e.target.value)} + options={brands.map((b) => ({ value: b.id, label: b.name }))} + /> + +
+ )} + + {/* AI Provider Section */} +
+
+
+
🤖
+
+

AI Provider

+

Configure AI models for intelligent features

+
+
+ + Configure AI + + + + +
+
+ +
+
+ + {/* Payment & Email integrations grid */} +
+

Communication & Payments

+
+ {loading ? ( +
+
+
+ Loading integrations... +
+
+ ) : ( + INTEGRATIONS.filter(i => i.id !== "openai").map((integration) => ( + + )) + )} +
+
+ + {/* Help text */} +
+
+ + + +
+

Need help?

+

+ Contact us at support@cielohermosa.com for assistance setting up integrations. +

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/settings/integrations/page.tsx b/src/app/admin/settings/integrations/page.tsx index 32dffac..31066c4 100644 --- a/src/app/admin/settings/integrations/page.tsx +++ b/src/app/admin/settings/integrations/page.tsx @@ -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 ; + + 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 ( +
+
+ {/* Breadcrumb */} + + + {/* Page Header */} +
+
+
+ + + +
+
+

Integrations

+

+ Connect AI, email, SMS, and payment providers to power your operations. +

+
+
+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/admin/settings/square-sync/SquareSyncSettingsClient.tsx b/src/app/admin/settings/square-sync/SquareSyncSettingsClient.tsx index 89fe268..e8b06b2 100644 --- a/src/app/admin/settings/square-sync/SquareSyncSettingsClient.tsx +++ b/src/app/admin/settings/square-sync/SquareSyncSettingsClient.tsx @@ -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(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(null); const [displayLogs, setDisplayLogs] = useState(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 ( -
- {/* Top nav */} -
-
- Admin - / - Settings - / - Square Sync +
+ {/* Page Header */} +
+
+
+
+
+ + + +
+

Square Sync

+
+

+ Sync products, orders, and inventory between Route Commerce and Square. +

+
+ {hasToken && ( +
+ handleSyncNow("all")} + disabled={syncing} + > + {syncing && syncingType === "all" ? ( + + + Syncing... + + ) : ( + <> + + + + Sync All Now + + )} + +
+ )}
-
- {/* Header */} -
-

Square Sync

-

- Sync products, orders, and inventory between Route Commerce and Square. -

-
- +
+ {/* Status messages */} {error && ( -
- {error} +
+ + + +
+

Error

+

{error}

+
+
+ )} + {saved && ( +
+ + + +

Settings saved successfully!

)} {syncMsg && ( -
- {syncMsg.text} + {syncMsg.kind === "success" ? ( + + + + ) : ( + + + + )} +

{syncMsg.text}

)} - {/* Connection status */} -
-

Connection Status

-
-
-

Provider

-

{settings?.provider ?? "Not set"}

-
-
-

Square Account

-

- {hasToken ? "Connected" : "Not connected"} -

-
-
-

Last Sync

-

{lastSyncAt}

-
-
-

Sync Status

- {lastError ? ( -

Error — {lastError}

- ) : isEnabled ? ( -

Active

- ) : hasToken ? ( -

Disabled

- ) : ( -

Not connected

- )} -
+ {/* Connection Status Card */} +
+
+

Connection Status

-
- - Manage Connection - - {hasToken && ( - - )} + + + + Back to Settings + +
- {/* Sync settings */} + {/* Sync Settings Card */} {hasToken && ( -
-

Sync Settings

- -
+
+
+

Sync Settings

+
+
{/* Enable toggle */} -
-
-

Enable Square Sync

-

Automatically sync products and orders with Square.

+
+
+

Enable Square Sync

+

+ Automatically sync products and orders with Square. +

- + { + setSquareSyncEnabled(checked); + setDirty(true); + }} + />
{squareSyncEnabled && ( <> {/* Inventory mode */} -
-

Inventory Direction

+
+
+

Inventory Direction

+

+ Choose how inventory data flows between Route Commerce and Square. +

+
{[ { 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) => ( ))}
{/* Manual sync buttons */} -
-

Manual Sync

+
+
+

Manual Sync

+

+ Manually trigger a sync for products or orders. +

+
- - + {syncing && syncingType === "orders" ? ( + + + Syncing... + + ) : ( + <> + + + + Sync Orders + + )} +
)} - - {saved && ( - Settings saved. + {/* Save button */} +
+ + {saving ? ( + + + Saving... + + ) : ( + "Save Settings" + )} + + {dirty && !saving && ( + You have unsaved changes + )} +
+
+
+ )} + + {/* Not connected state */} + {!hasToken && ( +
+
+ + + +
+

Square Not Connected

+

+ Connect your Square account to enable product and inventory sync. +

+ + Configure in Payment Settings + +
+ )} + + {/* Sync Log Card */} + {hasToken && ( +
+
+

Sync Log

+ + Last 50 entries + +
+
+ {displayLogs.length === 0 ? ( +
+
+ + + +
+

No sync activity yet

+

Sync logs will appear here after your first sync

+
+ ) : ( +
+ {displayLogs.map((log) => ( +
+
+
+ + {log.status} + + {log.event_type} + {log.direction && ( + ({log.direction}) + )} +
+ {log.message && ( +

{log.message}

+ )} +
+ + {timeAgo(log.created_at)} + +
+ ))} +
)}
)} - {/* Sync log */} -
-

Sync Log

- {displayLogs.length === 0 ? ( -

No sync activity yet.

- ) : ( -
- {displayLogs.map((log) => ( -
-
-
- - {log.status} - - {log.event_type} - {log.direction && ( - ({log.direction}) - )} -
- {log.message && ( -

{log.message}

- )} -
- - {formatDateTime(log.created_at)} - -
- ))} -
- )} -
- - {/* Quick links */} -
-

Quick Links

-
+ {/* Quick Links Card */} +
+
+

Quick Links

+
+
+ + + Square Orders + + + Products with Square + + + Payment Settings
-
+
); } \ No newline at end of file diff --git a/src/app/admin/water-log/page.tsx b/src/app/admin/water-log/page.tsx index a35a7ef..3353c2b 100644 --- a/src/app/admin/water-log/page.tsx +++ b/src/app/admin/water-log/page.tsx @@ -8,6 +8,51 @@ const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; export const dynamic = "force-dynamic"; +// ── Skeleton Loading ────────────────────────────────────────────────────────── +function WaterLogLoadingSkeleton() { + return ( +
+ {/* Navigation skeleton */} +
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ + {/* Summary skeleton */} +
+
+
+
+
+
+ {[1, 2, 3].map(i => ( +
+
+
+
+ ))} +
+
+ + {/* Table skeletons */} +
+
+
+ {[1, 2, 3].map(i => ( +
+
+
+
+
+
+ ))} +
+
+
+ ); +} + export default async function AdminWaterLogPage() { const adminUser = await getAdminUser(); diff --git a/src/app/admin/wholesale/WholesaleClient.tsx b/src/app/admin/wholesale/WholesaleClient.tsx index 1f32eb6..19a510d 100644 --- a/src/app/admin/wholesale/WholesaleClient.tsx +++ b/src/app/admin/wholesale/WholesaleClient.tsx @@ -95,8 +95,8 @@ export default function WholesaleClient({ brandId }: { brandId: string }) { if (loading) { return ( -
-

Loading wholesale data...

+
+
); } @@ -243,7 +243,15 @@ function DashboardTab({ stats, recentOrders, brandId, onMsg, webhookActivity }:

Recent Orders

{recentOrders.length === 0 ? ( -

No wholesale orders yet.

+
+
+ + + +
+

No wholesale orders yet

+

Wholesale orders placed by your customers will appear here.

+
) : (
@@ -702,7 +710,19 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [], {customers.length === 0 ? ( - + + + ) : customers.map(c => (
No customers yet.
+
+
+ + + +
+

No customers yet

+

Wholesale customers will appear here once registered.

+
+
@@ -885,7 +905,7 @@ function CustomerPricingPanel({ customer, products, onClose, onMsg }: { {loading ? (

Loading...

) : products.length === 0 ? ( -

No products available.

+

No products available. Add wholesale products to see them here.

) : ( @@ -1218,7 +1238,19 @@ function ProductsTab({ products, brandId, onMsg, onRefresh }: { {products.length === 0 ? ( - + + + ) : products.map(p => ( @@ -2281,6 +2313,54 @@ function AddRecipientForm({ onAdd }: { onAdd: (email: string, name: string) => v // ── Shared Components ───────────────────────────────────────────────────────── +// ── Loading Skeleton ────────────────────────────────────────────────────────── +function WholesaleLoadingSkeleton() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Tab bar skeleton */} +
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ + {/* Stat cards skeleton */} +
+ {[1, 2, 3, 4, 5, 6].map(i => ( +
+
+
+
+ ))} +
+ + {/* Recent orders skeleton */} +
+
+
+ {[1, 2, 3, 4].map(i => ( +
+
+
+
+
+
+ ))} +
+
+
+ ); +} + function StatusBadge({ status }: { status: string }) { const map: Record = { pending: "warning", diff --git a/src/app/cart/CartClient.tsx b/src/app/cart/CartClient.tsx new file mode 100644 index 0000000..ab51e2a --- /dev/null +++ b/src/app/cart/CartClient.tsx @@ -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([]); + const [loadingStops, setLoadingStops] = useState(false); + const [showStopPicker, setShowStopPicker] = useState(false); + const [incompatibleItems, setIncompatibleItems] = useState([]); + 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 ( +
+ {/* Background */} +
No products yet.
+
+
+ + + +
+

No wholesale products yet

+

Add products to your wholesale catalog to get started.

+
+
{p.name}