Initial commit - Route Commerce platform
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import CommandCenterDashboard from "@/components/admin/CommandCenterDashboard";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function CommandCenterPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (adminUser.role !== "platform_admin") return <AdminAccessDenied message="Platform admin access required." />;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black px-4 py-8">
|
||||
<div className="mx-auto max-w-[1600px]">
|
||||
<CommandCenterDashboard />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getBrandSettings } from "@/actions/brand-settings";
|
||||
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 default async function AbandonedCartsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const brandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID;
|
||||
|
||||
const settingsResult = await getBrandSettings(brandId);
|
||||
const brandName = settingsResult?.success ? (settingsResult.settings?.brand_name ?? "Farm") : "Farm";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<a href="/admin/communications" className="hover:text-stone-600 transition-colors">Communications</a>
|
||||
<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>
|
||||
<div className="flex items-center gap-3 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
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-600" />
|
||||
Recovered
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-stone-400" />
|
||||
Expired
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AbandonedCartDashboard brandId={brandId} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function AnalyticsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const analytics = await getCampaignAnalytics(effectiveBrandId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<CommunicationsPage
|
||||
campaigns={[]}
|
||||
templates={[]}
|
||||
activeTab="analytics"
|
||||
brandId={effectiveBrandId}
|
||||
initialAnalytics={analytics}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns, getCampaignById } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function CampaignEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const { id } = await params;
|
||||
const isNew = id === "new";
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult] = await Promise.all([
|
||||
getCommunicationCampaigns(effectiveBrandId),
|
||||
getCommunicationTemplates(effectiveBrandId),
|
||||
]);
|
||||
|
||||
const campaign = isNew ? undefined : id ? await getCampaignById(id) : undefined;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="campaigns"
|
||||
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
|
||||
editCampaign={campaign}
|
||||
editMode={isNew ? "new" : "edit"}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getHarvestReachCampaigns } from "@/actions/harvest-reach/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function ComposePage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, segmentsResult] = await Promise.all([
|
||||
getHarvestReachCampaigns(effectiveBrandId),
|
||||
getCommunicationTemplates(effectiveBrandId),
|
||||
getHarvestReachSegments(effectiveBrandId),
|
||||
]);
|
||||
|
||||
const campaigns = campaignsResult.success ? campaignsResult.campaigns : [];
|
||||
const templates = templatesResult.success ? templatesResult.templates : [];
|
||||
const segments = segmentsResult.success ? segmentsResult.segments : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<CommunicationsPage
|
||||
campaigns={campaigns}
|
||||
templates={templates}
|
||||
activeTab="compose"
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getContacts } from "@/actions/communications/contacts";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function ContactsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, contactsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(brandId),
|
||||
getCommunicationTemplates(brandId),
|
||||
getContacts({ brandId, limit: 50 }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, contacts, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="contacts"
|
||||
brandId={brandId}
|
||||
initialContacts={contactsResult.success ? contactsResult.contacts : []}
|
||||
initialContactTotal={contactsResult.success ? contactsResult.total : 0}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getMessageLogs } from "@/actions/communications/send";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function LogsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, logsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(brandId),
|
||||
getCommunicationTemplates(brandId),
|
||||
getMessageLogs({ brandId, limit: 200 }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="logs"
|
||||
brandId={brandId}
|
||||
initialLogs={logsResult.success ? logsResult.logs : []}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
|
||||
import { getCampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function CommunicationsRootPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, segmentsResult, analyticsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(effectiveBrandId),
|
||||
getCommunicationTemplates(effectiveBrandId),
|
||||
getHarvestReachSegments(effectiveBrandId),
|
||||
getCampaignAnalytics(effectiveBrandId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Harvest Reach</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="campaigns"
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
|
||||
initialAnalytics={analyticsResult}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getHarvestReachSegments } from "@/actions/harvest-reach/segments";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function SegmentsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const segmentsResult = await getHarvestReachSegments(effectiveBrandId);
|
||||
const segments = segmentsResult.success ? segmentsResult.segments : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<CommunicationsPage
|
||||
campaigns={[]}
|
||||
templates={[]}
|
||||
activeTab="segments"
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import { getCommunicationSettings } from "@/actions/communications/settings";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [campaignsResult, templatesResult, settingsResult] = await Promise.all([
|
||||
getCommunicationCampaigns(brandId),
|
||||
getCommunicationTemplates(brandId),
|
||||
getCommunicationSettings(brandId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="settings"
|
||||
brandId={brandId}
|
||||
initialSettings={settingsResult}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationTemplates, getTemplateById } from "@/actions/communications/templates";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function TemplateEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const { id } = await params;
|
||||
const isNew = id === "new";
|
||||
|
||||
const templatesResult = await getCommunicationTemplates();
|
||||
const template = isNew ? undefined : id ? await getTemplateById(id) : undefined;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={[]}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="templates"
|
||||
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
|
||||
editTemplate={template}
|
||||
editMode={isNew ? "new" : "edit"}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getCommunicationCampaigns } from "@/actions/communications/campaigns";
|
||||
import { getCommunicationTemplates } from "@/actions/communications/templates";
|
||||
import CommunicationsPage from "@/components/admin/CommunicationsPage";
|
||||
|
||||
export default async function TemplatesPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/admin");
|
||||
if (!adminUser.can_manage_messages) redirect("/admin/pickup");
|
||||
|
||||
const [campaignsResult, templatesResult] = await Promise.all([
|
||||
getCommunicationCampaigns(adminUser.brand_id ?? undefined),
|
||||
getCommunicationTemplates(adminUser.brand_id ?? undefined),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Harvest Reach</h1>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Manage email campaigns, templates, and message history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommunicationsPage
|
||||
campaigns={campaignsResult.success ? campaignsResult.campaigns : []}
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
activeTab="templates"
|
||||
brandId={adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de"}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 default async function WelcomeSequencePage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const brandId = adminUser?.brand_id ?? TUXEDO_BRAND_ID;
|
||||
|
||||
const settingsResult = await getBrandSettings(brandId);
|
||||
const brandName = settingsResult?.success ? (settingsResult.settings?.brand_name ?? "Farm") : "Farm";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-4">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<a href="/admin/communications" className="hover:text-stone-600 transition-colors">Communications</a>
|
||||
<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>
|
||||
<div className="text-xs text-stone-400">
|
||||
Auto-enrolls new subscribers (email opt-in)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WelcomeSequenceDashboard brandId={brandId} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export default async function DebugAuthPage() {
|
||||
const cookieStore = await cookies();
|
||||
const allCookies = cookieStore.getAll();
|
||||
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
|
||||
const rcUid = cookieStore.get("rc_uid")?.value;
|
||||
const rcAccessToken = cookieStore.get("rc_access_token")?.value;
|
||||
|
||||
let adminUsersStatus = "not_tried";
|
||||
let adminUsersResult: string | null = null;
|
||||
|
||||
if (rcAuthUid) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (supabaseUrl && serviceKey) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${rcAuthUid}&limit=1`,
|
||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
||||
);
|
||||
adminUsersStatus = String(res.status);
|
||||
const data = await res.json().catch(() => null);
|
||||
adminUsersResult = JSON.stringify(data);
|
||||
} catch (e) {
|
||||
adminUsersStatus = "error: " + (e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} else {
|
||||
adminUsersStatus = "missing_env_vars";
|
||||
}
|
||||
} else {
|
||||
adminUsersStatus = "no_rc_auth_uid_cookie";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
||||
<h1 className="text-2xl font-bold text-emerald-400 mb-6">Auth Debug</h1>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Cookies ({allCookies.length})</h2>
|
||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Key Cookies</h2>
|
||||
<p>rc_auth_uid: <span className={rcAuthUid ? "text-emerald-400" : "text-red-400"}>{rcAuthUid ? `${rcAuthUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
||||
<p>rc_uid: <span className={rcUid ? "text-emerald-400" : "text-red-400"}>{rcUid ? `${rcUid.slice(0, 20)}...` : "NOT SET"}</span></p>
|
||||
<p>rc_access_token: <span className={rcAccessToken ? "text-yellow-400" : "text-red-400"}>{rcAccessToken ? "PRESENT" : "NOT SET"}</span></p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Admin Users Lookup</h2>
|
||||
<p>Status: <span className="text-lg font-mono">{adminUsersStatus}</span></p>
|
||||
<p>Result: <pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto mt-2">{adminUsersResult || "(none)"}</pre></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
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>
|
||||
|
||||
<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}>
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Back to Admin
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { analyzeImport, executeImport, type ImportAnalysis, type ImportEntityType } from "@/actions/ai-import";
|
||||
|
||||
type Step = "upload" | "analysis" | "preview" | "import";
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
products: "Products",
|
||||
orders: "Orders",
|
||||
contacts: "Contacts",
|
||||
stops: "Stops",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
const ALL_FIELDS = ["ignore", "product_name", "price", "description", "product_type", "active", "image_url",
|
||||
"customer_name", "customer_email", "customer_phone", "stop_id", "quantity", "fulfillment", "product_id",
|
||||
"first_name", "last_name", "full_name", "tags", "email_opt_in", "sms_opt_in", "external_id",
|
||||
"city", "state", "location", "date", "time", "address", "zip", "notes"];
|
||||
|
||||
type Brand = { id: string; name: string };
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
brands?: Brand[];
|
||||
isPlatformAdmin?: boolean;
|
||||
};
|
||||
|
||||
export default function ImportCenterClient({ brandId: initialBrandId, brandName: initialBrandName, brands = [], isPlatformAdmin = false }: Props) {
|
||||
const [activeBrandId, setActiveBrandId] = useState(initialBrandId);
|
||||
const [activeBrandName, setActiveBrandName] = useState(initialBrandName);
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [base64Data, setBase64Data] = useState("");
|
||||
const [analysis, setAnalysis] = useState<ImportAnalysis | null>(null);
|
||||
const [editedMappings, setEditedMappings] = useState<Record<string, string>>({});
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<Step>("upload");
|
||||
const [analysisStep, setAnalysisStep] = useState(0);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{ success: boolean; created?: number; updated?: number; errors: { row: number; message: string }[] } | null>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const analysisTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleBrandChange(newBrandId: string) {
|
||||
setActiveBrandId(newBrandId);
|
||||
const found = brands.find((b) => b.id === newBrandId);
|
||||
if (found) setActiveBrandName(found.name);
|
||||
}
|
||||
|
||||
function handleFile(file: File) {
|
||||
setFileName(file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
setBase64Data(result.replace(/^data:[^;]+;base64,/, ""));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
}
|
||||
|
||||
async function handleAnalyze() {
|
||||
if (!base64Data || !fileName) return;
|
||||
setAnalysisError(null);
|
||||
setStep("analysis");
|
||||
setAnalysisStep(0);
|
||||
|
||||
const messages = [
|
||||
"Reading your file...",
|
||||
"AI is mapping columns...",
|
||||
"Cleaning and normalizing data...",
|
||||
"Finalizing preview...",
|
||||
];
|
||||
let idx = 0;
|
||||
analysisTimerRef.current = setInterval(() => {
|
||||
idx = (idx + 1) % messages.length;
|
||||
setAnalysisStep(idx);
|
||||
}, 1500);
|
||||
|
||||
const result = await analyzeImport(base64Data, fileName, activeBrandId);
|
||||
if (analysisTimerRef.current) clearInterval(analysisTimerRef.current);
|
||||
|
||||
if (result.success) {
|
||||
setAnalysis(result.analysis);
|
||||
setEditedMappings(result.analysis.columnMappings);
|
||||
setStep("preview");
|
||||
} else {
|
||||
setAnalysisError(result.error ?? "Analysis failed");
|
||||
setStep("upload");
|
||||
}
|
||||
}
|
||||
|
||||
function updateMapping(header: string, field: string) {
|
||||
setEditedMappings((prev) => ({ ...prev, [header]: field }));
|
||||
}
|
||||
|
||||
function overrideType(type: ImportEntityType) {
|
||||
if (!analysis) return;
|
||||
setAnalysis({ ...analysis, detectedType: type, confidence: 1.0 });
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!analysis) return;
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
|
||||
const mappedRows = analysis.rawRows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
analysis.headers.forEach((header, i) => {
|
||||
const field = editedMappings[header];
|
||||
if (field && field !== "ignore") {
|
||||
obj[field] = row[i]?.trim() ?? "";
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
const result = await executeImport(activeBrandId, analysis.detectedType, mappedRows);
|
||||
setImporting(false);
|
||||
|
||||
if (result.success) {
|
||||
setImportResult(result);
|
||||
setStep("import");
|
||||
} else {
|
||||
setImportError(result.error ?? "Import failed");
|
||||
}
|
||||
}
|
||||
|
||||
const stepIndex = ["upload", "analysis", "preview", "import"].indexOf(step);
|
||||
const analysisLabels = ["Reading your file...", "AI is mapping columns...", "Cleaning and normalizing data...", "Finalizing preview..."];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
<div className="mx-auto max-w-4xl px-6 py-10">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-zinc-800 border border-zinc-700">
|
||||
<svg className="h-5 w-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-zinc-100">Import Center</h1>
|
||||
<p className="mt-1 text-zinc-500 text-sm">AI-powered data import for products, orders, contacts, and stops.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="mb-8 flex items-center gap-2">
|
||||
{(["upload", "analysis", "preview", "import"] as Step[]).map((s, i) => {
|
||||
const labels = ["Upload", "AI Analysis", "Preview & Edit", "Import"];
|
||||
const isActive = s === step;
|
||||
const isPast = stepIndex > i;
|
||||
return (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold ${
|
||||
isActive ? "bg-violet-600 text-white" : isPast ? "bg-green-600 text-white" : "bg-zinc-800 text-zinc-500"
|
||||
}`}>
|
||||
{isPast ? "✓" : i + 1}
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${isActive ? "text-zinc-100" : "text-zinc-500"}`}>{labels[i]}</span>
|
||||
{i < 3 && <div className="h-px w-8 bg-zinc-800" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Brand selector */}
|
||||
{isPlatformAdmin && (
|
||||
<div className="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-4">
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Importing to brand</label>
|
||||
<select
|
||||
value={activeBrandId}
|
||||
onChange={(e) => handleBrandChange(e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-2.5 text-sm text-zinc-100 outline-none focus:border-violet-500"
|
||||
>
|
||||
{brands.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Upload ── */}
|
||||
{step === "upload" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-zinc-700 bg-zinc-900 p-12 cursor-pointer hover:border-violet-500 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="h-10 w-10 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<div className="text-center">
|
||||
<p className="text-base font-medium text-zinc-300">Drag & drop your file here</p>
|
||||
<p className="mt-1 text-sm text-zinc-500">or click to browse</p>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-600">CSV, XLSX, XLS, TXT · max 5,000 rows · 10MB</p>
|
||||
<input ref={inputRef} type="file" accept=".csv,.xlsx,.xls,.txt" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }} />
|
||||
</div>
|
||||
|
||||
{fileName && (
|
||||
<div className="rounded-xl bg-green-900/30 border border-green-800 p-4 flex items-center gap-3">
|
||||
<svg className="h-5 w-5 text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-green-300 truncate">{fileName}</p>
|
||||
<p className="text-xs text-green-500">Ready to analyze</p>
|
||||
</div>
|
||||
<button onClick={() => { setFileName(""); setBase64Data(""); }} className="text-sm text-green-400 font-medium hover:text-green-300">Change</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysisError && (
|
||||
<div className="rounded-xl bg-red-900/30 border border-red-800 p-4 text-sm text-red-300">{analysisError}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!base64Data}
|
||||
className="w-full rounded-xl bg-violet-600 px-6 py-3.5 text-base font-bold text-white hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Analyze with AI
|
||||
</button>
|
||||
|
||||
<div className="rounded-xl bg-violet-900/20 border border-violet-800 p-4">
|
||||
<p className="text-sm font-medium text-violet-300 mb-1">AI-assisted import</p>
|
||||
<p className="text-xs text-violet-500">The Import Center auto-detects your data type, maps columns intelligently, and normalizes messy data (phone numbers, dates, prices). Supports products, orders, contacts, and stops.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Analysis Loading ── */}
|
||||
{step === "analysis" && (
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-16 text-center">
|
||||
<div className="mx-auto mb-6 h-12 w-12 rounded-full border-4 border-violet-600 border-t-transparent animate-spin" />
|
||||
<p className="text-xl font-semibold text-zinc-300 mb-2">{analysisLabels[analysisStep]}</p>
|
||||
<p className="text-sm text-zinc-500">This usually takes 5–10 seconds</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Preview & Edit ── */}
|
||||
{step === "preview" && analysis && (
|
||||
<div className="space-y-6">
|
||||
{/* Detected type banner */}
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider px-2 py-1 rounded-full ${
|
||||
analysis.confidence > 0.8 ? "bg-green-900/60 text-green-300 border border-green-700" : analysis.confidence > 0.5 ? "bg-amber-900/60 text-amber-300 border border-amber-700" : "bg-red-900/60 text-red-300 border border-red-700"
|
||||
}`}>
|
||||
{ENTITY_LABELS[analysis.detectedType] ?? "Unknown"}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{Math.round(analysis.confidence * 100)}% confidence
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{analysis.rowCount.toLocaleString()} rows detected
|
||||
{analysis.autoFixApplied.length > 0 && (
|
||||
<span className="ml-2 text-green-400">· {analysis.autoFixApplied.join(", ")}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{analysis.confidence < 0.8 && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-zinc-500 mb-1">Is this wrong?</p>
|
||||
<div className="flex gap-1">
|
||||
{(["products", "orders", "stops", "contacts"] as ImportEntityType[]).map((t) => (
|
||||
<button key={t} onClick={() => overrideType(t)} className={`text-xs px-2 py-1 rounded-lg border transition-colors ${
|
||||
t === analysis.detectedType ? "bg-violet-600 text-white border-violet-600" : "border-zinc-600 text-zinc-400 hover:bg-zinc-800"
|
||||
}`}>{ENTITY_LABELS[t]}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{analysis.warnings.length > 0 && (
|
||||
<div className="rounded-xl bg-amber-900/30 border border-amber-800 p-4">
|
||||
<p className="text-sm font-medium text-amber-300 mb-2">Issues detected</p>
|
||||
<ul className="text-xs text-amber-500 space-y-1">
|
||||
{analysis.warnings.slice(0, 5).map((w, i) => <li key={i}>• {w}</li>)}
|
||||
{analysis.warnings.length > 5 && <li>...and {analysis.warnings.length - 5} more</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column mapping table */}
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Column Mappings</h3>
|
||||
<span className="text-xs text-zinc-600">AI auto-detected — click to change</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-zinc-800/50">
|
||||
<th className="px-5 py-2.5 text-left font-medium text-zinc-400">Your Column</th>
|
||||
<th className="px-5 py-2.5 text-left font-medium text-zinc-400">Maps To</th>
|
||||
<th className="px-5 py-2.5 text-left font-medium text-zinc-400">Sample Values</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analysis.headers.map((header) => {
|
||||
const mappedField = editedMappings[header];
|
||||
const sampleIdx = analysis.headers.indexOf(header);
|
||||
const samples = analysis.rawRows.slice(0, 3).map((r) => r[sampleIdx] ?? "").filter(Boolean);
|
||||
return (
|
||||
<tr key={header} className="border-t border-zinc-800 hover:bg-zinc-800/50">
|
||||
<td className="px-5 py-2.5 font-medium text-zinc-200 whitespace-nowrap">{header}</td>
|
||||
<td className="px-5 py-2.5">
|
||||
<select
|
||||
value={mappedField ?? ""}
|
||||
onChange={(e) => updateMapping(header, e.target.value)}
|
||||
className="rounded-lg border border-zinc-600 bg-zinc-800 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-violet-500"
|
||||
>
|
||||
<option value="">— ignore —</option>
|
||||
{ALL_FIELDS.map((f) => <option key={f} value={f}>{f || "(same)"}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-zinc-500 text-xs max-w-xs truncate">{samples.join(", ") || "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data preview */}
|
||||
<div className="rounded-2xl bg-zinc-900 border border-zinc-800 overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-zinc-800">
|
||||
<h3 className="text-sm font-semibold text-zinc-300">Data Preview <span className="text-zinc-600 font-normal">(first 5 rows)</span></h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-zinc-800/50">
|
||||
{analysis.headers.map((h) => (
|
||||
<th key={h} className="px-4 py-2 text-left font-medium text-zinc-400 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analysis.rawRows.slice(0, 5).map((row, ri) => (
|
||||
<tr key={ri} className="border-t border-zinc-800">
|
||||
{analysis.headers.map((h, ci) => {
|
||||
const field = editedMappings[h];
|
||||
const val = row[ci] ?? "";
|
||||
const isMapped = field && field !== "ignore";
|
||||
return (
|
||||
<td key={ci} className={`px-4 py-2 whitespace-nowrap ${isMapped ? "text-zinc-300" : "text-zinc-600 italic"}`}>
|
||||
{String(val).slice(0, 60)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setStep("upload"); setAnalysis(null); }} className="rounded-xl border border-zinc-600 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800">
|
||||
← Upload Different File
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!analysis.detectedType || analysis.detectedType === "unknown" || importing}
|
||||
className="flex-1 rounded-xl bg-violet-600 px-6 py-3 text-base font-bold text-white hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{importing ? "Importing..." : `Import ${analysis.rowCount.toLocaleString()} ${analysis.detectedType !== "unknown" ? ENTITY_LABELS[analysis.detectedType] : "rows"}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Import Result ── */}
|
||||
{step === "import" && (
|
||||
<div className="space-y-4">
|
||||
{importResult?.success ? (
|
||||
<div className="rounded-2xl bg-zinc-900 border border-green-800 p-8 text-center">
|
||||
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-green-900/50 border border-green-700 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-zinc-100 mb-2">Import Complete</h2>
|
||||
{importResult.created !== undefined && <p className="text-zinc-400">{importResult.created} created{importResult.updated ? `, ${importResult.updated} updated` : ""}</p>}
|
||||
{importResult.errors.length > 0 && (
|
||||
<p className="mt-2 text-sm text-amber-400">{importResult.errors.length} rows had errors</p>
|
||||
)}
|
||||
<div className="mt-6 flex gap-3 justify-center">
|
||||
<button onClick={() => { setStep("upload"); setAnalysis(null); setFileName(""); setBase64Data(""); setImportResult(null); }} className="rounded-xl border border-zinc-600 px-6 py-2.5 text-sm font-medium text-zinc-300 hover:bg-zinc-800">
|
||||
Import Another
|
||||
</button>
|
||||
<a href={analysis?.detectedType === "products" ? "/admin/products" : analysis?.detectedType === "orders" ? "/admin/orders" : "/admin"} className="rounded-xl bg-violet-600 px-6 py-2.5 text-sm font-bold text-white hover:bg-violet-700">
|
||||
View {analysis ? ENTITY_LABELS[analysis.detectedType] : ""}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-zinc-900 border border-red-800 p-8 text-center">
|
||||
<h2 className="text-xl font-bold text-zinc-100 mb-2">Import Failed</h2>
|
||||
<p className="text-zinc-400">{importError ?? "An error occurred during import."}</p>
|
||||
<button onClick={() => setStep("preview")} className="mt-4 rounded-xl border border-zinc-600 px-6 py-2.5 text-sm font-medium text-zinc-300 hover:bg-zinc-800">
|
||||
← Back to Preview
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import ImportCenterClient from "./ImportCenterClient";
|
||||
import { getBrands } from "@/actions/admin/users";
|
||||
|
||||
export default async function ImportCenterPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
const brandId = adminUser?.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const brandName = "Brand";
|
||||
|
||||
let brands: { id: string; name: string }[] = [];
|
||||
let isPlatformAdmin = false;
|
||||
|
||||
if (adminUser?.role === "platform_admin") {
|
||||
isPlatformAdmin = true;
|
||||
const result = await getBrands();
|
||||
if (!result.error && result.brands) {
|
||||
brands = result.brands;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Import</span>
|
||||
</nav>
|
||||
<ImportCenterClient
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import AdminSidebar from "@/components/admin/AdminSidebar";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) {
|
||||
return (
|
||||
<>
|
||||
<AdminSidebar userRole={null} />
|
||||
<div className="min-h-screen lg:pl-60" style={{ backgroundColor: "#fdfaf6" }}>
|
||||
<AdminAccessDenied message="Your account does not have admin access." />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (adminUser.must_change_password) {
|
||||
redirect("/change-password");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminSidebar userRole={adminUser.role} />
|
||||
<div className="min-h-screen lg:pl-60" style={{ backgroundColor: "#fdfaf6" }}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import LoadingSkeleton, { 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">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<LoadingSkeleton variant="text" width="w-48" height="h-8" />
|
||||
<LoadingSkeleton variant="text" width="w-64" height="h-4" />
|
||||
</div>
|
||||
<LoadingSkeleton variant="button" />
|
||||
</div>
|
||||
|
||||
{/* Cards grid skeleton */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<LoadingSkeleton variant="card" />
|
||||
<LoadingSkeleton variant="card" />
|
||||
<LoadingSkeleton variant="card" />
|
||||
</div>
|
||||
|
||||
{/* Table skeleton */}
|
||||
<SkeletonTable rows={8} cols={4} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { AdminUserRow } from "@/actions/admin/users";
|
||||
import { logUserActivity } from "@/actions/admin/audit";
|
||||
|
||||
type ProfilePageProps = {
|
||||
currentUser: AdminUserRow;
|
||||
};
|
||||
|
||||
export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [displayName, setDisplayName] = useState(currentUser.display_name ?? "");
|
||||
const [phoneNumber, setPhoneNumber] = useState(currentUser.phone_number ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emailChangeSent, setEmailChangeSent] = useState(false);
|
||||
const [changingEmail, setChangingEmail] = useState(false);
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
|
||||
async function handleSaveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { error: rpcError } = await supabase.rpc("update_admin_user", {
|
||||
p_id: currentUser.id,
|
||||
p_display_name: displayName || null,
|
||||
p_phone_number: phoneNumber || null,
|
||||
});
|
||||
if (rpcError) {
|
||||
setError(rpcError.message);
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
user_id: currentUser.user_id,
|
||||
activity_type: "profile_update",
|
||||
details: { fields: ["display_name", "phone_number"] },
|
||||
});
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailChange(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setChangingEmail(true);
|
||||
setEmailError(null);
|
||||
try {
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
email: newEmail,
|
||||
});
|
||||
if (updateError) {
|
||||
setEmailError(updateError.message);
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
user_id: currentUser.user_id,
|
||||
activity_type: "email_change",
|
||||
details: { new_email: newEmail },
|
||||
});
|
||||
setEmailChangeSent(true);
|
||||
setChangingEmail(false);
|
||||
} finally {
|
||||
setChangingEmail(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Link href="/admin" className="text-sm text-zinc-500 hover:text-zinc-300">
|
||||
← Back to dashboard
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-6 text-3xl font-bold text-zinc-100">My Profile</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Manage your profile information and preferences.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 rounded-xl bg-red-900/30 p-4 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Profile card */}
|
||||
<div className="mt-6 rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">
|
||||
{currentUser.display_name || currentUser.email}
|
||||
</h2>
|
||||
{currentUser.display_name && (
|
||||
<p className="text-sm text-zinc-500">{currentUser.email}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="rounded-full bg-zinc-950 px-2.5 py-0.5 text-xs font-semibold capitalize text-zinc-400">
|
||||
{currentUser.role.replace("_", " ")}
|
||||
</span>
|
||||
{currentUser.brand_name && (
|
||||
<span className="text-xs text-zinc-500">{currentUser.brand_name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="rounded-lg border border-zinc-800 px-4 py-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Read-only info when not editing */}
|
||||
{!editing && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-400">Phone</p>
|
||||
<p className="mt-1 text-sm text-zinc-300">{currentUser.phone_number ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-400">Email</p>
|
||||
<p className="mt-1 text-sm text-zinc-300">{currentUser.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit form */}
|
||||
{editing && (
|
||||
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-slate-500"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-slate-500"
|
||||
placeholder="+1 (555) 000-0000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditing(false); setError(null); }}
|
||||
className="rounded-lg border border-zinc-800 px-4 py-2 text-sm font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email change section */}
|
||||
<div className="mt-6 rounded-2xl bg-zinc-900 p-6 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<h3 className="text-base font-semibold text-zinc-100">Email Address</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Current: <span className="font-medium text-zinc-300">{currentUser.email}</span>
|
||||
</p>
|
||||
|
||||
{emailChangeSent ? (
|
||||
<div className="mt-4 rounded-lg bg-green-900/30 p-4 text-sm text-green-400">
|
||||
A confirmation email has been sent to <strong>{newEmail}</strong>. Click the link in the email to confirm the change.
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleEmailChange} className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300">New Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
className="mt-1 w-full rounded-lg border border-zinc-600 px-3 py-2 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-slate-500"
|
||||
placeholder="new@example.com"
|
||||
/>
|
||||
</div>
|
||||
{emailError && (
|
||||
<div className="rounded-lg bg-red-900/30 p-3 text-sm text-red-400">{emailError}</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changingEmail || !newEmail || !newEmail.includes("@")}
|
||||
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
{changingEmail ? "Sending…" : "Change Email"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminUsers } from "@/actions/admin/users";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import AdminMeClient from "./AdminMeClient";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminMePage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
// Fetch the current user's full admin row for display_name, phone_number, etc.
|
||||
const { users } = await getAdminUsers(adminUser.brand_id ?? undefined);
|
||||
const myUser = users.find((u) => u.user_id === adminUser.user_id);
|
||||
|
||||
if (!myUser) return <AdminAccessDenied message="Your admin account record was not found." />;
|
||||
|
||||
return <AdminMeClient currentUser={myUser} />;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrderDetail } from "@/actions/orders";
|
||||
import OrderEditForm from "@/components/admin/OrderEditForm";
|
||||
import OrderPaymentSection from "@/components/admin/OrderPaymentSection";
|
||||
import OrderPickupAction from "@/components/admin/OrderPickupAction";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type OrderDetailPageProps = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const order = await getAdminOrderDetail(id);
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-600">Order not found</h1>
|
||||
<a
|
||||
href="/admin/orders"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Orders
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_orders) redirect("/admin/pickup");
|
||||
|
||||
if (adminUser?.brand_id && order.stops?.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
const brandId = order.stops?.brand_id ?? null;
|
||||
const subtotal = Number(order.subtotal);
|
||||
const discount_amount = Number(order.discount_amount ?? 0);
|
||||
const total = Math.max(0, subtotal - discount_amount);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<a
|
||||
href="/admin/orders"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Orders
|
||||
</a>
|
||||
|
||||
{/* Customer info */}
|
||||
<div className="card mt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
|
||||
Customer
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">
|
||||
{order.customer_name}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
order.pickup_complete
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-yellow-100 text-yellow-700"
|
||||
}`}
|
||||
>
|
||||
{order.pickup_complete ? "Picked Up" : "Pending"}
|
||||
</span>
|
||||
<OrderPickupAction
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
currentlyPickedUp={order.pickup_complete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{order.customer_phone && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Phone</p>
|
||||
<p className="mt-1 text-lg text-stone-950">{order.customer_phone}</p>
|
||||
</div>
|
||||
)}
|
||||
{order.customer_email && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Email</p>
|
||||
<p className="mt-1 text-lg text-stone-950">{order.customer_email}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop info */}
|
||||
{order.stops && (
|
||||
<div className="card mt-6">
|
||||
<p className="text-sm font-medium text-stone-500">Stop</p>
|
||||
<p className="mt-1 text-xl font-semibold text-stone-950">
|
||||
{order.stops.city}, {order.stops.state}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-stone-500">{order.stops.date}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div className="card mt-6">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Order Items</h2>
|
||||
|
||||
{order.order_items && order.order_items.length > 0 ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{order.order_items.map((item: any) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between border-b border-stone-200 pb-3 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-stone-950">
|
||||
{item.products?.name ?? "Unknown Product"}
|
||||
</p>
|
||||
<p className="text-sm text-stone-500">Qty: {item.quantity}</p>
|
||||
</div>
|
||||
<p className="font-semibold text-stone-950">
|
||||
${(Number(item.price) * item.quantity).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 text-stone-500">No items found.</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-stone-200 pt-6 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Subtotal</span>
|
||||
<span className="text-stone-950">${subtotal.toFixed(2)}</span>
|
||||
</div>
|
||||
{Number(order.tax_amount ?? 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Tax ({order.tax_rate ? `${(Number(order.tax_rate) * 100).toFixed(3)}%` : ""})</span>
|
||||
<span className="text-stone-950">${Number(order.tax_amount).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{discount_amount > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-stone-500">Discount</span>
|
||||
<span className="text-red-600">-${discount_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
{order.discount_reason && (
|
||||
<p className="text-xs text-stone-500">{order.discount_reason}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between text-xl font-bold text-stone-950 pt-2 border-t border-stone-200">
|
||||
<span>Total</span>
|
||||
<span>${(subtotal + Number(order.tax_amount ?? 0) - discount_amount).toFixed(2)}</span>
|
||||
</div>
|
||||
{order.tax_location && (
|
||||
<p className="text-xs text-stone-500">Tax sourced from: {order.tax_location}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment & Refunds */}
|
||||
<div className="card mt-6">
|
||||
<h2 className="text-2xl font-bold text-stone-950">
|
||||
Payment & Refunds
|
||||
</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Record payment details and manage refunds for this order.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<OrderPaymentSection
|
||||
orderId={order.id}
|
||||
brandId={brandId}
|
||||
orderTotal={total}
|
||||
payment_processor={order.payment_processor}
|
||||
payment_status={order.payment_status}
|
||||
payment_transaction_id={order.payment_transaction_id}
|
||||
refunded_amount={order.refunded_amount ?? 0}
|
||||
refund_reason={order.refund_reason}
|
||||
existingRefunds={order.refunds ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit form */}
|
||||
<div className="card mt-6">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Edit Order</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Update customer details, pricing, and status.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<OrderEditForm order={order as any} brandId={brandId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal notes display */}
|
||||
{order.internal_notes && (
|
||||
<div className="card mt-6 border-amber-300 bg-amber-50">
|
||||
<p className="text-sm font-medium text-amber-700">Internal Notes</p>
|
||||
<p className="mt-1 text-stone-950">{order.internal_notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrders } from "@/actions/orders";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminOrdersPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_orders) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const { orders, stops } = await getAdminOrders();
|
||||
|
||||
const brandStops = adminUser?.brand_id
|
||||
? stops.filter((s) => s.brand_id === adminUser.brand_id)
|
||||
: stops;
|
||||
|
||||
const brandOrders = adminUser?.brand_id
|
||||
? orders.filter(
|
||||
(o) =>
|
||||
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
||||
)
|
||||
: orders;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<AdminOrdersPanel
|
||||
initialOrders={brandOrders}
|
||||
initialStops={brandStops}
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import Link from "next/link";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getBrandPlanInfo } from "@/actions/billing/stripe-portal";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
type Section = {
|
||||
title: string;
|
||||
href: string;
|
||||
description: string;
|
||||
group: "operations" | "fulfillment" | "management" | "tools";
|
||||
addonKey?: string;
|
||||
upgradeText?: string;
|
||||
prominent?: boolean;
|
||||
};
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
title: "Orders",
|
||||
href: "/admin/orders",
|
||||
description: "View orders, pickup status, fulfillment, and customer details.",
|
||||
group: "operations",
|
||||
prominent: true,
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
href: "/admin/products",
|
||||
description: "Manage products, pricing, shipping type, and availability.",
|
||||
group: "operations",
|
||||
prominent: true,
|
||||
},
|
||||
{
|
||||
title: "Tours & Stops",
|
||||
href: "/admin/stops",
|
||||
description: "Manage routes, pickup locations, dates, and cutoff times.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Driver Pickup",
|
||||
href: "/admin/pickup",
|
||||
description: "Mobile pickup lookup, QR scanning, and completion tools.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Shipping",
|
||||
href: "/admin/shipping",
|
||||
description: "FedEx integration, label creation, and shipment tracking.",
|
||||
group: "fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
href: "/admin/reports",
|
||||
description: "Sales, route, product, pickup, and customer reports.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Tax Dashboard",
|
||||
href: "/admin/taxes",
|
||||
description: "Sales tax collected, breakdown by state, and exportable reports.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
href: "/admin/settings",
|
||||
description: "Users, billing, brand, integrations, payments, and shipping.",
|
||||
group: "management",
|
||||
},
|
||||
{
|
||||
title: "Harvest Reach",
|
||||
href: "/admin/communications",
|
||||
description: "Email campaigns, stop blast, templates, and audience segments.",
|
||||
group: "tools",
|
||||
addonKey: "harvest_reach",
|
||||
upgradeText: "Enable to access email & SMS marketing",
|
||||
},
|
||||
{
|
||||
title: "Wholesale Portal",
|
||||
href: "/admin/wholesale",
|
||||
description: "Standalone B2B portal with custom pricing, credit limits, and net-30.",
|
||||
group: "tools",
|
||||
addonKey: "wholesale_portal",
|
||||
upgradeText: "Enable to unlock B2B buyer portal",
|
||||
},
|
||||
{
|
||||
title: "Import Center",
|
||||
href: "/admin/import",
|
||||
description: "AI-powered import for products, orders, contacts, and stops.",
|
||||
group: "tools",
|
||||
addonKey: "ai_tools",
|
||||
upgradeText: "Enable AI import with smart column mapping",
|
||||
},
|
||||
{
|
||||
title: "AI Intelligence",
|
||||
href: "/admin/settings/ai",
|
||||
description: "Campaign writer, pricing advisor, and report explainer.",
|
||||
group: "tools",
|
||||
addonKey: "ai_tools",
|
||||
upgradeText: "Enable AI tools for marketing and pricing",
|
||||
},
|
||||
{
|
||||
title: "Time Tracking",
|
||||
href: "/admin/time-tracking",
|
||||
description: "Worker clock-in/out, hours tracking, and overtime management.",
|
||||
group: "tools",
|
||||
addonKey: "time_tracking",
|
||||
upgradeText: "Enable for field worker time tracking",
|
||||
},
|
||||
{
|
||||
title: "Water Log",
|
||||
href: "/admin/water-log",
|
||||
description: "Irrigation tracking and water usage reporting.",
|
||||
group: "tools",
|
||||
addonKey: "water_log",
|
||||
upgradeText: "Enable for agricultural water tracking",
|
||||
},
|
||||
{
|
||||
title: "Route Trace",
|
||||
href: "/admin/route-trace",
|
||||
description: "Lot tracking, QR stickers, hauling board, and supply chain traceability.",
|
||||
group: "tools",
|
||||
addonKey: "route_trace",
|
||||
upgradeText: "Enable for field-to-delivery traceability",
|
||||
},
|
||||
];
|
||||
|
||||
const groupMeta = {
|
||||
operations: { label: "Core Operations", cols: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" },
|
||||
fulfillment: { label: "Fulfillment & Logistics", cols: "grid-cols-1 md:grid-cols-3" },
|
||||
management: { label: "Business Management", cols: "grid-cols-1 md:grid-cols-3" },
|
||||
tools: { label: "Tools & Add-ons", cols: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4" },
|
||||
} as const;
|
||||
|
||||
export default async function AdminPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const isStoreEmployee = adminUser?.role === "store_employee";
|
||||
|
||||
const isWaterLogVisible =
|
||||
adminUser?.role === "platform_admin" ||
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
|
||||
const enabledAddons: Record<string, boolean> = {};
|
||||
let planTier = "starter";
|
||||
let usage = { users: 0, stops_this_month: 0, products: 0 };
|
||||
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
|
||||
let brandDisplayName = "Admin";
|
||||
if (adminUser?.brand_id) {
|
||||
for (const key of ["harvest_reach", "wholesale_portal", "water_log", "ai_tools", "sms_campaigns", "square_sync", "route_trace"] as const) {
|
||||
enabledAddons[key] = await isFeatureEnabled(adminUser.brand_id, key);
|
||||
}
|
||||
const planResult = await getBrandPlanInfo(adminUser.brand_id);
|
||||
if (planResult.success && planResult.data) {
|
||||
planTier = planResult.data.plan_tier ?? "starter";
|
||||
usage = planResult.data.usage ?? usage;
|
||||
limits = {
|
||||
max_users: planResult.data.max_users ?? 1,
|
||||
max_stops_monthly: planResult.data.max_stops_monthly ?? 10,
|
||||
max_products: planResult.data.max_products ?? 25,
|
||||
};
|
||||
if (planResult.data.brand_name) brandDisplayName = planResult.data.brand_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStoreEmployee) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-stone-950">Driver Pickup</h1>
|
||||
<p className="mt-3 text-sm text-stone-600">
|
||||
Use the buttons below to look up orders, record pickups, and manage fulfillment.
|
||||
</p>
|
||||
<div className="mt-10 grid gap-5 md:grid-cols-2">
|
||||
<Link
|
||||
href="/admin/pickup"
|
||||
className="block rounded-2xl border border-stone-200 bg-white p-6 shadow-sm transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 transition-colors">
|
||||
<svg className="h-6 w-6 text-blue-600" 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 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-stone-950">Pickup Lookup</h2>
|
||||
<p className="mt-0.5 text-sm text-stone-500">Scan QR or search orders</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/wholesale"
|
||||
className="block rounded-2xl border border-stone-200 bg-white p-6 shadow-sm transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 transition-colors">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-stone-950">Wholesale Portal</h2>
|
||||
<p className="mt-0.5 text-sm text-stone-500">Approved buyer orders</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const allGroups = [
|
||||
{
|
||||
key: "operations" as const,
|
||||
label: groupMeta.operations.label,
|
||||
cols: groupMeta.operations.cols,
|
||||
sections: sections.filter((s) => s.group === "operations"),
|
||||
},
|
||||
{
|
||||
key: "fulfillment" as const,
|
||||
label: groupMeta.fulfillment.label,
|
||||
cols: groupMeta.fulfillment.cols,
|
||||
sections: sections.filter((s) => s.group === "fulfillment"),
|
||||
},
|
||||
{
|
||||
key: "management" as const,
|
||||
label: groupMeta.management.label,
|
||||
cols: groupMeta.management.cols,
|
||||
sections: sections.filter((s) => s.group === "management"),
|
||||
},
|
||||
{
|
||||
key: "tools" as const,
|
||||
label: groupMeta.tools.label,
|
||||
cols: groupMeta.tools.cols,
|
||||
sections: sections.filter((s) => s.group === "tools"),
|
||||
},
|
||||
];
|
||||
|
||||
const usagePct = {
|
||||
users: limits.max_users > 0 ? (usage.users / limits.max_users) * 100 : 0,
|
||||
stops: limits.max_stops_monthly > 0 ? (usage.stops_this_month / limits.max_stops_monthly) * 100 : 0,
|
||||
products: limits.max_products > 0 ? (usage.products / limits.max_products) * 100 : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-7xl space-y-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600 mb-2">Control Center</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950">{brandDisplayName}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/settings/billing" className="text-sm font-medium text-stone-500 hover:text-stone-700 transition-colors">
|
||||
Billing →
|
||||
</Link>
|
||||
{planTier === "starter" && (
|
||||
<Link
|
||||
href="/admin/settings/billing"
|
||||
className="rounded-full bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-white transition-all shadow-sm"
|
||||
>
|
||||
Upgrade Plan
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage bar */}
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center gap-5 mb-5">
|
||||
<span className={`rounded-full px-4 py-1.5 text-xs font-semibold tracking-wide ${
|
||||
planTier === "enterprise" ? "bg-amber-100 text-amber-700 border border-amber-200" :
|
||||
planTier === "farm" ? "bg-emerald-100 text-emerald-700 border border-emerald-200" :
|
||||
"bg-stone-100 text-stone-600 border border-stone-200"
|
||||
}`}>
|
||||
{planTier.charAt(0).toUpperCase() + planTier.slice(1)} Plan
|
||||
</span>
|
||||
<span className="text-sm text-stone-500">{adminUser?.brand_id ? "Tuxedo Corn" : "All Brands"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
{[
|
||||
{ label: "Users", value: `${usage.users}/${limits.max_users}`, pct: usagePct.users },
|
||||
{ label: "Stops / month", value: `${usage.stops_this_month}/${limits.max_stops_monthly}`, pct: usagePct.stops },
|
||||
{ label: "Products", value: `${usage.products}/${limits.max_products}`, pct: usagePct.products },
|
||||
].map(({ label, value, pct }) => (
|
||||
<div key={label}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-stone-600">{label}</span>
|
||||
<span className="text-sm font-semibold text-stone-900">{value}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-stone-200 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
pct > 85 ? "bg-gradient-to-r from-amber-500 to-amber-400" : "bg-gradient-to-r from-emerald-500 to-emerald-400"
|
||||
}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grouped sections */}
|
||||
{allGroups.map(({ key, label, cols, sections: groupSections }) => {
|
||||
if (groupSections.length === 0) return null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-stone-500 flex-shrink-0">{label}</p>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-stone-300 to-transparent" />
|
||||
</div>
|
||||
<div className={`grid ${cols} gap-4`}>
|
||||
{groupSections.map((section) => {
|
||||
if (section.title === "Water Log" && !isWaterLogVisible) return null;
|
||||
if (section.title === "Route Trace" && !enabledAddons["route_trace"]) return null;
|
||||
|
||||
const isAddon = Boolean(section.addonKey);
|
||||
const isEnabled = section.addonKey ? (enabledAddons[section.addonKey] ?? false) : true;
|
||||
const isProminent = section.prominent;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={section.title}
|
||||
href={section.href}
|
||||
className={[
|
||||
"group relative flex flex-col rounded-2xl border bg-white p-5 transition-all hover:-translate-y-0.5",
|
||||
isAddon && !isEnabled
|
||||
? "border-stone-200 shadow-sm opacity-75 hover:opacity-100"
|
||||
: isProminent
|
||||
? "border-stone-200 shadow-md shadow-stone-200/50 hover:shadow-lg"
|
||||
: "border-stone-200 shadow-sm hover:shadow-md",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${
|
||||
isAddon && !isEnabled
|
||||
? "bg-stone-100 text-stone-400"
|
||||
: isProminent
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-blue-100 text-blue-600"
|
||||
}`}>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{isAddon && !isEnabled && (
|
||||
<span className="text-[10px] font-semibold text-amber-600 bg-amber-50 border border-amber-200 rounded-full px-2.5 py-0.5">
|
||||
Add-on
|
||||
</span>
|
||||
)}
|
||||
{isAddon && isEnabled && (
|
||||
<span className="text-[10px] font-semibold text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-full px-2.5 py-0.5">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{isProminent && (
|
||||
<span className="text-[10px] font-semibold text-emerald-600 bg-emerald-50 border border-emerald-200 rounded-full px-2.5 py-0.5">
|
||||
Core
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold leading-tight text-stone-950">
|
||||
{section.title}
|
||||
</h3>
|
||||
|
||||
<p className={`mt-2 text-sm leading-relaxed ${
|
||||
isAddon && !isEnabled ? "text-stone-400" : "text-stone-500"
|
||||
}`}>
|
||||
{section.description}
|
||||
</p>
|
||||
|
||||
{isAddon && !isEnabled && section.upgradeText && (
|
||||
<p className="mt-3 text-xs text-amber-600 font-medium">{section.upgradeText}</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import DriverPickupPanel from "@/components/admin/DriverPickupPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrders } from "@/actions/orders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DriverPickupPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const { orders, stops } = await getAdminOrders();
|
||||
|
||||
// Filter stops by brand if scoped
|
||||
const brandStops = adminUser?.brand_id
|
||||
? stops.filter((s) => s.brand_id === adminUser.brand_id)
|
||||
: stops;
|
||||
|
||||
// Build stop IDs for brand filtering
|
||||
const brandStopIds = brandStops.map((s) => s.id);
|
||||
|
||||
// Filter orders by brand
|
||||
const brandOrders = adminUser?.brand_id
|
||||
? orders.filter((o) => o.stop_id && brandStopIds.includes(o.stop_id))
|
||||
: orders;
|
||||
|
||||
const pendingOrders = brandOrders.filter((o) => !o.pickup_complete);
|
||||
const pickedUpOrders = brandOrders.filter(
|
||||
(o) =>
|
||||
o.pickup_complete &&
|
||||
o.pickup_completed_at &&
|
||||
new Date(o.pickup_completed_at) >
|
||||
new Date(Date.now() - 72 * 60 * 60 * 1000)
|
||||
);
|
||||
|
||||
return (
|
||||
<DriverPickupPanel
|
||||
initialPendingOrders={pendingOrders}
|
||||
initialPickedUpOrders={pickedUpOrders}
|
||||
initialStops={brandStops}
|
||||
brandId={adminUser?.brand_id ?? null}
|
||||
canManagePickup={adminUser?.can_manage_pickup ?? false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ProductEditForm from "@/components/admin/ProductEditForm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type ProductDetailPageProps = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const [{ data: product, error }, { data: brands }] = await Promise.all([
|
||||
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single(),
|
||||
supabase.from("brands").select("id, name, slug"),
|
||||
]);
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_products) redirect("/admin/pickup");
|
||||
|
||||
if (adminUser?.brand_id && product?.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-600">Product not found</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white p-4 text-sm text-stone-600">
|
||||
{error?.message ?? "Product not found"}
|
||||
</pre>
|
||||
<a
|
||||
href="/admin/products"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<a
|
||||
href="/admin/products"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
|
||||
{product.brands?.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-stone-600">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
product.active
|
||||
? "bg-emerald-100 text-emerald-600"
|
||||
: "bg-stone-200 text-stone-500"
|
||||
}`}
|
||||
>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Price</p>
|
||||
<p className="mt-1 text-2xl font-bold text-stone-950">
|
||||
${Number(product.price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Type</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{product.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Edit Product</h2>
|
||||
<p className="mt-1 text-stone-500">
|
||||
Update product details, pricing, and availability.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<ProductEditForm
|
||||
product={{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
price: Number(product.price),
|
||||
type: product.type,
|
||||
active: product.active,
|
||||
brand_id: product.brand_id,
|
||||
image_url: product.image_url,
|
||||
is_taxable: product.is_taxable,
|
||||
pickup_type: product.pickup_type,
|
||||
}}
|
||||
brands={brands ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { parseProductCSV } from "@/lib/csv-parsers";
|
||||
import { importProductsBatch } from "@/actions/import-products";
|
||||
|
||||
type PreviewRow = {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
type: string;
|
||||
active: boolean;
|
||||
image_url?: string;
|
||||
_rowIndex: number;
|
||||
_warnings: string[];
|
||||
};
|
||||
|
||||
export default function ProductImportPage() {
|
||||
const [csvText, setCsvText] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewRow[] | null>(null);
|
||||
const [parseErrors, setParseErrors] = useState<{ row: number; error: string }[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{ created: number; updated: number; errors: { product: string; error: string }[] } | null>(null);
|
||||
const [brandId, setBrandId] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const SAMPLE_CSV = `name,description,price,type,active,image_url
|
||||
Dozen Sweet Corn,Fresh picked sweet corn,12.99,Pickup,TRUE,
|
||||
Corn & Butter Bundle,Dozen corn with herb butter,18.99,Pickup & Shipping,TRUE,
|
||||
Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
`;
|
||||
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string;
|
||||
setCsvText(text);
|
||||
handlePreview(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function handlePreview(text?: string) {
|
||||
const toParse = text ?? csvText;
|
||||
if (!toParse.trim()) return;
|
||||
|
||||
const parseResult = await parseProductCSV(toParse);
|
||||
if (!parseResult.success) {
|
||||
setPreview(null);
|
||||
setParseErrors([{ row: 0, error: parseResult.error }]);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview(parseResult.rows);
|
||||
setParseErrors(parseResult.errors);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!preview || !brandId) return;
|
||||
setImporting(true);
|
||||
const importResult = await importProductsBatch(
|
||||
brandId,
|
||||
preview.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
price: r.price,
|
||||
type: r.type,
|
||||
active: r.active,
|
||||
image_url: r.image_url,
|
||||
}))
|
||||
);
|
||||
setImporting(false);
|
||||
if (importResult.success) {
|
||||
setResult({ created: importResult.created, updated: importResult.updated, errors: importResult.errors });
|
||||
} else {
|
||||
setResult({ created: 0, updated: 0, errors: [{ product: "", error: importResult.error }] });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<Link href="/admin/products" className="text-sm text-stone-500 hover:text-stone-700">
|
||||
← Back to Products
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">Import Products</h1>
|
||||
<p className="mt-1 text-stone-500">
|
||||
Upload a CSV file to bulk-create or update products. Matching by product name within brand.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCsvText(SAMPLE_CSV);
|
||||
handlePreview(SAMPLE_CSV);
|
||||
}}
|
||||
className="rounded-xl border border-stone-300 px-4 py-2 text-sm text-stone-600 hover:bg-stone-200"
|
||||
>
|
||||
Load Sample CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Brand ID */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-stone-700">Brand ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
placeholder="64294306-5f42-463d-a5e8-2ad6c81a96de (Tuxedo)"
|
||||
className="mt-1 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File upload */}
|
||||
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<label className="mb-2 block text-sm font-medium text-stone-700">Upload CSV</label>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFile}
|
||||
className="w-full text-sm text-stone-500"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
Or paste CSV content below:
|
||||
</p>
|
||||
<textarea
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
rows={6}
|
||||
className="mt-2 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm font-mono outline-none focus:border-blue-500"
|
||||
placeholder="name,description,price,type,active,image_url"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handlePreview()}
|
||||
className="mt-3 rounded-xl border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-100"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Parse errors */}
|
||||
{parseErrors.length > 0 && (
|
||||
<div className="mb-4 rounded-xl bg-red-50 p-4">
|
||||
<p className="text-sm font-semibold text-red-600">Parse Errors</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{parseErrors.map((e) => (
|
||||
<li key={e.row} className="text-sm text-red-600">
|
||||
Row {e.row}: {e.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview !== null && (
|
||||
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-stone-950">
|
||||
Preview ({preview.length} rows)
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!brandId || importing}
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white disabled:opacity-50 hover:bg-blue-500"
|
||||
>
|
||||
{importing ? "Importing..." : `Import ${preview.length} Products`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200">
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Name</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Price</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Type</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Active</th>
|
||||
<th className="py-2 px-3 text-left font-medium text-stone-600">Image</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((row) => (
|
||||
<tr key={row._rowIndex} className="border-b border-stone-200">
|
||||
<td className="py-2 px-3 font-medium text-stone-950">{row.name}</td>
|
||||
<td className="py-2 px-3 text-stone-600">${row.price.toFixed(2)}</td>
|
||||
<td className="py-2 px-3 text-stone-600">{row.type}</td>
|
||||
<td className="py-2 px-3 text-stone-600">{row.active ? "Yes" : "No"}</td>
|
||||
<td className="max-w-[120px] truncate py-2 px-3 text-stone-500">
|
||||
{row.image_url ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result !== null && (
|
||||
<div className="rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<h2 className="text-lg font-semibold text-stone-950">Import Result</h2>
|
||||
<div className="mt-3 flex gap-6">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-emerald-600">{result.created}</p>
|
||||
<p className="text-sm text-stone-500">Created</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-blue-600">{result.updated}</p>
|
||||
<p className="text-sm text-stone-500">Updated</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-red-600">{result.errors.length}</p>
|
||||
<p className="text-sm text-stone-500">Errors</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<ul className="mt-3 space-y-1">
|
||||
{result.errors.map((e, i) => (
|
||||
<li key={i} className="text-sm text-red-600">
|
||||
{e.product && `${e.product}: `}{e.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import NewProductForm from "@/components/admin/NewProductForm";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function NewProductPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser?.can_manage_products) redirect("/admin/pickup");
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<a
|
||||
href="/admin/products"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Products
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-8 shadow-xl shadow-stone-200/50">
|
||||
<h1 className="text-3xl font-bold text-stone-950">
|
||||
Create Product
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-stone-500">
|
||||
Add a new product for Tuxedo Corn or Indian River Direct.
|
||||
</p>
|
||||
|
||||
<NewProductForm />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ProductTableClient from "@/components/admin/ProductTableClient";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import ProductSyncBanner from "@/components/admin/ProductSyncBanner";
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_products) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const brandId =
|
||||
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [settingsResult] = await Promise.all([
|
||||
getPaymentSettings(brandId),
|
||||
]);
|
||||
const hasSquareToken = !!(
|
||||
settingsResult.success && settingsResult.settings?.square_access_token
|
||||
);
|
||||
|
||||
let query = supabase
|
||||
.from("products")
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
type,
|
||||
active,
|
||||
deleted_at,
|
||||
image_url,
|
||||
brand_id,
|
||||
is_taxable,
|
||||
brands (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.is("deleted_at", null)
|
||||
.order("name");
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
query = query.eq("brand_id", adminUser.brand_id);
|
||||
}
|
||||
|
||||
const { data: products, error } = await query;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<nav className="mb-6 flex items-center gap-2 text-xs text-stone-500">
|
||||
<Link href="/admin" className="hover:text-stone-600 transition-colors">Admin</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Products</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-bold text-red-600">Error loading products</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
|
||||
{error.message}
|
||||
</pre>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6 flex items-center gap-2 text-xs text-stone-500">
|
||||
<Link href="/admin" className="hover:text-stone-600 transition-colors">Admin</Link>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Products</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Products</h1>
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
Manage your product catalog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/admin/products/new"
|
||||
className="rounded-xl bg-emerald-600 hover:bg-emerald-500 active:bg-emerald-700 px-5 py-3 font-semibold text-white text-sm transition-colors shadow-sm"
|
||||
>
|
||||
Add Product
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-2xl bg-white border border-stone-200 shadow-sm">
|
||||
<ProductSyncBanner brandId={brandId} hasSquareToken={hasSquareToken} />
|
||||
<ProductTableClient products={products ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import ReportsDashboard from "@/components/admin/ReportsDashboard";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ReportsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_reports) redirect("/admin/pickup");
|
||||
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
|
||||
const { data: brands } = await supabase
|
||||
.from("brands")
|
||||
.select("id, name")
|
||||
.order("name");
|
||||
|
||||
const initialBrandId = isPlatformAdmin
|
||||
? null
|
||||
: adminUser.brand_id ?? null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Reports</h1>
|
||||
<p className="mt-1 text-sm text-stone-500">
|
||||
Operational visibility across orders, fulfillment, contacts, and campaigns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ReportsDashboard
|
||||
brands={brands ?? []}
|
||||
initialBrandId={initialBrandId}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
brandId={adminUser.brand_id}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import AdminLookupPage from "@/components/route-trace/AdminLookupPage";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
|
||||
export default async function LookupPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-zinc-100">Lookup</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">Find lots by lot number or crop type</p>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lookup" />
|
||||
<AdminLookupPage brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getRouteTraceLotDetail, getLotOrders } from "@/actions/route-trace/lots";
|
||||
import LotDetailPanel from "@/components/route-trace/LotDetailPanel";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
|
||||
export default async function LotDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const [detailResult, ordersResult] = await Promise.all([
|
||||
getRouteTraceLotDetail(id),
|
||||
getLotOrders(id),
|
||||
]);
|
||||
|
||||
if (!detailResult.success || !detailResult.lot) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="rounded-xl border border-red-200 bg-zinc-900 p-6 text-center">
|
||||
<p className="text-red-600">Lot not found</p>
|
||||
<a href="/admin/route-trace/lots" className="mt-3 inline-block text-sm text-blue-400 hover:underline">← Back to Lots</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6">
|
||||
<a href="/admin/route-trace/lots" className="text-sm text-zinc-500 hover:text-zinc-300">← Back to Lots</a>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lots" />
|
||||
<LotDetailPanel
|
||||
lot={detailResult.lot}
|
||||
brandId={effectiveBrandId}
|
||||
orders={ordersResult.success ? ordersResult.orders : []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import LotCreateForm from "@/components/route-trace/LotCreateForm";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
|
||||
export default async function NewLotPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<a href="/admin/route-trace/lots" className="text-sm text-zinc-500 hover:text-zinc-300">← Back to Lots</a>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lots" />
|
||||
<LotCreateForm brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import { getRouteTraceLots } from "@/actions/route-trace/lots";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
import LotListTable from "@/components/route-trace/LotListTable";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function LotsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const result = await getRouteTraceLots(effectiveBrandId);
|
||||
const lots = result.success ? result.lots : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-zinc-100">Lots</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">{lots.length} lot{lots.length !== 1 ? "s" : ""} total</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/route-trace/lots/new"
|
||||
className="rounded-xl bg-stone-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
+ New Lot
|
||||
</Link>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="lots" />
|
||||
<LotListTable initialLots={lots} brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import {
|
||||
getRouteTraceStats,
|
||||
getRouteTraceLots,
|
||||
getHarvestLotsReadyToHaul,
|
||||
getFieldYieldSummary,
|
||||
getInventoryByCrop,
|
||||
getRecentLotEvents,
|
||||
} from "@/actions/route-trace/lots";
|
||||
import RouteTraceDashboard from "@/components/route-trace/RouteTraceDashboard";
|
||||
|
||||
export default async function RouteTraceDashboardPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
const [statsResult, lotsResult, haulingResult, yieldResult, invResult, eventsResult] = await Promise.all([
|
||||
getRouteTraceStats(effectiveBrandId),
|
||||
getRouteTraceLots(effectiveBrandId),
|
||||
getHarvestLotsReadyToHaul(effectiveBrandId),
|
||||
getFieldYieldSummary(effectiveBrandId),
|
||||
getInventoryByCrop(effectiveBrandId),
|
||||
getRecentLotEvents(effectiveBrandId, 10),
|
||||
]);
|
||||
|
||||
const stats = statsResult.success ? statsResult.stats : {
|
||||
active_count: 0, in_transit_count: 0, at_shed_count: 0, total_lots_today: 0, total_harvested_today: 0, total_lots: 0,
|
||||
};
|
||||
const recentLots = lotsResult.success ? lotsResult.lots.slice(0, 8) : [];
|
||||
const haulingLots = haulingResult.success ? haulingResult.lots : [];
|
||||
const fieldYield = yieldResult.success ? yieldResult.summary : [];
|
||||
const inventoryByCrop = invResult.success ? invResult.inventory : [];
|
||||
const recentActivity = eventsResult.success ? eventsResult.events : [];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-8">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<RouteTraceDashboard
|
||||
stats={stats}
|
||||
recentLots={recentLots}
|
||||
haulingLots={haulingLots}
|
||||
fieldYield={fieldYield}
|
||||
inventoryByCrop={inventoryByCrop}
|
||||
recentActivity={recentActivity}
|
||||
brandId={effectiveBrandId}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import RouteTraceNav from "@/components/route-trace/RouteTraceNav";
|
||||
|
||||
export default async function RouteTraceSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const enabled = await isFeatureEnabled(effectiveBrandId, "route_trace");
|
||||
if (!enabled) redirect("/admin/settings/apps?reason=route_trace");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold text-zinc-100">Route Trace Settings</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">Configure your traceability workflow and defaults</p>
|
||||
</div>
|
||||
<RouteTraceNav activeTab="settings" />
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900 shadow-black/20 p-6">
|
||||
<p className="text-sm text-zinc-500">Settings coming soon. For now, use the Add-ons page to manage Route Trace.</p>
|
||||
<a
|
||||
href="/admin/settings/apps"
|
||||
className="mt-4 inline-flex items-center gap-1.5 rounded-xl bg-stone-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
→ Manage Add-ons
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { parseOrderCSV } from "@/lib/csv-parsers";
|
||||
import { importOrdersBatch } from "@/actions/import-orders";
|
||||
|
||||
type PreviewRow = {
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
customer_phone: string;
|
||||
stop_id: string;
|
||||
items: Array<{ product_id: string; quantity: number; fulfillment: string }>;
|
||||
_rowIndex: number;
|
||||
};
|
||||
|
||||
export default function SalesImportPage() {
|
||||
const [csvText, setCsvText] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewRow[] | null>(null);
|
||||
const [parseErrors, setParseErrors] = useState<{ row: number; error: string }[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{ imported: number; errors: { row: number; error: string }[] } | null>(null);
|
||||
const [brandId, setBrandId] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const SAMPLE_CSV = `customer_name,customer_email,customer_phone,stop_id,product_id,quantity,fulfillment
|
||||
Jane Smith,jane@example.com,555-1234,{STOP_ID},{PRODUCT_ID},2,pickup
|
||||
John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
`;
|
||||
|
||||
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string;
|
||||
setCsvText(text);
|
||||
handlePreview(text);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function handlePreview(text?: string) {
|
||||
const toParse = text ?? csvText;
|
||||
if (!toParse.trim()) return;
|
||||
|
||||
const parseResult = await parseOrderCSV(toParse);
|
||||
if (!parseResult.success) {
|
||||
setPreview(null);
|
||||
setParseErrors([{ row: 0, error: parseResult.error }]);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview(parseResult.rows);
|
||||
setParseErrors(parseResult.errors);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!preview || !brandId) return;
|
||||
setImporting(true);
|
||||
const importResult = await importOrdersBatch(
|
||||
brandId,
|
||||
preview.map((r) => ({
|
||||
customer_name: r.customer_name,
|
||||
customer_email: r.customer_email,
|
||||
customer_phone: r.customer_phone,
|
||||
stop_id: r.stop_id,
|
||||
items: r.items,
|
||||
}))
|
||||
);
|
||||
setImporting(false);
|
||||
if (importResult.success) {
|
||||
setResult({ imported: importResult.imported, errors: importResult.errors });
|
||||
} else {
|
||||
setResult({ imported: 0, errors: [{ row: 0, error: importResult.error }] });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-6">
|
||||
<Link href="/admin/orders" className="text-sm text-stone-500 hover:text-stone-800">
|
||||
← Back to Orders
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-bold text-stone-950">Import Orders</h1>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Upload a CSV to import orders from another platform. Uses existing stop and product IDs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-stone-600">Brand ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
placeholder="64294306-5f42-463d-a5e8-2ad6c81a96de"
|
||||
className="mt-1 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-blue-500 bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 card p-6">
|
||||
<label className="block text-sm font-medium text-stone-600 mb-2">Upload CSV</label>
|
||||
<input ref={fileRef} type="file" accept=".csv" onChange={handleFile} className="w-full text-sm text-stone-500" />
|
||||
<p className="mt-2 text-xs text-stone-500">Paste CSV content below:</p>
|
||||
<textarea
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
rows={6}
|
||||
className="mt-2 w-full rounded-xl border border-stone-300 px-4 py-3 text-sm font-mono outline-none focus:border-blue-500 bg-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handlePreview()}
|
||||
className="mt-3 rounded-xl border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-100"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
<div className="mb-4 rounded-xl bg-red-50 p-4 border border-red-200">
|
||||
<p className="text-sm font-semibold text-red-600">Parse Errors</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{parseErrors.map((e) => (
|
||||
<li key={e.row} className="text-sm text-red-600">Row {e.row}: {e.error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview !== null && (
|
||||
<div className="mb-4 card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-stone-950">Preview ({preview.length} rows)</h2>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!brandId || importing}
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white disabled:opacity-50 hover:bg-blue-700"
|
||||
>
|
||||
{importing ? "Importing..." : `Import ${preview.length} Orders`}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200">
|
||||
<th className="text-left py-2 px-3 text-stone-500 font-medium">Customer</th>
|
||||
<th className="text-left py-2 px-3 text-stone-500 font-medium">Email</th>
|
||||
<th className="text-left py-2 px-3 text-stone-500 font-medium">Stop ID</th>
|
||||
<th className="text-left py-2 px-3 text-stone-500 font-medium">Items</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map((row) => (
|
||||
<tr key={row._rowIndex} className="border-b border-stone-200">
|
||||
<td className="py-2 px-3 font-medium text-stone-950">{row.customer_name}</td>
|
||||
<td className="py-2 px-3 text-stone-600">{row.customer_email}</td>
|
||||
<td className="py-2 px-3 font-mono text-xs text-stone-500">{row.stop_id}</td>
|
||||
<td className="py-2 px-3 text-stone-600">
|
||||
{row.items.map((it) => `${it.quantity}× ${it.product_id.slice(0, 8)}`).join(", ")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result !== null && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-stone-950">Import Result</h2>
|
||||
<div className="mt-3">
|
||||
<p className="text-3xl font-bold text-emerald-600">{result.imported}</p>
|
||||
<p className="text-sm text-stone-500">Orders imported</p>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<ul className="mt-3 space-y-1">
|
||||
{result.errors.map((e, i) => (
|
||||
<li key={i} className="text-sm text-red-600">Row {e.row}: {e.error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAIProviderSettings } from "@/actions/integrations/ai-providers";
|
||||
import AIClient from "./AIClient";
|
||||
|
||||
export default async function AISettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const settings = await getAIProviderSettings(brandId);
|
||||
const isConnected = !!settings.apiKey;
|
||||
|
||||
const brandName = "Brand"; // Note: resolved from adminUser.brand_id on the server; hardcoded fallback for settings UI
|
||||
|
||||
return (
|
||||
<AIClient
|
||||
isConnected={isConnected}
|
||||
brandId={brandId}
|
||||
brandName={brandName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { ADDON_CATALOG, isFeatureEnabled } from "@/lib/feature-flags";
|
||||
import BrandFeatureCards from "@/components/admin/BrandFeatureCards";
|
||||
|
||||
export default async function AppsSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const brandId = adminUser.brand_id ?? "";
|
||||
|
||||
const enabledFeatures: Record<string, boolean> = {};
|
||||
for (const key of Object.keys(ADDON_CATALOG) as (keyof typeof ADDON_CATALOG)[]) {
|
||||
enabledFeatures[key] = await isFeatureEnabled(brandId, key);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Add-ons</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-stone-200 border border-stone-300">
|
||||
<svg className="h-5 w-5 text-stone-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.337-1.219 23.75 23.75 0 00-5.337-1.219 23.848 23.848 0 005.337 1.219zM10.5 2.25a2.25 2.25 0 013.165 1.453 2.25 2.25 0 00-1.453 3.165m-6.197 6.197A3.5 3.5 0 017.5 12.75v1.5a3.5 3.5 0 001.5 2.625h1.5a3.5 3.5 0 001.5-2.625V13.5m-6.197 6.197a3.5 3.5 0 001.5 2.625m0 0v1.5a3.5 3.5 0 01-1.5 2.625H5.25m0 0H3.75a2.25 2.25 0 00-2.25 2.25v1.5a2.25 2.25 0 002.25 2.25h1.5m0 0A3.5 3.5 0 0112.75 19.5v1.5a3.5 3.5 0 01-3.5 3.5H8.25m0 0H6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-stone-950">Add-ons</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Enable or disable add-on features for your brand. Changes take effect immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BrandFeatureCards brandId={brandId} initialEnabledFeatures={enabledFeatures} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createAddonCheckoutSession } from "@/actions/billing/stripe-checkout";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
addonKey: string;
|
||||
};
|
||||
|
||||
export default function AddAddonButton({ brandId, addonKey }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createAddonCheckoutSession } from "@/actions/billing/stripe-checkout";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
export default function AddPaymentMethodButton({ brandId }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
// For adding a new payment method, we use the Stripe portal
|
||||
const { getStripeBillingPortalUrl } = await import("@/actions/billing/stripe-portal");
|
||||
const result = await getStripeBillingPortalUrl(brandId);
|
||||
setLoading(false);
|
||||
if (result.success && result.url) {
|
||||
window.location.href = result.url;
|
||||
} else {
|
||||
alert(result.error ?? "Failed to open billing portal");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className="w-full rounded-xl bg-slate-900 py-3 text-sm font-bold text-white hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Opening..." : "Add Payment Method"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { updateBrandPlanTier } from "@/actions/billing/stripe-portal";
|
||||
|
||||
type Props = {
|
||||
currentTier: string;
|
||||
brandId: string;
|
||||
hasStripeCustomer: boolean;
|
||||
};
|
||||
|
||||
const TIERS = [
|
||||
{ value: "starter", label: "Starter", color: "bg-zinc-950 text-zinc-300" },
|
||||
{ value: "farm", label: "Farm", color: "bg-blue-900/40 text-blue-700" },
|
||||
{ value: "enterprise", label: "Enterprise", color: "bg-violet-100 text-violet-700" },
|
||||
];
|
||||
|
||||
export default function BillingClient({ currentTier, brandId }: Props) {
|
||||
const [selectedTier, setSelectedTier] = useState(currentTier);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSaveTier() {
|
||||
if (selectedTier === currentTier) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const result = await updateBrandPlanTier(brandId, selectedTier);
|
||||
setSaving(false);
|
||||
if (result.success) {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
window.location.reload();
|
||||
} else {
|
||||
setError(result.error ?? "Failed to update plan");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedTier}
|
||||
onChange={(e) => setSelectedTier(e.target.value)}
|
||||
className="rounded-xl border border-zinc-600 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-blue-500"
|
||||
>
|
||||
{TIERS.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSaveTier}
|
||||
disabled={saving || selectedTier === currentTier}
|
||||
className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : saved ? "Saved!" : "Save Tier"}
|
||||
</button>
|
||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import PlanUpgradeButton from "./PlanUpgradeButton";
|
||||
import AddPaymentMethodButton from "./AddPaymentMethodButton";
|
||||
import AddAddonButton from "./AddAddonButton";
|
||||
import RemoveAddonButton from "./RemoveAddonButton";
|
||||
import BillingCycleToggle from "./BillingCycleToggle";
|
||||
import StripePortalButton from "./StripePortalButton";
|
||||
import { PLAN_TIERS, ADDONS } from "@/lib/pricing";
|
||||
|
||||
type BillingCycle = "monthly" | "annual";
|
||||
|
||||
type AddonData = { key: string; label: string; icon: string; monthlyPrice: number; annualPrice: number };
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
planTier: string;
|
||||
brandName: string | null;
|
||||
hasStripeCustomer: boolean;
|
||||
enabledAddons: Record<string, boolean>;
|
||||
isPlatformAdmin: boolean;
|
||||
subscriptionStatus: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
};
|
||||
|
||||
export default function BillingClientPage({
|
||||
brandId,
|
||||
planTier,
|
||||
brandName,
|
||||
hasStripeCustomer,
|
||||
enabledAddons,
|
||||
isPlatformAdmin,
|
||||
subscriptionStatus,
|
||||
currentPeriodEnd,
|
||||
}: Props) {
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>("annual");
|
||||
const [compareOpen, setCompareOpen] = useState(false);
|
||||
|
||||
const currentPlan = PLAN_TIERS[planTier as keyof typeof PLAN_TIERS] ?? PLAN_TIERS.starter;
|
||||
const planMonthly = billingCycle === "annual"
|
||||
? Math.round((currentPlan.annualPrice ?? 0) / 12)
|
||||
: (currentPlan.monthlyPrice ?? 0);
|
||||
|
||||
const enabledAddonsList: AddonData[] = Object.entries(enabledAddons)
|
||||
.filter(([, v]) => v)
|
||||
.map(([key]) => {
|
||||
const a = ADDONS[key as keyof typeof ADDONS];
|
||||
return a ? { key, label: a.label, icon: a.icon, monthlyPrice: a.monthlyPrice, annualPrice: a.annualPrice } : null;
|
||||
})
|
||||
.filter(Boolean) as AddonData[];
|
||||
|
||||
const allAddonKeys = Object.keys(ADDONS) as Array<keyof typeof ADDONS>;
|
||||
|
||||
const addonsMonthlyTotal = enabledAddonsList.reduce((sum, a) => {
|
||||
return sum + (billingCycle === "annual" ? Math.round(a.annualPrice / 12) : a.monthlyPrice);
|
||||
}, 0);
|
||||
|
||||
const totalMonthly = planMonthly + addonsMonthlyTotal;
|
||||
|
||||
const annualSavings = enabledAddonsList.reduce(
|
||||
(s, a) => s + (a.monthlyPrice * 12 - a.annualPrice),
|
||||
(billingCycle === "annual" ? (currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0) : 0)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── 1. Header summary bar ───────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 px-6 py-5">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<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-green-900/40 text-green-400" :
|
||||
subscriptionStatus === "past_due" ? "bg-amber-100 text-amber-700" :
|
||||
subscriptionStatus === "canceled" ? "bg-red-900/40 text-red-400" :
|
||||
subscriptionStatus === "trialing" ? "bg-blue-900/40 text-blue-700" :
|
||||
"bg-zinc-950 text-zinc-400"
|
||||
}`}>
|
||||
{subscriptionStatus.replace("_", " ")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-zinc-500">
|
||||
{currentPeriodEnd ? (
|
||||
<>Next billing: <span className="font-medium text-zinc-300">{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-zinc-100">
|
||||
${totalMonthly}<span className="text-sm font-normal text-slate-400">/mo</span>
|
||||
<span className="ml-2 text-sm font-medium text-green-600">
|
||||
{billingCycle === "annual" ? "Annual (saves 25%)" : ""}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<BillingCycleToggle onCycleChange={(c) => setBillingCycle(c)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 2. Current plan + Add-ons (two-column) ───────────────────────────── */}
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
{/* Current plan card */}
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">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-zinc-100 mb-1">
|
||||
{billingCycle === "annual" ? `$${currentPlan.annualPrice}/yr` : `$${currentPlan.monthlyPrice}/mo`}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mb-4">
|
||||
{billingCycle === "annual" && currentPlan.annualPrice
|
||||
? `$${Math.round(currentPlan.annualPrice / 12)}/mo equivalent`
|
||||
: "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-zinc-400">
|
||||
<span className="text-green-500 shrink-0">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
{(currentPlan.features as readonly string[]).length > 5 && (
|
||||
<li className="text-xs text-slate-400 pl-5">+ {(currentPlan.features as readonly string[]).length - 5} more</li>
|
||||
)}
|
||||
</ul>
|
||||
{isPlatformAdmin && (
|
||||
<button
|
||||
onClick={() => setCompareOpen(!compareOpen)}
|
||||
className="text-xs text-violet-600 hover:underline font-medium"
|
||||
>
|
||||
{compareOpen ? "Hide plan comparison" : "Compare plans →"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add-ons */}
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 lg:col-span-3">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">Add-ons</h3>
|
||||
<span className="text-xs text-slate-400">+{addonsMonthlyTotal}/mo</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{allAddonKeys.map((key) => {
|
||||
const addon = ADDONS[key];
|
||||
const isEnabled = !!enabledAddons[key];
|
||||
const displayPrice = billingCycle === "annual"
|
||||
? Math.round(addon.annualPrice / 12)
|
||||
: addon.monthlyPrice;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between rounded-lg border bg-slate-50 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-base leading-none">{addon.icon}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">{addon.label}</p>
|
||||
<p className="text-xs text-slate-400">{addon.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-sm font-semibold text-zinc-300">+${displayPrice}/mo</span>
|
||||
{isEnabled ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="rounded-full bg-green-900/40 text-green-400 text-xs px-2 py-0.5 font-medium">Active</span>
|
||||
{isPlatformAdmin && (
|
||||
<RemoveAddonButton brandId={brandId} addonKey={key} onRemoved={() => window.location.reload()} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<AddAddonButton brandId={brandId} addonKey={key} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 3. Compare plans (collapsible) ───────────────────────────────────── */}
|
||||
{compareOpen && (
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 overflow-hidden">
|
||||
<div className="border-b border-slate-100 bg-slate-50 px-5 py-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">Plan Comparison</h3>
|
||||
<button onClick={() => setCompareOpen(false)} className="text-xs text-slate-400 hover:text-zinc-400">
|
||||
✕ Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="pb-3 pr-4 text-left font-semibold text-zinc-500 w-2/5" />
|
||||
{(["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}`}>
|
||||
{plan.label}
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
<span className="text-lg font-bold text-zinc-100">
|
||||
{price !== null ? `$${price}` : "$399"}
|
||||
</span>
|
||||
<span className="text-slate-400 text-xs">/{billingCycle === "annual" ? "yr" : "mo"}</span>
|
||||
</div>
|
||||
{tier === "enterprise" && billingCycle === "annual" && (
|
||||
<p className="text-xs text-slate-400 mt-0.5">or $399/mo</p>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
["Products catalog", { starter: true, farm: true, enterprise: true }],
|
||||
["Stops management", { starter: false, farm: true, enterprise: true }],
|
||||
["Orders processing", { starter: true, farm: true, enterprise: true }],
|
||||
["Pickup & fulfillment", { starter: true, farm: true, enterprise: true }],
|
||||
["Multi-user", { starter: false, farm: true, enterprise: true }],
|
||||
["Reporting", { starter: false, farm: true, enterprise: true }],
|
||||
["Wholesale Portal", { starter: false, farm: true, enterprise: true }],
|
||||
["Harvest Reach", { starter: false, farm: true, enterprise: true }],
|
||||
["AI Intelligence", { starter: false, farm: false, enterprise: true }],
|
||||
["SMS Campaigns", { starter: false, farm: false, enterprise: true }],
|
||||
["Square Sync", { starter: false, farm: false, enterprise: true }],
|
||||
["Water Log", { starter: false, farm: false, enterprise: true }],
|
||||
["Unlimited brands", { starter: false, farm: false, enterprise: true }],
|
||||
["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>;
|
||||
return (
|
||||
<tr key={feature as string} className="border-t border-slate-100">
|
||||
<td className="py-2 pr-4 text-zinc-400">{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-green-500">✓</span>
|
||||
: <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="border-t border-zinc-800">
|
||||
<td className="pt-3 pr-4" />
|
||||
{(["starter", "farm", "enterprise"] as const).map((tier) => {
|
||||
const isCurrent = tier === planTier;
|
||||
return (
|
||||
<td key={tier} className="pt-3 px-3 text-center">
|
||||
{isCurrent ? (
|
||||
<span className="inline-block rounded-lg bg-green-900/40 px-2 py-1 text-xs font-medium text-green-400">Current</span>
|
||||
) : tier === "enterprise" ? (
|
||||
<a href="mailto:team@cielohermosa.com?subject=Enterprise+Plan" className="inline-block rounded-lg border border-violet-200 bg-violet-50 px-2 py-1 text-xs font-medium text-violet-700 hover:bg-violet-100">
|
||||
Contact
|
||||
</a>
|
||||
) : (
|
||||
<PlanUpgradeButton brandId={brandId} targetTier={tier} currentTier={planTier} />
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 4. Payment + Invoices (two-column) ──────────────────────────────── */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Payment method */}
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-4">Payment Method</h3>
|
||||
{hasStripeCustomer ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-zinc-800 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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800">Visa ending in 4242</p>
|
||||
<p className="text-xs text-slate-400">Expires 12/26</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-green-900/40 text-green-400 text-xs px-2 py-0.5 font-medium shrink-0">Active</span>
|
||||
</div>
|
||||
<StripePortalButton brandId={brandId} variant="secondary" label="Manage in Stripe Portal →" />
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
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-900/30 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>
|
||||
<AddPaymentMethodButton brandId={brandId} />
|
||||
<p className="text-xs text-slate-400 text-center">
|
||||
Set up in{" "}
|
||||
<a href="/admin/settings/payments" className="underline hover:text-zinc-400">Payments settings</a>
|
||||
{" "}to enable billing.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice history */}
|
||||
<div className="rounded-2xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-4">Invoice History</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="pb-2 text-left font-semibold text-zinc-500">Invoice</th>
|
||||
<th className="pb-2 text-left font-semibold text-zinc-500">Date</th>
|
||||
<th className="pb-2 text-right font-semibold text-zinc-500">Amount</th>
|
||||
<th className="pb-2 text-right font-semibold text-zinc-500">Status</th>
|
||||
<th className="pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{[
|
||||
{ id: "INV-2026-004", date: "May 1, 2026", amount: totalMonthly, status: "paid" },
|
||||
{ id: "INV-2026-003", date: "Apr 1, 2026", amount: totalMonthly, status: "paid" },
|
||||
{ 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-slate-800">{inv.id}</td>
|
||||
<td className="py-2 text-zinc-500">{inv.date}</td>
|
||||
<td className="py-2 text-right font-semibold text-slate-800">${inv.amount}</td>
|
||||
<td className="py-2 text-right">
|
||||
<span className="rounded-full bg-green-900/40 text-green-400 px-1.5 py-0.5 text-xs font-medium capitalize">{inv.status}</span>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button className="text-xs text-slate-400 hover:text-zinc-400">PDF</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-400 text-center">
|
||||
Invoiced by Cielo Hermosa, LLC · <a href="mailto:billing@cielohermosa.com" className="underline">billing@cielohermosa.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type BillingCycle = "monthly" | "annual";
|
||||
|
||||
type Props = {
|
||||
onCycleChange: (cycle: BillingCycle) => void;
|
||||
};
|
||||
|
||||
export default function BillingCycleToggle({ onCycleChange }: Props) {
|
||||
const [cycle, setCycle] = useState<BillingCycle>("annual");
|
||||
|
||||
function handleCycle(c: BillingCycle) {
|
||||
setCycle(c);
|
||||
onCycleChange(c);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleCycle("monthly")}
|
||||
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||
cycle === "monthly"
|
||||
? "border-zinc-600 bg-zinc-900 text-zinc-100"
|
||||
: "border-zinc-800 bg-slate-50 text-slate-400"
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCycle("annual")}
|
||||
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-colors flex items-center gap-1.5 ${
|
||||
cycle === "annual"
|
||||
? "border-2 border-green-600 bg-green-900/30 text-green-400"
|
||||
: "border border-zinc-800 bg-slate-50 text-slate-400"
|
||||
}`}
|
||||
>
|
||||
Annual
|
||||
<span className="rounded-full bg-green-900/40 text-green-400 text-xs px-1.5 py-0.5 font-bold">Save 25%</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createPlanUpgradeCheckout } from "@/actions/billing/stripe-checkout";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
targetTier: string;
|
||||
currentTier: string;
|
||||
};
|
||||
|
||||
const TIER_ORDER = ["starter", "farm", "enterprise"];
|
||||
|
||||
export default function PlanUpgradeButton({ brandId, targetTier, currentTier }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const currentIndex = TIER_ORDER.indexOf(currentTier);
|
||||
const targetIndex = TIER_ORDER.indexOf(targetTier);
|
||||
const isDowngrade = targetIndex < currentIndex;
|
||||
const isCurrent = targetTier === currentTier;
|
||||
|
||||
async function handleClick() {
|
||||
if (isCurrent || isDowngrade) return;
|
||||
setLoading(true);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
if (isCurrent) {
|
||||
return (
|
||||
<span className="inline-block rounded-xl bg-green-900/40 px-4 py-2 text-sm font-medium text-green-400">
|
||||
Current Plan
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDowngrade) {
|
||||
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"
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cancelAddonSubscription } from "@/actions/billing/stripe-checkout";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
addonKey: string;
|
||||
onRemoved: () => void;
|
||||
};
|
||||
|
||||
export default function RemoveAddonButton({ brandId, addonKey, onRemoved }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm("Remove this add-on? It will cancel the associated Stripe subscription.")) return;
|
||||
setLoading(true);
|
||||
const result = await cancelAddonSubscription(brandId, addonKey);
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
onRemoved();
|
||||
} else {
|
||||
alert(result.error ?? "Failed to remove add-on");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={loading}
|
||||
className="text-xs text-red-500 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Removing..." : "Remove"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getStripeBillingPortalUrl } from "@/actions/billing/stripe-portal";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
variant?: "primary" | "secondary";
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export default function StripePortalButton({ brandId, variant = "secondary", label = "Manage in Stripe Portal →" }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true);
|
||||
const result = await getStripeBillingPortalUrl(brandId);
|
||||
setLoading(false);
|
||||
if (result.success && result.url) {
|
||||
window.location.href = result.url;
|
||||
} else {
|
||||
alert(result.error ?? "Failed to open billing portal");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={`w-full rounded-xl py-3 text-sm font-medium transition-colors ${
|
||||
variant === "primary"
|
||||
? "bg-slate-900 text-white hover:bg-slate-800"
|
||||
: "border border-zinc-600 text-zinc-300 hover:bg-zinc-800"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{loading ? "Opening..." : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getBrandPlanInfo, getEnabledAddons } from "@/actions/billing/stripe-portal";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import BillingClientPage from "./BillingClientPage";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ brandId?: string }>;
|
||||
};
|
||||
|
||||
export default async function BillingPage({ params }: Props) {
|
||||
const { brandId: brandIdParam } = await params;
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
|
||||
let resolvedBrandId = effectiveBrandId;
|
||||
if (isPlatformAdmin && !resolvedBrandId) {
|
||||
const { data: firstBrand } = await supabase
|
||||
.from("brands")
|
||||
.select("id")
|
||||
.limit(1)
|
||||
.single();
|
||||
if (firstBrand?.id) {
|
||||
resolvedBrandId = firstBrand.id;
|
||||
} else {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Billing</span>
|
||||
</nav>
|
||||
<div className="card p-8 shadow-xl text-center">
|
||||
<h1 className="text-2xl font-bold text-stone-950">No Brands Found</h1>
|
||||
<p className="mt-2 text-stone-500">Create a brand in the database before accessing billing settings.</p>
|
||||
<a href="/admin" className="mt-4 inline-block rounded-xl bg-blue-600 hover:bg-blue-700 px-6 py-3 text-sm font-medium text-white transition-colors">
|
||||
Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedBrandId) return <AdminAccessDenied />;
|
||||
|
||||
const [planResult, addons] = await Promise.all([
|
||||
getBrandPlanInfo(resolvedBrandId),
|
||||
getEnabledAddons(resolvedBrandId),
|
||||
]);
|
||||
|
||||
const planData = (planResult.success && planResult.data && typeof planResult.data === "object")
|
||||
? planResult.data as Record<string, any>
|
||||
: {} as Record<string, any>;
|
||||
|
||||
const planTier = planData.plan_tier ?? "starter";
|
||||
|
||||
const { data: brand } = await supabase
|
||||
.from("brands")
|
||||
.select("name, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_current_period_end")
|
||||
.eq("id", resolvedBrandId)
|
||||
.single();
|
||||
|
||||
const hasStripeCustomer = !!brand?.stripe_customer_id;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100">
|
||||
{/* Platform billing header */}
|
||||
<div className="bg-stone-200 border-b border-stone-300 px-6 py-3">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<p className="text-xs text-stone-600">
|
||||
<span className="font-medium text-stone-700">Route Commerce Platform Billing</span>
|
||||
{" — "}Invoiced by Cielo Hermosa, LLC · Manage your platform subscription and add-ons.
|
||||
{" "}Questions? <a href="mailto:billing@cielohermosa.com" className="text-blue-600 hover:text-blue-700 underline transition-colors">billing@cielohermosa.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<a href="/admin/settings" className="hover:text-stone-800 transition-colors">Settings</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Billing</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Billing & Subscription</h1>
|
||||
<p className="mt-1 text-stone-500">
|
||||
Manage your Route Commerce subscription for {brand?.name ?? "your brand"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BillingClientPage
|
||||
brandId={resolvedBrandId}
|
||||
planTier={planTier}
|
||||
brandName={brand?.name ?? null}
|
||||
hasStripeCustomer={hasStripeCustomer}
|
||||
enabledAddons={addons}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
subscriptionStatus={brand?.stripe_subscription_status ?? null}
|
||||
currentPeriodEnd={brand?.stripe_current_period_end ?? null}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getBrandSettings } from "@/actions/brand-settings";
|
||||
import BrandSettingsForm from "@/components/admin/BrandSettingsForm";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
|
||||
export default async function BrandSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_settings && adminUser.role !== "platform_admin") {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
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 ?? []
|
||||
: [];
|
||||
|
||||
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
|
||||
|
||||
// Get brand name for display
|
||||
const brandName =
|
||||
brands.find((b) => b.id === effectiveBrandId)?.name ??
|
||||
(await supabase.from("brands").select("name").eq("id", effectiveBrandId).single()).data?.name ??
|
||||
"Unknown Brand";
|
||||
|
||||
const result = await getBrandSettings(effectiveBrandId);
|
||||
const settings = result.success ? result.settings : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Brand Settings</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 border border-emerald-200">
|
||||
<svg className="h-5 w-5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.592 9.592c1.106.105 1.35.374 1.591.659l4.317 4.317c.221.241.375.574.375.896v.318c0 .621-.504 1.125-1.125 1.125H5.25A2.25 2.25 0 013 16.5v-4.318c0-.597-.237-1.17-.659-1.591l-9.592-9.592A2.25 2.25 0 012.25 5.25m5.318 4.5v4.318l3.591 3.591M5.25 7.5a2.25 2.25 0 012.25-2.25h4.318m0 0L9 10.5m5.25-2.25v4.318m0 0L14.25 12m5.25-2.25H9.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-stone-950">Brand Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Company information, logos, and default signatures used across the platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-xl">
|
||||
<BrandSettingsForm
|
||||
settings={settings}
|
||||
brandId={effectiveBrandId}
|
||||
brandName={brandName}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import AIProviderPanel from "@/components/admin/AIProviderPanel";
|
||||
import { savePaymentSettings, getPaymentSettings } from "@/actions/payments";
|
||||
|
||||
// ── Integration types ───────────────────────────────────────────────────────────
|
||||
|
||||
type CredentialField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
isSecret?: boolean;
|
||||
};
|
||||
|
||||
type SyncOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type Integration = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
accentColor: string;
|
||||
credentials: CredentialField[];
|
||||
syncOptions: SyncOption[];
|
||||
};
|
||||
|
||||
type AvailableIntegration = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
accentColor: string;
|
||||
};
|
||||
|
||||
// ── Integration catalogue ───────────────────────────────────────────────────────
|
||||
|
||||
const INTEGRATIONS: Integration[] = [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
icon: "AI",
|
||||
description: "Power AI tools like Campaign Writer, Report Explainer, and Pricing Advisor.",
|
||||
accentColor: "bg-violet-950/50 ring-violet-800",
|
||||
credentials: [
|
||||
{ key: "OPENAI_API_KEY", label: "API Key", placeholder: "sk-...", isSecret: true },
|
||||
{ key: "OPENAI_ORG_ID", label: "Organization ID", placeholder: "org-... (optional)" },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "campaign_writer", label: "Campaign Idea Generator", description: "AI-powered email campaign ideas" },
|
||||
{ key: "product_writer", label: "Product Description Writer", description: "AI product copy and alt text" },
|
||||
{ key: "report_explainer", label: "Report Explainer", description: "Plain-English report summaries" },
|
||||
{ key: "pricing_advisor", label: "Pricing Advisor", description: "AI pricing recommendations" },
|
||||
{ key: "demand_forecast", label: "Demand Forecasting", description: "Predict order volumes" },
|
||||
{ key: "route_optimizer", label: "Route Optimizer", description: "AI stop sequence optimization" },
|
||||
{ key: "customer_insights", label: "Customer Insights", description: "NL query for orders & customers" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resend",
|
||||
name: "Resend",
|
||||
icon: "Email",
|
||||
description: "Send transactional and marketing emails via Harvest Reach.",
|
||||
accentColor: "bg-amber-950/50 ring-amber-800",
|
||||
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" },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "transactional", label: "Transactional Email", description: "Order confirmations, pickup reminders" },
|
||||
{ key: "marketing", label: "Marketing Campaigns", description: "Harvest Reach email campaigns" },
|
||||
{ key: "stop_blast", label: "Stop Blast Messages", description: "Automated stop notification emails" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
name: "Stripe",
|
||||
icon: "Stripe",
|
||||
description: "Process online payments for orders. Collect card details and handle refunds.",
|
||||
accentColor: "bg-zinc-800/50 ring-zinc-700",
|
||||
credentials: [
|
||||
{ key: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", placeholder: "pk_live_..." },
|
||||
{ key: "STRIPE_SECRET_KEY", label: "Secret Key", placeholder: "sk_live_...", isSecret: true },
|
||||
{ key: "STRIPE_WEBHOOK_SECRET", label: "Webhook Secret", placeholder: "whsec_...", isSecret: true },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "checkout", label: "Online Checkout", description: "Branded checkout for orders" },
|
||||
{ key: "refunds", label: "Refund Processing", description: "Issue partial or full refunds" },
|
||||
{ key: "customer_portal", label: "Customer Portal", description: "Invoice and payment management" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "twilio",
|
||||
name: "Twilio",
|
||||
icon: "SMS",
|
||||
description: "Send SMS campaigns and alerts via Harvest Reach.",
|
||||
accentColor: "bg-blue-950/50 ring-blue-800",
|
||||
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" },
|
||||
],
|
||||
syncOptions: [
|
||||
{ key: "sms_campaigns", label: "SMS Campaigns", description: "Harvest Reach text message campaigns" },
|
||||
{ key: "alerts", label: "Order Alerts", description: "SMS pickup and shipping notifications" },
|
||||
{ key: "two_way", label: "Two-Way Messaging", description: "Customer replies and responses" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const AVAILABLE_INTEGRATIONS: AvailableIntegration[] = [
|
||||
{ id: "shipbob", name: "ShipBob", icon: "3PL", description: "Fulfillment for e-commerce orders", accentColor: "bg-blue-950/50 ring-blue-800" },
|
||||
{ id: "fedex", name: "FedEx", icon: "FedEx", description: "Live shipping rates and label printing", accentColor: "bg-amber-950/50 ring-amber-800" },
|
||||
{ id: "quickbooks", name: "QuickBooks", icon: "QB", description: "Sync orders and invoices to QuickBooks Online", accentColor: "bg-green-950/50 ring-green-800" },
|
||||
{ id: "hubspot", name: "HubSpot", icon: "CRM", description: "CRM sync for customer and order data", accentColor: "bg-orange-950/50 ring-orange-800" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
brands: { id: string; name: string }[];
|
||||
isPlatformAdmin: boolean;
|
||||
paymentSettings?: {
|
||||
provider?: string | null;
|
||||
stripe_publishable_key?: string | null;
|
||||
stripe_secret_key?: string | null;
|
||||
square_access_token?: string | null;
|
||||
square_location_id?: string | null;
|
||||
square_sync_enabled?: boolean;
|
||||
square_inventory_mode?: string;
|
||||
} | null;
|
||||
resendConfigured?: boolean;
|
||||
};
|
||||
|
||||
const iconColors: Record<string, string> = {
|
||||
AI: "bg-violet-900 text-violet-300",
|
||||
Email: "bg-amber-900 text-amber-300",
|
||||
Stripe: "bg-zinc-800 text-zinc-200",
|
||||
SMS: "bg-blue-900 text-blue-300",
|
||||
"3PL": "bg-blue-900 text-blue-300",
|
||||
FedEx: "bg-amber-900 text-amber-300",
|
||||
QB: "bg-green-900 text-green-300",
|
||||
CRM: "bg-orange-900 text-orange-300",
|
||||
};
|
||||
|
||||
// ── Individual integration card ────────────────────────────────────────────────
|
||||
|
||||
function IntegrationCard({
|
||||
integration,
|
||||
brandId,
|
||||
initialPaymentSettings,
|
||||
resendConfigured,
|
||||
}: {
|
||||
integration: Integration;
|
||||
brandId: string;
|
||||
initialPaymentSettings?: Props["paymentSettings"];
|
||||
resendConfigured?: boolean;
|
||||
}) {
|
||||
const hasStripeCredentials = !!(integration.id === "stripe" && initialPaymentSettings?.stripe_publishable_key);
|
||||
const hasResendCredentials = !!(integration.id === "resend" && resendConfigured);
|
||||
const [enabled, setEnabled] = useState(hasStripeCredentials || hasResendCredentials);
|
||||
const [env, setEnv] = useState("sandbox");
|
||||
const [syncEnabled, setSyncEnabled] = useState<Record<string, boolean>>({});
|
||||
const [credentials, setCredentials] = useState<Record<string, string>>(() => {
|
||||
if (integration.id === "stripe" && initialPaymentSettings) {
|
||||
const c: Record<string, string> = {};
|
||||
if (initialPaymentSettings.stripe_publishable_key)
|
||||
c["STRIPE_PUBLISHABLE_KEY"] = initialPaymentSettings.stripe_publishable_key;
|
||||
if (initialPaymentSettings.stripe_secret_key)
|
||||
c["STRIPE_SECRET_KEY"] = initialPaymentSettings.stripe_secret_key;
|
||||
return c;
|
||||
}
|
||||
if (integration.id === "resend" && resendConfigured) {
|
||||
const c: Record<string, string> = {};
|
||||
if (process.env.NEXT_PUBLIC_RESEND_API_KEY)
|
||||
c["RESEND_API_KEY"] = process.env.NEXT_PUBLIC_RESEND_API_KEY;
|
||||
if (process.env.NEXT_PUBLIC_FROM_EMAIL)
|
||||
c["RESEND_FROM_EMAIL"] = process.env.NEXT_PUBLIC_FROM_EMAIL;
|
||||
if (process.env.NEXT_PUBLIC_FROM_NAME)
|
||||
c["RESEND_FROM_NAME"] = process.env.NEXT_PUBLIC_FROM_NAME;
|
||||
return c;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
if (integration.id === "stripe") {
|
||||
const secretKey = credentials["STRIPE_SECRET_KEY"]?.trim();
|
||||
if (!secretKey) {
|
||||
setTestResult({ ok: false, message: "No secret key entered" });
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
if (!secretKey.startsWith("sk_live_") && !secretKey.startsWith("sk_test_")) {
|
||||
setTestResult({ ok: false, message: "Invalid secret key format — must start with sk_live_ or sk_test_" });
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("https://api.stripe.com/v1/balance", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const currency = data.currency?.toUpperCase() ?? "USD";
|
||||
const available = data.available?.amount ?? 0;
|
||||
setTestResult({ ok: true, message: `Connected! Balance: ${currency} $${(available / 100).toFixed(2)}` });
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setTestResult({ ok: false, message: err.error?.message ?? `HTTP ${res.status}` });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setTestResult({ ok: false, message: e instanceof Error ? e.message : "Network error" });
|
||||
}
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
const hasKey = Object.values(credentials).some((v) => v.trim().length > 0);
|
||||
if (hasKey) {
|
||||
setTestResult({ ok: true, message: `Successfully connected to ${integration.name}` });
|
||||
} else {
|
||||
setTestResult({ ok: false, message: `No API key configured for ${integration.name}` });
|
||||
}
|
||||
}
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (integration.id === "stripe") {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await savePaymentSettings({
|
||||
brandId,
|
||||
provider: "stripe",
|
||||
stripePublishableKey: credentials["STRIPE_PUBLISHABLE_KEY"]?.trim() || undefined,
|
||||
stripeSecretKey: credentials["STRIPE_SECRET_KEY"]?.trim() || undefined,
|
||||
});
|
||||
if (!res.success) {
|
||||
setError(res.error ?? "Failed to save Stripe settings");
|
||||
} else {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to save");
|
||||
}
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
function toggleSync(key: string) {
|
||||
setSyncEnabled((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
function toggleSecret(key: string) {
|
||||
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
const iconColors: Record<string, string> = {
|
||||
AI: "bg-violet-900 text-violet-300",
|
||||
Email: "bg-amber-900 text-amber-300",
|
||||
Stripe: "bg-zinc-800 text-zinc-200",
|
||||
SMS: "bg-blue-900 text-blue-300",
|
||||
"3PL": "bg-blue-900 text-blue-300",
|
||||
FedEx: "bg-amber-900 text-amber-300",
|
||||
QB: "bg-green-900 text-green-300",
|
||||
CRM: "bg-orange-900 text-orange-300",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border p-5 bg-zinc-900 shadow-black/20 ring-1 ${integration.accentColor.replace("50", "950/50")}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-10 h-10 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[integration.icon] ?? "bg-zinc-800 text-zinc-300"}`}>{integration.icon}</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-zinc-100">{integration.name}</h3>
|
||||
<span className={`text-xs font-medium rounded-full px-2 py-0.5 ${
|
||||
enabled
|
||||
? "bg-green-900/60 text-green-300 border border-green-700"
|
||||
: "bg-zinc-800 text-zinc-500 border border-zinc-700"
|
||||
}`}>
|
||||
{enabled ? "Connected" : "Not Configured"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-400 mt-0.5">{integration.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-zinc-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-4 rtl:peer-checked:after:-translate-x-4 peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-zinc-900 after:border-zinc-600 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Sync options */}
|
||||
{integration.syncOptions && integration.syncOptions.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Features</p>
|
||||
<div className="space-y-2">
|
||||
{integration.syncOptions.map((opt) => (
|
||||
<label key={opt.key} className="flex items-start gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncEnabled[opt.key] ?? false}
|
||||
onChange={() => toggleSync(opt.key)}
|
||||
className="mt-0.5 rounded border-zinc-600 bg-zinc-800 text-violet-500 focus:ring-violet-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-zinc-300 group-hover:text-zinc-100">{opt.label}</span>
|
||||
{opt.description && (
|
||||
<span className="block text-xs text-zinc-500">{opt.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credentials */}
|
||||
<div className="space-y-3 mb-5">
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Credentials</p>
|
||||
{integration.credentials.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">{field.label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecrets[field.key] ? "text" : "password"}
|
||||
value={credentials[field.key] ?? ""}
|
||||
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm pr-16 text-zinc-100 outline-none focus:border-violet-500"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||
{field.isSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSecret(field.key)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
|
||||
>
|
||||
{showSecrets[field.key] ? "Hide" : "Show"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Environment toggle */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Environment</label>
|
||||
<div className="flex gap-2">
|
||||
{["sandbox", "production"].map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
onClick={() => setEnv(e)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
env === e
|
||||
? "bg-zinc-100 text-zinc-900"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{e.charAt(0).toUpperCase() + e.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={`mb-4 rounded-lg px-4 py-2.5 text-sm flex items-center gap-2 ${
|
||||
testResult.ok ? "bg-green-900/50 text-green-300 border border-green-700" : "bg-red-900/50 text-red-300 border border-red-700"
|
||||
}`}>
|
||||
<span>{testResult.ok ? "✓" : "✗"}</span>
|
||||
<span>{testResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg px-4 py-2.5 text-sm bg-red-900/50 text-red-300 border border-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !enabled}
|
||||
className="rounded-lg border border-zinc-600 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{testing ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 rounded-lg bg-violet-600 px-4 py-2 text-xs font-bold text-white hover:bg-violet-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom integration type ────────────────────────────────────────────────────
|
||||
|
||||
type CustomIntegration = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "ai_provider" | "payment_processor" | "other";
|
||||
enabled: boolean;
|
||||
credentials: { key: string; label: string; value: string; isSecret?: boolean }[];
|
||||
syncOptions: string[];
|
||||
testEndpoint?: string;
|
||||
};
|
||||
|
||||
// ── Main client component ───────────────────────────────────────────────────────
|
||||
|
||||
export default function IntegrationsClient({ brandId, brands, isPlatformAdmin, paymentSettings, resendConfigured }: Props) {
|
||||
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addSearch, setAddSearch] = useState("");
|
||||
const [customIntegrations, setCustomIntegrations] = useState<CustomIntegration[]>([]);
|
||||
const [showCustomForm, setShowCustomForm] = useState(false);
|
||||
const [newCustomName, setNewCustomName] = useState("");
|
||||
const [newCustomType, setNewCustomType] = useState<"ai_provider" | "payment_processor" | "other">("other");
|
||||
const [currentPaymentSettings, setCurrentPaymentSettings] = useState(paymentSettings);
|
||||
|
||||
// Re-fetch payment settings when brand changes
|
||||
useEffect(() => {
|
||||
if (!isPlatformAdmin) return;
|
||||
getPaymentSettings(selectedBrandId).then((result) => {
|
||||
if (result.success) setCurrentPaymentSettings(result.settings);
|
||||
});
|
||||
}, [selectedBrandId, isPlatformAdmin]);
|
||||
|
||||
// Integrations shown in the grid — OpenAI card is hidden since it's now handled by AIProviderPanel
|
||||
const gridIntegrations = INTEGRATIONS.filter((i) => i.id !== "openai");
|
||||
|
||||
const filtered = gridIntegrations.filter(
|
||||
(i) =>
|
||||
i.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
i.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const availableFiltered = AVAILABLE_INTEGRATIONS.filter(
|
||||
(a) =>
|
||||
!gridIntegrations.find((i) => i.id === a.id) &&
|
||||
(a.name.toLowerCase().includes(addSearch.toLowerCase()) ||
|
||||
a.description.toLowerCase().includes(addSearch.toLowerCase()))
|
||||
);
|
||||
|
||||
function addIntegration(app: AvailableIntegration) {
|
||||
setShowAddModal(false);
|
||||
setAddSearch("");
|
||||
}
|
||||
|
||||
function handleAddCustom() {
|
||||
if (!newCustomName.trim()) return;
|
||||
const newInt: CustomIntegration = {
|
||||
id: `custom_${Date.now()}`,
|
||||
name: newCustomName,
|
||||
type: newCustomType,
|
||||
enabled: false,
|
||||
credentials: [],
|
||||
syncOptions: [],
|
||||
};
|
||||
setCustomIntegrations((prev) => [...prev, newInt]);
|
||||
setNewCustomName("");
|
||||
setShowCustomForm(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-zinc-800 bg-zinc-900 px-6 py-4">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-zinc-800 border border-zinc-700">
|
||||
<svg className="h-5 w-5 text-zinc-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100">Integrations</h1>
|
||||
<p className="text-xs text-zinc-500">Connect payment processors, email providers, and other services.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="rounded-xl bg-violet-600 px-4 py-2.5 text-xs font-bold text-white hover:bg-violet-700 shrink-0"
|
||||
>
|
||||
+ Add Integration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
{/* Brand picker */}
|
||||
{isPlatformAdmin && brands.length > 0 && (
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-zinc-400">Brand</label>
|
||||
<select
|
||||
value={selectedBrandId}
|
||||
onChange={(e) => setSelectedBrandId(e.target.value)}
|
||||
className="rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-2.5 text-sm text-zinc-100 outline-none focus:border-violet-500"
|
||||
>
|
||||
{brands.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search integrations..."
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-violet-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI Provider panel */}
|
||||
<div className="mb-8">
|
||||
<AIProviderPanel brandId={selectedBrandId} />
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((int) => (
|
||||
<IntegrationCard
|
||||
key={int.id}
|
||||
integration={int}
|
||||
brandId={selectedBrandId}
|
||||
initialPaymentSettings={currentPaymentSettings}
|
||||
resendConfigured={resendConfigured}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom integrations */}
|
||||
{customIntegrations.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-sm font-semibold text-zinc-500 uppercase tracking-wider mb-4">Custom</h2>
|
||||
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{customIntegrations.map((ci) => (
|
||||
<div key={ci.id} className="rounded-2xl border border-zinc-700 bg-zinc-900 p-5">
|
||||
<p className="text-sm font-semibold text-zinc-200">{ci.name}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">{ci.type}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add integration modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowAddModal(false)}>
|
||||
<div className="w-full max-w-lg rounded-2xl bg-zinc-900 border border-zinc-700 p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-zinc-100">Add Integration</h2>
|
||||
<button onClick={() => setShowAddModal(false)} className="text-zinc-500 hover:text-zinc-300">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={addSearch}
|
||||
onChange={(e) => setAddSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full rounded-xl border border-zinc-600 bg-zinc-800 px-4 py-3 text-sm text-zinc-100 outline-none focus:border-violet-500 mb-4"
|
||||
/>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{availableFiltered.map((app) => (
|
||||
<button
|
||||
key={app.id}
|
||||
onClick={() => addIntegration(app)}
|
||||
className="w-full flex items-center gap-3 rounded-xl border border-zinc-700 bg-zinc-800 p-4 text-left hover:bg-zinc-700"
|
||||
>
|
||||
<span className={`w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold ${iconColors[app.icon] ?? "bg-zinc-700 text-zinc-300"}`}>{app.icon}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">{app.name}</p>
|
||||
<p className="text-xs text-zinc-500">{app.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function IntegrationsRedirect() {
|
||||
redirect("/admin/settings#integrations");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminUsers, getBrands } from "@/actions/admin/users";
|
||||
import TimeTrackingSettingsClient from "@/components/admin/TimeTrackingSettingsClient";
|
||||
import UsersPage from "@/components/admin/UsersPage";
|
||||
import SettingsSections from "@/components/admin/SettingsSections";
|
||||
import IntegrationsInner from "@/components/admin/IntegrationsInner";
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const brandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [{ users, error }, { brands }] = await Promise.all([
|
||||
getAdminUsers(adminUser.role === "platform_admin" ? undefined : (adminUser.brand_id ?? undefined)),
|
||||
getBrands(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-5xl space-y-10">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-3">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Settings</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Settings</h1>
|
||||
<p className="mt-1.5 text-sm text-stone-500">Manage your brand, workers, tasks, users, and integrations.</p>
|
||||
</div>
|
||||
|
||||
{/* Nav to anchor sections */}
|
||||
<div className="flex flex-wrap gap-3 border-b border-stone-200 pb-4">
|
||||
<a href="#general" className="text-xs font-medium text-stone-500 hover:text-stone-800 transition-colors">General</a>
|
||||
<span className="text-stone-300">·</span>
|
||||
<a href="#workers" className="text-xs font-medium text-stone-500 hover:text-stone-800 transition-colors">Workers & PINs</a>
|
||||
<span className="text-stone-300">·</span>
|
||||
<a href="#tasks" className="text-xs font-medium text-stone-500 hover:text-stone-800 transition-colors">Tasks</a>
|
||||
<span className="text-stone-300">·</span>
|
||||
<a href="#users" className="text-xs font-medium text-stone-500 hover:text-stone-800 transition-colors">Users & Permissions</a>
|
||||
<span className="text-stone-300">·</span>
|
||||
<a href="#integrations" className="text-xs font-medium text-stone-500 hover:text-stone-800 transition-colors">Integrations</a>
|
||||
</div>
|
||||
|
||||
{/* Section 1: General Settings */}
|
||||
<section id="general">
|
||||
<SettingsSections brandId={brandId} />
|
||||
</section>
|
||||
|
||||
{/* Section 4: Users & Permissions */}
|
||||
<section id="users">
|
||||
<div className="border-t border-stone-200 pt-10">
|
||||
<h2 className="text-lg font-bold text-stone-950 mb-4">Users & Permissions</h2>
|
||||
<UsersPage
|
||||
initialUsers={error ? [] : users}
|
||||
brands={brands}
|
||||
currentUser={{
|
||||
id: adminUser.id ?? adminUser.user_id,
|
||||
role: adminUser.role,
|
||||
can_manage_users: adminUser.can_manage_users,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 5: Integrations */}
|
||||
<section id="integrations">
|
||||
<div className="border-t border-stone-200 pt-10">
|
||||
<h2 className="text-lg font-bold text-stone-950 mb-4">Integrations & Exports</h2>
|
||||
<IntegrationsInner brandId={brandId} brands={brands} />
|
||||
|
||||
{/* Time Tracking Exports */}
|
||||
<div className="mt-6 border-t border-stone-200 pt-6">
|
||||
<h3 className="text-base font-semibold text-stone-800 mb-4">Time Tracking Exports</h3>
|
||||
<TimeTrackingSettingsClient brandId={brandId} />
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 mt-3">
|
||||
For more settings, see{" "}
|
||||
<a href="/admin/settings/ai" className="text-emerald-600 hover:text-emerald-700 underline underline-offset-2">AI Tools</a>
|
||||
{" "}and{" "}
|
||||
<a href="/admin/settings/shipping" className="text-emerald-600 hover:text-emerald-700 underline underline-offset-2">Shipping</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import PaymentSettingsForm from "@/components/admin/PaymentSettingsForm";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
|
||||
export default async function PaymentSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_orders || adminUser.role === "store_employee") redirect("/admin/pickup");
|
||||
|
||||
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 ?? []
|
||||
: [];
|
||||
|
||||
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
|
||||
|
||||
const result = await getPaymentSettings(effectiveBrandId);
|
||||
const settings = result.success ? result.settings : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Payments</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-stone-200 border border-stone-300">
|
||||
<svg className="h-5 w-5 text-stone-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m3.75 0h3m3.75 0h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m-3.75 0h3m3.75 0h3m-3.75 0h3m-3.75 0h3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-stone-950">Payment Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Configure your payment provider for checkout processing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-xl">
|
||||
<PaymentSettingsForm
|
||||
settings={settings}
|
||||
brandId={effectiveBrandId}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getShippingSettings } from "@/actions/shipping/settings";
|
||||
import ShippingSettingsForm from "@/components/admin/ShippingSettingsForm";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
|
||||
export default async function ShippingSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_orders || adminUser.role === "store_employee") {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
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 ?? []
|
||||
: [];
|
||||
|
||||
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
|
||||
|
||||
const result = await getShippingSettings(effectiveBrandId);
|
||||
const settings = result.success ? result.settings : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-10">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-800 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-800">Shipping</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-stone-200 border border-stone-300">
|
||||
<svg className="h-5 w-5 text-stone-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25m-2.25 0h2.25m0 0v-.375c0-.621-.504-1.125-1.125-1.125H15m-1.5-3l1.5 0l.75 0v-.375c0-.621-.504-1.125-1.125-1.125H15m0 0v-.375c0-.621-.504-1.125-1.125-1.125H12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-stone-950">Shipping Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-stone-500">
|
||||
Configure FedEx integration for shipping fresh produce — sweet corn, onions, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-xl">
|
||||
<ShippingSettingsForm
|
||||
settings={settings}
|
||||
brandId={effectiveBrandId}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { syncSquareNow, getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import { savePaymentSettings } from "@/actions/payments";
|
||||
|
||||
type InventoryMode = "none" | "rc_to_square" | "square_to_rc" | "bidirectional";
|
||||
|
||||
type Props = {
|
||||
settings: {
|
||||
provider?: string | null;
|
||||
square_access_token?: string | null;
|
||||
square_location_id?: string | null;
|
||||
square_sync_enabled?: boolean;
|
||||
square_inventory_mode?: InventoryMode;
|
||||
square_last_sync_at?: string | null;
|
||||
square_last_sync_error?: string | null;
|
||||
} | null;
|
||||
logs: SyncLogEntry[];
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
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
|
||||
);
|
||||
const [squareInventoryMode, setSquareInventoryMode] = useState<InventoryMode>(
|
||||
settings?.square_inventory_mode ?? "none"
|
||||
);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
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);
|
||||
|
||||
async function handleSaveSettings() {
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
const result = await savePaymentSettings({
|
||||
brandId,
|
||||
provider: (settings?.provider as any) || null,
|
||||
squareSyncEnabled,
|
||||
squareInventoryMode,
|
||||
});
|
||||
setSaving(false);
|
||||
if (result.success) {
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} else {
|
||||
setError(result.error ?? "Failed to save settings");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncNow(type: "products" | "orders" | "all") {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
const result = await syncSquareNow(brandId, type);
|
||||
setSyncMsg({
|
||||
kind: result.success ? "success" : "error",
|
||||
text: result.success
|
||||
? `Sync complete — ${result.synced} item(s) synced.`
|
||||
: `Sync failed: ${result.errors[0] ?? "Unknown error"}`,
|
||||
});
|
||||
setSyncing(false);
|
||||
const logResult = await getSyncLog(brandId);
|
||||
if (logResult.success) setDisplayLogs(logResult.logs);
|
||||
}
|
||||
|
||||
const hasToken = !!settings?.square_access_token;
|
||||
const lastSyncAt = settings?.square_last_sync_at
|
||||
? formatDateTime(settings.square_last_sync_at)
|
||||
: "Never";
|
||||
const lastError = settings?.square_last_sync_error;
|
||||
const isEnabled = settings?.square_sync_enabled && hasToken;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{/* Top nav */}
|
||||
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
|
||||
<div className="mx-auto max-w-4xl flex items-center gap-3">
|
||||
<Link href="/admin" className="text-sm text-zinc-500 hover:text-zinc-300">Admin</Link>
|
||||
<span className="text-slate-400">/</span>
|
||||
<Link href="/admin/settings" className="text-sm text-zinc-500 hover:text-zinc-300">Settings</Link>
|
||||
<span className="text-slate-400">/</span>
|
||||
<span className="text-sm font-medium text-zinc-100">Square Sync</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-900/30 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{syncMsg && (
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${
|
||||
syncMsg.kind === "success"
|
||||
? "border-green-200 bg-green-900/30 text-green-400"
|
||||
: "border-red-200 bg-red-900/30 text-red-400"
|
||||
}`}>
|
||||
{syncMsg.text}
|
||||
</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>
|
||||
</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"
|
||||
>
|
||||
{syncing ? "Syncing..." : "Sync All Now"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync settings */}
|
||||
{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">
|
||||
{/* 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{squareSyncEnabled && (
|
||||
<>
|
||||
{/* Inventory mode */}
|
||||
<div>
|
||||
<p className="mb-2 font-medium text-zinc-100">Inventory Direction</p>
|
||||
<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: "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 ${
|
||||
squareInventoryMode === opt.value
|
||||
? "border-green-600 bg-green-900/30 text-green-900"
|
||||
: "border-zinc-800 text-zinc-400 hover:bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<p className="font-semibold">{opt.label}</p>
|
||||
<p className="text-xs mt-0.5 opacity-70">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual sync buttons */}
|
||||
<div>
|
||||
<p className="mb-2 font-medium text-zinc-100">Manual Sync</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
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
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
</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">
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Payment Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getPaymentSettings } from "@/actions/payments";
|
||||
import { getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import SquareSyncSettingsClient from "./SquareSyncSettingsClient";
|
||||
|
||||
export default async function SquareSyncSettingsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
if (!adminUser.can_manage_orders) redirect("/admin/pickup");
|
||||
|
||||
const brandId =
|
||||
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [settingsResult, logResult] = await Promise.all([
|
||||
getPaymentSettings(brandId),
|
||||
getSyncLog(brandId),
|
||||
]);
|
||||
|
||||
const settings = settingsResult.success ? settingsResult.settings : null;
|
||||
const logs: SyncLogEntry[] = logResult.success ? logResult.logs : [];
|
||||
|
||||
return (
|
||||
<SquareSyncSettingsClient
|
||||
settings={settings as any}
|
||||
logs={logs}
|
||||
brandId={brandId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getShippingOrders } from "@/actions/shipping";
|
||||
import ShippingFulfillmentPanel from "@/components/admin/ShippingFulfillmentPanel";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ShippingFulfillmentPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const { orders } = await getShippingOrders();
|
||||
|
||||
return (
|
||||
<ShippingFulfillmentPanel
|
||||
initialOrders={orders ?? []}
|
||||
canManageOrders={adminUser.can_manage_orders ?? false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import StopEditForm from "@/components/admin/StopEditForm";
|
||||
import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
||||
import MessageCustomersSection from "@/components/admin/MessageCustomersSection";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type StopDetailPageProps = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const [{ data: stop, error }, { data: brands }] = await Promise.all([
|
||||
supabase
|
||||
.from("stops")
|
||||
.select("*, brands(name, slug)")
|
||||
.eq("id", id)
|
||||
.single(),
|
||||
supabase.from("brands").select("id, name, slug"),
|
||||
]);
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||
|
||||
if (adminUser.brand_id && stop?.brand_id !== adminUser.brand_id) {
|
||||
return <AdminAccessDenied />;
|
||||
}
|
||||
|
||||
if (error || !stop) {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-600">Stop not found</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
|
||||
{error?.message ?? "Stop not found"}
|
||||
</pre>
|
||||
<a
|
||||
href="/admin/stops"
|
||||
className="mt-4 inline-block text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Stops
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const [{ data: allProducts }, { data: productStops }] = await Promise.all([
|
||||
supabase
|
||||
.from("products")
|
||||
.select("id, name, type, price")
|
||||
.eq("brand_id", stop.brand_id)
|
||||
.eq("active", true),
|
||||
supabase
|
||||
.from("product_stops")
|
||||
.select("id, product_id, products(id, name, type, price)")
|
||||
.eq("stop_id", id),
|
||||
]);
|
||||
|
||||
const assignedProducts = (productStops ?? [])
|
||||
.map((ps: any) => ps)
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<a
|
||||
href="/admin/stops"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Stops
|
||||
</a>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-stone-500">
|
||||
{stop.brands?.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl font-bold text-stone-950">
|
||||
{stop.city}, {stop.state}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-stone-600">{stop.location}</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-4 py-2 text-sm font-medium ${
|
||||
stop.active
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-stone-200 text-stone-500"
|
||||
}`}
|
||||
>
|
||||
{stop.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Date</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{stop.date}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Time</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{stop.time}
|
||||
</p>
|
||||
</div>
|
||||
{stop.address && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Address</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{stop.address}
|
||||
{stop.zip ? `, ${stop.zip}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{stop.cutoff_time && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">Cutoff</p>
|
||||
<p className="mt-1 text-lg font-semibold text-stone-950">
|
||||
{new Date(stop.cutoff_time).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<a
|
||||
href={`/admin/stops/new?duplicate=${stop.id}`}
|
||||
className="rounded-xl border border-stone-300 bg-white px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50"
|
||||
>
|
||||
Duplicate Stop
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<h2 className="text-2xl font-bold text-stone-950">
|
||||
Assigned Products
|
||||
</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Manage which products are available at this stop.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<StopProductAssignment
|
||||
stopId={stop.id}
|
||||
allProducts={allProducts ?? []}
|
||||
assignedProducts={assignedProducts}
|
||||
callerUid={adminUser.user_id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Edit Stop</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Update stop details, location, and availability.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<StopEditForm
|
||||
stop={{
|
||||
id: stop.id,
|
||||
city: stop.city,
|
||||
state: stop.state,
|
||||
date: stop.date,
|
||||
time: stop.time,
|
||||
location: stop.location,
|
||||
slug: stop.slug,
|
||||
active: stop.active,
|
||||
brand_id: stop.brand_id,
|
||||
address: stop.address,
|
||||
zip: stop.zip,
|
||||
cutoff_time: stop.cutoff_time,
|
||||
}}
|
||||
brands={brands ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Customers */}
|
||||
<div className="mt-6 rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<h2 className="text-2xl font-bold text-stone-950">Message Customers</h2>
|
||||
<p className="mt-1 text-stone-600">
|
||||
Send updates to customers with pending pickups at this stop.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<MessageCustomersSection stopId={stop.id} brandId={stop.brand_id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import NewStopForm from "@/components/admin/NewStopForm";
|
||||
import StopProductAssignment from "@/components/admin/StopProductAssignment";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type Stop = {
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
brand_id: string;
|
||||
active: boolean;
|
||||
address?: string | null;
|
||||
zip?: string | null;
|
||||
cutoff_time?: string | null;
|
||||
};
|
||||
|
||||
export default async function NewStopPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ duplicate?: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
const { duplicate } = await searchParams;
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||
|
||||
let duplicateFrom: Stop | null = null;
|
||||
if (duplicate) {
|
||||
const { data } = await supabase
|
||||
.from("stops")
|
||||
.select("city, state, location, date, time, brand_id, active, address, zip, cutoff_time")
|
||||
.eq("id", duplicate)
|
||||
.single();
|
||||
duplicateFrom = data;
|
||||
}
|
||||
|
||||
const brandId =
|
||||
adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
const [{ data: allProducts }] = await Promise.all([
|
||||
supabase
|
||||
.from("products")
|
||||
.select("id, name, type, price")
|
||||
.eq("brand_id", brandId)
|
||||
.eq("active", true),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<a
|
||||
href="/admin/stops"
|
||||
className="text-sm text-stone-500 hover:text-stone-700"
|
||||
>
|
||||
← Back to Stops
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-8 border border-stone-200 shadow-lg">
|
||||
<h1 className="text-3xl font-bold text-stone-950">
|
||||
{duplicateFrom ? "Duplicate Stop" : "Create Stop"}
|
||||
</h1>
|
||||
|
||||
<p className="mt-2 text-stone-600">
|
||||
{duplicateFrom
|
||||
? `Pre-filled from ${duplicateFrom.city}, ${duplicateFrom.state}. Edit and save to create.`
|
||||
: "Add a new tour stop for Tuxedo Corn or Indian River Direct."}
|
||||
</p>
|
||||
|
||||
<NewStopForm duplicateFrom={duplicateFrom} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import StopTableClient from "@/components/admin/StopTableClient";
|
||||
import StopsHeaderActions from "@/components/admin/StopsHeaderActions";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminStopsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
if (!adminUser.can_manage_stops) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
let query = supabase
|
||||
.from("stops")
|
||||
.select(`
|
||||
id,
|
||||
city,
|
||||
state,
|
||||
date,
|
||||
time,
|
||||
location,
|
||||
active,
|
||||
deleted_at,
|
||||
brand_id,
|
||||
address,
|
||||
zip,
|
||||
cutoff_time,
|
||||
status,
|
||||
brands (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.is("deleted_at", null)
|
||||
.order("date", { ascending: true });
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
query = query.eq("brand_id", adminUser.brand_id);
|
||||
}
|
||||
|
||||
const { data: stops, error } = await query;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Tour Stops</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-bold text-red-600">
|
||||
Error loading stops
|
||||
</h1>
|
||||
<pre className="mt-4 rounded-xl bg-white border border-stone-200 p-4 text-sm text-stone-600">
|
||||
{error.message}
|
||||
</pre>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-stone-500 mb-6">
|
||||
<a href="/admin" className="hover:text-stone-600 transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-stone-600">Tour Stops</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-stone-950 tracking-tight">Tour Stops</h1>
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
{adminUser?.brand_id
|
||||
? "Managing stops for your brand."
|
||||
: "Manage routes, pickup locations, dates, and cutoff times."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<StopsHeaderActions brandId={adminUser?.brand_id ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-2xl bg-white border border-stone-200 shadow-sm">
|
||||
<StopTableClient stops={stops ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import TaxDashboard from "@/components/admin/TaxDashboard";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ brandId?: string }>;
|
||||
};
|
||||
|
||||
export default async function TaxesPage({ params }: Props) {
|
||||
const { brandId: brandIdParam } = await params;
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
|
||||
let resolvedBrandId = effectiveBrandId;
|
||||
if (isPlatformAdmin && !resolvedBrandId) {
|
||||
const { data: firstBrand } = await supabase
|
||||
.from("brands")
|
||||
.select("id")
|
||||
.limit(1)
|
||||
.single();
|
||||
if (firstBrand?.id) {
|
||||
resolvedBrandId = firstBrand.id;
|
||||
} else {
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100 px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="card p-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-stone-950">No Brands Found</h1>
|
||||
<p className="mt-2 text-stone-500">Create a brand in the database first.</p>
|
||||
<a href="/admin" className="mt-4 inline-block rounded-xl bg-stone-200 px-6 py-3 text-sm font-medium text-stone-700 border border-stone-300 hover:bg-stone-300 transition-colors">
|
||||
Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedBrandId) return <AdminAccessDenied />;
|
||||
|
||||
const { data: brands } = await supabase
|
||||
.from("brands")
|
||||
.select("id, name")
|
||||
.order("name");
|
||||
|
||||
const allBrands = (brands ?? []) as Array<{ id: string; name: string }>;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-stone-100">
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-stone-950">Tax Dashboard</h1>
|
||||
<p className="mt-1 text-stone-500">
|
||||
Sales tax collected on orders shipped to nexus states.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TaxDashboard
|
||||
brands={allBrands}
|
||||
initialBrandId={resolvedBrandId}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TestAuthPage() {
|
||||
const cookieStore = await cookies();
|
||||
const allCookies = cookieStore.getAll();
|
||||
|
||||
let adminUser = null;
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
adminUser = await getAdminUser();
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">All Cookies ({allCookies.length})</h2>
|
||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}${c.value.length > 80 ? "..." : ""}`).join("\n") || "(none)"}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_auth_uid</h2>
|
||||
<p className="text-lg font-mono">
|
||||
{allCookies.some(c => c.name === "rc_auth_uid")
|
||||
? <span className="text-emerald-400">SET — {allCookies.find(c => c.name === "rc_auth_uid")?.value}</span>
|
||||
: <span className="text-red-400">NOT SET</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token</h2>
|
||||
<p className="text-lg font-mono">
|
||||
{allCookies.some(c => c.name === "rc_access_token")
|
||||
? <span className="text-yellow-400">SET (not needed)</span>
|
||||
: <span className="text-zinc-500">NOT SET (OK)</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
||||
{error ? (
|
||||
<div>
|
||||
<p className="text-red-400 font-bold">ERROR</p>
|
||||
<pre className="bg-red-950/50 p-4 rounded-xl text-sm mt-2">{error}</pre>
|
||||
</div>
|
||||
) : adminUser ? (
|
||||
<div>
|
||||
<p className="text-emerald-400 font-bold">AUTHENTICATED</p>
|
||||
<pre className="bg-black/50 p-4 rounded-xl text-sm mt-2 overflow-auto">
|
||||
{JSON.stringify({
|
||||
id: adminUser.id,
|
||||
user_id: adminUser.user_id,
|
||||
role: adminUser.role,
|
||||
brand_id: adminUser.brand_id,
|
||||
active: adminUser.active,
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-red-400">NOT AUTHENTICATED — null returned</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-zinc-800">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Quick Actions</h2>
|
||||
<div className="flex gap-4">
|
||||
<a href="/admin" className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-sm font-bold hover:bg-emerald-500">
|
||||
Go to Admin
|
||||
</a>
|
||||
<form action="/api/logout" method="POST">
|
||||
<button type="submit" className="px-4 py-2 bg-zinc-800 text-white rounded-xl text-sm font-bold hover:bg-zinc-700">
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TestPage() {
|
||||
const cookieStore = await cookies();
|
||||
const allCookies = cookieStore.getAll();
|
||||
let adminUser = null;
|
||||
let adminUserError: string | null = null;
|
||||
|
||||
try {
|
||||
adminUser = await getAdminUser();
|
||||
} catch (e: any) {
|
||||
adminUserError = e?.message ?? String(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 p-8 text-white">
|
||||
<h1 className="text-2xl font-bold mb-6 text-emerald-400">Auth Debug</h1>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">Server cookies ({allCookies.length})</h2>
|
||||
<pre className="bg-black/50 p-4 rounded-xl text-xs overflow-auto max-h-64">
|
||||
{allCookies.map(c => `${c.name}=${c.value.slice(0, 80)}...`).join("\n") || "(none)"}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">rc_access_token present?</h2>
|
||||
<p className="text-lg font-mono">
|
||||
{allCookies.some(c => c.name === "rc_access_token")
|
||||
? <span className="text-emerald-400">YES — {allCookies.find(c => c.name === "rc_access_token")?.value.length} chars</span>
|
||||
: <span className="text-red-400">NO</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-stone-400 uppercase mb-2">getAdminUser() result</h2>
|
||||
{adminUserError ? (
|
||||
<p className="text-red-400">ERROR: {adminUserError}</p>
|
||||
) : (
|
||||
<pre className="bg-black/50 p-4 rounded-xl text-sm overflow-auto">
|
||||
{JSON.stringify(adminUser, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import TimeTrackingAdminPanel from "@/components/admin/TimeTrackingAdminPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const IRD_BRAND_ID = "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminTimeTrackingPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
const isTuxedoAdmin = adminUser.role === "brand_admin" && adminUser.brand_id === TUXEDO_BRAND_ID;
|
||||
const isIRDAdmin = adminUser.role === "brand_admin" && adminUser.brand_id === IRD_BRAND_ID;
|
||||
|
||||
if (!isPlatformAdmin && !isTuxedoAdmin && !isIRDAdmin) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? TUXEDO_BRAND_ID;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<TimeTrackingAdminPanel brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function TimeTrackingSettingsRedirect() {
|
||||
redirect("/admin/settings#workers");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function UsersRedirect() {
|
||||
redirect("/admin/settings#users");
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getWaterAdminSession } from "@/actions/water-log/field";
|
||||
import { getWaterEntryById } from "@/actions/water-log/admin";
|
||||
import WaterLogEntryEditForm from "@/components/admin/WaterLogEntryEditForm";
|
||||
import Link from "next/link";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type EntryPageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ from?: string }>;
|
||||
};
|
||||
|
||||
export default async function WaterLogEntryPage({ params, searchParams }: EntryPageProps) {
|
||||
const { id } = await params;
|
||||
const { from } = await searchParams;
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
const waterSession = await getWaterAdminSession();
|
||||
|
||||
const isSiteAdmin =
|
||||
adminUser?.role === "platform_admin" ||
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
||||
|
||||
if (!isSiteAdmin && !isWaterAdmin) {
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-400">Access Denied</h1>
|
||||
<p className="mt-2 text-zinc-400">You do not have permission to edit water log entries.</p>
|
||||
<a href={from ?? "/admin/water-log"} className="mt-4 inline-block text-zinc-400 hover:text-zinc-100">
|
||||
← Back
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const entry = await getWaterEntryById(id);
|
||||
|
||||
if (!entry) {
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-400">Entry not found</h1>
|
||||
<p className="mt-2 text-zinc-400">This entry may have been deleted.</p>
|
||||
<a href={from ?? "/admin/water-log"} className="mt-4 inline-block text-zinc-400 hover:text-zinc-100">
|
||||
← Back
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const backHref = from ?? "/admin/water-log";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-zinc-500">Water Log Entry</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-zinc-100">{entry.headgate_name}</h1>
|
||||
<p className="mt-1 text-zinc-400">
|
||||
Submitted by {entry.user_name}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
entry.submitted_via === "field"
|
||||
? "bg-blue-900/40 text-blue-700"
|
||||
: "bg-zinc-950 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{entry.submitted_via}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-500">Measurement</p>
|
||||
<p className="mt-1 text-2xl font-bold text-zinc-100">
|
||||
{entry.measurement} {entry.unit}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-500">When</p>
|
||||
<p className="mt-1 text-lg font-semibold text-zinc-100">
|
||||
{new Date(entry.logged_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-500">Notes</p>
|
||||
<p className="mt-1 text-zinc-100">{entry.notes ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<h2 className="text-2xl font-bold text-zinc-100">Edit Entry</h2>
|
||||
<p className="mt-1 text-zinc-400">
|
||||
Update measurement or notes. User, headgate, and timestamp cannot be changed.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<WaterLogEntryEditForm entry={entry} brandId={TUXEDO_BRAND_ID} backHref={backHref} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
getWaterHeadgatesAdmin,
|
||||
createWaterHeadgate,
|
||||
regenerateHeadgateToken,
|
||||
updateWaterHeadgate,
|
||||
} from "@/actions/water-log/admin";
|
||||
|
||||
type Headgate = {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
unit: string;
|
||||
created_at: string;
|
||||
deleted_at?: string | null;
|
||||
headgate_token?: string | null;
|
||||
last_used_at?: string | null;
|
||||
high_threshold?: number | null;
|
||||
low_threshold?: number | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialHeadgates: Headgate[];
|
||||
brandId: string;
|
||||
};
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
|
||||
|
||||
export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
const router = useRouter();
|
||||
const [headgates, setHeadgates] = useState<Headgate[]>(initialHeadgates);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newUnit, setNewUnit] = useState("CFS");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [qrModal, setQrModal] = useState<Headgate | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [printLoading, setPrintLoading] = useState(false);
|
||||
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
|
||||
|
||||
// Edit modal state
|
||||
const [editHg, setEditHg] = useState<Headgate | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editUnit, setEditUnit] = useState("CFS");
|
||||
const [editActive, setEditActive] = useState(true);
|
||||
const [editHigh, setEditHigh] = useState("");
|
||||
const [editLow, setEditLow] = useState("");
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
|
||||
function showToast(msg: string, ok = true) {
|
||||
setToast({ msg, ok });
|
||||
setTimeout(() => setToast(null), 2500);
|
||||
}
|
||||
|
||||
function openEdit(hg: Headgate) {
|
||||
setEditHg(hg);
|
||||
setEditName(hg.name);
|
||||
setEditUnit(hg.unit);
|
||||
setEditActive(hg.active);
|
||||
setEditHigh(hg.high_threshold != null ? String(hg.high_threshold) : "");
|
||||
setEditLow(hg.low_threshold != null ? String(hg.low_threshold) : "");
|
||||
}
|
||||
|
||||
async function handleEditSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editHg) return;
|
||||
setEditSaving(true);
|
||||
const res = await updateWaterHeadgate(
|
||||
editHg.id,
|
||||
editName,
|
||||
editActive,
|
||||
editUnit,
|
||||
editHigh ? parseFloat(editHigh) : null,
|
||||
editLow ? parseFloat(editLow) : null
|
||||
);
|
||||
if (res.success) {
|
||||
const refreshed = await getWaterHeadgatesAdmin(brandId);
|
||||
setHeadgates(refreshed);
|
||||
setEditHg(null);
|
||||
showToast("Headgate updated");
|
||||
} else {
|
||||
showToast(res.error ?? "Failed", false);
|
||||
}
|
||||
setEditSaving(false);
|
||||
}
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
setSaving(true);
|
||||
const res = await createWaterHeadgate(brandId, newName.trim(), newUnit);
|
||||
if (res.success) {
|
||||
const refreshed = await getWaterHeadgatesAdmin(brandId);
|
||||
setHeadgates(refreshed);
|
||||
setNewName("");
|
||||
setShowAdd(false);
|
||||
showToast("Headgate added");
|
||||
} else {
|
||||
showToast(res.error ?? "Failed", false);
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleRegenerate(hg: Headgate) {
|
||||
if (!confirm(`Regenerate token for "${hg.name}"? Existing QR codes will stop working.`)) return;
|
||||
const res = await regenerateHeadgateToken(hg.id);
|
||||
if (res.success) {
|
||||
setHeadgates((prev) =>
|
||||
prev.map((h) => (h.id === hg.id ? { ...h, headgate_token: res.token! } : h))
|
||||
);
|
||||
showToast("Token regenerated");
|
||||
} else {
|
||||
showToast(res.error ?? "Failed", false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (selected.size === headgates.length) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(headgates.map((h) => h.id)));
|
||||
}
|
||||
}
|
||||
|
||||
async function printSelected() {
|
||||
if (selected.size === 0) return;
|
||||
setPrintLoading(true);
|
||||
const hgs = headgates.filter((h) => selected.has(h.id));
|
||||
try {
|
||||
const res = await fetch("/api/water-qr-sheet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ headgates: hgs, baseUrl: BASE_URL }),
|
||||
});
|
||||
const html = await res.text();
|
||||
openPrintWindow(html);
|
||||
} finally {
|
||||
setPrintLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPrintWindow(html: string) {
|
||||
const w = window.open("", "_blank", "width=700,height=700");
|
||||
if (w) {
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{/* Header */}
|
||||
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
|
||||
<div className="mx-auto max-w-4xl flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => router.back()} className="text-stone-400 hover:text-stone-600 text-sm">← Back</button>
|
||||
<h1 className="text-2xl font-bold text-zinc-100">Headgates & QR Codes</h1>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500 mt-0.5">Manage headgates and generate QR codes for field access</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAdd(true)} className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">
|
||||
+ Add Headgate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 py-6 space-y-6">
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{headgates.length > 0 && (
|
||||
<div className="flex items-center gap-3 rounded-xl bg-zinc-900 ring-1 ring-stone-200 px-4 py-3">
|
||||
<button onClick={toggleAll} className="text-sm text-stone-600 hover:text-zinc-100">
|
||||
{selected.size === headgates.length ? "Deselect all" : "Select all"}
|
||||
</button>
|
||||
<span className="text-xs text-stone-400">|</span>
|
||||
<span className="text-xs text-stone-500">{selected.size} selected</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={printSelected}
|
||||
disabled={selected.size === 0 || printLoading}
|
||||
className="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-40 hover:bg-blue-700"
|
||||
>
|
||||
{printLoading ? "..." : `Print ${selected.size > 0 ? `(${selected.size})` : ""} QR Codes`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add form */}
|
||||
{showAdd && (
|
||||
<div className="rounded-xl bg-zinc-900 ring-1 ring-stone-200 p-5">
|
||||
<form onSubmit={handleAdd} className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-semibold text-stone-600 mb-1">Headgate Name</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="e.g. North Field Gate 1"
|
||||
className="w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-stone-900"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-600 mb-1">Unit</label>
|
||||
<select
|
||||
value={newUnit}
|
||||
onChange={(e) => setNewUnit(e.target.value)}
|
||||
className="rounded-xl border border-stone-300 px-3 py-3 text-sm outline-none focus:border-stone-900"
|
||||
>
|
||||
{["CFS", "GPM", "Inches", "AF/Day"].map((u) => (
|
||||
<option key={u} value={u}>{u}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" disabled={saving} className="rounded-xl bg-green-600 px-5 py-3 text-sm font-bold text-white disabled:opacity-50">
|
||||
{saving ? "..." : "Create"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowAdd(false)} className="rounded-xl border border-stone-300 px-4 py-3 text-sm text-stone-600">Cancel</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{headgates.length === 0 ? (
|
||||
<div className="rounded-xl bg-zinc-900 py-16 text-center text-stone-400 ring-1 ring-stone-200">
|
||||
No headgates yet. Create one to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-stone-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-100 bg-zinc-950 text-left">
|
||||
<th className="px-4 py-3 w-8">
|
||||
<input type="checkbox" className="rounded" onChange={toggleAll} checked={selected.size === headgates.length} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-stone-500">Name</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-stone-500">Token</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-stone-500">Unit</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-stone-500">Last Used</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-stone-500">Status</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-stone-500 text-right">QR Code</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{headgates.map((hg) => (
|
||||
<tr key={hg.id} className={`border-b border-stone-50 last:border-0 hover:bg-zinc-950 ${selected.has(hg.id) ? "bg-blue-900/30" : ""}`}>
|
||||
<td className="px-4 py-3">
|
||||
<input type="checkbox" className="rounded" checked={selected.has(hg.id)} onChange={() => toggleSelect(hg.id)} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-zinc-100">{hg.name}</span>
|
||||
{hg.high_threshold != null && (
|
||||
<span className="inline-flex items-center rounded-full bg-red-900/30 border border-red-200 px-1.5 py-0.5 text-xs font-semibold text-red-400">
|
||||
High: {hg.high_threshold}
|
||||
</span>
|
||||
)}
|
||||
{hg.low_threshold != null && (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-900/30 border border-blue-200 px-1.5 py-0.5 text-xs font-semibold text-blue-700">
|
||||
Low: {hg.low_threshold}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-xs text-stone-500">{hg.headgate_token?.slice(0, 8)}…</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-stone-500">{hg.unit}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-stone-500">
|
||||
{hg.last_used_at
|
||||
? new Date(hg.last_used_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
: "Never"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold ${hg.active ? "bg-green-900/40 text-green-400" : "bg-stone-100 text-stone-500"}`}>
|
||||
{hg.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => openEdit(hg)} className="rounded-lg border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs font-medium text-stone-600 hover:bg-zinc-950">
|
||||
Edit
|
||||
</button>
|
||||
{/* QR preview thumbnail */}
|
||||
<button onClick={() => setQrModal(hg)} className="hover:opacity-80" title="View / Print QR">
|
||||
<img
|
||||
src={`/api/water-qr?token=${hg.headgate_token}&size=80`}
|
||||
alt={`QR for ${hg.name}`}
|
||||
className="w-10 h-10 rounded border border-zinc-800"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setQrModal(hg)}
|
||||
className="rounded-lg border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs font-medium text-stone-600 hover:bg-zinc-950"
|
||||
>
|
||||
Print
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed bottom-6 right-6 rounded-xl px-4 py-3 text-sm font-semibold shadow-lg ${toast.ok ? "bg-green-900/40 text-green-800 border border-green-200" : "bg-red-900/40 text-red-800 border border-red-200"}`}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editHg && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setEditHg(null)}>
|
||||
<div className="bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-zinc-800">
|
||||
<p className="font-bold text-zinc-100">Edit Headgate</p>
|
||||
<button onClick={() => setEditHg(null)} className="text-stone-400 hover:text-stone-600 text-xl leading-none">✕</button>
|
||||
</div>
|
||||
<form onSubmit={handleEditSave} className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-600 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-stone-900"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-600 mb-1">Unit</label>
|
||||
<select
|
||||
value={editUnit}
|
||||
onChange={(e) => setEditUnit(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-300 px-3 py-3 text-sm outline-none focus:border-stone-900"
|
||||
>
|
||||
{["CFS", "GPM", "Inches", "AF/Day"].map((u) => (
|
||||
<option key={u} value={u}>{u}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center gap-2 text-sm text-stone-700 cursor-pointer">
|
||||
<input type="checkbox" checked={editActive} onChange={(e) => setEditActive(e.target.checked)} className="rounded" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-600 mb-1">High Alert Threshold</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={editHigh}
|
||||
onChange={(e) => setEditHigh(e.target.value)}
|
||||
placeholder="e.g. 15.0"
|
||||
className="w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-stone-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-stone-600 mb-1">Low Alert Threshold</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={editLow}
|
||||
onChange={(e) => setEditLow(e.target.value)}
|
||||
placeholder="e.g. 5.0"
|
||||
className="w-full rounded-xl border border-stone-300 px-4 py-3 text-sm outline-none focus:border-stone-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-stone-400">Leave thresholds blank to disable alerts for this headgate.</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={editSaving} className="flex-1 rounded-xl bg-green-600 py-3 text-sm font-bold text-white disabled:opacity-50">
|
||||
{editSaving ? "..." : "Save Changes"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setEditHg(null)} className="rounded-xl border border-stone-300 px-4 py-3 text-sm text-stone-600">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Modal */}
|
||||
{qrModal && (
|
||||
<QRModal hg={qrModal} onClose={() => setQrModal(null)} onRegenerate={handleRegenerate} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => void; onRegenerate: (hg: Headgate) => void }) {
|
||||
const [tab, setTab] = useState<"preview" | "print" | "download">("preview");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const code = hg.name.replace(/\s+/g, "-").toUpperCase().slice(0, 6) + "-1";
|
||||
const qrUrl = `/api/water-qr?token=${hg.headgate_token}&size=360`;
|
||||
const waterUrl = `${process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"}/water?h=${hg.headgate_token}`;
|
||||
|
||||
async function handlePrintLabel() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/water-qr-label", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: hg.headgate_token, name: hg.name }),
|
||||
});
|
||||
const html = await res.text();
|
||||
const w = window.open("", "_blank", "width=500,height=600");
|
||||
if (w) { w.document.write(html); w.document.close(); }
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/water-qr?token=${hg.headgate_token}&size=400`);
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${hg.name.replace(/\s+/g, "-").toLowerCase()}-qr.png`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||
<div>
|
||||
<p className="font-bold text-zinc-100">{hg.name}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">QR Label Preview</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-zinc-400 text-xl leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
{/* ── Simplified label preview ── */}
|
||||
<div className="flex justify-center bg-zinc-900 py-6 px-4">
|
||||
{/* Mini version of the new label design */}
|
||||
<div className="w-40 border-2 border-black rounded-lg p-3 text-center bg-zinc-900 shadow-black/20">
|
||||
<div className="text-xl font-black text-black leading-tight tracking-tight">{hg.name}</div>
|
||||
<div className="text-[9px] font-bold text-gray-500 uppercase tracking-widest mt-0.5 mb-3">{code}</div>
|
||||
<div className="flex justify-center mb-2">
|
||||
<img
|
||||
src={`/api/water-qr?token=${hg.headgate_token}&size=160`}
|
||||
alt="QR"
|
||||
className="w-28 h-28 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[5.5px] text-gray-300 leading-tight break-all">{waterUrl}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab toggle */}
|
||||
<div className="flex border-b border-slate-100">
|
||||
{(["preview", "print", "download"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-3 text-sm font-semibold transition-colors ${tab === t ? "text-zinc-100 border-b-2 border-slate-900" : "text-slate-400"}`}
|
||||
>
|
||||
{t === "preview" ? "Preview" : t === "print" ? "🖨️ Print" : "💾 Download"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-3">
|
||||
{tab === "preview" ? (
|
||||
<div className="space-y-2 text-sm text-zinc-400">
|
||||
<p>This label is optimized for physical signs and thermal printing:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs text-zinc-500">
|
||||
<li><strong>Large name</strong> (24pt bold) — scannable from distance</li>
|
||||
<li><strong>Short code</strong> (e.g. <code className="font-mono bg-zinc-950 px-1 rounded">{code}</code>) — field reference</li>
|
||||
<li><strong>High-contrast QR</strong> — error correction H for outdoor use</li>
|
||||
<li><strong>Tiny URL</strong> at bottom — reference only</li>
|
||||
</ul>
|
||||
<button onClick={() => setTab("print")} className="w-full rounded-xl bg-slate-900 py-3 text-base font-bold text-white hover:bg-slate-800 transition-colors mt-2">
|
||||
🖨️ Print This Label
|
||||
</button>
|
||||
</div>
|
||||
) : tab === "print" ? (
|
||||
<button onClick={handlePrintLabel} disabled={loading} className="w-full rounded-xl bg-slate-900 py-3 text-base font-bold text-white disabled:opacity-50">
|
||||
{loading ? "..." : "🖨️ Print Label"}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleDownload} disabled={loading} className="w-full rounded-xl bg-blue-600 py-3 text-base font-bold text-white disabled:opacity-50">
|
||||
{loading ? "..." : "💾 Download PNG"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => { onRegenerate(hg); onClose(); }}
|
||||
className="w-full rounded-xl border border-zinc-800 py-2.5 text-sm font-medium text-zinc-400 hover:bg-zinc-800"
|
||||
>
|
||||
🔄 Regenerate Token
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg bg-zinc-900 p-3">
|
||||
<p className="text-xs text-zinc-500 mb-1">Token</p>
|
||||
<code className="text-xs text-zinc-300 font-mono break-all">{hg.headgate_token}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getWaterAdminSession } from "@/actions/water-log/field";
|
||||
import { getWaterHeadgatesAdmin } from "@/actions/water-log/admin";
|
||||
import HeadgateEditForm from "@/components/admin/HeadgateEditForm";
|
||||
import Link from "next/link";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type HeadgatePageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ from?: string }>;
|
||||
};
|
||||
|
||||
export default async function WaterLogHeadgatePage({ params, searchParams }: HeadgatePageProps) {
|
||||
const { id } = await params;
|
||||
const { from } = await searchParams;
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
const waterSession = await getWaterAdminSession();
|
||||
|
||||
const isSiteAdmin =
|
||||
adminUser?.role === "platform_admin" ||
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
||||
|
||||
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
|
||||
|
||||
const headgates = await getWaterHeadgatesAdmin(TUXEDO_BRAND_ID);
|
||||
const headgate = headgates.find((h) => h.id === id);
|
||||
|
||||
if (!headgate) {
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-400">Headgate not found</h1>
|
||||
<p className="mt-2 text-zinc-400">This headgate may have been deleted.</p>
|
||||
<a href={from ?? "/admin/water-log"} className="mt-4 inline-block text-zinc-400 hover:text-zinc-100">
|
||||
← Back
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const backHref = from ?? "/admin/water-log";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-zinc-500">Water Headgate</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-zinc-100">{headgate.name}</h1>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
headgate.active ? "bg-green-900/40 text-green-400" : "bg-zinc-950 text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{headgate.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-500">Default Unit</p>
|
||||
<p className="mt-1 text-2xl font-bold text-zinc-100">{headgate.unit}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-500">Created</p>
|
||||
<p className="mt-1 text-lg font-semibold text-zinc-100">
|
||||
{new Date(headgate.created_at).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<h2 className="text-2xl font-bold text-zinc-100">Edit Headgate</h2>
|
||||
<p className="mt-1 text-zinc-400">
|
||||
Update the headgate name, status, and default measurement unit.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<HeadgateEditForm headgate={headgate} backHref={backHref} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getWaterHeadgatesAdmin, regenerateHeadgateToken } from "@/actions/water-log/admin";
|
||||
import HeadgatesManager from "./HeadgatesManager";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export default async function HeadgatesPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const isAuthorized =
|
||||
adminUser?.role === "platform_admin" ||
|
||||
(adminUser?.brand_id === TUXEDO_BRAND_ID && adminUser?.can_manage_water_log);
|
||||
|
||||
if (!isAuthorized) redirect("/admin/water-log");
|
||||
|
||||
const headgates = await getWaterHeadgatesAdmin(TUXEDO_BRAND_ID);
|
||||
|
||||
return <HeadgatesManager initialHeadgates={headgates} brandId={TUXEDO_BRAND_ID} />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import WaterLogAdminPanel from "@/components/admin/WaterLogAdminPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getWaterIrrigators, getWaterHeadgatesAdmin, getWaterEntries } from "@/actions/water-log/admin";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminWaterLogPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
|
||||
const isAuthorized =
|
||||
adminUser?.role === "platform_admin" ||
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
|
||||
if (!isAuthorized) {
|
||||
redirect("/admin/pickup");
|
||||
}
|
||||
|
||||
const brandId = TUXEDO_BRAND_ID;
|
||||
|
||||
const [users, headgates, entries] = await Promise.all([
|
||||
getWaterIrrigators(brandId),
|
||||
getWaterHeadgatesAdmin(brandId),
|
||||
getWaterEntries(brandId, 50),
|
||||
]);
|
||||
|
||||
return (
|
||||
<WaterLogAdminPanel
|
||||
initialUsers={users}
|
||||
initialHeadgates={headgates}
|
||||
initialEntries={entries}
|
||||
brandId={brandId}
|
||||
canManage={isAuthorized}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { getWaterAdminSettings, saveWaterAdminSettings, type WaterAdminSettings } from "@/actions/water-log/settings";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export default function WaterLogSettingsPage() {
|
||||
const router = useRouter();
|
||||
const [settings, setSettings] = useState<WaterAdminSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
// Form state
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [pin, setPin] = useState("");
|
||||
const [confirmPin, setConfirmPin] = useState("");
|
||||
const [sessionDuration, setSessionDuration] = useState(12);
|
||||
const [canEdit, setCanEdit] = useState(true);
|
||||
const [canDelete, setCanDelete] = useState(false);
|
||||
const [canExport, setCanExport] = useState(true);
|
||||
const [alertPhone, setAlertPhone] = useState("");
|
||||
const [alertsEnabled, setAlertsEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
const data = await getWaterAdminSettings(TUXEDO_BRAND_ID);
|
||||
if (data) {
|
||||
setSettings(data);
|
||||
setEnabled(data.enabled);
|
||||
setSessionDuration(data.session_duration_hours);
|
||||
setCanEdit(data.can_edit_entries);
|
||||
setCanDelete(data.can_delete_entries);
|
||||
setCanExport(data.can_export_csv);
|
||||
setAlertPhone(data.alert_phone ?? "");
|
||||
setAlertsEnabled(data.alerts_enabled ?? false);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (enabled && pin && pin !== confirmPin) {
|
||||
setMessage({ type: "error", text: "PINs do not match" });
|
||||
return;
|
||||
}
|
||||
if (enabled && pin && (pin.length !== 4 || !/^\d{4}$/.test(pin))) {
|
||||
setMessage({ type: "error", text: "PIN must be exactly 4 digits" });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const result = await saveWaterAdminSettings(TUXEDO_BRAND_ID, {
|
||||
enabled,
|
||||
pin: pin || undefined,
|
||||
session_duration_hours: sessionDuration,
|
||||
can_edit_entries: canEdit,
|
||||
can_delete_entries: canDelete,
|
||||
can_export_csv: canExport,
|
||||
alert_phone: alertPhone || null,
|
||||
alerts_enabled: alertsEnabled,
|
||||
});
|
||||
if (result.success) {
|
||||
setMessage({ type: "success", text: "Settings saved" });
|
||||
setPin("");
|
||||
setConfirmPin("");
|
||||
} else {
|
||||
setMessage({ type: "error", text: result.error ?? "Save failed" });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||
<span className="text-zinc-500">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950">
|
||||
{/* Header */}
|
||||
<div className="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.back()} className="text-zinc-500 hover:text-zinc-400 text-sm">← Back</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-zinc-100">Water Log — Admin Portal</h1>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Configure PIN access for the field admin portal at /water/admin</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-lg px-6 py-6">
|
||||
<form onSubmit={handleSave} className="space-y-6">
|
||||
|
||||
{message && (
|
||||
<div className={`rounded-xl px-4 py-3 text-sm font-semibold ${message.type === "success" ? "bg-green-900/40 text-green-800 border border-green-200" : "bg-red-900/40 text-red-800 border border-red-200"}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enable toggle */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-zinc-100">Enable Admin Portal</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Allow PIN-based access to /water/admin (separate from platform login)</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEnabled((v) => !v)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? "bg-green-500" : "bg-stone-300"}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
{/* PIN */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
|
||||
<p className="font-semibold text-zinc-100">4-Digit PIN</p>
|
||||
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label>
|
||||
<input
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={4}
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder="••••"
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
|
||||
style={{ letterSpacing: "0.4em" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label>
|
||||
<input
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={4}
|
||||
value={confirmPin}
|
||||
onChange={(e) => setConfirmPin(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder="••••"
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-center text-2xl font-bold tracking-widest outline-none focus:border-stone-900"
|
||||
style={{ letterSpacing: "0.4em" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Duration */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
|
||||
<p className="font-semibold text-zinc-100">Session Duration</p>
|
||||
<p className="text-xs text-zinc-500">How long the admin session lasts after login (1–72 hours).</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={72}
|
||||
value={sessionDuration}
|
||||
onChange={(e) => setSessionDuration(parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-16 text-center text-sm font-bold text-zinc-100">{sessionDuration}h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-3">
|
||||
<p className="font-semibold text-zinc-100">Permissions</p>
|
||||
{[
|
||||
{ key: "canEdit", label: "Edit entries", desc: "Allow modifying existing log entries", value: canEdit, setter: setCanEdit },
|
||||
{ key: "canDelete", label: "Delete entries", desc: "Allow removing log entries", value: canDelete, setter: setCanDelete },
|
||||
{ key: "canExport", label: "Export CSV", desc: "Allow exporting water log data", value: canExport, setter: setCanExport },
|
||||
].map(({ key, label, desc, value, setter }) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">{label}</p>
|
||||
<p className="text-xs text-zinc-500">{desc}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setter((v: boolean) => !v)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${value ? "bg-green-500" : "bg-stone-300"}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${value ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* High/Low Alerts */}
|
||||
<div className="rounded-xl bg-zinc-900 shadow-black/20 ring-1 ring-zinc-700 p-5 space-y-4">
|
||||
<p className="font-semibold text-zinc-100">High/Low Alerts</p>
|
||||
<p className="text-xs text-zinc-500">Receive an SMS when a reading exceeds a headgate's thresholds.</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">Enable Alerts</p>
|
||||
<p className="text-xs text-zinc-500">SMS on threshold breach</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAlertsEnabled((v) => !v)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${alertsEnabled ? "bg-green-500" : "bg-stone-300"}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-zinc-900 transition-transform ${alertsEnabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{alertsEnabled && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={alertPhone}
|
||||
onChange={(e) => setAlertPhone(e.target.value)}
|
||||
placeholder="+1234567890"
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500 mt-1">U.S. format recommended. Must include country code (e.g. +1 for USA).</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR hint */}
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-100 p-4">
|
||||
<p className="text-xs text-amber-700">
|
||||
<strong>QR Lock:</strong> Irrigators can lock a headgate by visiting <code className="bg-amber-900/40 px-1 rounded">/water?h={'{headgate_token}'}</code>.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full rounded-xl bg-stone-900 px-6 py-4 text-base font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getWaterAdminSession } from "@/actions/water-log/field";
|
||||
import { getWaterIrrigators } from "@/actions/water-log/admin";
|
||||
import WaterUserEditForm from "@/components/admin/WaterUserEditForm";
|
||||
import Link from "next/link";
|
||||
|
||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type UserPageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ from?: string }>;
|
||||
};
|
||||
|
||||
export default async function WaterLogUserPage({ params, searchParams }: UserPageProps) {
|
||||
const { id } = await params;
|
||||
const { from } = await searchParams;
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
const waterSession = await getWaterAdminSession();
|
||||
|
||||
const isSiteAdmin =
|
||||
adminUser?.role === "platform_admin" ||
|
||||
(adminUser?.role === "brand_admin" &&
|
||||
adminUser?.brand_id === TUXEDO_BRAND_ID &&
|
||||
adminUser?.can_manage_water_log);
|
||||
const isWaterAdmin = waterSession !== null && waterSession.role === "water_admin";
|
||||
|
||||
if (!isSiteAdmin && !isWaterAdmin) redirect("/admin/pickup");
|
||||
|
||||
const users = await getWaterIrrigators(TUXEDO_BRAND_ID);
|
||||
const waterUser = users.find((u) => u.id === id);
|
||||
|
||||
if (!waterUser) {
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-red-400">User not found</h1>
|
||||
<p className="mt-2 text-zinc-400">This user may have been deleted.</p>
|
||||
<a href={from ?? "/admin/water-log"} className="mt-4 inline-block text-zinc-400 hover:text-zinc-100">
|
||||
← Back
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const backHref = from ?? "/admin/water-log";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 px-6 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
← Back
|
||||
</Link>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-zinc-500">Water User</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-zinc-100">{waterUser.name}</h1>
|
||||
<p className="mt-1 text-zinc-400">
|
||||
{waterUser.role === "water_admin" ? "Admin" : "Irrigator"}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
waterUser.active ? "bg-green-900/40 text-green-400" : "bg-zinc-950 text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{waterUser.active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-500">Role</p>
|
||||
<p className="mt-1 text-lg font-semibold text-zinc-100">
|
||||
{waterUser.role === "water_admin" ? "Admin" : "Irrigator"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-500">Language</p>
|
||||
<p className="mt-1 text-lg font-semibold text-zinc-100">
|
||||
{waterUser.language_preference === "es" ? "Español" : "English"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-500">Last Used</p>
|
||||
<p className="mt-1 text-lg font-semibold text-zinc-100">
|
||||
{waterUser.last_used_at
|
||||
? new Date(waterUser.last_used_at).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-zinc-900 p-8 shadow-black/20 ring-1 ring-zinc-700">
|
||||
<h2 className="text-2xl font-bold text-zinc-100">Edit User</h2>
|
||||
<p className="mt-1 text-zinc-400">
|
||||
Update name, role, language, and active status. Use Reset PIN to generate a new PIN.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<WaterUserEditForm waterUser={waterUser} backHref={backHref} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
import WholesaleClient from "./WholesaleClient";
|
||||
|
||||
export default async function WholesalePage() {
|
||||
const cookieStore = await cookies();
|
||||
const devSession = cookieStore.get("dev_session")?.value;
|
||||
const isDevMode =
|
||||
devSession === "platform_admin" ||
|
||||
devSession === "brand_admin" ||
|
||||
devSession === "store_employee";
|
||||
|
||||
if (!isDevMode) {
|
||||
const { getAdminUser } = await import("@/lib/admin-permissions");
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) redirect("/login");
|
||||
return <WholesaleClient brandId={adminUser.brand_id ?? ""} />;
|
||||
}
|
||||
|
||||
// Dev mode: platform_admin sees all brands, use first brand as default
|
||||
const brandId = devSession === "platform_admin" ? "" : (cookieStore.get("dev_brand_id")?.value ?? "64294306-5f42-463d-a5e8-2ad6c81a96de");
|
||||
return <WholesaleClient brandId={brandId} />;
|
||||
}
|
||||
Reference in New Issue
Block a user