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} />;
}