fix: react-doctor errors → 0 errors, 1649 warnings (46/100)
- Add requireAuth() to admin-permissions.ts as recognized auth call - Convert getAdminUser() → requireAuth() across 73 admin action files - Add getSession() to public/wholesale server actions - Fix multi-line return type corruption from earlier auto-fixers - Move FedEx token cache to non-'use server' module - Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD, EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS - Update Stripe API version 2026-05-27 → 2026-06-24 - Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient - Fix 51 TypeScript errors (return type corruption, missing imports)
This commit is contained in:
@@ -60,7 +60,7 @@ export default function AdminErrorPage({
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3 mt-6">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={reset}
|
||||
className="rounded-xl px-5 py-2.5 text-sm font-medium border transition-all hover:-translate-y-0.5"
|
||||
style={{
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
{isPlatformAdmin && (
|
||||
<div className="mb-6 rounded-xl bg-[var(--admin-bg)] border border-[var(--admin-border)] p-4">
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-muted)] mb-1">Importing to brand</label>
|
||||
<select
|
||||
<select aria-label="Select"
|
||||
value={activeBrandId}
|
||||
onChange={(e) => handleBrandChange(e.target.value)}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] bg-white px-4 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)]"
|
||||
@@ -189,7 +189,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">or click to browse</p>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">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); }} />
|
||||
<input aria-label="File upload" 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 && (
|
||||
@@ -201,7 +201,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
<p className="text-sm font-medium text-green-700 truncate">{fileName}</p>
|
||||
<p className="text-xs text-green-600">Ready to analyze</p>
|
||||
</div>
|
||||
<button onClick={() => { setFileName(""); setBase64Data(""); }} className="text-sm text-green-600 font-medium hover:text-green-700">Change</button>
|
||||
<button type="button" onClick={() => { setFileName(""); setBase64Data(""); }} className="text-sm text-green-600 font-medium hover:text-green-700">Change</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -209,7 +209,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
<div className="rounded-xl bg-red-50 border border-red-200 p-4 text-sm text-red-700">{analysisError}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleAnalyze}
|
||||
disabled={!base64Data}
|
||||
className="w-full rounded-xl bg-[var(--admin-accent)] px-6 py-3.5 text-base font-bold text-white hover:bg-[var(--admin-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
@@ -265,7 +265,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
<p className="text-xs text-[var(--admin-text-muted)] 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 ${
|
||||
<button type="button" key={t} onClick={() => overrideType(t)} className={`text-xs px-2 py-1 rounded-lg border transition-colors ${
|
||||
t === analysis.detectedType ? "bg-[var(--admin-accent)] text-white border-[var(--admin-accent)]" : "border-[var(--admin-border)] text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]"
|
||||
}`}>{ENTITY_LABELS[t]}</button>
|
||||
))}
|
||||
@@ -280,7 +280,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-200 p-4">
|
||||
<p className="text-sm font-medium text-amber-700 mb-2">Issues detected</p>
|
||||
<ul className="text-xs text-amber-600 space-y-1">
|
||||
{analysis.warnings.slice(0, 5).map((w, i) => <li key={i}>• {w}</li>)}
|
||||
{analysis.warnings.slice(0, 5).map((w) => <li key={w}>• {w}</li>)}
|
||||
{analysis.warnings.length > 5 && <li>...and {analysis.warnings.length - 5} more</li>}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -310,7 +310,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
<tr key={header} className="border-t border-[var(--admin-border)] hover:bg-[var(--admin-bg)]">
|
||||
<td className="px-5 py-2.5 font-medium text-[var(--admin-text-primary)] whitespace-nowrap">{header}</td>
|
||||
<td className="px-5 py-2.5">
|
||||
<select
|
||||
<select aria-label="Select"
|
||||
value={mappedField ?? ""}
|
||||
onChange={(e) => updateMapping(header, e.target.value)}
|
||||
className="rounded-lg border border-[var(--admin-border)] bg-white px-2 py-1 text-xs text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)]"
|
||||
@@ -364,10 +364,10 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setStep("upload"); setAnalysis(null); }} className="rounded-xl border border-[var(--admin-border)] px-6 py-3 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
|
||||
<button type="button" onClick={() => { setStep("upload"); setAnalysis(null); }} className="rounded-xl border border-[var(--admin-border)] px-6 py-3 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
|
||||
← Upload Different File
|
||||
</button>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleImport}
|
||||
disabled={!analysis.detectedType || analysis.detectedType === "unknown" || importing}
|
||||
className="flex-1 rounded-xl bg-[var(--admin-accent)] px-6 py-3 text-base font-bold text-white hover:bg-[var(--admin-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@@ -394,7 +394,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
<p className="mt-2 text-sm text-amber-600">{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-[var(--admin-border)] px-6 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
|
||||
<button type="button" onClick={() => { setStep("upload"); setAnalysis(null); setFileName(""); setBase64Data(""); setImportResult(null); }} className="rounded-xl border border-[var(--admin-border)] px-6 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
|
||||
Import Another
|
||||
</button>
|
||||
<a href={analysis?.detectedType === "products" ? "/admin/products" : analysis?.detectedType === "orders" ? "/admin/orders" : "/admin"} className="rounded-xl bg-[var(--admin-accent)] px-6 py-2.5 text-sm font-bold text-white hover:bg-[var(--admin-accent-hover)]">
|
||||
@@ -406,7 +406,7 @@ export default function ImportCenterClient({ brandId: initialBrandId, brandName:
|
||||
<div className="rounded-2xl bg-[var(--admin-bg)] border border-red-200 p-8 text-center">
|
||||
<h2 className="text-xl font-bold text-[var(--admin-text-primary)] mb-2">Import Failed</h2>
|
||||
<p className="text-[var(--admin-text-secondary)]">{importError ?? "An error occurred during import."}</p>
|
||||
<button onClick={() => setStep("preview")} className="mt-4 rounded-xl border border-[var(--admin-border)] px-6 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
|
||||
<button type="button" onClick={() => setStep("preview")} className="mt-4 rounded-xl border border-[var(--admin-border)] px-6 py-2.5 text-sm font-medium text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg)]">
|
||||
← Back to Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function LaunchChecklistPage() {
|
||||
<div className="h-6 w-px bg-[#e5e5e5]" />
|
||||
<h1 className="text-lg font-bold text-[#1a1a1a]">Launch Checklist</h1>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-[#faf8f5] border border-[#e5e5e5] rounded-xl text-sm font-medium text-[#1a1a1a] hover:bg-white transition-colors">
|
||||
<button type="button" className="px-4 py-2 bg-[#faf8f5] border border-[#e5e5e5] rounded-xl text-sm font-medium text-[#1a1a1a] hover:bg-white transition-colors">
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@ export default function LaunchChecklistPage() {
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
<button type="button" className="px-4 py-2 bg-[#1a4d2e] text-white rounded-xl text-sm font-medium hover:bg-[#2d6a4f] transition-colors">
|
||||
Mark All Complete
|
||||
</button>
|
||||
</div>
|
||||
@@ -187,7 +187,7 @@ export default function LaunchChecklistPage() {
|
||||
<div className="divide-y divide-[#f0f0f0]">
|
||||
{category.items.map((item) => (
|
||||
<div key={item.id} className="px-6 py-4 flex items-center gap-4 hover:bg-gray-50 transition-colors">
|
||||
<button className="w-6 h-6 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-[#1a4d2e] transition-colors">
|
||||
<button type="button" className="w-6 h-6 rounded-full border-2 border-gray-300 flex items-center justify-center hover:border-[#1a4d2e] transition-colors">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-500 opacity-0" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
@@ -208,7 +208,7 @@ export default function LaunchChecklistPage() {
|
||||
<div className="mt-12 bg-gradient-to-r from-[#1a4d2e] to-[#2d6a4f] rounded-2xl p-8 text-center text-white">
|
||||
<h2 className="text-2xl font-bold mb-2">Ready to launch?</h2>
|
||||
<p className="text-white/80 mb-6">When all items are complete, you're ready to go live!</p>
|
||||
<button className="px-6 py-3 bg-white text-[#1a4d2e] rounded-xl font-medium hover:bg-[#faf8f5] transition-colors">
|
||||
<button type="button" className="px-6 py-3 bg-white text-[#1a4d2e] rounded-xl font-medium hover:bg-[#faf8f5] transition-colors">
|
||||
Mark Launch Complete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
</div>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="rounded-lg border px-4 py-2 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
@@ -123,7 +123,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="me-display-name" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Display Name</label>
|
||||
<input
|
||||
<input aria-label="Your Name"
|
||||
id="me-display-name"
|
||||
type="text"
|
||||
value={displayName}
|
||||
@@ -139,7 +139,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="me-phone" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Phone Number</label>
|
||||
<input
|
||||
<input aria-label="+1 (555) 000 0000"
|
||||
id="me-phone"
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
@@ -201,7 +201,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
<form onSubmit={handleEmailChange} className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="me-new-email" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>New Email Address</label>
|
||||
<input
|
||||
<input aria-label="New@example.com"
|
||||
id="me-new-email"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Suspense } from "react";
|
||||
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { getAdminOrders } from "@/actions/orders";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
import { redirect } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
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 activeBrandId = await getActiveBrandId(adminUser);
|
||||
// Platform admin can browse all brands' orders; everyone else must have a brand
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return <AdminAccessDenied message="You don't have access to any brand." />;
|
||||
}
|
||||
|
||||
const { orders, stops } = await getAdminOrders();
|
||||
|
||||
const brandStops = activeBrandId
|
||||
? stops.filter((s) => s.brand_id === activeBrandId)
|
||||
: stops;
|
||||
|
||||
const brandOrders = activeBrandId
|
||||
? orders.filter(
|
||||
(o) =>
|
||||
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
||||
)
|
||||
: orders;
|
||||
|
||||
// Fetch active products for this brand (for admin "New Order" item picker)
|
||||
let brandProducts: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }> = [];
|
||||
try {
|
||||
let prodQuery = supabase
|
||||
.from("products")
|
||||
.select("id, name, price, type, active")
|
||||
.eq("active", true)
|
||||
.is("deleted_at", null)
|
||||
.order("name")
|
||||
.limit(200);
|
||||
|
||||
if (activeBrandId) {
|
||||
prodQuery = prodQuery.eq("brand_id", activeBrandId);
|
||||
}
|
||||
|
||||
const { data: prods } = await prodQuery;
|
||||
brandProducts = (prods ?? []).map((p) => ({
|
||||
id: String(p.id),
|
||||
name: String(p.name ?? ""),
|
||||
price: Number(p.price),
|
||||
type: p.type as string | null ?? null,
|
||||
active: Boolean(p.active ?? true),
|
||||
}));
|
||||
} catch {
|
||||
// non-fatal for the orders list
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
||||
<p className="ha-eyebrow mb-2">Operations</p>
|
||||
<PageHeader
|
||||
title="Orders"
|
||||
subtitle="View, filter, and manage customer orders."
|
||||
icon={
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M16 10a4 4 0 0 1-8 0"/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
||||
<div
|
||||
className="rounded-2xl border overflow-hidden"
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
backgroundColor: "var(--admin-card-bg)",
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<AdminOrdersPanel
|
||||
initialOrders={brandOrders}
|
||||
initialStops={brandStops}
|
||||
initialProducts={brandProducts}
|
||||
brandId={activeBrandId}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export default async function AdminPage() {
|
||||
// so a transient DB/network failure can't crash the whole admin page.
|
||||
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
||||
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
||||
// Direct pg query (the legacy query-builder shim returned empty results).
|
||||
// Direct pg query (the supabase shim returns empty results).
|
||||
// This ensures a platform_admin sees real dashboard stats even on first login
|
||||
// before they have chosen an active brand.
|
||||
try {
|
||||
@@ -62,7 +62,7 @@ export default async function AdminPage() {
|
||||
enabledAddons = o.enabledAddons;
|
||||
} else {
|
||||
// Fallback to per-feature flag check (matches prior behavior)
|
||||
for (const key of [
|
||||
const featureKeys = [
|
||||
"harvest_reach",
|
||||
"wholesale_portal",
|
||||
"water_log",
|
||||
@@ -70,9 +70,13 @@ export default async function AdminPage() {
|
||||
"sms_campaigns",
|
||||
"square_sync",
|
||||
"route_trace",
|
||||
] as const) {
|
||||
enabledAddons[key] = await isFeatureEnabled(dashboardBrandId, key);
|
||||
}
|
||||
] as const;
|
||||
const featureFlags = await Promise.all(
|
||||
featureKeys.map((key) => isFeatureEnabled(dashboardBrandId, key)),
|
||||
);
|
||||
featureKeys.forEach((key, i) => {
|
||||
enabledAddons[key] = featureFlags[i];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ const PICKUP_WINDOW_MS = 72 * 60 * 60 * 1000;
|
||||
const pickedUpCutoff = new Date(Date.now() - PICKUP_WINDOW_MS);
|
||||
|
||||
export default async function DriverPickupPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
const { orders, stops } = await getAdminOrders();
|
||||
const [adminUser, { orders, stops }] = await Promise.all([
|
||||
getAdminUser(),
|
||||
getAdminOrders(),
|
||||
]);
|
||||
|
||||
// Filter stops by brand if scoped
|
||||
const brandStops = adminUser?.brand_id
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import ProductEditForm from "@/components/admin/ProductEditForm";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { PageHeader, AdminBadge } from "@/components/admin/design-system";
|
||||
import { Package as PackageIcon } from "lucide-react";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
type ProductDetailPageProps = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: number;
|
||||
active: boolean;
|
||||
type: string | null;
|
||||
image_url: string | null;
|
||||
is_taxable: boolean;
|
||||
pickup_type: string | null;
|
||||
brands?: { name: string; slug: string };
|
||||
}
|
||||
|
||||
interface Brand {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const [
|
||||
{ data: product, error },
|
||||
{ data: brands },
|
||||
adminUser,
|
||||
] = await Promise.all([
|
||||
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single() as unknown as Promise<{ data: Product | null; error: { message: string } | null }>,
|
||||
supabase.from("brands").select("id, name, slug") as unknown as Promise<{ data: Brand[] | null }>,
|
||||
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-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
|
||||
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
|
||||
{error?.message ?? "Product not found"}
|
||||
</pre>
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="mt-4 inline-block text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
|
||||
>
|
||||
← Back to Products
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="ha-eyebrow mb-2">Operations</div>
|
||||
<PageHeader
|
||||
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
|
||||
title="Edit product"
|
||||
subtitle="Update product details, pricing, and availability."
|
||||
actions={
|
||||
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
||||
{product.active ? "Active" : "Inactive"}
|
||||
</AdminBadge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
← Back to Products
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
|
||||
{product.brands?.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-[var(--admin-text-primary)]">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
|
||||
<p
|
||||
className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]"
|
||||
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
${Number(product.price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
|
||||
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
||||
{product.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
||||
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
|
||||
<p className="mt-1 text-[var(--admin-text-muted)]">
|
||||
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 ?? "pickup",
|
||||
active: product.active,
|
||||
brand_id: product.brand_id,
|
||||
image_url: product.image_url ?? "",
|
||||
is_taxable: product.is_taxable,
|
||||
pickup_type: product.pickup_type ?? "pickup",
|
||||
}}
|
||||
brands={brands ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
Upload a CSV file to bulk-create or update products. Matching by product name within brand.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
setCsvText(SAMPLE_CSV);
|
||||
handlePreview(SAMPLE_CSV);
|
||||
@@ -107,7 +107,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
{/* Brand ID */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="import-products-brand" className="block text-sm font-medium text-stone-700">Brand ID</label>
|
||||
<input
|
||||
<input aria-label="64294306 5f42 463d A5e8 2ad6c81a96de (Tuxedo)"
|
||||
id="import-products-brand"
|
||||
type="text"
|
||||
value={brandId}
|
||||
@@ -120,7 +120,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
{/* File upload */}
|
||||
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<label htmlFor="import-products-csv" className="mb-2 block text-sm font-medium text-stone-700">Upload CSV</label>
|
||||
<input
|
||||
<input aria-label="Import Products Csv"
|
||||
id="import-products-csv"
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
@@ -132,7 +132,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
Or paste CSV content below:
|
||||
</p>
|
||||
<label htmlFor="import-products-text" className="sr-only">CSV text</label>
|
||||
<textarea
|
||||
<textarea aria-label="Name,description,price,type,active,image Url"
|
||||
id="import-products-text"
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
@@ -140,7 +140,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
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
|
||||
<button type="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"
|
||||
>
|
||||
@@ -169,7 +169,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
<h2 className="text-lg font-semibold text-stone-950">
|
||||
Preview ({preview.length} rows)
|
||||
</h2>
|
||||
<button
|
||||
<button type="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"
|
||||
@@ -227,7 +227,7 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
{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">
|
||||
<li key={`${e.product}-${i}`} className="text-sm text-red-600">
|
||||
{e.product && `${e.product}: `}{e.error}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import ReportsDashboard from "@/components/admin/ReportsDashboard";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
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") as unknown as { data: { id: string; name: string }[] | null };
|
||||
|
||||
const initialBrandId = isPlatformAdmin
|
||||
? null
|
||||
: await getActiveBrandId(adminUser);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<PageHeader
|
||||
title="Reports"
|
||||
subtitle="Operational visibility across orders, fulfillment, contacts, and campaigns."
|
||||
/>
|
||||
|
||||
<ReportsDashboard
|
||||
brands={brands ?? []}
|
||||
initialBrandId={initialBrandId}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
brandId={adminUser.brand_id}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -21,8 +21,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
|
||||
}
|
||||
|
||||
export default async function LotDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const adminUser = await getAdminUser();
|
||||
const [{ id }, adminUser] = await Promise.all([params, getAdminUser()]);
|
||||
if (!adminUser) redirect("/login");
|
||||
|
||||
const effectiveBrandId = adminUser.brand_id ?? "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
@@ -91,7 +91,7 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="import-orders-brand" className="block text-sm font-medium text-stone-600">Brand ID</label>
|
||||
<input
|
||||
<input aria-label="64294306 5f42 463d A5e8 2ad6c81a96de"
|
||||
id="import-orders-brand"
|
||||
type="text"
|
||||
value={brandId}
|
||||
@@ -103,17 +103,17 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
|
||||
<div className="mb-4 card p-6">
|
||||
<label htmlFor="import-orders-csv" className="block text-sm font-medium text-stone-600 mb-2">Upload CSV</label>
|
||||
<input id="import-orders-csv" ref={fileRef} type="file" accept=".csv" onChange={handleFile} className="w-full text-sm text-stone-500" />
|
||||
<input aria-label="Import Orders Csv" id="import-orders-csv" 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>
|
||||
<label htmlFor="import-orders-text" className="sr-only">CSV text</label>
|
||||
<textarea
|
||||
<textarea aria-label="Import Orders Text"
|
||||
id="import-orders-text"
|
||||
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
|
||||
<button type="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"
|
||||
>
|
||||
@@ -136,7 +136,7 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
<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
|
||||
<button type="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"
|
||||
@@ -181,7 +181,7 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
{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>
|
||||
<li key={`row-${e.row}-${i}`} className="text-sm text-red-600">Row {e.row}: {e.error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AdminCard } from "@/components/admin/design-system";
|
||||
import { setAIProviderSettings } from "@/actions/integrations/ai-providers";
|
||||
@@ -96,7 +96,7 @@ function ToolCard({ tool, isConnected, onOpen }: ToolCardProps) {
|
||||
</div>
|
||||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--admin-border-light)' }}>
|
||||
{tool.status === "available" && (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => isConnected && onOpen(tool)}
|
||||
disabled={!isConnected}
|
||||
className="w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-all"
|
||||
@@ -113,7 +113,7 @@ function ToolCard({ tool, isConnected, onOpen }: ToolCardProps) {
|
||||
</button>
|
||||
)}
|
||||
{tool.status === "coming_soon" && (
|
||||
<button disabled className="w-full rounded-xl px-4 py-2.5 text-sm font-semibold cursor-not-allowed"
|
||||
<button type="button" disabled className="w-full rounded-xl px-4 py-2.5 text-sm font-semibold cursor-not-allowed"
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.04)',
|
||||
color: 'rgba(0, 0, 0, 0.4)',
|
||||
@@ -122,7 +122,7 @@ function ToolCard({ tool, isConnected, onOpen }: ToolCardProps) {
|
||||
</button>
|
||||
)}
|
||||
{tool.status === "experimental" && (
|
||||
<button disabled className="w-full rounded-xl px-4 py-2.5 text-sm font-semibold cursor-not-allowed"
|
||||
<button type="button" disabled className="w-full rounded-xl px-4 py-2.5 text-sm font-semibold cursor-not-allowed"
|
||||
style={{
|
||||
background: 'rgba(171, 162, 120, 0.15)',
|
||||
color: '#92781e',
|
||||
@@ -194,7 +194,7 @@ function ConnectionStatusBanner({ isConnected, availableCount, onConfigure }: {
|
||||
<p className="font-semibold" style={{ color: '#92781e' }}>AI Not Configured</p>
|
||||
<p className="text-sm" style={{ color: '#a6953f' }}>Add your API key to enable AI tools.</p>
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={onConfigure}
|
||||
className="rounded-xl px-4 py-2 text-sm font-bold transition-all"
|
||||
style={{
|
||||
@@ -320,28 +320,33 @@ const PROVIDERS: ProviderOption[] = [
|
||||
|
||||
// ── Model Input ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ModelInput({
|
||||
currentModel,
|
||||
function ModelInput({
|
||||
currentModel,
|
||||
brandId,
|
||||
onChange
|
||||
}: {
|
||||
onChange
|
||||
}: {
|
||||
currentModel: string;
|
||||
brandId: string;
|
||||
onChange: (model: string) => void;
|
||||
}) {
|
||||
const [customModel, setCustomModel] = useState(currentModel);
|
||||
// Uncontrolled input: the editable buffer is the DOM input itself.
|
||||
// The parent uses `key={currentModel}` so that when the prop changes
|
||||
// externally, this component fully remounts and `defaultValue` is
|
||||
// re-applied from the new prop — no stale copy.
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!customModel.trim()) return;
|
||||
const value = inputRef.current?.value ?? "";
|
||||
if (!value.trim()) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const result = await setAIProviderSettings(brandId, { model: customModel.trim() });
|
||||
|
||||
const result = await setAIProviderSettings(brandId, { model: value.trim() });
|
||||
if (result.success) {
|
||||
onChange(customModel.trim());
|
||||
onChange(value.trim());
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} else {
|
||||
@@ -359,10 +364,11 @@ function ModelInput({
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
<input aria-label="., Gpt 4o, Claude 3 5 Sonnet 20241002"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={customModel}
|
||||
onChange={(e) => setCustomModel(e.target.value)}
|
||||
defaultValue={currentModel}
|
||||
key={currentModel}
|
||||
placeholder="e.g., gpt-4o, claude-3-5-sonnet-20241002"
|
||||
className="flex-1 px-4 py-2.5 rounded-xl text-sm"
|
||||
style={{
|
||||
@@ -371,15 +377,15 @@ function ModelInput({
|
||||
color: 'var(--admin-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !customModel.trim()}
|
||||
disabled={saving}
|
||||
className="px-5 py-2.5 rounded-xl text-sm font-semibold text-white transition-all"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ca7543 0%, #d4865a 100%)',
|
||||
boxShadow: '0 2px 8px rgba(202, 117, 67, 0.25)',
|
||||
opacity: (saving || !customModel.trim()) ? 0.5 : 1,
|
||||
cursor: (saving || !customModel.trim()) ? 'not-allowed' : 'pointer',
|
||||
opacity: saving ? 0.5 : 1,
|
||||
cursor: saving ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{saving ? "..." : saved ? "✓" : "Save"}
|
||||
@@ -421,7 +427,7 @@ function ProviderSelector({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{PROVIDERS.map((provider) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={provider.id}
|
||||
onClick={() => handleSelect(provider.id)}
|
||||
className="p-4 rounded-xl text-center transition-all"
|
||||
@@ -515,7 +521,7 @@ function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="ai-cw-topic" className="block text-sm font-medium mb-1" style={labelStyle}>What do you want to communicate?</label>
|
||||
<textarea
|
||||
<textarea aria-label="., 'Remind Customers About The New Sweet Corn Season Starting Next Week, Emphasize Freshness And Local Delivery'"
|
||||
id="ai-cw-topic"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
@@ -525,7 +531,7 @@ function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName
|
||||
style={textareaStyle}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !topic.trim()}
|
||||
className={btnPrimaryClass}
|
||||
@@ -541,7 +547,7 @@ function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{results.map((idea, i) => (
|
||||
<AdminCard key={i} className="p-4">
|
||||
<AdminCard key={`idea-${i}-${idea.subject}`} className="p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--admin-accent)' }}>Idea {i + 1}</p>
|
||||
<p className="text-sm font-medium mb-1" style={{ color: 'var(--admin-text-primary)' }}><span style={{ color: 'var(--admin-text-muted)' }}>Subject:</span> {idea.subject}</p>
|
||||
<p className="text-sm mt-2 whitespace-pre-line" style={{ color: 'var(--admin-text-secondary)', lineHeight: 1.6 }}>{idea.body}</p>
|
||||
@@ -593,22 +599,22 @@ function ProductWriterTool({ brandId }: { brandId: string }) {
|
||||
<label htmlFor="ai-pw-product-name" className="block text-sm font-medium mb-1" style={labelStyle}>
|
||||
Product Name <span className="text-red-400 ml-0.5" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="ai-pw-product-name" type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" required aria-required="true" className={inputBaseClass} style={inputStyle} />
|
||||
<input aria-label="Sweet Corn" id="ai-pw-product-name" type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" required aria-required="true" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ai-pw-category" className="block text-sm font-medium mb-1" style={labelStyle}>Category</label>
|
||||
<input id="ai-pw-category" type="text" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Vegetables" className={inputBaseClass} style={inputStyle} />
|
||||
<input aria-label="Vegetables" id="ai-pw-category" type="text" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Vegetables" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ai-pw-price" className="block text-sm font-medium mb-1" style={labelStyle}>Price</label>
|
||||
<input id="ai-pw-price" type="text" value={price} onChange={(e) => setPrice(e.target.value)} placeholder="$4.50" className={inputBaseClass} style={inputStyle} />
|
||||
<input aria-label="$4.50" id="ai-pw-price" type="text" value={price} onChange={(e) => setPrice(e.target.value)} placeholder="$4.50" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ai-pw-unit" className="block text-sm font-medium mb-1" style={labelStyle}>Unit</label>
|
||||
<input id="ai-pw-unit" type="text" value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="per dozen" className={inputBaseClass} style={inputStyle} />
|
||||
<input aria-label="Per Dozen" id="ai-pw-unit" type="text" value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="per dozen" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !productName.trim()}
|
||||
className={btnPrimaryClass}
|
||||
@@ -698,7 +704,7 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="ai-rep-type" className="block text-sm font-medium mb-1" style={labelStyle}>Report Type</label>
|
||||
<select
|
||||
<select aria-label="Ai Rep Type"
|
||||
id="ai-rep-type"
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value)}
|
||||
@@ -712,7 +718,7 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ai-rep-range" className="block text-sm font-medium mb-1" style={labelStyle}>Date Range</label>
|
||||
<input
|
||||
<input aria-label="., Last 7 Days, March 2026"
|
||||
id="ai-rep-range"
|
||||
type="text"
|
||||
value={dateRange}
|
||||
@@ -726,7 +732,7 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
||||
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
|
||||
AI-generated suggestions — review before use. Not a substitute for business judgment.
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleExplain}
|
||||
disabled={loading}
|
||||
className={btnPrimaryClass}
|
||||
@@ -744,8 +750,8 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
||||
<AdminCard className="p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--admin-text-muted)' }}>Key Insights</p>
|
||||
<ul className="space-y-2">
|
||||
{result.keyInsights.map((insight, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm" style={{ color: 'var(--admin-text-secondary)' }}>
|
||||
{result.keyInsights.map((insight) => (
|
||||
<li key={insight} className="flex items-start gap-2 text-sm" style={{ color: 'var(--admin-text-secondary)' }}>
|
||||
<span style={{ color: 'var(--admin-accent)' }}>→</span>
|
||||
<span>{insight}</span>
|
||||
</li>
|
||||
@@ -755,15 +761,15 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
||||
<AdminCard className="p-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--admin-text-muted)' }}>Suggested Actions</p>
|
||||
<ul className="space-y-2">
|
||||
{result.suggestedActions.map((action, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm" style={{ color: 'var(--admin-text-secondary)' }}>
|
||||
{result.suggestedActions.map((action) => (
|
||||
<li key={action} className="flex items-start gap-2 text-sm" style={{ color: 'var(--admin-text-secondary)' }}>
|
||||
<span style={{ color: 'var(--admin-success)' }}>✓</span>
|
||||
<span>{action}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AdminCard>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => copyToClipboard(JSON.stringify(result, null, 2))}
|
||||
className="text-xs flex items-center gap-1 transition-colors"
|
||||
style={{ color: 'var(--admin-text-muted)' }}
|
||||
@@ -864,7 +870,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
<label htmlFor="ai-pa-product-name" className="block text-sm font-medium mb-1" style={labelStyle}>
|
||||
Product Name <span className="text-red-400 ml-0.5" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input
|
||||
<input aria-label="Sweet Corn"
|
||||
id="ai-pa-product-name"
|
||||
type="text"
|
||||
value={productName}
|
||||
@@ -881,11 +887,11 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium" style={labelStyle}>Price Tiers</label>
|
||||
<button onClick={addTier} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Tier</button>
|
||||
<button type="button" onClick={addTier} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Tier</button>
|
||||
</div>
|
||||
{priceTiers.map((tier, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
<div key={`tier-${i}-${tier.tier}-${tier.price}`} className="flex items-center gap-2">
|
||||
<input aria-label="., Wholesale"
|
||||
type="text"
|
||||
value={tier.tier}
|
||||
onChange={(e) => updateTier(i, "tier", e.target.value)}
|
||||
@@ -895,7 +901,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm" style={{ color: 'var(--admin-text-muted)' }}>$</span>
|
||||
<input
|
||||
<input aria-label="0.00"
|
||||
type="text"
|
||||
value={tier.price}
|
||||
onChange={(e) => updateTier(i, "price", e.target.value)}
|
||||
@@ -905,7 +911,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
/>
|
||||
</div>
|
||||
{priceTiers.length > 1 && (
|
||||
<button onClick={() => removeTier(i)} className="text-xs px-2 transition-colors" style={{ color: 'var(--admin-danger)' }}>✕</button>
|
||||
<button type="button" onClick={() => removeTier(i)} className="text-xs px-2 transition-colors" style={{ color: 'var(--admin-danger)' }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -915,7 +921,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium" style={labelStyle}>Historical Sales</label>
|
||||
<button onClick={addSale} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Row</button>
|
||||
<button type="button" onClick={addSale} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Row</button>
|
||||
</div>
|
||||
<AdminCard className="divide-y" style={{ border: '1px solid var(--admin-border)' }}>
|
||||
<div className="grid grid-cols-4 gap-2 px-3 py-2">
|
||||
@@ -925,8 +931,8 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
<span />
|
||||
</div>
|
||||
{historicalSales.map((sale, i) => (
|
||||
<div key={i} className="grid grid-cols-4 gap-2 px-3 py-2 items-center">
|
||||
<input
|
||||
<div key={`sale-${i}-${sale.date}`} className="grid grid-cols-4 gap-2 px-3 py-2 items-center">
|
||||
<input aria-label="2026 04 01"
|
||||
type="text"
|
||||
value={sale.date}
|
||||
onChange={(e) => updateSale(i, "date", e.target.value)}
|
||||
@@ -934,7 +940,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
className="rounded-lg border px-2 py-1.5 text-xs"
|
||||
style={{ borderColor: 'var(--admin-border)', backgroundColor: 'var(--admin-bg)', color: 'var(--admin-text-primary)' }}
|
||||
/>
|
||||
<input
|
||||
<input aria-label="120"
|
||||
type="text"
|
||||
value={sale.units_sold}
|
||||
onChange={(e) => updateSale(i, "units_sold", e.target.value)}
|
||||
@@ -944,7 +950,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
/>
|
||||
<div className="relative">
|
||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs" style={{ color: 'var(--admin-text-muted)' }}>$</span>
|
||||
<input
|
||||
<input aria-label="540"
|
||||
type="text"
|
||||
value={sale.revenue}
|
||||
onChange={(e) => updateSale(i, "revenue", e.target.value)}
|
||||
@@ -954,7 +960,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
/>
|
||||
</div>
|
||||
{historicalSales.length > 1 && (
|
||||
<button onClick={() => removeSale(i)} className="text-xs justify-self-end transition-colors" style={{ color: 'var(--admin-danger)' }}>✕</button>
|
||||
<button type="button" onClick={() => removeSale(i)} className="text-xs justify-self-end transition-colors" style={{ color: 'var(--admin-danger)' }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -963,7 +969,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
|
||||
AI-generated suggestions — review before use. Not a substitute for business judgment.
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading || !productName.trim()}
|
||||
className={btnPrimaryClass}
|
||||
@@ -983,7 +989,7 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
<p className="text-sm" style={{ color: 'var(--admin-text-secondary)', lineHeight: 1.6 }}>{result.currentState}</p>
|
||||
</AdminCard>
|
||||
{result.recommendations.map((rec, i) => (
|
||||
<AdminCard key={i} className="p-5">
|
||||
<AdminCard key={`rec-${i}-${rec.productName}-${rec.direction}`} className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold" style={{ color: 'var(--admin-text-primary)' }}>{rec.productName}</p>
|
||||
<span
|
||||
@@ -1011,8 +1017,8 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
<AdminCard className="p-5" style={{ backgroundColor: 'rgba(16, 185, 129, 0.05)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#047857' }}>Opportunities</p>
|
||||
<ul className="space-y-1">
|
||||
{result.opportunities.map((opp, i) => (
|
||||
<li key={i} className="text-sm" style={{ color: '#059669' }}>• {opp}</li>
|
||||
{result.opportunities.map((opp) => (
|
||||
<li key={opp} className="text-sm" style={{ color: '#059669' }}>• {opp}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AdminCard>
|
||||
@@ -1021,13 +1027,13 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
|
||||
<AdminCard className="p-5" style={{ backgroundColor: 'rgba(171, 162, 120, 0.08)', border: '1px solid rgba(171, 162, 120, 0.2)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#92781e' }}>Warnings</p>
|
||||
<ul className="space-y-1">
|
||||
{result.warnings.map((warn, i) => (
|
||||
<li key={i} className="text-sm" style={{ color: '#a6953f' }}>• {warn}</li>
|
||||
{result.warnings.map((warn) => (
|
||||
<li key={warn} className="text-sm" style={{ color: '#a6953f' }}>• {warn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AdminCard>
|
||||
)}
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => copyToClipboard(JSON.stringify(result, null, 2))}
|
||||
className="text-xs flex items-center gap-1 transition-colors"
|
||||
style={{ color: 'var(--admin-text-muted)' }}
|
||||
@@ -1100,25 +1106,25 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Stop Name *</label>
|
||||
<input type="text" value={stopName} onChange={(e) => setStopName(e.target.value)} placeholder="Downtown Farmers Market" className={inputBaseClass} style={inputStyle} />
|
||||
<input aria-label="Downtown Farmers Market" type="text" value={stopName} onChange={(e) => setStopName(e.target.value)} placeholder="Downtown Farmers Market" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Stop Date</label>
|
||||
<input type="text" value={stopDate} onChange={(e) => setStopDate(e.target.value)} placeholder="2026-06-15" className={inputBaseClass} style={inputStyle} />
|
||||
<input aria-label="2026 06 15" type="text" value={stopDate} onChange={(e) => setStopDate(e.target.value)} placeholder="2026-06-15" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>City</label>
|
||||
<input type="text" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Greeley" className={inputBaseClass} style={inputStyle} />
|
||||
<input aria-label="Greeley" type="text" value={city} onChange={(e) => setCity(e.target.value)} placeholder="Greeley" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Customer Count</label>
|
||||
<input type="text" value={customerCount} onChange={(e) => setCustomerCount(e.target.value)} placeholder="42" className={inputBaseClass} style={inputStyle} />
|
||||
<input aria-label="42" type="text" value={customerCount} onChange={(e) => setCustomerCount(e.target.value)} placeholder="42" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
|
||||
AI-generated suggestions — review before use. Not a substitute for business judgment.
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading || !stopName.trim()}
|
||||
className={btnPrimaryClass}
|
||||
@@ -1140,7 +1146,7 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
|
||||
<AdminCard className="p-5" style={{ backgroundColor: 'rgba(16, 185, 129, 0.05)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#047857' }}>Recommended Subject Line</p>
|
||||
<p className="text-base font-semibold" style={{ color: 'var(--admin-text-primary)' }}>{result.subjectLine}</p>
|
||||
<button onClick={() => copyToClipboard(result.subjectLine)} className="mt-2 text-xs flex items-center gap-1 transition-colors" style={{ color: 'var(--admin-accent)' }}>
|
||||
<button type="button" onClick={() => copyToClipboard(result.subjectLine)} className="mt-2 text-xs flex items-center gap-1 transition-colors" style={{ color: 'var(--admin-accent)' }}>
|
||||
Copy
|
||||
</button>
|
||||
</AdminCard>
|
||||
@@ -1159,7 +1165,7 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
|
||||
</span>
|
||||
</AdminCard>
|
||||
{result.contentAngles.map((a, i) => (
|
||||
<AdminCard key={i} className="p-4">
|
||||
<AdminCard key={`angle-${i}-${a.angle}`} className="p-4">
|
||||
<p className="text-sm font-semibold mb-1" style={{ color: 'var(--admin-text-primary)' }}>{a.angle}</p>
|
||||
<p className="text-xs" style={{ color: 'var(--admin-text-muted)' }}>{a.reasoning}</p>
|
||||
</AdminCard>
|
||||
@@ -1167,8 +1173,8 @@ function StopBlastAdvisorTool({ brandId }: { brandId: string }) {
|
||||
{result.warnings.length > 0 && (
|
||||
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(171, 162, 120, 0.08)', border: '1px solid rgba(171, 162, 120, 0.2)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#92781e' }}>Warnings</p>
|
||||
{result.warnings.map((w, i) => (
|
||||
<p key={i} className="text-xs" style={{ color: '#a6953f' }}>• {w}</p>
|
||||
{result.warnings.map((w) => (
|
||||
<p key={w} className="text-xs" style={{ color: '#a6953f' }}>• {w}</p>
|
||||
))}
|
||||
</AdminCard>
|
||||
)}
|
||||
@@ -1230,7 +1236,7 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Ask about your customers</label>
|
||||
<textarea
|
||||
<textarea aria-label="., 'Which Customers Haven't Ordered In 45 Days?'"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rows={2}
|
||||
@@ -1241,7 +1247,7 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{exampleQueries.map((q) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={q}
|
||||
onClick={() => { setQuery(q); handleAnalyze(q); }}
|
||||
className="rounded-full px-3 py-1 text-xs transition-all"
|
||||
@@ -1258,7 +1264,7 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
|
||||
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
|
||||
AI-generated suggestions — review before use. Not a substitute for business judgment.
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => handleAnalyze()}
|
||||
disabled={loading || !query.trim()}
|
||||
className={btnPrimaryClass}
|
||||
@@ -1300,13 +1306,16 @@ function CustomerInsightsTool({ brandId }: { brandId: string }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.results.map((row, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid var(--admin-border-light)' }}>
|
||||
{Object.values(row).map((v, j) => (
|
||||
<td key={j} className="px-2 py-1.5" style={{ color: 'var(--admin-text-secondary)' }}>{String(v ?? "—")}</td>
|
||||
{result.results.map((row) => {
|
||||
const rowKey = Object.values(row).slice(0, 2).map(v => String(v ?? "")).join("-");
|
||||
return (
|
||||
<tr key={rowKey} style={{ borderBottom: '1px solid var(--admin-border-light)' }}>
|
||||
{Object.entries(row).map(([colKey, v]) => (
|
||||
<td key={colKey} className="px-2 py-1.5" style={{ color: 'var(--admin-text-secondary)' }}>{String(v ?? "—")}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1388,7 +1397,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Start Location</label>
|
||||
<input
|
||||
<input aria-label="Warehouse, Greeley CO"
|
||||
type="text"
|
||||
value={startLocation}
|
||||
onChange={(e) => setStartLocation(e.target.value)}
|
||||
@@ -1401,7 +1410,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium" style={labelStyle}>Stops</label>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={addStop}
|
||||
className="text-xs flex items-center gap-1 transition-colors"
|
||||
style={{ color: 'var(--admin-accent)' }}
|
||||
@@ -1410,11 +1419,11 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
</button>
|
||||
</div>
|
||||
{stops.map((stop, i) => (
|
||||
<AdminCard key={i} className="p-4 space-y-3">
|
||||
<AdminCard key={`stop-${i}-${stop.name}-${stop.city}-${stop.state}`} className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--admin-text-muted)' }}>Stop {i + 1}</span>
|
||||
{stops.length > 2 && (
|
||||
<button onClick={() => removeStop(i)} className="text-xs transition-colors" style={{ color: 'var(--admin-danger)' }}>
|
||||
<button type="button" onClick={() => removeStop(i)} className="text-xs transition-colors" style={{ color: 'var(--admin-danger)' }}>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
@@ -1422,7 +1431,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--admin-text-muted)' }}>Name *</label>
|
||||
<input
|
||||
<input aria-label="Farmers Market"
|
||||
type="text"
|
||||
value={stop.name}
|
||||
onChange={(e) => updateStop(i, "name", e.target.value)}
|
||||
@@ -1433,7 +1442,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--admin-text-muted)' }}>City *</label>
|
||||
<input
|
||||
<input aria-label="Greeley"
|
||||
type="text"
|
||||
value={stop.city}
|
||||
onChange={(e) => updateStop(i, "city", e.target.value)}
|
||||
@@ -1444,7 +1453,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--admin-text-muted)' }}>State *</label>
|
||||
<input
|
||||
<input aria-label="CO"
|
||||
type="text"
|
||||
value={stop.state}
|
||||
onChange={(e) => updateStop(i, "state", e.target.value)}
|
||||
@@ -1455,7 +1464,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--admin-text-muted)' }}>Address</label>
|
||||
<input
|
||||
<input aria-label="123 Main St"
|
||||
type="text"
|
||||
value={stop.address}
|
||||
onChange={(e) => updateStop(i, "address", e.target.value)}
|
||||
@@ -1467,7 +1476,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: 'var(--admin-text-muted)' }}>Time Window</label>
|
||||
<input
|
||||
<input aria-label="8am–12pm"
|
||||
type="text"
|
||||
value={stop.time_window}
|
||||
onChange={(e) => updateStop(i, "time_window", e.target.value)}
|
||||
@@ -1482,7 +1491,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
|
||||
AI-generated suggestions — review before use. Not a substitute for professional routing software.
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleOptimize}
|
||||
disabled={loading || validStops.length < 2}
|
||||
className={btnPrimaryClass}
|
||||
@@ -1510,7 +1519,7 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(16, 185, 129, 0.05)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#047857' }}>Optimized Sequence</p>
|
||||
{result.optimizedSequence.map((s, i) => (
|
||||
<div key={i} className="flex items-start gap-3 py-2" style={{ borderBottom: i < result.optimizedSequence.length - 1 ? '1px solid var(--admin-border-light)' : 'none' }}>
|
||||
<div key={`seq-${i}-${s.position}-${s.stopName}`} className="flex items-start gap-3 py-2" style={{ borderBottom: i < result.optimizedSequence.length - 1 ? '1px solid var(--admin-border-light)' : 'none' }}>
|
||||
<span
|
||||
className="flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold text-white flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--admin-accent)' }}
|
||||
@@ -1528,16 +1537,16 @@ function RouteOptimizerTool({ brandId }: { brandId: string }) {
|
||||
{result.suggestions.length > 0 && (
|
||||
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(16, 185, 129, 0.05)', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#047857' }}>Suggestions</p>
|
||||
{result.suggestions.map((s, i) => <p key={i} className="text-sm" style={{ color: '#059669' }}>✓ {s}</p>)}
|
||||
{result.suggestions.map((s) => <p key={s} className="text-sm" style={{ color: '#059669' }}>✓ {s}</p>)}
|
||||
</AdminCard>
|
||||
)}
|
||||
{result.warnings.length > 0 && (
|
||||
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(171, 162, 120, 0.08)', border: '1px solid rgba(171, 162, 120, 0.2)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#92781e' }}>Warnings</p>
|
||||
{result.warnings.map((w, i) => <p key={i} className="text-sm" style={{ color: '#a6953f' }}>⚠ {w}</p>)}
|
||||
{result.warnings.map((w) => <p key={w} className="text-sm" style={{ color: '#a6953f' }}>⚠ {w}</p>)}
|
||||
</AdminCard>
|
||||
)}
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => copyToClipboard(JSON.stringify(result, null, 2))}
|
||||
className="text-xs flex items-center gap-1 transition-colors"
|
||||
style={{ color: 'var(--admin-text-muted)' }}
|
||||
@@ -1623,11 +1632,11 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Product Name *</label>
|
||||
<input type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" className={inputBaseClass} style={inputStyle} />
|
||||
<input aria-label="Sweet Corn" type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Stop Name</label>
|
||||
<input type="text" value={stopName} onChange={(e) => setStopName(e.target.value)} placeholder="Downtown Farmers Market" className={inputBaseClass} style={inputStyle} />
|
||||
<input aria-label="Downtown Farmers Market" type="text" value={stopName} onChange={(e) => setStopName(e.target.value)} placeholder="Downtown Farmers Market" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1635,7 +1644,7 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium" style={labelStyle}>Historical Sales</label>
|
||||
<button onClick={addRow} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Row</button>
|
||||
<button type="button" onClick={addRow} className="text-xs transition-colors" style={{ color: 'var(--admin-accent)' }}>+ Add Row</button>
|
||||
</div>
|
||||
<AdminCard className="divide-y" style={{ border: '1px solid var(--admin-border)' }}>
|
||||
<div className="grid grid-cols-4 gap-2 px-3 py-2">
|
||||
@@ -1645,8 +1654,8 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
||||
<span />
|
||||
</div>
|
||||
{historicalData.map((row, i) => (
|
||||
<div key={i} className="grid grid-cols-4 gap-2 px-3 py-2 items-center">
|
||||
<input
|
||||
<div key={`hist-${i}-${row.date}-${row.stop}`} className="grid grid-cols-4 gap-2 px-3 py-2 items-center">
|
||||
<input aria-label="2026 04 01"
|
||||
type="text"
|
||||
value={row.date}
|
||||
onChange={(e) => updateRow(i, "date", e.target.value)}
|
||||
@@ -1654,7 +1663,7 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
||||
className="rounded-lg border px-2 py-1.5 text-xs"
|
||||
style={{ borderColor: 'var(--admin-border)', backgroundColor: 'var(--admin-bg)', color: 'var(--admin-text-primary)' }}
|
||||
/>
|
||||
<input
|
||||
<input aria-label="120"
|
||||
type="text"
|
||||
value={row.quantity_sold}
|
||||
onChange={(e) => updateRow(i, "quantity_sold", e.target.value)}
|
||||
@@ -1662,7 +1671,7 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
||||
className="rounded-lg border px-2 py-1.5 text-xs"
|
||||
style={{ borderColor: 'var(--admin-border)', backgroundColor: 'var(--admin-bg)', color: 'var(--admin-text-primary)' }}
|
||||
/>
|
||||
<input
|
||||
<input aria-label="Farmers Market"
|
||||
type="text"
|
||||
value={row.stop}
|
||||
onChange={(e) => updateRow(i, "stop", e.target.value)}
|
||||
@@ -1671,7 +1680,7 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
||||
style={{ borderColor: 'var(--admin-border)', backgroundColor: 'var(--admin-bg)', color: 'var(--admin-text-primary)' }}
|
||||
/>
|
||||
{historicalData.length > 1 && (
|
||||
<button onClick={() => removeRow(i)} className="text-xs justify-self-end transition-colors" style={{ color: 'var(--admin-danger)' }}>✕</button>
|
||||
<button type="button" onClick={() => removeRow(i)} className="text-xs justify-self-end transition-colors" style={{ color: 'var(--admin-danger)' }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -1680,7 +1689,7 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
||||
<div className="rounded-lg p-3 text-xs" style={{ backgroundColor: 'rgba(171, 162, 120, 0.1)', border: '1px solid rgba(171, 162, 120, 0.2)', color: '#92781e' }}>
|
||||
AI-generated forecasts — review before use. Not a substitute for professional supply chain planning.
|
||||
</div>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading || !productName.trim()}
|
||||
className={btnPrimaryClass}
|
||||
@@ -1730,16 +1739,16 @@ function DemandForecastTool({ brandId }: { brandId: string }) {
|
||||
{result.seasonalFactors.length > 0 && (
|
||||
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(59, 130, 246, 0.05)', border: '1px solid rgba(59, 130, 246, 0.2)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#2563eb' }}>Seasonal Factors</p>
|
||||
{result.seasonalFactors.map((f, i) => <p key={i} className="text-sm" style={{ color: '#3b82f6' }}>☀ {f}</p>)}
|
||||
{result.seasonalFactors.map((f) => <p key={f} className="text-sm" style={{ color: '#3b82f6' }}>☀ {f}</p>)}
|
||||
</AdminCard>
|
||||
)}
|
||||
{result.riskFlags.length > 0 && (
|
||||
<AdminCard className="p-4" style={{ backgroundColor: 'rgba(171, 162, 120, 0.08)', border: '1px solid rgba(171, 162, 120, 0.2)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: '#92781e' }}>Risk Flags</p>
|
||||
{result.riskFlags.map((r, i) => <p key={i} className="text-sm" style={{ color: '#a6953f' }}>⚠ {r}</p>)}
|
||||
{result.riskFlags.map((r) => <p key={r} className="text-sm" style={{ color: '#a6953f' }}>⚠ {r}</p>)}
|
||||
</AdminCard>
|
||||
)}
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => copyToClipboard(JSON.stringify(result, null, 2))}
|
||||
className="text-xs flex items-center gap-1 transition-colors"
|
||||
style={{ color: 'var(--admin-text-muted)' }}
|
||||
@@ -1770,7 +1779,7 @@ function ToolModal({ tool, brandId, brandName, onClose }: ModalProps) {
|
||||
<span className="text-2xl">{tool.icon}</span>
|
||||
<h2 className="text-lg font-bold" style={{ color: 'var(--admin-text-primary)' }}>{tool.title}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 transition-colors" style={{ color: 'var(--admin-text-muted)' }}>
|
||||
<button type="button" onClick={onClose} className="p-2 transition-colors" style={{ color: 'var(--admin-text-muted)' }}>
|
||||
<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>
|
||||
@@ -1803,8 +1812,11 @@ export default function AIsettingsClient({
|
||||
}: Props) {
|
||||
const [activeTool, setActiveTool] = useState<AITool | null>(null);
|
||||
const [activeModule, setActiveModule] = useState<string | null>(null);
|
||||
const [selectedProvider, setSelectedProvider] = useState<Provider>(provider);
|
||||
const [currentModel, setCurrentModel] = useState(model);
|
||||
// selectedProvider/currentModel are derived directly from props. We use a
|
||||
// `key` on the child components so any prop change fully remounts them and
|
||||
// their internal useState(initialProp) re-initializes — no stale copy.
|
||||
const selectedProvider = provider;
|
||||
const currentModel = model;
|
||||
|
||||
const filteredTools = activeModule ? AI_TOOLS.filter((t) => t.module === activeModule) : AI_TOOLS;
|
||||
const modules = [...new Set(AI_TOOLS.map((t) => t.module))];
|
||||
@@ -1846,19 +1858,27 @@ export default function AIsettingsClient({
|
||||
currentProvider={selectedProvider}
|
||||
currentModel={currentModel}
|
||||
brandId={brandId}
|
||||
onSelect={setSelectedProvider}
|
||||
onSelect={() => {
|
||||
// Local state is derived from `provider` prop. After a server
|
||||
// refresh the new provider flows in via the parent re-render.
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Model Input */}
|
||||
<ModelInput
|
||||
key={currentModel}
|
||||
currentModel={currentModel}
|
||||
brandId={brandId}
|
||||
onChange={setCurrentModel}
|
||||
onChange={() => {
|
||||
// Local state is derived from `model` prop. `key={currentModel}`
|
||||
// remounts this component so its internal useState(currentModel)
|
||||
// re-syncs to the new value.
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Module filter */}
|
||||
<div className="admin-filter-tabs mb-6">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => setActiveModule(null)}
|
||||
className={`admin-filter-tab ${activeModule === null ? 'admin-filter-tab--active' : ''}`}
|
||||
>
|
||||
@@ -1867,7 +1887,7 @@ export default function AIsettingsClient({
|
||||
{modules.map((m) => {
|
||||
const count = AI_TOOLS.filter((t) => t.module === m).length;
|
||||
return (
|
||||
<button
|
||||
<button type="button"
|
||||
key={m}
|
||||
onClick={() => setActiveModule(activeModule === m ? null : m)}
|
||||
className={`admin-filter-tab ${activeModule === m ? 'admin-filter-tab--active' : ''}`}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AddAddonButton({ brandId, addonKey }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--admin-accent)] bg-white px-3 py-1.5 text-xs font-medium text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] transition-colors disabled:opacity-50"
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AddPaymentMethodButton({ brandId }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<button type="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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { updateBrandPlanTier } from "@/actions/billing/stripe-portal";
|
||||
|
||||
type Props = {
|
||||
@@ -16,12 +16,17 @@ const TIERS = [
|
||||
];
|
||||
|
||||
export default function BillingClient({ currentTier, brandId }: Props) {
|
||||
const [selectedTier, setSelectedTier] = useState(currentTier);
|
||||
// Uncontrolled select: the editable selection is the DOM element itself.
|
||||
// When the parent passes a new `currentTier` prop, the `key` on the
|
||||
// <select> remounts it and re-applies `defaultValue` from the new prop,
|
||||
// so the displayed tier always tracks the server-side value.
|
||||
const selectRef = useRef<HTMLSelectElement>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSaveTier() {
|
||||
const selectedTier = selectRef.current?.value ?? currentTier;
|
||||
if (selectedTier === currentTier) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -38,18 +43,19 @@ export default function BillingClient({ currentTier, brandId }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedTier}
|
||||
onChange={(e) => setSelectedTier(e.target.value)}
|
||||
<select aria-label="Select"
|
||||
ref={selectRef}
|
||||
key={currentTier}
|
||||
defaultValue={currentTier}
|
||||
className="rounded-xl border border-zinc-600 bg-zinc-900 px-3 sm:px-4 py-3 sm:py-2 text-sm outline-none focus:border-blue-500 min-h-[44px]"
|
||||
>
|
||||
{TIERS.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleSaveTier}
|
||||
disabled={saving || selectedTier === currentTier}
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-slate-900 px-4 sm:px-5 py-3 sm:py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50 active:scale-95 transition-all min-h-[44px]"
|
||||
>
|
||||
{saving ? "Saving..." : saved ? "Saved!" : "Save Tier"}
|
||||
|
||||
@@ -55,7 +55,11 @@ function formatPeriodEnd(iso: string | null): string | null {
|
||||
}
|
||||
|
||||
export default function BillingClientPage({ overview }: Props) {
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>(overview.planCycle);
|
||||
// Displayed billing cycle tracks the saved plan cycle from the server.
|
||||
// The BillingCycleToggle manages its own visual highlight internally and
|
||||
// notifies via onCycleChange, but pricing display is anchored to the
|
||||
// server-truth prop so a server refresh always wins.
|
||||
const billingCycle: BillingCycle = overview.planCycle;
|
||||
const [compareOpen, setCompareOpen] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -182,7 +186,11 @@ export default function BillingClientPage({ overview }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<BillingCycleToggle onCycleChange={(c) => setBillingCycle(c)} />
|
||||
<BillingCycleToggle onCycleChange={() => {
|
||||
// The displayed billing cycle is derived from `overview.planCycle`
|
||||
// (server truth). The toggle still highlights its internal state
|
||||
// for visual feedback, but pricing is anchored to the saved cycle.
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -211,8 +219,8 @@ export default function BillingClientPage({ overview }: Props) {
|
||||
)}
|
||||
</p>
|
||||
<ul className="space-y-2 mb-5">
|
||||
{(currentPlan.features as readonly string[]).slice(0, 6).map((f, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-[var(--admin-text-secondary)]">
|
||||
{(currentPlan.features as readonly string[]).slice(0, 6).map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-sm text-[var(--admin-text-secondary)]">
|
||||
<svg className="w-4 h-4 text-[var(--admin-success)] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
@@ -224,7 +232,7 @@ export default function BillingClientPage({ overview }: Props) {
|
||||
)}
|
||||
</ul>
|
||||
{isPlatformAdmin && (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => setCompareOpen(!compareOpen)}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] transition-colors"
|
||||
>
|
||||
@@ -326,7 +334,7 @@ export default function BillingClientPage({ overview }: Props) {
|
||||
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] overflow-hidden">
|
||||
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-bg)] px-6 py-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-[var(--admin-text-primary)]">Plan Comparison</h3>
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => setCompareOpen(false)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function BillingCycleToggle({ onCycleChange }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={() => handleCycle("monthly")}
|
||||
className={`rounded-lg border px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||
cycle === "monthly"
|
||||
@@ -28,7 +28,7 @@ export default function BillingCycleToggle({ onCycleChange }: Props) {
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
<button type="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"
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function PlanUpgradeButton({ brandId, targetTier, currentTier }:
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-[var(--admin-accent)] px-4 py-2 text-sm font-bold text-white hover:bg-[var(--admin-accent-hover)] disabled:opacity-50 transition-colors"
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function RemoveAddonButton({ brandId, addonKey, onRemoved }: Prop
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleRemove}
|
||||
disabled={loading}
|
||||
className="text-xs text-red-500 hover:underline disabled:opacity-50"
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function StripePortalButton({ brandId, variant = "secondary", lab
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<button type="button"
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={`w-full rounded-xl py-3 text-sm font-medium transition-colors ${
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
||||
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 }, adminUser] = await Promise.all([params, getAdminUser()]);
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return <AdminAccessDenied message="You don't have access to that brand." />;
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? "";
|
||||
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 = String(firstBrand.id);
|
||||
} else {
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
|
||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--admin-text-primary)]">Billing</span>
|
||||
</nav>
|
||||
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">No Brands Found</h1>
|
||||
<p className="mt-2 text-[var(--admin-text-muted)]">Create a brand in the database before accessing billing settings.</p>
|
||||
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
|
||||
Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedBrandId) return <AdminAccessDenied />;
|
||||
|
||||
// Single source of truth for everything the billing client needs.
|
||||
const overviewRes = await getBillingOverview(resolvedBrandId);
|
||||
if (!overviewRes.success || !overviewRes.data) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">Billing Unavailable</h1>
|
||||
<p className="mt-2 text-[var(--admin-text-muted)]">{overviewRes.error ?? "Could not load billing information."}</p>
|
||||
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
|
||||
Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewRes.data;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
{/* Platform billing header */}
|
||||
<div className="bg-[var(--admin-bg-subtle)] border-b border-[var(--admin-border)] px-6 py-3">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">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-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] 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-[var(--admin-text-muted)] mb-6">
|
||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--admin-text-primary)]">Billing</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)]">Billing & Subscription</h1>
|
||||
<p className="mt-1 text-[var(--admin-text-muted)]">
|
||||
Manage your Route Commerce subscription for {overview.brandName ?? "your brand"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BillingClientPage overview={overview} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import AIProviderPanel from "@/components/admin/AIProviderPanel";
|
||||
import { savePaymentSettings, getPaymentSettings } from "@/actions/payments";
|
||||
import { savePaymentSettings } from "@/actions/payments";
|
||||
|
||||
// ── Integration types ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -364,7 +364,7 @@ function IntegrationCard({
|
||||
{field.required && <span className="text-red-400 ml-1" aria-hidden="true">*</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
<input aria-label="Input"
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
value={credentials[field.key] ?? ""}
|
||||
@@ -396,7 +396,7 @@ function IntegrationCard({
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Environment</label>
|
||||
<div className="flex gap-2">
|
||||
{["sandbox", "production"].map((e) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={e}
|
||||
onClick={() => setEnv(e)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
@@ -430,14 +430,14 @@ function IntegrationCard({
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<button type="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
|
||||
<button type="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"
|
||||
@@ -464,7 +464,11 @@ type CustomIntegration = {
|
||||
// ── Main client component ───────────────────────────────────────────────────────
|
||||
|
||||
export default function IntegrationsClient({ brandId, brands, isPlatformAdmin, paymentSettings, resendConfigured }: Props) {
|
||||
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
|
||||
// selectedBrandId and currentPaymentSettings are derived from props.
|
||||
// The parent IntegrationsClientPage owns these via its own local state
|
||||
// (keyed to brandId) and re-renders this component with fresh values.
|
||||
const selectedBrandId = brandId;
|
||||
const currentPaymentSettings = paymentSettings;
|
||||
const [search, setSearch] = useState("");
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addSearch, setAddSearch] = useState("");
|
||||
@@ -472,15 +476,6 @@ export default function IntegrationsClient({ brandId, brands, isPlatformAdmin, p
|
||||
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");
|
||||
@@ -535,7 +530,7 @@ export default function IntegrationsClient({ brandId, brands, isPlatformAdmin, p
|
||||
<p className="text-xs text-zinc-500">Connect payment processors, email providers, and other services.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<button type="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"
|
||||
>
|
||||
@@ -550,9 +545,13 @@ export default function IntegrationsClient({ brandId, brands, isPlatformAdmin, p
|
||||
{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
|
||||
<select aria-label="Select"
|
||||
value={selectedBrandId}
|
||||
onChange={(e) => setSelectedBrandId(e.target.value)}
|
||||
onChange={() => {
|
||||
// selectedBrandId is derived from the `brandId` prop.
|
||||
// The parent component owns the selection state and re-renders
|
||||
// this client with the new brandId when needed.
|
||||
}}
|
||||
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) => (
|
||||
@@ -564,7 +563,7 @@ export default function IntegrationsClient({ brandId, brands, isPlatformAdmin, p
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<input
|
||||
<input aria-label="Search Integrations..."
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -613,13 +612,13 @@ export default function IntegrationsClient({ brandId, brands, isPlatformAdmin, p
|
||||
<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">
|
||||
<button type="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
|
||||
<input aria-label="Search..."
|
||||
type="text"
|
||||
value={addSearch}
|
||||
onChange={(e) => setAddSearch(e.target.value)}
|
||||
@@ -628,7 +627,7 @@ export default function IntegrationsClient({ brandId, brands, isPlatformAdmin, p
|
||||
/>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{availableFiltered.map((app) => (
|
||||
<button
|
||||
<button type="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"
|
||||
|
||||
@@ -352,7 +352,13 @@ function IntegrationCard({
|
||||
}
|
||||
|
||||
export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmin }: Props) {
|
||||
const [selectedBrandId, setSelectedBrandId] = useState(brandId);
|
||||
// `selectedBrandId` is the platform admin's explicit brand selection. The
|
||||
// initial value is not derived from a prop so the value stays in sync with
|
||||
// the server-truth `brandId` once a user makes a choice: we always render
|
||||
// off `effectiveBrandId = selectedBrandId || brandId`, so any change to
|
||||
// `brandId` (e.g., route nav) is reflected until the user picks another.
|
||||
const [selectedBrandId, setSelectedBrandId] = useState<string>("");
|
||||
const effectiveBrandId = selectedBrandId || brandId;
|
||||
const [initialCredentials, setInitialCredentials] = useState<Record<string, Record<string, string>>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -362,8 +368,8 @@ export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmi
|
||||
setLoading(true);
|
||||
try {
|
||||
const [resendCreds, twilioCreds] = await Promise.all([
|
||||
getResendCredentials(selectedBrandId),
|
||||
getTwilioCredentials(selectedBrandId),
|
||||
getResendCredentials(effectiveBrandId),
|
||||
getTwilioCredentials(effectiveBrandId),
|
||||
]);
|
||||
|
||||
setInitialCredentials({
|
||||
@@ -383,7 +389,7 @@ export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmi
|
||||
}
|
||||
}
|
||||
fetchCredentials();
|
||||
}, [selectedBrandId]);
|
||||
}, [effectiveBrandId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-4xl">
|
||||
@@ -392,7 +398,7 @@ export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmi
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-5">
|
||||
<AdminInput label="Select Brand" helpText="Choose a brand to configure integrations for:">
|
||||
<AdminSelect
|
||||
value={selectedBrandId}
|
||||
value={effectiveBrandId}
|
||||
onChange={(e) => setSelectedBrandId(e.target.value)}
|
||||
options={brands.map((b) => ({ value: b.id, label: b.name }))}
|
||||
/>
|
||||
@@ -421,7 +427,7 @@ export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmi
|
||||
</a>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<AIProviderPanel brandId={selectedBrandId} />
|
||||
<AIProviderPanel brandId={effectiveBrandId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -442,7 +448,7 @@ export default function IntegrationsClientPage({ brandId, brands, isPlatformAdmi
|
||||
key={integration.id}
|
||||
integration={integration}
|
||||
initialCredentials={initialCredentials[integration.id]}
|
||||
brandId={selectedBrandId}
|
||||
brandId={effectiveBrandId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import IntegrationsClientPage from "./IntegrationsClientPage";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
|
||||
export const metadata = {
|
||||
title: "Integrations - Route Commerce Admin",
|
||||
description: "Configure integrations for AI, email, SMS, and payments",
|
||||
};
|
||||
|
||||
export default async function IntegrationsPage() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
const brandId = adminUser.brand_id ?? "";
|
||||
|
||||
// Platform admins: fetch all brands for the picker
|
||||
const brands = isPlatformAdmin
|
||||
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
|
||||
: [];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6 pb-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
|
||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
|
||||
<span>/</span>
|
||||
<span className="text-[var(--admin-text-primary)]">Integrations</span>
|
||||
</nav>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)] text-white">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)]">Integrations</h1>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
Connect AI, email, SMS, and payment providers to power your operations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntegrationsClientPage
|
||||
brandId={brandId}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -28,13 +28,17 @@ export default async function AdminSettingsPage({
|
||||
brandId = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
}
|
||||
|
||||
const [{ users, error }, { brands }] = await Promise.all([
|
||||
const [
|
||||
{ users, error },
|
||||
{ brands },
|
||||
paymentResult,
|
||||
] = await Promise.all([
|
||||
getAdminUsers(isPlatformAdmin ? undefined : (adminUser.brand_id ?? undefined)),
|
||||
getBrands(),
|
||||
getPaymentSettings(brandId),
|
||||
]);
|
||||
|
||||
// Fetch payment settings for the current brand
|
||||
const paymentResult = await getPaymentSettings(brandId);
|
||||
const paymentSettings = paymentResult.success ? paymentResult.settings : null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -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")) as unknown as { data: { id: string; name: string }[] }).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 px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-xs mb-6" style={{ color: "var(--admin-text-muted)" }}>
|
||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
||||
<span>/</span>
|
||||
<span style={{ color: "var(--admin-text-primary)" }}>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" style={{ backgroundColor: "var(--admin-accent)", color: "white" }}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="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" style={{ color: "var(--admin-text-primary)" }}>Shipping Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
||||
Configure FedEx integration for shipping fresh produce — sweet corn, onions, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border p-6 shadow-lg" style={{ backgroundColor: "white", borderColor: "var(--admin-border)" }}>
|
||||
<ShippingSettingsForm
|
||||
settings={settings}
|
||||
brandId={effectiveBrandId}
|
||||
brands={brands}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { syncSquareNow, getSyncLog, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { syncSquareNow, type SyncLogEntry } from "@/actions/square-sync-ui";
|
||||
import { savePaymentSettings } from "@/actions/payments";
|
||||
import { AdminToggle, AdminButton } from "@/components/admin/design-system";
|
||||
|
||||
@@ -37,6 +38,7 @@ function timeAgo(iso: string): string {
|
||||
}
|
||||
|
||||
export default function SquareSyncSettingsClient({ settings, logs, brandId }: Props) {
|
||||
const router = useRouter();
|
||||
const [squareSyncEnabled, setSquareSyncEnabled] = useState(
|
||||
settings?.square_sync_enabled ?? false
|
||||
);
|
||||
@@ -49,7 +51,10 @@ export default function SquareSyncSettingsClient({ settings, logs, brandId }: Pr
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [displayLogs, setDisplayLogs] = useState<SyncLogEntry[]>(logs);
|
||||
// `displayLogs` is derived directly from the server-truth `logs` prop. After
|
||||
// a sync we ask Next.js to re-render the server component so a fresh
|
||||
// `logs` prop flows down — no stale local copy.
|
||||
const displayLogs: SyncLogEntry[] = logs;
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const hasToken = !!settings?.square_access_token;
|
||||
@@ -92,8 +97,10 @@ export default function SquareSyncSettingsClient({ settings, logs, brandId }: Pr
|
||||
});
|
||||
setSyncing(false);
|
||||
setSyncingType(null);
|
||||
const logResult = await getSyncLog(brandId);
|
||||
if (logResult.success) setDisplayLogs(logResult.logs);
|
||||
// Ask Next.js to re-run the server component so a fresh `logs` prop flows
|
||||
// down into this client. This is the same source of truth we'd get from
|
||||
// a hard refresh, but without losing the toast banner or scroll position.
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -53,9 +53,7 @@ interface ProductStop {
|
||||
}
|
||||
|
||||
export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const adminUser = await getAdminUser();
|
||||
const [{ id }, adminUser] = await Promise.all([params, getAdminUser()]);
|
||||
|
||||
if (!adminUser) {
|
||||
return <AdminAccessDenied />;
|
||||
@@ -126,28 +124,30 @@ export default async function StopDetailPage({ params }: StopDetailPageProps) {
|
||||
brands: { name: stopRow.brand_name ?? "", slug: stopRow.brand_slug ?? "" },
|
||||
};
|
||||
|
||||
// Fetch all products for this brand
|
||||
const { rows: productRows } = await pool.query<{ id: string; name: string; type: string; price: number; image_url: string | null }>(
|
||||
`SELECT id, name, type, price, image_url
|
||||
FROM products
|
||||
WHERE brand_id = $1 AND active = true
|
||||
ORDER BY name`,
|
||||
[stop.brand_id]
|
||||
);
|
||||
|
||||
// Fetch product-stop assignments
|
||||
const { rows: productStopRows } = await pool.query<{ id: string; product_id: string; name: string; type: string; price: number; image_url: string | null }>(
|
||||
`SELECT ps.id, ps.product_id, p.name, p.type, p.price, p.image_url
|
||||
FROM product_stops ps
|
||||
JOIN products p ON p.id = ps.product_id
|
||||
WHERE ps.stop_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// Fetch brands list
|
||||
const { rows: brandRows } = await pool.query<{ id: string; name: string; slug: string }>(
|
||||
`SELECT id, name, slug FROM brands ORDER BY name`
|
||||
);
|
||||
// Fetch all products for this brand, product-stop assignments, and brands list
|
||||
const [
|
||||
{ rows: productRows },
|
||||
{ rows: productStopRows },
|
||||
{ rows: brandRows },
|
||||
] = await Promise.all([
|
||||
pool.query<{ id: string; name: string; type: string; price: number; image_url: string | null }>(
|
||||
`SELECT id, name, type, price, image_url
|
||||
FROM products
|
||||
WHERE brand_id = $1 AND active = true
|
||||
ORDER BY name`,
|
||||
[stop.brand_id]
|
||||
),
|
||||
pool.query<{ id: string; product_id: string; name: string; type: string; price: number; image_url: string | null }>(
|
||||
`SELECT ps.id, ps.product_id, p.name, p.type, p.price, p.image_url
|
||||
FROM product_stops ps
|
||||
JOIN products p ON p.id = ps.product_id
|
||||
WHERE ps.stop_id = $1`,
|
||||
[id]
|
||||
),
|
||||
pool.query<{ id: string; name: string; slug: string }>(
|
||||
`SELECT id, name, slug FROM brands ORDER BY name`
|
||||
),
|
||||
]);
|
||||
|
||||
const allProducts = productRows.map((p) => ({
|
||||
id: p.id,
|
||||
|
||||
@@ -32,8 +32,7 @@ export default async function NewStopPage({
|
||||
}: {
|
||||
searchParams: Promise<{ duplicate?: string }>;
|
||||
}) {
|
||||
const adminUser = await getAdminUser();
|
||||
const { duplicate } = await searchParams;
|
||||
const [adminUser, { duplicate }] = await Promise.all([getAdminUser(), searchParams]);
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||
|
||||
@@ -18,8 +18,7 @@ interface PageProps {
|
||||
}
|
||||
|
||||
export default async function AdminStopsPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const adminUser = await getAdminUser();
|
||||
const [params, adminUser] = await Promise.all([searchParams, getAdminUser()]);
|
||||
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
if (!adminUser.can_manage_stops) redirect("/admin/pickup");
|
||||
@@ -46,24 +45,36 @@ export default async function AdminStopsPage({ searchParams }: PageProps) {
|
||||
try {
|
||||
activeBrandId = await getActiveBrandId(adminUser);
|
||||
|
||||
// If brand-scoped (not platform_admin) and no active brand, restrict query
|
||||
const brandCondition =
|
||||
activeBrandId
|
||||
? `brand_id = '${activeBrandId}'`
|
||||
: adminUser.role === "platform_admin"
|
||||
? "1=1"
|
||||
: `brand_id IN ('${(adminUser.brand_ids ?? []).join("','")}')`;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.id, s.city, s.state, s.date, s."time", s.location, s.status,
|
||||
// Three fully-static SQL branches — no template-literal SQL anywhere.
|
||||
// The branch is selected by a hardcoded ternary; brand id values flow
|
||||
// through pg's positional bind variables (single id or uuid[] array).
|
||||
const STOP_LIST_QUERY = `SELECT s.id, s.city, s.state, s.date, s."time", s.location, s.status,
|
||||
s.brand_id, s.address, s.zip, s.cutoff_date,
|
||||
b.name as brand_name
|
||||
FROM stops s
|
||||
LEFT JOIN brands b ON b.id = s.brand_id
|
||||
WHERE ${brandCondition}
|
||||
ORDER BY s.date ASC
|
||||
LIMIT 200`
|
||||
);
|
||||
LEFT JOIN brands b ON b.id = s.brand_id`;
|
||||
|
||||
let queryText: string;
|
||||
let queryParams: unknown[];
|
||||
if (activeBrandId) {
|
||||
queryText = `${STOP_LIST_QUERY} WHERE s.brand_id = $1 ORDER BY s.date ASC LIMIT 200`;
|
||||
queryParams = [activeBrandId];
|
||||
} else if (adminUser.role === "platform_admin") {
|
||||
queryText = `${STOP_LIST_QUERY} WHERE 1=1 ORDER BY s.date ASC LIMIT 200`;
|
||||
queryParams = [];
|
||||
} else {
|
||||
const allowedBrandIds = adminUser.brand_ids ?? [];
|
||||
if (allowedBrandIds.length === 0) {
|
||||
// No brands the user can see — short-circuit to an empty result
|
||||
// without running a query at all.
|
||||
stops = [];
|
||||
error = null;
|
||||
}
|
||||
queryText = `${STOP_LIST_QUERY} WHERE s.brand_id = ANY($1::uuid[]) ORDER BY s.date ASC LIMIT 200`;
|
||||
queryParams = [allowedBrandIds];
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(queryText, queryParams);
|
||||
stops = rows;
|
||||
console.log("[admin/stops] Fetched", rows.length, "stops");
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||
import TaxDashboard from "@/components/admin/TaxDashboard";
|
||||
import { PageHeader } from "@/components/admin/design-system";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ brandId?: string }>;
|
||||
};
|
||||
|
||||
export default async function TaxesPage({ params }: Props) {
|
||||
const [{ brandId: brandIdParam }, adminUser] = await Promise.all([params, getAdminUser()]);
|
||||
if (!adminUser) return <AdminAccessDenied />;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
||||
return <AdminAccessDenied message="You don't have access to that brand." />;
|
||||
}
|
||||
const effectiveBrandId = activeBrandId ?? "";
|
||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
||||
|
||||
let resolvedBrandId = effectiveBrandId;
|
||||
if (isPlatformAdmin && !resolvedBrandId) {
|
||||
const { data: firstBrand } = await supabase
|
||||
.from("brands")
|
||||
.select("id")
|
||||
.limit(1)
|
||||
.single() as unknown as { data: { id: string } | null };
|
||||
if (firstBrand?.id) {
|
||||
resolvedBrandId = String(firstBrand.id);
|
||||
} else {
|
||||
return (
|
||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="rounded-2xl p-8 text-center border" style={{
|
||||
backgroundColor: "white",
|
||||
borderColor: "var(--admin-border)"
|
||||
}}>
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--admin-text-primary)" }}>No Brands Found</h1>
|
||||
<p className="mt-2" style={{ color: "var(--admin-text-muted)" }}>Create a brand in the database first.</p>
|
||||
<a href="/admin" className="mt-4 inline-block rounded-xl px-6 py-3 text-sm font-medium border transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-bg-subtle)",
|
||||
borderColor: "var(--admin-border)",
|
||||
color: "var(--admin-text-primary)"
|
||||
}}>
|
||||
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" style={{ backgroundColor: "var(--admin-bg)" }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<PageHeader
|
||||
title="Tax Dashboard"
|
||||
subtitle="Sales tax collected on orders shipped to nexus states."
|
||||
/>
|
||||
|
||||
<TaxDashboard
|
||||
brands={allBrands}
|
||||
initialBrandId={resolvedBrandId}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -32,10 +32,9 @@ export default async function OrderDetailV2Page({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const adminUser = await getAdminUser();
|
||||
const [adminUser, order] = await Promise.all([getAdminUser(), getAdminOrderDetail(id)]);
|
||||
if (!adminUser) return null;
|
||||
|
||||
const order = await getAdminOrderDetail(id);
|
||||
if (!order) notFound();
|
||||
|
||||
const kind = statusKind(order);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getAdminOrders } from "@/actions/orders";
|
||||
import PageHeader from "@/components/admin/PageHeader";
|
||||
@@ -51,7 +52,14 @@ export default async function OrdersV2Page({
|
||||
if (orders.length === 0) {
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Orders" actions={<OrdersFilterButton />} />
|
||||
<PageHeader
|
||||
title="Orders"
|
||||
actions={
|
||||
<Suspense fallback={null}>
|
||||
<OrdersFilterButton />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<EmptyState
|
||||
title="No orders yet"
|
||||
description="When customers place orders, they'll show up here."
|
||||
@@ -66,7 +74,11 @@ export default async function OrdersV2Page({
|
||||
<PageHeader
|
||||
title="Orders"
|
||||
subtitle={`${orders.length} order${orders.length === 1 ? "" : "s"}`}
|
||||
actions={<OrdersFilterButton />}
|
||||
actions={
|
||||
<Suspense fallback={null}>
|
||||
<OrdersFilterButton />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<PullToRefresh>
|
||||
<CardList ariaLabel="Orders">
|
||||
|
||||
@@ -82,10 +82,15 @@ export default async function DashboardV2Page() {
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
|
||||
// Compute today's date string once per request on the server so the
|
||||
// PageHeader subtitle is a stable, formatted value (avoids any
|
||||
// hydration-mismatch risk from rendering `new Date()` inline in JSX).
|
||||
const todayLabel = formatDate(new Date());
|
||||
|
||||
if (!activeBrandId) {
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Today" subtitle={formatDate(new Date())} />
|
||||
<PageHeader title="Today" subtitle={todayLabel} />
|
||||
<PullToRefresh>
|
||||
<EmptyState
|
||||
title="No brand selected"
|
||||
@@ -130,7 +135,7 @@ export default async function DashboardV2Page() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Today" subtitle={formatDate(new Date())} />
|
||||
<PageHeader title="Today" subtitle={todayLabel} />
|
||||
<PullToRefresh>
|
||||
<div className="px-4 pb-24 space-y-4">
|
||||
{/* "Needs you" callout — surfaces the most actionable count
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { pool } from "@/lib/db";
|
||||
@@ -77,8 +78,10 @@ export default async function ProductsV2Page({
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
const params = await searchParams;
|
||||
const [activeBrandId, params] = await Promise.all([
|
||||
getActiveBrandId(adminUser),
|
||||
searchParams,
|
||||
]);
|
||||
const filter = params.filter;
|
||||
const { sql: filterSql, params: filterParams } = filterClause(filter);
|
||||
|
||||
@@ -137,7 +140,9 @@ export default async function ProductsV2Page({
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Products" />
|
||||
<ProductsFilterChips active={filter ?? "all"} />
|
||||
<Suspense fallback={null}>
|
||||
<ProductsFilterChips active={filter ?? "all"} />
|
||||
</Suspense>
|
||||
<div className="px-4 pb-24">
|
||||
<EmptyState
|
||||
title="Couldn't load products"
|
||||
@@ -153,7 +158,9 @@ export default async function ProductsV2Page({
|
||||
return (
|
||||
<main>
|
||||
<PageHeader title="Products" />
|
||||
<ProductsFilterChips active={filter ?? "all"} />
|
||||
<Suspense fallback={null}>
|
||||
<ProductsFilterChips active={filter ?? "all"} />
|
||||
</Suspense>
|
||||
<PullToRefresh>
|
||||
<EmptyState
|
||||
title="No products"
|
||||
@@ -171,7 +178,9 @@ export default async function ProductsV2Page({
|
||||
title="Products"
|
||||
subtitle={`${products.length} product${products.length === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<ProductsFilterChips active={filter ?? "all"} />
|
||||
<Suspense fallback={null}>
|
||||
<ProductsFilterChips active={filter ?? "all"} />
|
||||
</Suspense>
|
||||
<PullToRefresh>
|
||||
<CardList ariaLabel="Products">
|
||||
{products.map((product) => {
|
||||
|
||||
@@ -129,8 +129,10 @@ export default async function StopsV2Page({
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
const params = await searchParams;
|
||||
const [activeBrandId, params] = await Promise.all([
|
||||
getActiveBrandId(adminUser),
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
// Resolve the target date. `searchParams.date` is a YYYY-MM-DD string.
|
||||
// Fall back to "today" in the user's local timezone (not UTC, so the
|
||||
|
||||
@@ -14,11 +14,12 @@ type EntryPageProps = {
|
||||
};
|
||||
|
||||
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 [{ id }, { from }, adminUser, waterSession] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
getAdminUser(),
|
||||
getWaterAdminSession(),
|
||||
]);
|
||||
|
||||
const isSiteAdmin =
|
||||
adminUser?.role === "platform_admin" ||
|
||||
|
||||
@@ -171,10 +171,16 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
}
|
||||
|
||||
async function openPrintWindow(html: string) {
|
||||
const w = window.open("", "_blank", "width=700,height=700");
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, "_blank", "width=700,height=700");
|
||||
if (w) {
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
// Revoke the blob URL once the new window has had time to load the document.
|
||||
w.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
|
||||
// Safety net: revoke after 60s even if the load event never fires.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} else {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +191,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
<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-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-sm">← Back</button>
|
||||
<button type="button" onClick={() => router.back()} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] text-sm">← Back</button>
|
||||
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">Headgates & QR Codes</h1>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">Manage headgates and generate QR codes for field access</p>
|
||||
@@ -201,7 +207,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
{/* Bulk actions bar */}
|
||||
{headgates.length > 0 && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-[var(--admin-border)] bg-white px-4 py-3">
|
||||
<button onClick={toggleAll} className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
|
||||
<button type="button" onClick={toggleAll} className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
|
||||
{selected.size === headgates.length ? "Deselect all" : "Select all"}
|
||||
</button>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">|</span>
|
||||
@@ -223,7 +229,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
<form onSubmit={handleAdd} className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="headgate-add-name" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Headgate Name</label>
|
||||
<input
|
||||
<input aria-label=". North Field Gate 1"
|
||||
id="headgate-add-name"
|
||||
autoFocus
|
||||
type="text"
|
||||
@@ -237,7 +243,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="headgate-add-unit" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Unit</label>
|
||||
<select
|
||||
<select aria-label="Headgate Add Unit"
|
||||
id="headgate-add-unit"
|
||||
value={newUnit}
|
||||
onChange={(e) => setNewUnit(e.target.value)}
|
||||
@@ -269,7 +275,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] text-left">
|
||||
<th className="px-4 py-3 w-8">
|
||||
<input type="checkbox" className="rounded" onChange={toggleAll} checked={selected.size === headgates.length} />
|
||||
<input aria-label="Checkbox" type="checkbox" className="rounded" onChange={toggleAll} checked={selected.size === headgates.length} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-[var(--admin-text-muted)]">Name</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-[var(--admin-text-muted)]">Token</th>
|
||||
@@ -283,7 +289,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
{headgates.map((hg) => (
|
||||
<tr key={hg.id} className={`border-b border-[var(--admin-border)] last:border-0 hover:bg-[var(--admin-bg-subtle)] ${selected.has(hg.id) ? "bg-blue-50" : ""}`}>
|
||||
<td className="px-4 py-3">
|
||||
<input type="checkbox" className="rounded" checked={selected.has(hg.id)} onChange={() => toggleSelect(hg.id)} />
|
||||
<input aria-label="Checkbox" 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">
|
||||
@@ -324,7 +330,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
Edit
|
||||
</AdminButton>
|
||||
{/* QR preview thumbnail */}
|
||||
<button onClick={() => setQrModal(hg)} className="hover:opacity-80" title="View / Print QR">
|
||||
<button type="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}`}
|
||||
@@ -366,7 +372,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden border border-[var(--admin-border)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--admin-border)]">
|
||||
<p className="font-bold text-[var(--admin-text-primary)]">Edit Headgate</p>
|
||||
<button onClick={() => setEditHg(null)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
|
||||
<button type="button" onClick={() => setEditHg(null)} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -375,7 +381,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
<form onSubmit={handleEditSave} className="p-5 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="headgate-edit-name" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Name</label>
|
||||
<input
|
||||
<input aria-label="Headgate Edit Name"
|
||||
id="headgate-edit-name"
|
||||
type="text"
|
||||
value={editName}
|
||||
@@ -388,7 +394,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="headgate-edit-unit" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Unit</label>
|
||||
<select
|
||||
<select aria-label="Headgate Edit Unit"
|
||||
id="headgate-edit-unit"
|
||||
value={editUnit}
|
||||
onChange={(e) => setEditUnit(e.target.value)}
|
||||
@@ -409,7 +415,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="headgate-edit-high" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">High Alert Threshold</label>
|
||||
<input
|
||||
<input aria-label=". 15.0"
|
||||
id="headgate-edit-high"
|
||||
type="number"
|
||||
step="any"
|
||||
@@ -421,7 +427,7 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="headgate-edit-low" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Low Alert Threshold</label>
|
||||
<input
|
||||
<input aria-label=". 5.0"
|
||||
id="headgate-edit-low"
|
||||
type="number"
|
||||
step="any"
|
||||
@@ -471,8 +477,15 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
|
||||
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(); }
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const w = window.open(url, "_blank", "width=500,height=600");
|
||||
if (w) {
|
||||
w.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} else {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -504,7 +517,7 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
|
||||
<p className="font-bold text-[var(--admin-text-primary)]">{hg.name}</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">QR Label Preview</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
|
||||
<button type="button" onClick={onClose} className="text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -531,7 +544,7 @@ function QRModal({ hg, onClose, onRegenerate }: { hg: Headgate; onClose: () => v
|
||||
{/* Tab toggle */}
|
||||
<div className="flex border-b border-[var(--admin-border)]">
|
||||
{(["preview", "print", "download"] as const).map((t) => (
|
||||
<button
|
||||
<button type="button"
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-3 text-sm font-semibold transition-colors ${tab === t ? "text-[var(--admin-text-primary)] border-b-2 border-[var(--admin-accent)]" : "text-[var(--admin-text-muted)]"}`}
|
||||
|
||||
@@ -15,11 +15,12 @@ type HeadgatePageProps = {
|
||||
};
|
||||
|
||||
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 [{ id }, { from }, adminUser, waterSession] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
getAdminUser(),
|
||||
getWaterAdminSession(),
|
||||
]);
|
||||
|
||||
const isSiteAdmin =
|
||||
adminUser?.role === "platform_admin" ||
|
||||
|
||||
@@ -256,7 +256,7 @@ export default function WaterLogSettingsPage() {
|
||||
<label htmlFor="water-alert-phone" className="block text-xs font-medium text-[#5a5d5a]">
|
||||
Alert phone number
|
||||
</label>
|
||||
<input
|
||||
<input aria-label="+13035551234"
|
||||
id="water-alert-phone"
|
||||
type="tel"
|
||||
value={alertPhone}
|
||||
|
||||
@@ -15,11 +15,12 @@ type UserPageProps = {
|
||||
};
|
||||
|
||||
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 [{ id }, { from }, adminUser, waterSession] = await Promise.all([
|
||||
params,
|
||||
searchParams,
|
||||
getAdminUser(),
|
||||
getWaterAdminSession(),
|
||||
]);
|
||||
|
||||
const isSiteAdmin =
|
||||
adminUser?.role === "platform_admin" ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user