Initial commit - Route Commerce platform

This commit is contained in:
2026-06-01 19:40:55 +00:00
commit 53a9671461
617 changed files with 106132 additions and 0 deletions
+18
View File
@@ -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>
);
}
+50
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+434
View File
@@ -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 510 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>
);
}
+39
View File
@@ -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>
);
}
+32
View File
@@ -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>
</>
);
}
+28
View File
@@ -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>
);
}
+216
View File
@@ -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>
);
}
+19
View File
@@ -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} />;
}
+222
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+389
View File
@@ -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>
);
}
+42
View File
@@ -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}
/>
);
}
+127
View File
@@ -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>
);
}
+237
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+190
View File
@@ -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
+24
View File
@@ -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}
/>
);
}
+48
View File
@@ -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>
);
}
+114
View File
@@ -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 &amp; 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>
);
}
+70
View File
@@ -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");
}
+91
View File
@@ -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>
);
}
+61
View File
@@ -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>
);
}
+63
View File
@@ -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}
/>
);
}
+20
View File
@@ -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}
/>
);
}
+208
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+95
View File
@@ -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>
);
}
+73
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+31
View File
@@ -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");
}
+5
View File
@@ -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} />;
}
+40
View File
@@ -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}
/>
);
}
+261
View File
@@ -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 (172 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>
);
}
+122
View File
@@ -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
+23
View File
@@ -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} />;
}
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_messages) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { topic, brandId, brandName } = await req.json();
if (!topic) {
return NextResponse.json({ error: "topic is required" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client) {
return NextResponse.json({ error: "error" in aiResult ? aiResult.error : "AI not configured" }, { status: 503 });
}
const systemPrompt = `You are a campaign idea generator for Route Commerce, a B2B fresh produce wholesale platform.
Given a topic, generate exactly 3 campaign ideas for a produce brand.
Each idea should include: angle (one sentence describing the campaign approach), subject line (compelling, max 60 chars), body (3-4 sentences, persuasive, fits brand voice).
Return valid JSON: { "ideas": [{ "angle": "...", "subject": "...", "body": "..." }] }
Brand voice: friendly, transparent, emphasizes freshness and local origin.
Audience: wholesale produce buyers, restaurant owners, farm stand operators.`;
try {
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: `Topic: ${topic}\nBrand: ${brandName || "our produce brand"}` },
],
response_format: { type: "json_object" },
temperature: 0.8,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
const parsed = JSON.parse(content);
return NextResponse.json({ ideas: parsed.ideas ?? [] });
} catch (err) {
return NextResponse.json({ error: "Generation failed" }, { status: 500 });
}
}
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
import { svcHeaders } from "@/lib/svc-headers";
const SYSTEM = `You are a data analyst for a B2B produce wholesale platform.
Given a natural language customer query, return a JSON response indicating which predefined query to run and what parameters to use.
Query types (each has a predefined SQL template):
1. "dormant" — customers who haven't ordered in X days (parameter: days, default 45)
2. "trending" — products ordered most in the last N days (parameter: days, default 30)
3. "top_customers" — top customers by order total in last N days (parameter: days, default 90)
4. "recent_orders" — orders created in last N days (parameter: days, default 14)
5. "at_risk" — customers who had orders in past 90 days but none in last 30 days
Return JSON with:
{
"queryType": "dormant" | "trending" | "top_customers" | "recent_orders" | "at_risk",
"days": number (or null for at_risk which always uses 30/90),
"explanation": "plain English explanation of what this query will return",
"fallback": "plain language result if AI analysis fails"
}`;
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, nlQuery } = await req.json();
if (!brandId || !nlQuery) {
return NextResponse.json({ error: "Missing brandId or nlQuery" }, { status: 400 });
}
// Brand scoping: platform_admin passes explicit brandId; brand_admin limited to own brand
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const userMessage = `Brand: ${effectiveBrandId}
User asked: "${nlQuery}"
Classify this query and return JSON with queryType, days, explanation, and fallback.
Use "at_risk" for customers who had orders in past 90 days but none in last 30 days.
Use "dormant" for customers who haven't ordered in a while.
Use "trending" for product popularity questions.
Use "top_customers" for customer ranking questions.
Use "recent_orders" for recent order questions.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.2,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
const parsed = JSON.parse(content);
const queryType = parsed.queryType ?? "recent_orders";
const days = parsed.days ?? 30;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
// Route to appropriate RPC based on query type
let rpcName = "get_recent_orders_insights";
let rpcParams: Record<string, unknown> = { p_brand_id: effectiveBrandId, p_days: days };
if (queryType === "dormant") {
rpcName = "get_dormant_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
} else if (queryType === "trending") {
rpcName = "get_trending_products_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
} else if (queryType === "top_customers") {
rpcName = "get_top_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId, p_days: days };
} else if (queryType === "at_risk") {
rpcName = "get_at_risk_customers_insights";
rpcParams = { p_brand_id: effectiveBrandId };
}
const dbResponse = await fetch(
`${supabaseUrl}/rest/v1/rpc/${rpcName}`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey!), "Content-Type": "application/json" },
body: JSON.stringify(rpcParams),
}
);
let results: unknown[] = [];
if (dbResponse.ok) {
const data = await dbResponse.json();
results = Array.isArray(data) ? data.slice(0, 100) : [];
}
return NextResponse.json({
queryType,
explanation: parsed.explanation ?? "",
results,
count: results.length,
fallback: parsed.fallback ?? "",
nlQuery,
});
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
+82
View File
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, productName, historicalData, stopName } = await req.json();
if (!brandId) {
return NextResponse.json({ error: "Missing brandId" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const SYSTEM = `You are a demand forecasting analyst for a B2B produce wholesale platform.
Given historical order data for a product or stop, predict future demand and recommend stock levels.
Return JSON with this exact shape:
{
"currentTrend": "description of recent demand pattern",
"prediction": {
"nextStopVolume": number,
"nextWeekVolume": number,
"confidence": "high" | "medium" | "low",
"confidenceReason": "why confidence is this level"
},
"recommendedStock": {
"units": number,
"reasoning": "why this level"
},
"seasonalFactors": ["factor 1", "factor 2"],
"riskFlags": ["concern 1"]
}`;
const userMessage = `Brand: ${effectiveBrandId}
Product: ${productName ?? "unknown"}
Stop: ${stopName ?? "general"}
Historical Order Data: ${JSON.stringify(historicalData ?? [])}
Analyze demand patterns and return JSON with currentTrend, prediction (nextStopVolume, nextWeekVolume, confidence, confidenceReason), recommendedStock (units, reasoning), seasonalFactors array, and riskFlags array.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.4,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
return NextResponse.json(JSON.parse(content));
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
+82
View File
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, productName, currentPriceTiers, historicalSales } = await req.json();
if (!brandId) {
return NextResponse.json({ error: "Missing brandId" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const SYSTEM = `You are a pricing strategist for a B2B produce wholesale platform.
Given product sales data, recommend price adjustments with reasoning and estimated revenue impact.
Return JSON with exact shape:
{
"currentState": "brief description of current pricing situation",
"recommendations": [
{
"productName": "product name",
"currentPrice": number,
"suggestedPrice": number,
"direction": "increase" | "decrease" | "maintain",
"reasoning": "why this change",
"estimatedRevenueImpact": "±$X or 'minimal'"
}
],
"opportunities": ["high-level opportunity 1", "opportunity 2"],
"warnings": ["potential issue 1"]
}`;
const userMessage = `Brand: ${effectiveBrandId}
Product: ${productName ?? "unknown"}
Current Price Tiers: ${JSON.stringify(currentPriceTiers ?? [])}
Historical Sales (last 30-90 days): ${JSON.stringify(historicalSales ?? [])}
Analyze pricing and return JSON with currentState, recommendations array, opportunities, and warnings.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.4,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
return NextResponse.json(JSON.parse(content));
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
+77
View File
@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_products) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { productName, category, price, unit, brandId } = await req.json();
if (!productName) {
return NextResponse.json({ error: "productName is required" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const systemPrompt = `You are a product copywriter for Route Commerce, a B2B fresh produce wholesale platform.
Given a product, write: a product name (can improve on input), a marketing description (2-3 sentences, persuasive, highlights freshness/origin/quality), image alt text (max 125 chars, SEO-friendly), and a brief pricing note if relevant.
Return valid JSON: { "name": "...", "description": "...", "altText": "...", "priceNote": "..." (or null) }
Brand voice: friendly, transparent, professional.
Audience: wholesale buyers, restaurant owners, farm stand operators.`;
try {
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content: [
`Product: ${productName}`,
category ? `Category: ${category}` : "",
price ? `Price: ${price}${unit ? ` ${unit}` : ""}` : "",
]
.filter(Boolean)
.join("\n"),
},
],
response_format: { type: "json_object" },
temperature: 0.7,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
const parsed = JSON.parse(content);
return NextResponse.json({
name: parsed.name ?? productName,
description: parsed.description ?? "",
altText: parsed.altText ?? "",
priceNote: parsed.priceNote ?? null,
});
} catch (err) {
return NextResponse.json({ error: "Generation failed" }, { status: 500 });
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
const REPORT_PROMPTS: Record<string, string> = {
"orders-by-stop": `This is an Orders by Stop report. Each row has: stop_name, city, state, date, order_count, gross_sales, pending_count, completed_count. Identify top-performing stops, underperforming stops, and patterns in pending vs completed orders.`,
"sales-by-product": `This is a Sales by Product report. Each row has: product_name, units_sold, gross_revenue, avg_price. Identify top sellers, underperformers, and pricing anomalies. Flag any products with unusually high/low average prices.`,
"fulfillment": `This is a Fulfillment Breakdown report. Each row has: fulfillment_type (pickup/shipping), order_count, revenue, pct_of_total. Analyze pickup vs shipping split and revenue distribution.`,
"pickup-status": `This is a Pickup Status report. Each row has: stop_name, city, date, total_orders, pending, completed, canceled. Identify stops with high pending rates, high cancellation rates, and completion speed.`,
"contact-growth": `This is a Contact Growth report. Each row has: date, new_contacts, imports, total. Identify growth trends, import spikes, and rate-of-change patterns.`,
"campaigns": `This is a Campaign Activity report. Each row has: campaign_name, status, campaign_type, sent_at, messages_logged. Identify which campaign types perform best, timing patterns, and engagement levels.`,
};
const SYSTEM = `You are a data analyst for a B2B produce wholesale distribution platform.
You analyze report data and return structured insights in plain English.
Always be specific and cite numbers from the data.
Format your response as JSON with this exact shape:
{
"summary": "2-3 sentence plain-English summary of what this report shows",
"keyInsights": ["insight 1", "insight 2", "insight 3"],
"suggestedActions": ["action 1", "action 2"]
}
Be direct and actionable. Do not use hedging language.`;
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { reportType, dateRange, brandId, reportData } = await req.json();
if (!reportType || !reportData) {
return NextResponse.json({ error: "Missing reportType or reportData" }, { status: 400 });
}
// Brand scoping: platform_admin passes explicit brandId; brand_admin limited to own brand
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const prompt = REPORT_PROMPTS[reportType] ?? `This is a ${reportType} report. Analyze it.`;
// Build a compact sample of rows (max 20 for token efficiency)
const sampleRows = Array.isArray(reportData) ? reportData.slice(0, 20) : [];
const userMessage = `Date range: ${dateRange?.start ?? "unknown"} to ${dateRange?.end ?? "unknown"}
Brand: ${effectiveBrandId}
${prompt}
Data:
${JSON.stringify(sampleRows, null, 2)}
Return JSON with summary, keyInsights (array), and suggestedActions (array).`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.3,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
return NextResponse.json(JSON.parse(content));
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_reports) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, stops, startLocation } = await req.json();
if (!brandId || !stops || !Array.isArray(stops) || stops.length < 2) {
return NextResponse.json({ error: "Need at least 2 stops to optimize" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const SYSTEM = `You are a route optimization assistant for a B2B produce wholesale platform.
Given a list of stops with city/state/address and constraints, return an optimized delivery sequence.
Return JSON with this exact shape:
{
"optimizedSequence": [
{
"position": 1,
"stopName": "Stop A",
"city": "Greeley",
"state": "CO",
"reason": "why placed here (e.g., 'nearest to start point', 'time window constraint')"
}
],
"totalEstimatedDistance": "X miles",
"totalEstimatedDriveTime": "X hours Y minutes",
"warnings": ["issue 1"],
"suggestions": ["improvement 1"]
}`;
const stopsList = stops.map((s: { name?: string; city?: string; state?: string; address?: string; time_window?: string }, i: number) =>
`${i + 1}. ${s.name ?? "Stop"}${s.city ?? ""}, ${s.state ?? ""} ${s.address ? "(" + s.address + ")" : ""} ${s.time_window ? "[window: " + s.time_window + "]" : ""}`
).join("\n");
const userMessage = `Brand: ${effectiveBrandId}
Start location: ${startLocation ?? "First stop"}
Stops to sequence:
${stopsList}
Optimize the route for efficiency. Return JSON with optimizedSequence, totalEstimatedDistance, totalEstimatedDriveTime, warnings, and suggestions.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.3,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
return NextResponse.json(JSON.parse(content));
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getAIClient } from "@/actions/integrations/ai-providers";
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_messages) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, stopId, stopName, stopDate, city, state, recentOrders, customerCount } = await req.json();
if (!brandId || !stopName) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Brand scoping
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const aiResult = await getAIClient(effectiveBrandId);
if (!aiResult.client || !("client" in aiResult)) {
return NextResponse.json({ error: "error" in aiResult ? (aiResult as { error?: string }).error ?? "AI not configured" : "AI not configured" }, { status: 503 });
}
const SYSTEM = `You are a marketing strategist for a B2B produce wholesale platform.
Given a stop's order history and customer data, recommend the optimal stop blast message.
Return JSON with exact shape:
{
"timingRecommendation": "e.g., 'Send 2 days before the stop, between 9-11am for best open rates'",
"subjectLine": "email subject line",
"bodyPreview": "2-3 sentence email body preview",
"audienceSize": "estimated number of recipients",
"audienceRecommendation": "who should receive this (e.g., 'all pickup customers for this stop' or 'customers who ordered from this stop in last 90 days')",
"contentAngles": [
{
"angle": "e.g., 'Freshness angle'",
"reasoning": "why this angle works for this stop"
},
{
"angle": "e.g., 'Urgency/discount angle'",
"reasoning": "why this angle works"
}
],
"warnings": ["potential issue 1"]
}`;
const userMessage = `Brand: ${effectiveBrandId}
Stop: ${stopName}
Date: ${stopDate ?? "unknown"}
City: ${city ?? ""}, ${state ?? ""}
Recent Orders: ${JSON.stringify(recentOrders ?? [])}
Customer Count: ${customerCount ?? "unknown"}
Analyze this stop's context and order history to recommend the optimal stop blast message.
Return JSON with timingRecommendation, subjectLine, bodyPreview, audienceSize, audienceRecommendation, contentAngles array, and warnings.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: userMessage },
],
response_format: { type: "json_object" },
temperature: 0.4,
});
const content = response.choices[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: "No response from AI" }, { status: 500 });
}
return NextResponse.json(JSON.parse(content));
} catch (err) {
return NextResponse.json({ error: "AI analysis failed" }, { status: 500 });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const uid =
cookieStore.get("rc_auth_uid")?.value ??
cookieStore.get("rc_uid")?.value ??
null;
return NextResponse.json({ uid });
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
export async function GET() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
return NextResponse.json({ error: "Missing configuration" }, { status: 500 });
}
const response = await fetch(
`${supabaseUrl}/functions/v1/send-scheduled-campaigns`,
{ headers: { ...svcHeaders(supabaseKey!), "Content-Type": "application/json" } }
);
const text = await response.text();
if (!response.ok) {
return NextResponse.json({ error: "Failed to send campaigns", detail: text }, { status: 500 });
}
let results: unknown[] = [];
try {
const data = JSON.parse(text);
if (data && typeof data === "object") {
const d = data as Record<string, unknown>;
if (Array.isArray(d.results)) results = d.results as unknown[];
else if (Array.isArray(d.send_scheduled_campaigns)) results = d.send_scheduled_campaigns as unknown[];
else if (Array.isArray(data)) results = data as unknown[];
}
} catch { /* ignore */ }
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
export async function GET() {
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
if (!serviceKey || !supabaseUrl) {
return NextResponse.json({ error: "Missing env vars", serviceKey: !!serviceKey, supabaseUrl: !!supabaseUrl }, { status: 500 });
}
// Test 1: just a simple health endpoint that doesn't require the key
let healthResult = null;
try {
const res = await fetch(`${supabaseUrl}/rest/v1/`, {
headers: { apikey: serviceKey },
});
healthResult = { status: res.status, ok: res.ok };
} catch (e: any) {
healthResult = { error: e?.message };
}
// Test 2: try admin_users with POST (bypasses RLS select policies)
let adminResult = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?select=id,user_id,role,email,display_name&limit=5`,
{
method: "GET",
headers: {
apikey: serviceKey,
"Content-Type": "application/json",
Prefer: "return=representation",
},
}
);
const body = await res.text();
let parsed = null;
try { parsed = JSON.parse(body); } catch { parsed = body; }
adminResult = { status: res.status, body: parsed };
} catch (e: any) {
adminResult = { error: e?.message };
}
return NextResponse.json({ healthResult, adminResult, serviceKeyLen: serviceKey.length });
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
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 NextResponse.json({
cookies: {
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 10)}...` : null,
rc_uid: rcUid ? `${rcUid.slice(0, 10)}...` : null,
rc_access_token: rcAccessToken ? "present" : null,
all_cookie_names: allCookies.map(c => c.name),
},
admin_users: {
status: adminUsersStatus,
result: adminUsersResult,
},
});
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET() {
const cookieStore = await cookies();
const allCookies = cookieStore.getAll();
const rcAuthUid = cookieStore.get("rc_auth_uid")?.value;
const rcUid = cookieStore.get("rc_uid")?.value;
return NextResponse.json({
received_cookies: allCookies.map(c => ({ name: c.name, value_len: c.value.length })),
rc_auth_uid: rcAuthUid ? `${rcAuthUid.slice(0, 8)}...` : "MISSING",
rc_uid: rcUid ? `${rcUid.slice(0, 8)}...` : "MISSING",
raw_rc_auth_uid: rcAuthUid ?? null,
});
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
export async function GET() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
const nodeEnv = process.env.NODE_ENV;
const result = {
NODE_ENV: nodeEnv,
NEXT_PUBLIC_SUPABASE_URL: url ? `${url.substring(0, 40)}... (SET)` : "MISSING",
NEXT_PUBLIC_SUPABASE_ANON_KEY: key ? `${key.substring(0, 20)}... (SET)` : "MISSING",
SUPABASE_SERVICE_ROLE_KEY: serviceKey ? `${serviceKey.substring(0, 20)}... (SET)` : "MISSING",
supabaseClientCanCreate: false as boolean,
error: null as string | null,
};
if (url && key) {
try {
const { createClient } = await import("@supabase/supabase-js");
const client = createClient(url, key);
result.supabaseClientCanCreate = true;
} catch (e: any) {
result.error = e?.message ?? String(e);
}
} else {
result.error = "Missing env vars";
}
return NextResponse.json(result, { status: 200 });
}
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getAdminUser } from "@/lib/admin-permissions";
export async function GET() {
// Test the REST call directly from this endpoint
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uid = "c023efb3-e3ef-4156-bed6-d17b92ea8aca";
let restResult = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
const data = await res.json().catch(() => null);
restResult = { status: res.status, data, keyLen: serviceKey.length };
} catch (e: any) {
restResult = { error: e?.message };
}
const adminUser = await getAdminUser();
return NextResponse.json({
adminUser: adminUser ? {
id: adminUser.id,
user_id: adminUser.user_id,
role: adminUser.role,
brand_id: adminUser.brand_id,
active: adminUser.active,
} : null,
restResult,
supabaseUrl: supabaseUrl ? "SET" : "MISSING",
});
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
ts: new Date().toISOString(),
deployment: "debug-hello",
msg: "hello from latest build"
});
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET() {
const cookieStore = await cookies();
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
if (!uid) {
return NextResponse.json({ error: "No uid cookie found" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceKey) {
return NextResponse.json({
uid,
error: "Missing env vars",
supabaseUrl: supabaseUrl ?? "MISSING",
serviceKeyPresent: !!serviceKey,
});
}
// Exact same lookup as getAdminUser
const lookupRes = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
let adminUsers: unknown[] = [];
let lookupOk = lookupRes.ok;
let lookupStatus = lookupRes.status;
let lookupData: unknown = null;
if (lookupRes.ok) {
lookupData = await lookupRes.json().catch(() => []);
adminUsers = Array.isArray(lookupData) ? lookupData : [];
} else {
lookupData = await lookupRes.text().catch(() => "unknown error");
}
if (adminUsers.length > 0) {
return NextResponse.json({
uid,
result: "found",
adminUser: adminUsers[0],
});
}
// Try auto-create
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) {
return NextResponse.json({ uid, result: "invalid_uuid" });
}
const postRes = await fetch(`${supabaseUrl}/rest/v1/admin_users`, {
method: "POST",
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({
user_id: uid, role: "platform_admin", brand_id: null, active: true,
can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true,
can_manage_settings: true, must_change_password: false
}),
});
return NextResponse.json({
uid,
result: "auto_created",
lookupOk,
lookupStatus,
adminUsersFound: adminUsers.length,
postStatus: postRes.status,
postOk: postRes.ok,
postData: postRes.ok ? await postRes.json().catch(() => null) : await postRes.text().catch(() => null),
});
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
response.cookies.set("dev_session", "platform_admin", {
path: "/",
sameSite: "lax",
httpOnly: false,
});
return response;
}
export async function GET() {
const response = NextResponse.redirect(new URL("/admin", "http://localhost:3000"));
response.cookies.set("dev_session", "platform_admin", {
path: "/",
sameSite: "lax",
httpOnly: false,
});
return response;
}
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { sendAbandonedCartEmail, type AbandonedCart } from "@/actions/email-automation/abandoned-cart";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const BRAND_IDS = [
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct" },
];
const EMAIL_INTERVALS_HOURS = [1, 24, 48] as const;
async function processAbandonedCartsForBrand(brandId: string, brandName: string): Promise<{ sent: number; failed: number; skipped: number }> {
let sent = 0, failed = 0, skipped = 0;
// 1. Detect newly abandoned wholesale carts
const detectRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/detect_abandoned_wholesale_carts`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!detectRes.ok) {
return { sent: 0, failed: 0, skipped: 0 };
}
const detected: Array<{ order_id: string; customer_id: string; contact_email: string; contact_name: string; cart_snapshot: unknown }> = await detectRes.json();
// 2. Enroll new abandoned carts
for (const row of detected) {
await fetch(
`${supabaseUrl}/rest/v1/rpc/enroll_abandoned_cart`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_customer_id: row.customer_id,
p_contact_email: row.contact_email,
p_contact_name: row.contact_name,
p_cart_snapshot: row.cart_snapshot,
p_brand_name: brandName,
p_locale: "en",
p_next_email_at: new Date(Date.now() + EMAIL_INTERVALS_HOURS[0] * 3600000).toISOString(),
}),
}
);
}
// 3. Fetch active carts ready for next email
const activeRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_abandoned_carts`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!activeRes.ok) {
return { sent, failed, skipped };
}
const activeData = await activeRes.json();
const row = Array.isArray(activeData) ? activeData[0] : activeData;
const activeCarts: AbandonedCart[] = row?.carts ?? [];
// 4. Send emails
for (const cart of activeCarts) {
const nextStep = cart.sequence_step + 1;
if (nextStep > 3) { skipped++; continue; }
const result = await sendAbandonedCartEmail(cart, nextStep, brandId);
if (result.success) {
sent++;
} else {
failed++;
}
}
return { sent, failed, skipped };
}
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret) {
return NextResponse.json({ error: "Server misconfiguration: CRON_SECRET not set" }, { status: 500 });
}
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const results: Record<string, { sent: number; failed: number; skipped: number }> = {};
for (const brand of BRAND_IDS) {
try {
results[brand.name] = await processAbandonedCartsForBrand(brand.id, brand.name);
} catch {
results[brand.name] = { sent: 0, failed: 0, skipped: 0 };
}
}
const total = Object.values(results).reduce((acc, r) => ({ sent: acc.sent + r.sent, failed: acc.failed + r.failed, skipped: acc.skipped + r.skipped }), { sent: 0, failed: 0, skipped: 0 });
return NextResponse.json({ ok: true, results, ...total });
}
export async function GET() {
return NextResponse.json({
status: "ok",
path: "/api/email-automation/abandoned-cart",
brands: BRAND_IDS.map(b => ({ id: b.id, name: b.name })),
resend_configured: Boolean(process.env.RESEND_API_KEY),
cron_secret_configured: Boolean(process.env.CRON_SECRET),
});
}
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { sendWelcomeEmail, type WelcomeSequenceEntry } from "@/actions/email-automation/welcome-sequence";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const BRAND_IDS = [
{ id: "64294306-5f42-463d-a5e8-2ad6c81a96de", name: "Tuxedo Corn" },
{ id: "b1cb7a96-d82b-40b1-80b1-d6dd26c56e28", name: "Indian River Direct" },
];
async function processWelcomeSequencesForBrand(brandId: string): Promise<{ sent: number; failed: number; skipped: number }> {
let sent = 0, failed = 0, skipped = 0;
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_active_welcome_sequence`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
if (!res.ok) {
return { sent: 0, failed: 0, skipped: 0 };
}
const data = await res.json();
const row = Array.isArray(data) ? data[0] : data;
const entries: WelcomeSequenceEntry[] = row?.entries ?? [];
for (const entry of entries) {
const nextStep = entry.sequence_step + 1;
if (nextStep > 4) { skipped++; continue; }
if (entry.status === "unsubscribed" || entry.status === "bounced") { skipped++; continue; }
const result = await sendWelcomeEmail(entry, nextStep);
if (result.success) {
sent++;
} else {
failed++;
}
}
return { sent, failed, skipped };
}
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret) {
return NextResponse.json({ error: "Server misconfiguration: CRON_SECRET not set" }, { status: 500 });
}
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const results: Record<string, { sent: number; failed: number; skipped: number }> = {};
for (const brand of BRAND_IDS) {
try {
results[brand.name] = await processWelcomeSequencesForBrand(brand.id);
} catch {
results[brand.name] = { sent: 0, failed: 0, skipped: 0 };
}
}
const total = Object.values(results).reduce((acc, r) => ({ sent: acc.sent + r.sent, failed: acc.failed + r.failed, skipped: acc.skipped + r.skipped }), { sent: 0, failed: 0, skipped: 0 });
return NextResponse.json({ ok: true, results, ...total });
}
export async function GET() {
return NextResponse.json({
status: "ok",
path: "/api/email-automation/welcome-sequence",
brands: BRAND_IDS.map(b => ({ id: b.id, name: b.name })),
resend_configured: Boolean(process.env.RESEND_API_KEY),
cron_secret_configured: Boolean(process.env.CRON_SECRET),
});
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
const DEV_ROLES = ["platform_admin", "brand_admin", "store_employee"];
export async function GET(request: Request) {
const url = new URL(request.url);
const role = url.searchParams.get("role") ?? "platform_admin";
const safeRole = DEV_ROLES.includes(role) ? role : "platform_admin";
const origin = url.origin;
const response = NextResponse.redirect(new URL("/admin", origin));
const cookieOptions = {
path: "/",
maxAge: 60 * 60 * 24 * 30,
sameSite: "lax" as const,
};
response.cookies.set("dev_session", safeRole, {
...cookieOptions,
httpOnly: false,
});
response.cookies.set("rc_auth_uid", DEV_ADMIN_UID, {
...cookieOptions,
httpOnly: false,
});
return response;
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
export async function POST(request: Request) {
const formData = await request.formData();
const email = formData.get("email") as string;
if (!email) {
return NextResponse.json({ error: "Email is required." }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
}
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
const res = await fetch(`${supabaseUrl}/auth/v1/recover`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseAnonKey,
},
body: JSON.stringify({ email, redirectTo: `${origin}/auth/callback` }),
});
// Always return 200 to avoid email enumeration — Supabase may still send or not
if (!res.ok) {
const data = await res.json().catch(() => null);
return NextResponse.json({ error: data?.message ?? "Failed to send reset link." }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,179 @@
import { NextResponse } from "next/server";
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
import { supabase } from "@/lib/supabase";
export async function GET() {
const brandSlug = "indian-river-direct";
const { data: brand } = await supabase
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single();
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
}
const { data: stops } = await supabase
.from("stops")
.select("*")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true });
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single();
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
stops: stops ?? [],
logoUrl: settings?.logo_url ?? null,
contactEmail: settings?.email ?? null,
contactPhone: settings?.phone ?? null,
footerNotes: settings?.schedule_pdf_notes ?? null,
});
return new NextResponse(Buffer.from(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${brand.name.replace(/\s+/g, "-")}-Schedule.pdf"`,
},
});
}
async function buildProfessionalSchedulePdf({
brandName,
stops,
logoUrl,
contactEmail,
contactPhone,
footerNotes,
}: {
brandName: string;
stops: Array<{ city: string; state: string; date: string; time: string; location: string }>;
logoUrl: string | null;
contactEmail: string | null;
contactPhone: string | null;
footerNotes: string | null;
}) {
const pdfDoc = await PDFDocument.create();
const page = pdfDoc.addPage([612, 792]);
const { width, height } = page.getSize();
const margin = 50;
const helvetica = await page.doc.embedFont(StandardFonts.Helvetica);
const helveticaBold = await page.doc.embedFont(StandardFonts.HelveticaBold);
const colWidths = [130, 80, 80, 210];
const headers = ["City / State", "Date", "Time", "Location"];
let y = height - margin;
// ── Header block ─────────────────────────────────────────────────────────
page.drawText(brandName, { x: margin, y, font: helveticaBold, size: 18, color: rgb(0.1, 0.1, 0.1) });
y -= 10;
page.drawText("Stop Schedule", { x: margin, y, font: helvetica, size: 11, color: rgb(0.4, 0.4, 0.4) });
y -= 8;
const stopLabel = stops.length === 0
? "No upcoming stops"
: stops.length === 1
? "1 upcoming stop"
: `${stops.length} upcoming stops`;
page.drawText(stopLabel, { x: margin, y, font: helvetica, size: 9, color: rgb(0.5, 0.5, 0.5) });
y -= 18;
// ── Logo (top-right) ────────────────────────────────────────────────────
if (logoUrl) {
try {
const logoResponse = await fetch(logoUrl);
if (logoResponse.ok) {
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
const ext = logoUrl.split(".").pop()?.toLowerCase() ?? "";
const embedFn = ext === "png" ? pdfDoc.embedPng : pdfDoc.embedJpg;
const logoImage = await embedFn(logoBuffer);
const logoH = 36;
const logoW = Math.min(120, logoImage.width * (logoH / logoImage.height));
page.drawImage(logoImage, {
x: width - margin - logoW,
y: height - margin - logoH,
width: logoW,
height: logoH,
});
}
} catch {
// logo fetch failed — skip
}
}
// ── Divider ─────────────────────────────────────────────────────────────
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 1, color: rgb(0.2, 0.2, 0.2) });
y -= 14;
// ── Table headers ────────────────────────────────────────────────────────
let x = margin;
for (let i = 0; i < headers.length; i++) {
page.drawText(headers[i], { x, y, font: helveticaBold, size: 9, color: rgb(0.3, 0.3, 0.3) });
x += colWidths[i];
}
y -= 8;
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 0.5, color: rgb(0.75, 0.75, 0.75) });
y -= 16;
// ── Stop rows ───────────────────────────────────────────────────────────
if (stops.length === 0) {
page.drawText("No upcoming stops scheduled.", { x: margin, y, font: helvetica, size: 10, color: rgb(0.5, 0.5, 0.5) });
y -= 20;
} else {
for (const stop of stops) {
x = margin;
const cols = [stop.city + ", " + stop.state, stop.date, stop.time, stop.location];
for (let i = 0; i < cols.length; i++) {
page.drawText(cols[i], { x, y, font: helvetica, size: 9, color: rgb(0.15, 0.15, 0.15) });
x += colWidths[i];
}
y -= 20;
if (y < margin + 40) {
const newPage = pdfDoc.addPage([612, 792]);
y = newPage.getSize().height - margin;
}
}
}
// ── Bottom divider ───────────────────────────────────────────────────────
y -= 4;
page.drawLine({ start: { x: margin, y }, end: { x: width - margin, y }, thickness: 0.5, color: rgb(0.8, 0.8, 0.8) });
y -= 14;
// ── Footer notes ────────────────────────────────────────────────────────
if (footerNotes) {
page.drawText(footerNotes, { x: margin, y, font: helvetica, size: 8, color: rgb(0.45, 0.45, 0.45) });
y -= 14;
}
// ── Contact line ───────────────────────────────────────────────────────
const contactParts = [];
if (contactEmail) contactParts.push(contactEmail);
if (contactPhone) contactParts.push(contactPhone);
if (contactParts.length > 0) {
page.drawText(contactParts.join(" | "), { x: margin, y, font: helvetica, size: 8, color: rgb(0.4, 0.4, 0.4) });
y -= 12;
}
// ── Generated date ──────────────────────────────────────────────────────
page.drawText(`Generated ${new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`, {
x: width - margin - 160,
y: 16,
font: helvetica,
size: 8,
color: rgb(0.6, 0.6, 0.6),
});
return pdfDoc.save();
}
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { getAIProviderSettings, setAIProviderSettings } from "@/actions/integrations/ai-providers";
import { getAdminUser } from "@/lib/admin-permissions";
export async function GET(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_settings) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(req.url);
let brandId = searchParams.get("brandId");
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
brandId = effectiveBrandId;
const settings = await getAIProviderSettings(brandId);
return NextResponse.json(settings);
}
export async function POST(req: NextRequest) {
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!adminUser.can_manage_settings) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const { brandId, provider, apiKey, orgId, model, customEndpoint } = await req.json();
const effectiveBrandId = adminUser.brand_id ?? brandId ?? null;
if (!effectiveBrandId) {
return NextResponse.json({ error: "Brand ID required" }, { status: 400 });
}
if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) {
return NextResponse.json({ error: "Not authorized for this brand" }, { status: 403 });
}
const result = await setAIProviderSettings(effectiveBrandId, { provider, apiKey, orgId, model, customEndpoint });
if (!result.success) return NextResponse.json({ error: result.error }, { status: 500 });
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
}

Some files were not shown because too many files have changed in this diff Show More