feat: comprehensive frontend polish - UI/UX improvements across all pages
Public Pages: - Landing page with server/client split and metadata export - Cart page with ARIA accessibility and loading states - Checkout page with form accessibility and proper design system - Contact page with SEO metadata and improved accessibility - Pricing page with enhanced FAQ accessibility - Login page with Suspense boundaries and secure patterns Brand Storefronts: - Premium loading skeletons for Tuxedo and Indian River Direct - Branded error boundaries with animations - Loading.tsx for about, contact, FAQ, stops pages - Error.tsx for all storefront subpages Admin Dashboard: - AdminSidebar: ARIA labels, keyboard navigation, mobile improvements - DashboardClient: Stats cards, quick actions, usage progress - Admin layout: Toast provider integration Admin Orders/Products/Stops: - Toast notification system with auto-dismiss - Skeleton loading components (Table, Card, Stats, Form) - Bulk actions (mark picked up, publish stops) - Form validation with error styling Admin Communications: - AnalyticsDashboard: sparklines, stat cards, engagement badges - CampaignComposerPage: step wizard, template selection, email preview - SegmentBuilderPage: empty state, active segment handling - ContactListPanel: loading skeletons, professional empty states - MessageLogPanel: stats cards, engagement indicators - HarvestReachNav: branded tab navigation - All pages: metadata exports and loading.tsx Admin Settings: - SquareSyncSettingsClient: design system colors, save/cancel UX - ShippingSettingsForm: validation, dirty state tracking - Integrations page: proper layout with AI and communications sections - Billing: improved plan comparison, add-on cards - PaymentSettings: toggle components, validation Wholesale Portal: - Portal: loading skeletons, quantity stepper, search/filter - Login/Register: FormField validation, success states - Success/Cancel pages: animated checkmarks - Employee portal: skeletons, empty states, mobile responsive Water Log: - FieldClient: loading step, progress indicator, spinner - AdminClient: loading skeletons - Admin pages: loading.tsx files New Components: - Toast.tsx/ToastContainer.tsx: comprehensive notification system - Skeleton.tsx: shimmer loading components - AdminToggle.tsx: consistent toggle/switch - CommunicationsLoading.tsx: loading skeleton for comms - ToastExport.ts: exports CSS Improvements: - Shimmer animation keyframes - Toast slide-in animation Accessibility: - ARIA labels throughout - Keyboard navigation - Focus states - Semantic HTML - Screen reader support
This commit is contained in:
@@ -1,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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import CommunicationsLoading from "@/components/admin/CommunicationsLoading";
|
||||
|
||||
export default function Loading() {
|
||||
return <CommunicationsLoading activeTab="campaigns" />;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user