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:
Nora
2026-06-25 23:49:37 -06:00
parent 4d295ef062
commit 0ac4beaaa8
580 changed files with 52565 additions and 4953 deletions
+1 -1
View File
@@ -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={{
+11 -11
View File
@@ -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>
+4 -4
View File
@@ -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&apos;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>
+4 -4
View File
@@ -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}
+106
View File
@@ -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>
);
}
+9 -5
View File
@@ -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];
});
}
}
+4 -2
View File
@@ -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
+161
View File
@@ -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>
);
}
+7 -7
View File
@@ -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>
))}
+42
View File
@@ -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>
);
}
+1 -2
View File
@@ -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";
+6 -6
View File
@@ -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>
)}
+127 -107
View File
@@ -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="8am12pm"
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 ${
+110
View File
@@ -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 &amp; 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>
);
}
+6 -2
View File
@@ -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 (
+63
View File
@@ -0,0 +1,63 @@
import { redirect } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions";
import { getShippingSettings } from "@/actions/shipping/settings";
import ShippingSettingsForm from "@/components/admin/ShippingSettingsForm";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
export default async function ShippingSettingsPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
if (!adminUser.can_manage_orders || adminUser.role === "store_employee") {
redirect("/admin/pickup");
}
const isPlatformAdmin = adminUser.role === "platform_admin";
const brandId = adminUser.brand_id ?? "";
// Platform admins: fetch all brands for the picker
const brands = isPlatformAdmin
? ((await supabase.from("brands").select("id, name").order("name")) 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 (
+25 -25
View File
@@ -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,
+1 -2
View File
@@ -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");
+28 -17
View File
@@ -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) {
+84
View File
@@ -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>
);
}
+1 -2
View File
@@ -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);
+14 -2
View File
@@ -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">
+7 -2
View File
@@ -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
+14 -5
View File
@@ -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) => {
+4 -2
View File
@@ -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" ||
+1 -1
View File
@@ -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}
+6 -5
View File
@@ -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