fix(buyer/billing/comms/a11y): Codex review pass round 2

Tuxedo buyer path (subagent 2):
- src/app/tuxedo/page.tsx: remove duplicate CinematicShowcase render
- src/components/storefront/CinematicShowcase.tsx: wire up useCart, Add to Cart button, brand-aware fulfillment
- src/app/tuxedo/stops/TuxedoStopsList.tsx: improved empty state with calendar icon + CTAs
- src/app/cart/CartClient.tsx: guard against empty cart checkout; 'Cart is Empty' state

Billing reconciliation (subagent 3):
- src/actions/billing/billing-overview.ts: NEW — single source of truth
- src/app/admin/settings/billing/page.tsx: use getBillingOverview
- src/app/admin/settings/billing/BillingClientPage.tsx: rewritten to consume BillingOverview (status pill, addons state, removable flags, derived invoice amounts, usage footer)
- src/app/admin/page.tsx: use getBillingOverview (aligns dashboard with billing)
- src/components/admin/DashboardClient.tsx: 'Active Products' now reads from getBillingOverview
- supabase/migrations/203_plan_usage_active_products.sql: get_brand_plan_info counts products as active=true AND deleted_at IS NULL

Harvest Reach dedup + audience preview (manual, subagent 4 didn't complete):
- src/components/admin/CommunicationsPage.tsx: add initialTab prop
- src/app/admin/communications/compose/page.tsx: now renders with initialTab='compose' (single compose experience, no duplicate edit panel)
- src/components/admin/HarvestReach/CampaignComposerPage.tsx: always-visible audience preview panel (count + sample emails), loads via previewCampaignAudience action

Layout/content consistency + a11y sweep (subagent 5):
- src/components/layout/SiteHeader.tsx: Admin link only shows for authenticated admin users
- src/components/Providers.tsx: suppress public SiteHeader/Footer for /admin, /cart, /checkout, /wholesale, /water (fixes duplicate headers)
- src/app/contact/ContactClientPage.tsx: Phone/Email now use tel:/mailto: links; dynamic year
- src/app/blog/page.tsx, changelog, privacy-policy, roadmap, security, terms-and-conditions, waitlist: dynamic year
- src/app/admin/wholesale/WholesaleClient.tsx: proper htmlFor/id, type=email/tel, autoComplete
- src/app/admin/settings/ai/AIClient.tsx: proper htmlFor/id, required + aria-required
- src/app/admin/settings/integrations/IntegrationsClient.tsx: only mask secret fields; add required + aria-required + CredentialField.required type
- src/app/admin/water-log/headgates/HeadgatesManager.tsx: htmlFor/id, aria-required
- src/components/admin/CreateUserModal.tsx: htmlFor/id, required, aria-required, aria-describedby, autoComplete
- src/app/admin/me/AdminMeClient.tsx, products/import, sales/import, water-log/settings, login, brands, tuxedo: a11y polish (ids/required/aria)
This commit is contained in:
2026-06-03 16:39:19 +00:00
parent 03ae372509
commit 0245aa29cc
34 changed files with 1122 additions and 295 deletions
+236
View File
@@ -0,0 +1,236 @@
"use server";
/**
* Centralized Billing Overview
*
* Single source of truth for everything the billing page + dashboard usage
* bars need to render. Replaces ad-hoc calls to `getBrandPlanInfo`,
* `getEnabledAddons`, brand-row subscription columns, and hard-coded invoice
* data with one consistent, denormalized payload.
*
* Why a single action:
* - Before this, the billing page combined `getBrandPlanInfo` +
* `getEnabledAddons` + a brands-table read of subscription fields +
* hard-coded mock invoices. The dashboard's product count came from
* `getDashboardStats` which used a different filter than `get_brand_plan_info`
* (`active=true` vs `deleted_at IS NULL`), causing "Active Products 1" vs
* "Products 0/25" in the same UI.
* - Add-ons could be "Active" in `brand_features` while no Stripe
* subscription existed, with a Remove button that would fail at Stripe.
*
* What this returns:
* - planTier + planCycle (we default to "monthly" because the brand row
* doesn't store a cycle — the UI toggle controls display, but a real
* cycle would be detected from a `stripe_subscription.items.data[0].price
* .recurring.interval` when fetched)
* - hasActiveSubscription + subscriptionStatus (so the UI can hide
* misleading "Active" add-on badges / Remove buttons)
* - usage: { users, stops_this_month, products } — products counted
* consistently with the dashboard (`active=true AND deleted_at IS NULL`).
* - enabledAddons with a derived `removable` flag — only true when
* there is an active subscription AND the feature is enabled AND the
* current user can manage settings.
* - displayedInvoiceAmount — monthly or annual equivalent matching the
* billingCycle toggle, summed across plan + active add-ons. Used by the
* invoice table so amounts always match the displayed plan.
*/
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export type BillingSubscriptionStatus =
| "active"
| "trialing"
| "past_due"
| "canceled"
| "incomplete"
| "unpaid"
| "inactive"
| "none";
export type BillingOverview = {
brandId: string;
brandName: string | null;
planTier: PlanTierKey;
planCycle: "monthly" | "annual";
planMonthlyPrice: number;
planAnnualPrice: number;
hasStripeCustomer: boolean;
hasActiveSubscription: boolean;
subscriptionStatus: BillingSubscriptionStatus | null;
currentPeriodEnd: string | null;
limits: { max_users: number; max_stops_monthly: number; max_products: number };
usage: { users: number; stops_this_month: number; products: number };
enabledAddons: Record<string, boolean>;
// Denormalized add-on list (for direct UI rendering)
addons: Array<{
key: AddonKey;
label: string;
icon: string;
description: string;
enabled: boolean;
removable: boolean;
monthlyPrice: number;
annualPrice: number;
}>;
// Pre-computed totals so the billing page never disagrees with itself
displayedInvoiceAmount: number; // current cycle total
monthlyTotal: number; // always monthly equivalent
annualTotal: number; // always annual equivalent
isPlatformAdmin: boolean;
canManageBilling: boolean;
};
/**
* Fetch a single, consistent billing summary for a brand.
* Safe to call from a server component (uses service-role key).
*/
export async function getBillingOverview(
brandId: string,
options?: { planCycle?: "monthly" | "annual" }
): Promise<{ success: boolean; data?: BillingOverview; error?: string }> {
try {
if (!brandId) return { success: false, error: "brandId required" };
// 1) Plan info (plan_tier + limits + usage via get_brand_plan_info)
const planRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
const planData = planRes.ok ? await planRes.json() : null;
// 2) Brand row: subscription state, name, stripe_customer_id
const brandRes = await fetch(
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`,
{ headers: svcHeaders(supabaseKey) }
);
const brandRows = brandRes.ok ? await brandRes.json() : [];
const brand = Array.isArray(brandRows) ? brandRows[0] : null;
// 3) Enabled add-ons (feature flags)
const addonsRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ p_brand_id: brandId }),
}
);
const enabledAddons = addonsRes.ok ? await addonsRes.json() : {};
// 4) Active product count — same semantics as the dashboard
// (active=true AND deleted_at IS NULL). Keeps the billing page in
// sync with /admin "Active Products" stat.
const productRes = await fetch(
`${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`,
{ headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } }
);
const productCountHeader = productRes.headers.get("Content-Range");
let activeProductCount = 0;
if (productCountHeader && productCountHeader.includes("/")) {
const total = productCountHeader.split("/").pop();
activeProductCount = parseInt(total ?? "0", 10) || 0;
}
// 5) Admin context (so we know if the user can manage / is platform)
const adminUser = await getAdminUser();
const isPlatformAdmin = adminUser?.role === "platform_admin";
const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings;
// ── Normalize plan data ──────────────────────────────────────────────────
const planTier = (planData?.plan_tier ?? "starter") as PlanTierKey;
const limits = {
max_users: planData?.max_users ?? 1,
max_stops_monthly: planData?.max_stops_monthly ?? 10,
max_products: planData?.max_products ?? 25,
};
const planInfo = PLAN_TIERS[planTier] ?? PLAN_TIERS.starter;
const planMonthlyPrice = planInfo.monthlyPrice;
const planAnnualPrice = planInfo.annualPrice;
// ── Normalize subscription state ─────────────────────────────────────────
const rawStatus = (brand?.stripe_subscription_status ?? null) as BillingSubscriptionStatus | null;
const hasStripeCustomer = !!brand?.stripe_customer_id;
const hasActiveSubscription =
rawStatus === "active" || rawStatus === "trialing";
// ── Build add-on list with derived `removable` ──────────────────────────
const addons: BillingOverview["addons"] = (Object.keys(ADDONS) as AddonKey[]).map((key) => {
const cfg = ADDONS[key];
const enabled = !!enabledAddons?.[key];
return {
key,
label: cfg.label,
icon: cfg.icon,
description: cfg.description,
enabled,
removable: enabled && hasActiveSubscription && canManageBilling,
monthlyPrice: cfg.monthlyPrice,
annualPrice: cfg.annualPrice,
};
});
// ── Usage (products override from active=true+not-deleted count) ───────
const usage = {
users: planData?.usage?.users ?? 0,
stops_this_month: planData?.usage?.stops_this_month ?? 0,
// Prefer the active+non-deleted count for cross-UI consistency.
products: activeProductCount || (planData?.usage?.products ?? 0),
};
// ── Totals ───────────────────────────────────────────────────────────────
const addonsMonthlyTotal = addons
.filter((a) => a.enabled && hasActiveSubscription)
.reduce((s, a) => s + a.monthlyPrice, 0);
const addonsAnnualTotal = addons
.filter((a) => a.enabled && hasActiveSubscription)
.reduce((s, a) => s + a.annualPrice, 0);
const monthlyTotal = planMonthlyPrice + addonsMonthlyTotal;
const annualTotal = planAnnualPrice + addonsAnnualTotal;
const planCycle: "monthly" | "annual" =
options?.planCycle ?? "monthly"; // safer default — see header comment
const displayedInvoiceAmount =
planCycle === "annual"
? planAnnualPrice + addonsAnnualTotal
: planMonthlyPrice + addonsMonthlyTotal;
return {
success: true,
data: {
brandId,
brandName: brand?.name ?? planData?.brand_name ?? null,
planTier,
planCycle,
planMonthlyPrice,
planAnnualPrice,
hasStripeCustomer,
hasActiveSubscription,
subscriptionStatus: rawStatus,
currentPeriodEnd: brand?.stripe_current_period_end ?? null,
limits,
usage,
enabledAddons: (enabledAddons ?? {}) as Record<string, boolean>,
addons,
displayedInvoiceAmount,
monthlyTotal,
annualTotal,
isPlatformAdmin,
canManageBilling,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: `Failed to build billing overview: ${message}` };
}
}
@@ -11,6 +11,12 @@ export const metadata: Metadata = {
description: "Create and send email campaigns to your customers.", description: "Create and send email campaigns to your customers.",
}; };
// Legacy /compose route: now consolidated into the main Communications page.
// We render the same component with initialTab="compose" so users land directly
// in the unified CampaignComposerPage (no duplicate "edit" / "new" experience).
// The previous render passed editMode="new" which forced a separate
// CampaignEditPanel render in the campaigns tab — that duplicate has been
// removed. /compose is preserved for backwards compatibility (existing links).
export default async function ComposePage() { export default async function ComposePage() {
const adminUser = await getAdminUser(); const adminUser = await getAdminUser();
if (!adminUser || !adminUser.can_manage_messages) { if (!adminUser || !adminUser.can_manage_messages) {
@@ -31,7 +37,7 @@ export default async function ComposePage() {
templates={templatesResult.success ? templatesResult.templates : []} templates={templatesResult.success ? templatesResult.templates : []}
brandId={effectiveBrandId} brandId={effectiveBrandId}
initialSegments={segmentsResult.success ? segmentsResult.segments : []} initialSegments={segmentsResult.success ? segmentsResult.segments : []}
editMode="new" initialTab="compose"
/> />
); );
} }
+10 -3
View File
@@ -153,8 +153,9 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
{editing && ( {editing && (
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4"> <form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
<div> <div>
<label className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Display Name</label> <label htmlFor="me-display-name" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Display Name</label>
<input <input
id="me-display-name"
type="text" type="text"
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
@@ -168,8 +169,9 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Phone Number</label> <label htmlFor="me-phone" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>Phone Number</label>
<input <input
id="me-phone"
type="tel" type="tel"
value={phoneNumber} value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)} onChange={(e) => setPhoneNumber(e.target.value)}
@@ -180,6 +182,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
backgroundColor: "white" backgroundColor: "white"
}} }}
placeholder="+1 (555) 000-0000" placeholder="+1 (555) 000-0000"
autoComplete="tel"
/> />
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
@@ -228,11 +231,14 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
) : ( ) : (
<form onSubmit={handleEmailChange} className="mt-4 space-y-3"> <form onSubmit={handleEmailChange} className="mt-4 space-y-3">
<div> <div>
<label className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>New Email Address</label> <label htmlFor="me-new-email" className="block text-sm font-medium" style={{ color: "var(--admin-text-primary)" }}>New Email Address</label>
<input <input
id="me-new-email"
type="email" type="email"
value={newEmail} value={newEmail}
onChange={(e) => setNewEmail(e.target.value)} onChange={(e) => setNewEmail(e.target.value)}
required
aria-required="true"
className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1" className="mt-1 w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-1"
style={{ style={{
borderColor: "var(--admin-border)", borderColor: "var(--admin-border)",
@@ -240,6 +246,7 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
backgroundColor: "white" backgroundColor: "white"
}} }}
placeholder="new@example.com" placeholder="new@example.com"
autoComplete="email"
/> />
</div> </div>
{emailError && ( {emailError && (
+44 -14
View File
@@ -1,7 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { isFeatureEnabled } from "@/lib/feature-flags"; import { isFeatureEnabled } from "@/lib/feature-flags";
import { getBrandPlanInfo } from "@/actions/billing/stripe-portal"; import { getBillingOverview } from "@/actions/billing/billing-overview";
import DashboardClient from "@/components/admin/DashboardClient"; import DashboardClient from "@/components/admin/DashboardClient";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
@@ -22,20 +23,49 @@ export default async function AdminPage() {
let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 }; let limits = { max_users: 1, max_stops_monthly: 10, max_products: 25 };
let brandDisplayName = "Admin"; let brandDisplayName = "Admin";
if (adminUser?.brand_id) { // For platform_admin in dev mode, adminUser.brand_id is null. To keep
for (const key of ["harvest_reach", "wholesale_portal", "water_log", "ai_tools", "sms_campaigns", "square_sync", "route_trace"] as const) { // the dashboard's "Active Products" stat in sync with the billing page,
enabledAddons[key] = await isFeatureEnabled(adminUser.brand_id, key); // we need to pick a brand and use the same getBillingOverview action
// the billing page does. Otherwise the dashboard falls back to default
// (0/0/0) usage values and contradicts the billing page's "Products 1/25".
let dashboardBrandId: string | null = adminUser?.brand_id ?? null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
const { data: firstBrand } = await supabase
.from("brands")
.select("id")
.limit(1)
.single();
if (firstBrand?.id) {
dashboardBrandId = firstBrand.id;
}
}
if (dashboardBrandId) {
// Use the centralized billing overview so the dashboard's "Active Products"
// stat agrees with the plan usage in /admin/settings/billing. Previously
// these were derived from two different queries with different filters
// (active=true vs deleted_at IS NULL), causing the "1 vs 0/25" mismatch.
const overviewRes = await getBillingOverview(dashboardBrandId);
if (overviewRes.success && overviewRes.data) {
const o = overviewRes.data;
planTier = o.planTier;
usage = o.usage;
limits = o.limits;
if (o.brandName) brandDisplayName = o.brandName;
enabledAddons = o.enabledAddons;
} else {
// Fallback to per-feature flag check (matches prior behavior)
for (const key of [
"harvest_reach",
"wholesale_portal",
"water_log",
"ai_tools",
"sms_campaigns",
"square_sync",
"route_trace",
] as const) {
enabledAddons[key] = await isFeatureEnabled(dashboardBrandId, key);
} }
const planResult = await getBrandPlanInfo(adminUser.brand_id);
if (planResult.success && planResult.data) {
planTier = planResult.data.plan_tier ?? "starter";
usage = planResult.data.usage ?? usage;
limits = {
max_users: planResult.data.max_users ?? 1,
max_stops_monthly: planResult.data.max_stops_monthly ?? 10,
max_products: planResult.data.max_products ?? 25,
};
if (planResult.data.brand_name) brandDisplayName = planResult.data.brand_name;
} }
} }
+6 -2
View File
@@ -106,8 +106,9 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
{/* Brand ID */} {/* Brand ID */}
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-stone-700">Brand ID</label> <label htmlFor="import-products-brand" className="block text-sm font-medium text-stone-700">Brand ID</label>
<input <input
id="import-products-brand"
type="text" type="text"
value={brandId} value={brandId}
onChange={(e) => setBrandId(e.target.value)} onChange={(e) => setBrandId(e.target.value)}
@@ -118,8 +119,9 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
{/* File upload */} {/* File upload */}
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50"> <div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
<label className="mb-2 block text-sm font-medium text-stone-700">Upload CSV</label> <label htmlFor="import-products-csv" className="mb-2 block text-sm font-medium text-stone-700">Upload CSV</label>
<input <input
id="import-products-csv"
ref={fileRef} ref={fileRef}
type="file" type="file"
accept=".csv" accept=".csv"
@@ -129,7 +131,9 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
<p className="mt-2 text-xs text-stone-500"> <p className="mt-2 text-xs text-stone-500">
Or paste CSV content below: Or paste CSV content below:
</p> </p>
<label htmlFor="import-products-text" className="sr-only">CSV text</label>
<textarea <textarea
id="import-products-text"
value={csvText} value={csvText}
onChange={(e) => setCsvText(e.target.value)} onChange={(e) => setCsvText(e.target.value)}
rows={6} rows={6}
+6 -3
View File
@@ -90,8 +90,9 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-stone-600">Brand ID</label> <label htmlFor="import-orders-brand" className="block text-sm font-medium text-stone-600">Brand ID</label>
<input <input
id="import-orders-brand"
type="text" type="text"
value={brandId} value={brandId}
onChange={(e) => setBrandId(e.target.value)} onChange={(e) => setBrandId(e.target.value)}
@@ -101,10 +102,12 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
</div> </div>
<div className="mb-4 card p-6"> <div className="mb-4 card p-6">
<label className="block text-sm font-medium text-stone-600 mb-2">Upload CSV</label> <label htmlFor="import-orders-csv" className="block text-sm font-medium text-stone-600 mb-2">Upload CSV</label>
<input ref={fileRef} type="file" accept=".csv" onChange={handleFile} className="w-full text-sm text-stone-500" /> <input 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> <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
id="import-orders-text"
value={csvText} value={csvText}
onChange={(e) => setCsvText(e.target.value)} onChange={(e) => setCsvText(e.target.value)}
rows={6} rows={6}
+22 -12
View File
@@ -514,8 +514,9 @@ function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>What do you want to communicate?</label> <label htmlFor="ai-cw-topic" className="block text-sm font-medium mb-1" style={labelStyle}>What do you want to communicate?</label>
<textarea <textarea
id="ai-cw-topic"
value={topic} value={topic}
onChange={(e) => setTopic(e.target.value)} onChange={(e) => setTopic(e.target.value)}
rows={3} rows={3}
@@ -589,20 +590,22 @@ function ProductWriterTool({ brandId }: { brandId: string }) {
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Product Name *</label> <label htmlFor="ai-pw-product-name" className="block text-sm font-medium mb-1" style={labelStyle}>
<input type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" className={inputBaseClass} style={inputStyle} /> 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} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Category</label> <label htmlFor="ai-pw-category" className="block text-sm font-medium mb-1" style={labelStyle}>Category</label>
<input type="text" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Vegetables" className={inputBaseClass} style={inputStyle} /> <input id="ai-pw-category" type="text" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Vegetables" className={inputBaseClass} style={inputStyle} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Price</label> <label htmlFor="ai-pw-price" className="block text-sm font-medium mb-1" style={labelStyle}>Price</label>
<input type="text" value={price} onChange={(e) => setPrice(e.target.value)} placeholder="$4.50" className={inputBaseClass} style={inputStyle} /> <input id="ai-pw-price" type="text" value={price} onChange={(e) => setPrice(e.target.value)} placeholder="$4.50" className={inputBaseClass} style={inputStyle} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Unit</label> <label htmlFor="ai-pw-unit" className="block text-sm font-medium mb-1" style={labelStyle}>Unit</label>
<input type="text" value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="per dozen" className={inputBaseClass} style={inputStyle} /> <input id="ai-pw-unit" type="text" value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="per dozen" className={inputBaseClass} style={inputStyle} />
</div> </div>
</div> </div>
<button <button
@@ -694,8 +697,9 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
</p> </p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Report Type</label> <label htmlFor="ai-rep-type" className="block text-sm font-medium mb-1" style={labelStyle}>Report Type</label>
<select <select
id="ai-rep-type"
value={reportType} value={reportType}
onChange={(e) => setReportType(e.target.value)} onChange={(e) => setReportType(e.target.value)}
className={inputBaseClass} className={inputBaseClass}
@@ -707,8 +711,9 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Date Range</label> <label htmlFor="ai-rep-range" className="block text-sm font-medium mb-1" style={labelStyle}>Date Range</label>
<input <input
id="ai-rep-range"
type="text" type="text"
value={dateRange} value={dateRange}
onChange={(e) => setDateRange(e.target.value)} onChange={(e) => setDateRange(e.target.value)}
@@ -856,12 +861,17 @@ function PricingAdvisorTool({ brandId }: { brandId: string }) {
Enter a product name and optional price tiers or historical sales data for AI-powered pricing recommendations. Enter a product name and optional price tiers or historical sales data for AI-powered pricing recommendations.
</p> </p>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Product Name *</label> <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
id="ai-pa-product-name"
type="text" type="text"
value={productName} value={productName}
onChange={(e) => setProductName(e.target.value)} onChange={(e) => setProductName(e.target.value)}
placeholder="Sweet Corn" placeholder="Sweet Corn"
required
aria-required="true"
className={inputBaseClass} className={inputBaseClass}
style={inputStyle} style={inputStyle}
/> />
@@ -1,88 +1,132 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import PlanUpgradeButton from "./PlanUpgradeButton"; import PlanUpgradeButton from "./PlanUpgradeButton";
import AddPaymentMethodButton from "./AddPaymentMethodButton"; import AddPaymentMethodButton from "./AddPaymentMethodButton";
import AddAddonButton from "./AddAddonButton"; import AddAddonButton from "./AddAddonButton";
import RemoveAddonButton from "./RemoveAddonButton"; import RemoveAddonButton from "./RemoveAddonButton";
import BillingCycleToggle from "./BillingCycleToggle"; import BillingCycleToggle from "./BillingCycleToggle";
import StripePortalButton from "./StripePortalButton"; import StripePortalButton from "./StripePortalButton";
import { PLAN_TIERS, ADDONS } from "@/lib/pricing"; import { PLAN_TIERS } from "@/lib/pricing";
import { AdminButton } from "@/components/admin/design-system"; import type { BillingOverview, BillingSubscriptionStatus } from "@/actions/billing/billing-overview";
type BillingCycle = "monthly" | "annual"; type BillingCycle = "monthly" | "annual";
type AddonData = { key: string; label: string; icon: string; monthlyPrice: number; annualPrice: number };
type Props = { type Props = {
brandId: string; overview: BillingOverview;
planTier: string;
brandName: string | null;
hasStripeCustomer: boolean;
enabledAddons: Record<string, boolean>;
isPlatformAdmin: boolean;
subscriptionStatus: string | null;
currentPeriodEnd: string | null;
}; };
export default function BillingClientPage({ /**
brandId, * Status badge class for the subscription state pill.
planTier, * Returns null for "none" so we can hide it entirely instead of showing
brandName, * a generic "Inactive" pill that contradicts the "Active" add-on badges.
hasStripeCustomer, */
enabledAddons, function subscriptionStatusBadgeClass(status: BillingSubscriptionStatus | null): {
isPlatformAdmin, label: string;
subscriptionStatus, cls: string;
currentPeriodEnd, show: boolean;
}: Props) { } {
const [billingCycle, setBillingCycle] = useState<BillingCycle>("annual"); if (!status || status === "none" || status === "inactive") {
const [compareOpen, setCompareOpen] = useState(false); return { label: "No active subscription", cls: "bg-stone-100 text-stone-600", show: true };
}
const currentPlan = PLAN_TIERS[planTier as keyof typeof PLAN_TIERS] ?? PLAN_TIERS.starter;
const planMonthly = billingCycle === "annual"
? Math.round((currentPlan.annualPrice ?? 0) / 12)
: (currentPlan.monthlyPrice ?? 0);
const enabledAddonsList: AddonData[] = Object.entries(enabledAddons)
.filter(([, v]) => v)
.map(([key]) => {
const a = ADDONS[key as keyof typeof ADDONS];
return a ? { key, label: a.label, icon: a.icon, monthlyPrice: a.monthlyPrice, annualPrice: a.annualPrice } : null;
})
.filter(Boolean) as AddonData[];
const allAddonKeys = Object.keys(ADDONS) as Array<keyof typeof ADDONS>;
const addonsMonthlyTotal = enabledAddonsList.reduce((sum, a) => {
return sum + (billingCycle === "annual" ? Math.round(a.annualPrice / 12) : a.monthlyPrice);
}, 0);
const totalMonthly = planMonthly + addonsMonthlyTotal;
const annualSavings = enabledAddonsList.reduce(
(s, a) => s + (a.monthlyPrice * 12 - a.annualPrice),
(billingCycle === "annual" ? (currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0) : 0)
);
const getStatusBadgeClass = (status: string | null) => {
if (!status) return "bg-stone-100 text-stone-600";
switch (status) { switch (status) {
case "active": case "active":
return "bg-[var(--admin-success)]/10 text-[var(--admin-success)]"; return { label: "Active", cls: "bg-[var(--admin-success)]/10 text-[var(--admin-success)]", show: true };
case "past_due":
return "bg-amber-100 text-amber-700";
case "canceled":
return "bg-red-100 text-red-600";
case "trialing": case "trialing":
return "bg-blue-100 text-blue-600"; return { label: "Trial", cls: "bg-blue-100 text-blue-600", show: true };
case "past_due":
return { label: "Past Due", cls: "bg-amber-100 text-amber-700", show: true };
case "canceled":
return { label: "Canceled", cls: "bg-red-100 text-red-600", show: true };
case "incomplete":
return { label: "Incomplete", cls: "bg-amber-100 text-amber-700", show: true };
case "unpaid":
return { label: "Unpaid", cls: "bg-red-100 text-red-600", show: true };
default: default:
return "bg-stone-100 text-stone-600"; return { label: String(status), cls: "bg-stone-100 text-stone-600", show: true };
} }
}; }
function formatPeriodEnd(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
}
export default function BillingClientPage({ overview }: Props) {
const [billingCycle, setBillingCycle] = useState<BillingCycle>(overview.planCycle);
const [compareOpen, setCompareOpen] = useState(false);
const {
brandId,
planTier,
hasStripeCustomer,
hasActiveSubscription,
subscriptionStatus,
currentPeriodEnd,
isPlatformAdmin,
addons,
limits,
usage,
} = overview;
const currentPlan = PLAN_TIERS[planTier] ?? PLAN_TIERS.starter;
// ── Pricing math (driven solely by billingCycle toggle) ────────────────────
const { planDisplayPrice, addonsMonthlyTotal, addonsAnnualTotal, displayedTotal, planDisplayLabel } =
useMemo(() => {
const planDisplayPrice =
billingCycle === "annual" ? currentPlan.annualPrice : currentPlan.monthlyPrice;
const planDisplayLabel = billingCycle === "annual" ? "/yr" : "/mo";
// Add-ons that are "live" (subscription active) — only these contribute
// to displayed totals. Inactive add-ons would inflate the number.
const liveAddons = addons.filter((a) => a.enabled && hasActiveSubscription);
const addonsMonthlyTotal = liveAddons.reduce((s, a) => s + a.monthlyPrice, 0);
const addonsAnnualTotal = liveAddons.reduce((s, a) => s + a.annualPrice, 0);
const displayedTotal =
billingCycle === "annual"
? planDisplayPrice + addonsAnnualTotal
: planDisplayPrice + addonsMonthlyTotal;
return { planDisplayPrice, addonsMonthlyTotal, addonsAnnualTotal, displayedTotal, planDisplayLabel };
}, [billingCycle, currentPlan, addons, hasActiveSubscription]);
const monthlyEquivalent =
billingCycle === "annual"
? Math.round(displayedTotal / 12)
: displayedTotal;
const statusBadge = subscriptionStatusBadgeClass(subscriptionStatus);
const periodEndLabel = formatPeriodEnd(currentPeriodEnd);
// ── Invoice data (synthesized, since we have no invoice table) ─────────────
// Amounts always equal the displayed total for the selected cycle so the
// user never sees an invoice that disagrees with the plan above.
const placeholderInvoices = useMemo(() => {
const today = new Date();
return [3, 2, 1].map((monthsAgo) => {
const d = new Date(today);
d.setMonth(d.getMonth() - monthsAgo);
const dateLabel = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
return {
id: `INV-${d.getFullYear()}-${String(d.getMonth() + 1).padStart(3, "0")}`,
date: dateLabel,
amount: displayedTotal,
status: "paid" as const,
// Until we wire a real billing_invoices table we mark them as samples
// so users don't mistake them for authentic records.
isSample: !hasActiveSubscription,
};
});
}, [displayedTotal, hasActiveSubscription]);
// ── Render ─────────────────────────────────────────────────────────────────
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ── 1. Header summary bar ───────────────────────────────────────────── */} {/* ── 1. Header summary bar ─────────────────────────────────────────── */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] px-6 py-6"> <div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] px-6 py-6">
<div className="flex items-center justify-between flex-wrap gap-6"> <div className="flex items-center justify-between flex-wrap gap-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -91,18 +135,25 @@ export default function BillingClientPage({
<span className={`rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${currentPlan.color}`}> <span className={`rounded-full px-3 py-1 text-xs font-bold uppercase tracking-wide ${currentPlan.color}`}>
{currentPlan.label} {currentPlan.label}
</span> </span>
{subscriptionStatus && ( {statusBadge.show && (
<span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${getStatusBadgeClass(subscriptionStatus)}`}> <span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${statusBadge.cls}`}>
{subscriptionStatus.replace("_", " ")} {statusBadge.label}
</span>
)}
{hasActiveSubscription && periodEndLabel && (
<span className="text-xs text-[var(--admin-text-muted)]">
renews {periodEndLabel}
</span> </span>
)} )}
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<p className="text-3xl font-bold text-[var(--admin-text-primary)]"> <p className="text-3xl font-bold text-[var(--admin-text-primary)]">
${totalMonthly} {hasActiveSubscription ? `$${displayedTotal}` : `$${currentPlan.monthlyPrice}`}
<span className="text-base font-normal text-[var(--admin-text-muted)]">/mo</span> <span className="text-base font-normal text-[var(--admin-text-muted)]">
{hasActiveSubscription ? planDisplayLabel : "/mo"}
</span>
</p> </p>
{billingCycle === "annual" && ( {hasActiveSubscription && billingCycle === "annual" && (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--admin-success)]"> <span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--admin-success)]">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -112,10 +163,21 @@ export default function BillingClientPage({
)} )}
</div> </div>
<p className="text-sm text-[var(--admin-text-muted)] mt-1"> <p className="text-sm text-[var(--admin-text-muted)] mt-1">
{currentPeriodEnd ? ( {hasActiveSubscription ? (
<>Next billing: <span className="font-medium text-[var(--admin-text-primary)]">{new Date(currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</span></> <>
{addons.filter((a) => a.enabled).length > 0 ? (
<>Includes plan + {addons.filter((a) => a.enabled).length} active add-on{addons.filter((a) => a.enabled).length === 1 ? "" : "s"}</>
) : ( ) : (
"No active subscription" <>Base plan only · no add-ons</>
)}
{billingCycle === "annual" && (
<> · ${monthlyEquivalent}/mo equivalent</>
)}
</>
) : hasStripeCustomer ? (
<>No active subscription. Add-ons will not bill until you subscribe to a plan.</>
) : (
<>No active subscription. Add a payment method to start.</>
)} )}
</p> </p>
</div> </div>
@@ -124,7 +186,7 @@ export default function BillingClientPage({
</div> </div>
</div> </div>
{/* ── 2. Current plan + Add-ons (two-column) ───────────────────────────── */} {/* ── 2. Current plan + Add-ons (two-column) ────────────────────────── */}
<div className="grid gap-4 lg:grid-cols-5"> <div className="grid gap-4 lg:grid-cols-5">
{/* Current plan card */} {/* Current plan card */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6 lg:col-span-2"> <div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6 lg:col-span-2">
@@ -135,12 +197,18 @@ export default function BillingClientPage({
</span> </span>
</div> </div>
<p className="text-xl font-bold text-[var(--admin-text-primary)] mb-1"> <p className="text-xl font-bold text-[var(--admin-text-primary)] mb-1">
{billingCycle === "annual" ? `$${currentPlan.annualPrice}/yr` : `$${currentPlan.monthlyPrice}/mo`} {hasActiveSubscription
? `$${planDisplayPrice}${planDisplayLabel}`
: `$${currentPlan.monthlyPrice}/mo`}
</p> </p>
<p className="text-xs text-[var(--admin-text-muted)] mb-5"> <p className="text-xs text-[var(--admin-text-muted)] mb-5">
{billingCycle === "annual" && currentPlan.annualPrice {hasActiveSubscription ? (
billingCycle === "annual" && currentPlan.annualPrice
? `$${Math.round(currentPlan.annualPrice / 12)}/mo equivalent — saves $${Math.round(((currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0)))}/yr` ? `$${Math.round(currentPlan.annualPrice / 12)}/mo equivalent — saves $${Math.round(((currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0)))}/yr`
: "billed monthly"} : "billed monthly"
) : (
"Not yet subscribed"
)}
</p> </p>
<ul className="space-y-2 mb-5"> <ul className="space-y-2 mb-5">
{(currentPlan.features as readonly string[]).slice(0, 6).map((f, i) => ( {(currentPlan.features as readonly string[]).slice(0, 6).map((f, i) => (
@@ -173,19 +241,45 @@ export default function BillingClientPage({
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div> <div>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Add-ons</h3> <h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Add-ons</h3>
<p className="text-xs text-[var(--admin-text-muted)] mt-0.5">Extend your plan with optional features</p> <p className="text-xs text-[var(--admin-text-muted)] mt-0.5">
{hasActiveSubscription
? "Extend your plan with optional features"
: "Add a subscription to enable add-ons"}
</p>
</div> </div>
<span className="text-sm font-semibold text-[var(--admin-text-secondary)]">+${addonsMonthlyTotal}/mo</span> <span className="text-sm font-semibold text-[var(--admin-text-secondary)]">
{hasActiveSubscription
? `+$${billingCycle === "annual" ? Math.round(addonsAnnualTotal / 12) : addonsMonthlyTotal}/mo`
: "—"}
</span>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{allAddonKeys.map((key) => { {addons.map((addon) => {
const addon = ADDONS[key]; const displayPrice =
const isEnabled = !!enabledAddons[key]; billingCycle === "annual"
const displayPrice = billingCycle === "annual"
? Math.round(addon.annualPrice / 12) ? Math.round(addon.annualPrice / 12)
: addon.monthlyPrice; : addon.monthlyPrice;
// State: enabled+active = "Active", enabled+no sub = "Pending",
// disabled = "Available"
let stateBadge: { label: string; cls: string };
if (addon.enabled && hasActiveSubscription) {
stateBadge = {
label: "Active",
cls: "bg-[var(--admin-success)]/10 text-[var(--admin-success)]",
};
} else if (addon.enabled && !hasActiveSubscription) {
stateBadge = {
label: "Pending",
cls: "bg-amber-100 text-amber-700",
};
} else {
stateBadge = {
label: "Available",
cls: "bg-stone-100 text-stone-600",
};
}
return ( return (
<div key={key} className="flex items-center justify-between rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg)] px-4 py-3"> <div key={addon.key} className="flex items-center justify-between rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg)] px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xl leading-none">{addon.icon}</span> <span className="text-xl leading-none">{addon.icon}</span>
<div> <div>
@@ -195,18 +289,29 @@ export default function BillingClientPage({
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<span className="text-sm font-semibold text-[var(--admin-text-secondary)]">+${displayPrice}/mo</span> <span className="text-sm font-semibold text-[var(--admin-text-secondary)]">+${displayPrice}/mo</span>
{isEnabled ? ( <span className={`inline-flex items-center gap-1 rounded-full text-xs px-2.5 py-1 font-medium ${stateBadge.cls}`}>
<div className="flex items-center gap-2"> <span
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 text-[var(--admin-success)] text-xs px-2.5 py-1 font-medium"> className={`w-1.5 h-1.5 rounded-full ${
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" /> stateBadge.label === "Active"
Active ? "bg-[var(--admin-success)]"
: stateBadge.label === "Pending"
? "bg-amber-500"
: "bg-stone-400"
}`}
/>
{stateBadge.label}
</span> </span>
{isPlatformAdmin && ( {addon.removable ? (
<RemoveAddonButton brandId={brandId} addonKey={key} onRemoved={() => window.location.reload()} /> <RemoveAddonButton brandId={brandId} addonKey={addon.key} onRemoved={() => window.location.reload()} />
)} ) : hasActiveSubscription ? (
</div> <AddAddonButton brandId={brandId} addonKey={addon.key} />
) : ( ) : (
<AddAddonButton brandId={brandId} addonKey={key} /> <span
className="text-xs text-[var(--admin-text-muted)]"
title="Subscribe to a plan first"
>
</span>
)} )}
</div> </div>
</div> </div>
@@ -216,7 +321,7 @@ export default function BillingClientPage({
</div> </div>
</div> </div>
{/* ── 3. Compare plans (collapsible) ───────────────────────────────────── */} {/* ── 3. Compare plans (collapsible) ──────────────────────────────── */}
{compareOpen && isPlatformAdmin && ( {compareOpen && isPlatformAdmin && (
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] overflow-hidden"> <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"> <div className="border-b border-[var(--admin-border)] bg-[var(--admin-bg)] px-6 py-4 flex items-center justify-between">
@@ -334,7 +439,7 @@ export default function BillingClientPage({
</div> </div>
)} )}
{/* ── 4. Payment + Invoices (two-column) ──────────────────────────────── */} {/* ── 4. Payment + Invoices (two-column) ───────────────────────────── */}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
{/* Payment method */} {/* Payment method */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6"> <div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6">
@@ -344,16 +449,30 @@ export default function BillingClientPage({
<div className="flex items-center gap-3 rounded-xl border border-[var(--admin-border)] p-4"> <div className="flex items-center gap-3 rounded-xl border border-[var(--admin-border)] p-4">
<div className="flex items-center justify-center h-10 w-12 rounded-lg bg-gradient-to-br from-blue-600 to-violet-600 shrink-0"> <div className="flex items-center justify-center h-10 w-12 rounded-lg bg-gradient-to-br from-blue-600 to-violet-600 shrink-0">
<svg className="h-5 w-8 text-white" viewBox="0 0 32 20" fill="currentColor"> <svg className="h-5 w-8 text-white" viewBox="0 0 32 20" fill="currentColor">
<path d="M13.193 7.448c-.114-.272-.379-.358-.65-.358-.27 0-.535.09-.65.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .534-.09.648-.357.13-.28.115-.561-.115-.837-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.27 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.536-.357.806-.357.271 0 .536.09.65.357l.001-.001c.115-.272.38-.358.65-.358.271 0 .536.09.65.357l.001.001c.122.28.122.56-.008.838zm8.697-.001c-.122-.276-.379-.357-.65-.357-.27 0-.535.09-.649.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .535-.09.65-.357.13-.28.114-.561-.115-.837-.238-.286-.536-.357-.807-.357-.27 0-.535.09-.65.357l-.001-.001-.001.001c-.114-.267-.379-.357-.65-.357-.27 0-.536.09-.649.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .535-.09.649-.357.13-.277.114-.558-.115-.838-.236-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.271 0-.536-.09-.65-.357-.13-.276-.379-.357-.65-.357-.271 0-.536.09-.649.357-.13.277-.114.558.115.838.236.286.536.357.806.357.271 0 .536-.09.65-.357.13-.277.114-.558-.115-.838-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001-.001c-.114.267-.379.357-.65.357-.271 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.535.357.806-.357.27 0 .535.09.65.357l.001.001c.122.28.122.56-.008.838z"/> <path d="M13.193 7.448c-.114-.272-.379-.358-.65-.358-.27 0-.535.09-.65.357-.13.277-.114.558.115.838.236.286.535.357.806.357.27 0 .534-.09.648-.357.13-.28.115-.561-.115-.837-.237-.286-.536-.357-.806-.357-.271 0-.536.09-.65.357l-.001.001-.001-.001c-.114.267-.379.357-.65.357-.27 0-.536-.09-.65-.357-.13-.277-.114-.558.115-.838.236-.286.536-.357.806-.357.271 0 .536.09.65.357l.001-.001c.115-.272.38-.358.65-.358.271 0 .536.09.65.357l.001.001c.122.28.122.56-.008.838z" />
</svg> </svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--admin-text-primary)]">Visa ending in 4242</p> <p className="text-sm font-medium text-[var(--admin-text-primary)]">Card on file</p>
<p className="text-xs text-[var(--admin-text-muted)]">Expires 12/26</p> <p className="text-xs text-[var(--admin-text-muted)]">
{hasActiveSubscription
? "Billed automatically per the cycle you selected"
: "On file — no active subscription yet"}
</p>
</div> </div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-[var(--admin-success)]/10 text-[var(--admin-success)] text-xs px-2.5 py-1 font-medium shrink-0"> <span
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" /> className={`inline-flex items-center gap-1.5 rounded-full text-xs px-2.5 py-1 font-medium shrink-0 ${
Active hasActiveSubscription
? "bg-[var(--admin-success)]/10 text-[var(--admin-success)]"
: "bg-amber-100 text-amber-700"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
hasActiveSubscription ? "bg-[var(--admin-success)]" : "bg-amber-500"
}`}
/>
{hasActiveSubscription ? "Active" : "On File"}
</span> </span>
</div> </div>
<StripePortalButton brandId={brandId} variant="secondary" label="Manage in Stripe Portal" /> <StripePortalButton brandId={brandId} variant="secondary" label="Manage in Stripe Portal" />
@@ -389,6 +508,11 @@ export default function BillingClientPage({
{/* Invoice history */} {/* Invoice history */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6"> <div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-6">
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-4">Invoice History</h3> <h3 className="text-sm font-semibold text-[var(--admin-text-primary)] mb-4">Invoice History</h3>
{!hasActiveSubscription && (
<p className="text-xs text-[var(--admin-text-muted)] mb-3 italic">
No subscription on file yet. The sample rows below show the price you would have been billed.
</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@@ -397,30 +521,32 @@ export default function BillingClientPage({
<th className="pb-3 text-left font-semibold text-[var(--admin-text-muted)]">Date</th> <th className="pb-3 text-left font-semibold text-[var(--admin-text-muted)]">Date</th>
<th className="pb-3 text-right font-semibold text-[var(--admin-text-muted)]">Amount</th> <th className="pb-3 text-right font-semibold text-[var(--admin-text-muted)]">Amount</th>
<th className="pb-3 text-right font-semibold text-[var(--admin-text-muted)]">Status</th> <th className="pb-3 text-right font-semibold text-[var(--admin-text-muted)]">Status</th>
<th className="pb-3"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--admin-border)]"> <tbody className="divide-y divide-[var(--admin-border)]">
{[ {placeholderInvoices.map((inv) => (
{ id: "INV-2026-004", date: "May 1, 2026", amount: totalMonthly, status: "paid" },
{ id: "INV-2026-003", date: "Apr 1, 2026", amount: totalMonthly, status: "paid" },
{ id: "INV-2026-002", date: "Mar 1, 2026", amount: totalMonthly, status: "paid" },
].map((inv) => (
<tr key={inv.id}> <tr key={inv.id}>
<td className="py-3 font-medium text-[var(--admin-text-primary)]">{inv.id}</td> <td className="py-3 font-medium text-[var(--admin-text-primary)]">
{inv.id}
{inv.isSample && (
<span className="ml-2 inline-block text-[10px] uppercase tracking-wide text-[var(--admin-text-muted)]">
sample
</span>
)}
</td>
<td className="py-3 text-[var(--admin-text-muted)]">{inv.date}</td> <td className="py-3 text-[var(--admin-text-muted)]">{inv.date}</td>
<td className="py-3 text-right font-semibold text-[var(--admin-text-primary)]">${inv.amount}</td> <td className="py-3 text-right font-semibold text-[var(--admin-text-primary)]">${inv.amount}</td>
<td className="py-3 text-right"> <td className="py-3 text-right">
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--admin-success)]/10 text-[var(--admin-success)] px-2 py-0.5 text-xs font-medium capitalize"> <span
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium capitalize ${
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> inv.isSample
</svg> ? "bg-stone-100 text-stone-600"
{inv.status} : "bg-[var(--admin-success)]/10 text-[var(--admin-success)]"
}`}
>
{inv.isSample ? "—" : inv.status}
</span> </span>
</td> </td>
<td className="py-3 text-right">
<button className="text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)] transition-colors">PDF</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -431,6 +557,21 @@ export default function BillingClientPage({
</p> </p>
</div> </div>
</div> </div>
{/* Usage footer (small) — keep usage in sync with /admin dashboard */}
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] px-6 py-4">
<div className="flex items-center justify-between flex-wrap gap-3 text-sm">
<div className="flex items-center gap-4 flex-wrap">
<span className="text-[var(--admin-text-muted)]">Current usage:</span>
<span><strong>{usage.users}</strong>/{limits.max_users} users</span>
<span><strong>{usage.stops_this_month}</strong>/{limits.max_stops_monthly === -1 ? "∞" : limits.max_stops_monthly} stops this month</span>
<span><strong>{usage.products}</strong>/{limits.max_products === -1 ? "∞" : limits.max_products} products</span>
</div>
<span className="text-xs text-[var(--admin-text-muted)]">
Mirrors your admin dashboard
</span>
</div>
</div>
</div> </div>
); );
} }
+21 -29
View File
@@ -1,6 +1,6 @@
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
import { getBrandPlanInfo, getEnabledAddons } from "@/actions/billing/stripe-portal"; import { getBillingOverview } from "@/actions/billing/billing-overview";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied"; import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
import BillingClientPage from "./BillingClientPage"; import BillingClientPage from "./BillingClientPage";
@@ -49,24 +49,25 @@ export default async function BillingPage({ params }: Props) {
if (!resolvedBrandId) return <AdminAccessDenied />; if (!resolvedBrandId) return <AdminAccessDenied />;
const [planResult, addons] = await Promise.all([ // Single source of truth for everything the billing client needs.
getBrandPlanInfo(resolvedBrandId), const overviewRes = await getBillingOverview(resolvedBrandId);
getEnabledAddons(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 planData = (planResult.success && planResult.data && typeof planResult.data === "object") const overview = overviewRes.data;
? planResult.data as Record<string, any>
: {} as Record<string, any>;
const planTier = planData.plan_tier ?? "starter";
const { data: brand } = await supabase
.from("brands")
.select("name, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_current_period_end")
.eq("id", resolvedBrandId)
.single();
const hasStripeCustomer = !!brand?.stripe_customer_id;
return ( return (
<main className="min-h-screen bg-[var(--admin-bg)]"> <main className="min-h-screen bg-[var(--admin-bg)]">
@@ -94,20 +95,11 @@ export default async function BillingPage({ params }: Props) {
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)]">Billing &amp; Subscription</h1> <h1 className="text-3xl font-bold text-[var(--admin-text-primary)]">Billing &amp; Subscription</h1>
<p className="mt-1 text-[var(--admin-text-muted)]"> <p className="mt-1 text-[var(--admin-text-muted)]">
Manage your Route Commerce subscription for {brand?.name ?? "your brand"}. Manage your Route Commerce subscription for {overview.brandName ?? "your brand"}.
</p> </p>
</div> </div>
<BillingClientPage <BillingClientPage overview={overview} />
brandId={resolvedBrandId}
planTier={planTier}
brandName={brand?.name ?? null}
hasStripeCustomer={hasStripeCustomer}
enabledAddons={addons}
isPlatformAdmin={isPlatformAdmin}
subscriptionStatus={brand?.stripe_subscription_status ?? null}
currentPeriodEnd={brand?.stripe_current_period_end ?? null}
/>
</div> </div>
</main> </main>
); );
@@ -11,6 +11,7 @@ type CredentialField = {
label: string; label: string;
placeholder: string; placeholder: string;
isSecret?: boolean; isSecret?: boolean;
required?: boolean;
}; };
type SyncOption = { type SyncOption = {
@@ -346,15 +347,30 @@ function IntegrationCard({
{/* Credentials */} {/* Credentials */}
<div className="space-y-3 mb-5"> <div className="space-y-3 mb-5">
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Credentials</p> <p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Credentials</p>
{integration.credentials.map((field) => ( {integration.credentials.map((field) => {
// Only mask the field as password when explicitly marked as a secret
// (e.g. API keys, tokens). Plain identifiers (emails, phone numbers,
// IDs) stay readable so admins can verify what they entered.
const inputId = `integration-${integration.id}-${field.key}`;
const inputType = field.isSecret
? showSecrets[field.key]
? "text"
: "password"
: (field as { type?: string }).type ?? "text";
return (
<div key={field.key}> <div key={field.key}>
<label className="block text-xs font-medium text-zinc-400 mb-1">{field.label}</label> <label htmlFor={inputId} className="block text-xs font-medium text-zinc-400 mb-1">
{field.label}
{field.required && <span className="text-red-400 ml-1" aria-hidden="true">*</span>}
</label>
<div className="relative"> <div className="relative">
<input <input
type={showSecrets[field.key] ? "text" : "password"} id={inputId}
type={inputType}
value={credentials[field.key] ?? ""} value={credentials[field.key] ?? ""}
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))} onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
placeholder={field.placeholder} placeholder={field.placeholder}
aria-required={field.required ? "true" : undefined}
className="w-full rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm pr-16 text-zinc-100 outline-none focus:border-violet-500" className="w-full rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm pr-16 text-zinc-100 outline-none focus:border-violet-500"
/> />
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1"> <div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
@@ -362,6 +378,7 @@ function IntegrationCard({
<button <button
type="button" type="button"
onClick={() => toggleSecret(field.key)} onClick={() => toggleSecret(field.key)}
aria-label={showSecrets[field.key] ? `Hide ${field.label}` : `Show ${field.label}`}
className="text-xs text-zinc-500 hover:text-zinc-300 px-1" className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
> >
{showSecrets[field.key] ? "Hide" : "Show"} {showSecrets[field.key] ? "Hide" : "Show"}
@@ -370,7 +387,8 @@ function IntegrationCard({
</div> </div>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
{/* Environment toggle */} {/* Environment toggle */}
@@ -222,8 +222,9 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-5"> <div className="rounded-xl border border-[var(--admin-border)] bg-white p-5">
<form onSubmit={handleAdd} className="flex gap-3 items-end"> <form onSubmit={handleAdd} className="flex gap-3 items-end">
<div className="flex-1"> <div className="flex-1">
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Headgate Name</label> <label htmlFor="headgate-add-name" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Headgate Name</label>
<input <input
id="headgate-add-name"
autoFocus autoFocus
type="text" type="text"
value={newName} value={newName}
@@ -231,11 +232,13 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
placeholder="e.g. North Field Gate 1" placeholder="e.g. North Field Gate 1"
className="w-full rounded-xl border border-[var(--admin-border)] px-4 py-3 text-sm outline-none focus:border-[var(--admin-accent)]" className="w-full rounded-xl border border-[var(--admin-border)] px-4 py-3 text-sm outline-none focus:border-[var(--admin-accent)]"
required required
aria-required="true"
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Unit</label> <label htmlFor="headgate-add-unit" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Unit</label>
<select <select
id="headgate-add-unit"
value={newUnit} value={newUnit}
onChange={(e) => setNewUnit(e.target.value)} onChange={(e) => setNewUnit(e.target.value)}
className="rounded-xl border border-[var(--admin-border)] px-3 py-3 text-sm outline-none focus:border-[var(--admin-accent)]" className="rounded-xl border border-[var(--admin-border)] px-3 py-3 text-sm outline-none focus:border-[var(--admin-accent)]"
@@ -371,19 +374,22 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
</div> </div>
<form onSubmit={handleEditSave} className="p-5 space-y-4"> <form onSubmit={handleEditSave} className="p-5 space-y-4">
<div> <div>
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Name</label> <label htmlFor="headgate-edit-name" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Name</label>
<input <input
id="headgate-edit-name"
type="text" type="text"
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} onChange={(e) => setEditName(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-4 py-3 text-sm outline-none focus:border-[var(--admin-accent)]" className="w-full rounded-xl border border-[var(--admin-border)] px-4 py-3 text-sm outline-none focus:border-[var(--admin-accent)]"
required required
aria-required="true"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Unit</label> <label htmlFor="headgate-edit-unit" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Unit</label>
<select <select
id="headgate-edit-unit"
value={editUnit} value={editUnit}
onChange={(e) => setEditUnit(e.target.value)} onChange={(e) => setEditUnit(e.target.value)}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-3 text-sm outline-none focus:border-[var(--admin-accent)]" className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-3 text-sm outline-none focus:border-[var(--admin-accent)]"
@@ -395,15 +401,16 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<label className="flex items-center gap-2 text-sm text-[var(--admin-text-secondary)] cursor-pointer"> <label className="flex items-center gap-2 text-sm text-[var(--admin-text-secondary)] cursor-pointer">
<input type="checkbox" checked={editActive} onChange={(e) => setEditActive(e.target.checked)} className="rounded" /> <input id="headgate-edit-active" type="checkbox" checked={editActive} onChange={(e) => setEditActive(e.target.checked)} className="rounded" />
Active Active
</label> </label>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">High Alert Threshold</label> <label htmlFor="headgate-edit-high" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">High Alert Threshold</label>
<input <input
id="headgate-edit-high"
type="number" type="number"
step="any" step="any"
value={editHigh} value={editHigh}
@@ -413,8 +420,9 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Low Alert Threshold</label> <label htmlFor="headgate-edit-low" className="block text-xs font-semibold text-[var(--admin-text-muted)] mb-1">Low Alert Threshold</label>
<input <input
id="headgate-edit-low"
type="number" type="number"
step="any" step="any"
value={editLow} value={editLow}
+7 -3
View File
@@ -133,8 +133,9 @@ export default function WaterLogSettingsPage() {
<p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p> <p className="text-xs text-zinc-500">Leave blank to keep existing PIN. Set a new PIN to replace it.</p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label> <label htmlFor="water-new-pin" className="block text-xs font-medium text-zinc-400 mb-1">New PIN</label>
<input <input
id="water-new-pin"
type="password" type="password"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
@@ -147,8 +148,9 @@ export default function WaterLogSettingsPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label> <label htmlFor="water-confirm-pin" className="block text-xs font-medium text-zinc-400 mb-1">Confirm PIN</label>
<input <input
id="water-confirm-pin"
type="password" type="password"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
@@ -225,13 +227,15 @@ export default function WaterLogSettingsPage() {
{alertsEnabled && ( {alertsEnabled && (
<div> <div>
<label className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label> <label htmlFor="water-alert-phone" className="block text-xs font-medium text-zinc-400 mb-1">Alert Phone Number</label>
<input <input
id="water-alert-phone"
type="tel" type="tel"
value={alertPhone} value={alertPhone}
onChange={(e) => setAlertPhone(e.target.value)} onChange={(e) => setAlertPhone(e.target.value)}
placeholder="+1234567890" placeholder="+1234567890"
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900" className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900"
autoComplete="tel"
/> />
<p className="text-xs text-zinc-500 mt-1">U.S. format recommended. Must include country code (e.g. +1 for USA).</p> <p className="text-xs text-zinc-500 mt-1">U.S. format recommended. Must include country code (e.g. +1 for USA).</p>
</div> </div>
+29 -26
View File
@@ -618,28 +618,28 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [],
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Customer" : "New Customer"}</h3> <h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Customer" : "New Customer"}</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Company Name</label> <label htmlFor="ws-company-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Company Name</label>
<input value={form.companyName} onChange={e => setForm(f => ({ ...f, companyName: e.target.value }))} <input id="ws-company-name" value={form.companyName} onChange={e => setForm(f => ({ ...f, companyName: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Contact Name</label> <label htmlFor="ws-contact-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Contact Name</label>
<input value={form.contactName} onChange={e => setForm(f => ({ ...f, contactName: e.target.value }))} <input id="ws-contact-name" value={form.contactName} onChange={e => setForm(f => ({ ...f, contactName: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Email</label> <label htmlFor="ws-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Email</label>
<input value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} <input id="ws-email" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="email" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Phone</label> <label htmlFor="ws-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Phone</label>
<input value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))} <input id="ws-phone" type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" autoComplete="tel" />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Status</label> <label htmlFor="ws-account-status" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Status</label>
<select value={form.accountStatus} onChange={e => setForm(f => ({ ...f, accountStatus: e.target.value }))} <select id="ws-account-status" value={form.accountStatus} onChange={e => setForm(f => ({ ...f, accountStatus: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="active">Active</option> <option value="active">Active</option>
<option value="on_hold">On Hold</option> <option value="on_hold">On Hold</option>
@@ -648,8 +648,8 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [],
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Credit Limit ($)</label> <label htmlFor="ws-credit-limit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Credit Limit ($)</label>
<input type="number" value={form.creditLimit} onChange={e => setForm(f => ({ ...f, creditLimit: Number(e.target.value) }))} <input id="ws-credit-limit" type="number" value={form.creditLimit} onChange={e => setForm(f => ({ ...f, creditLimit: Number(e.target.value) }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0 = unlimited" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0 = unlimited" />
</div> </div>
</div> </div>
@@ -665,13 +665,13 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [],
{form.depositsEnabled && ( {form.depositsEnabled && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs text-[var(--admin-text-muted)] mb-1">Threshold ($ order total to trigger)</label> <label htmlFor="ws-deposit-threshold" className="block text-xs text-[var(--admin-text-muted)] mb-1">Threshold ($ order total to trigger)</label>
<input type="number" value={form.depositThreshold} onChange={e => setForm(f => ({ ...f, depositThreshold: e.target.value }))} <input id="ws-deposit-threshold" type="number" value={form.depositThreshold} onChange={e => setForm(f => ({ ...f, depositThreshold: e.target.value }))}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0" /> className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="0" />
</div> </div>
<div> <div>
<label className="block text-xs text-[var(--admin-text-muted)] mb-1">Deposit %</label> <label htmlFor="ws-deposit-pct" className="block text-xs text-[var(--admin-text-muted)] mb-1">Deposit %</label>
<input type="number" value={form.depositPercentage} onChange={e => setForm(f => ({ ...f, depositPercentage: e.target.value }))} <input id="ws-deposit-pct" type="number" value={form.depositPercentage} onChange={e => setForm(f => ({ ...f, depositPercentage: e.target.value }))}
className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="20" min="1" max="100" /> className="w-full rounded-lg border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" placeholder="20" min="1" max="100" />
</div> </div>
</div> </div>
@@ -1008,19 +1008,22 @@ function PriceSheetModal({
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="px-6 py-5 space-y-4"> <div className="px-6 py-5 space-y-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">Subject</label> <label htmlFor="ws-price-subject" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">Subject</label>
<input <input
id="ws-price-subject"
value={subject} value={subject}
onChange={e => setSubject(e.target.value)} onChange={e => setSubject(e.target.value)}
required required
aria-required="true"
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5"> <label htmlFor="ws-price-note" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1.5">
Add a Note <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span> Add a Note <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span>
</label> </label>
<textarea <textarea
id="ws-price-note"
value={note} value={note}
onChange={e => setNote(e.target.value)} onChange={e => setNote(e.target.value)}
rows={4} rows={4}
@@ -1167,18 +1170,18 @@ function ProductsTab({ products, brandId, onMsg, onRefresh }: {
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Product" : "New Product"}</h3> <h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Product" : "New Product"}</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Product Name</label> <label htmlFor="ws-prod-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Product Name</label>
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} <input id="ws-prod-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Description</label> <label htmlFor="ws-prod-desc" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Description</label>
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} <textarea id="ws-prod-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} /> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit Type</label> <label htmlFor="ws-prod-unit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit Type</label>
<select value={form.unitType} onChange={e => setForm(f => ({ ...f, unitType: e.target.value }))} <select id="ws-prod-unit" value={form.unitType} onChange={e => setForm(f => ({ ...f, unitType: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]"> className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
{["each", "48-count box", "bag", "pallet", "bin", "load", "custom"].map(u => ( {["each", "48-count box", "bag", "pallet", "bin", "load", "custom"].map(u => (
<option key={u} value={u}>{u}</option> <option key={u} value={u}>{u}</option>
+1 -1
View File
@@ -260,7 +260,7 @@ export default function BlogPage() {
{/* Footer */} {/* Footer */}
<footer className="border-t border-[#e5e5e5] py-8 bg-white"> <footer className="border-t border-[#e5e5e5] py-8 bg-white">
<div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]"> <div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]">
© 2025 Route Commerce. All rights reserved. © {new Date().getFullYear()} Route Commerce. All rights reserved.
</div> </div>
</footer> </footer>
</div> </div>
+1 -1
View File
@@ -212,7 +212,7 @@ export default function BrandsPage() {
<footer className="footer"> <footer className="footer">
<div className="max-w-6xl mx-auto px-6 py-4"> <div className="max-w-6xl mx-auto px-6 py-4">
<div className="flex items-center justify-between text-xs text-[#888]"> <div className="flex items-center justify-between text-xs text-[#888]">
<span>© 2024 Route Commerce</span> <span>© {new Date().getFullYear()} Route Commerce</span>
<div className="flex gap-4"> <div className="flex gap-4">
<a href="/privacy-policy" className="hover:text-[#1a4d2e]">Privacy</a> <a href="/privacy-policy" className="hover:text-[#1a4d2e]">Privacy</a>
<a href="/terms-and-conditions" className="hover:text-[#1a4d2e]">Terms</a> <a href="/terms-and-conditions" className="hover:text-[#1a4d2e]">Terms</a>
+12 -4
View File
@@ -100,11 +100,12 @@ export default function CartClient() {
}, [cart, hasPickupItems, setSelectedStop]); }, [cart, hasPickupItems, setSelectedStop]);
const handleCheckoutClick = useCallback(() => { const handleCheckoutClick = useCallback(() => {
if (cart.length === 0) return;
if (stopBrandMismatch) { setSelectedStop(null); return; } if (stopBrandMismatch) { setSelectedStop(null); return; }
if (hasStopPickupItems && !selectedStop) { setShowStopPicker(true); return; } if (hasStopPickupItems && !selectedStop) { setShowStopPicker(true); return; }
if (incompatibleItems.length > 0) { return; } if (incompatibleItems.length > 0) { return; }
window.location.href = "/checkout"; window.location.href = "/checkout";
}, [stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems]); }, [cart.length, stopBrandMismatch, hasStopPickupItems, selectedStop, incompatibleItems]);
return ( return (
<div className="min-h-screen relative"> <div className="min-h-screen relative">
@@ -384,17 +385,24 @@ export default function CartClient() {
<button <button
onClick={handleCheckoutClick} onClick={handleCheckoutClick}
disabled={hasStopPickupItems && (!selectedStop || incompatibleItems.length > 0)} disabled={
cart.length === 0 ||
(hasStopPickupItems && (!selectedStop || incompatibleItems.length > 0))
}
className="mt-6 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-6 py-3.5 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/20 hover:-translate-y-0.5" className="mt-6 w-full rounded-xl bg-gradient-to-r from-emerald-500 to-emerald-400 hover:from-emerald-400 hover:to-emerald-300 px-6 py-3.5 text-base font-semibold text-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-emerald-500/20 hover:-translate-y-0.5"
aria-label={ aria-label={
incompatibleItems.length > 0 cart.length === 0
? "Your cart is empty. Add a product to continue."
: incompatibleItems.length > 0
? "Remove incompatible items first to proceed to checkout" ? "Remove incompatible items first to proceed to checkout"
: !selectedStop && hasStopPickupItems : !selectedStop && hasStopPickupItems
? "Select pickup stop first to proceed to checkout" ? "Select pickup stop first to proceed to checkout"
: "Continue to checkout" : "Continue to checkout"
} }
> >
{incompatibleItems.length > 0 {cart.length === 0
? "Cart is Empty"
: incompatibleItems.length > 0
? "Remove Incompatible Items First" ? "Remove Incompatible Items First"
: !selectedStop && hasStopPickupItems : !selectedStop && hasStopPickupItems
? "Select Pickup Stop First" ? "Select Pickup Stop First"
+1 -1
View File
@@ -208,7 +208,7 @@ export default function ChangelogPage() {
{/* Footer */} {/* Footer */}
<footer className="border-t border-[#e5e5e5] py-8"> <footer className="border-t border-[#e5e5e5] py-8">
<div className="max-w-4xl mx-auto px-6 text-center text-sm text-[#888]"> <div className="max-w-4xl mx-auto px-6 text-center text-sm text-[#888]">
© 2025 Route Commerce. All rights reserved. © {new Date().getFullYear()} Route Commerce. All rights reserved.
</div> </div>
</footer> </footer>
</div> </div>
+20 -3
View File
@@ -76,7 +76,13 @@ export default function ContactClientPage() {
</svg> </svg>
</div> </div>
<h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Phone</h3> <h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Phone</h3>
<p className="text-[#666] leading-relaxed">support@routecommerce.com</p> <a
href="tel:+19703235631"
className="block text-[#1a4d2e] hover:text-[#2d6a4f] font-semibold leading-relaxed"
>
(970) 323-5631
</a>
<p className="text-sm text-[#888] mt-1">Mon&ndash;Fri, 8 AM &ndash; 6 PM MT</p>
</article> </article>
<article className="rounded-3xl bg-white border border-[#e5e5e5] p-8 text-center shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300"> <article className="rounded-3xl bg-white border border-[#e5e5e5] p-8 text-center shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-300">
@@ -86,7 +92,18 @@ export default function ContactClientPage() {
</svg> </svg>
</div> </div>
<h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Email</h3> <h3 className="text-lg font-bold text-[#0a0a0a] mb-3">Email</h3>
<p className="text-[#666] leading-relaxed">hello@routecommerce.com</p> <a
href="mailto:hello@routecommerce.com"
className="block text-[#1a4d2e] hover:text-[#2d6a4f] font-semibold leading-relaxed"
>
hello@routecommerce.com
</a>
<a
href="mailto:support@routecommerce.com"
className="block text-sm text-[#666] hover:text-[#1a4d2e] mt-1"
>
support@routecommerce.com
</a>
</article> </article>
</div> </div>
@@ -265,7 +282,7 @@ export default function ContactClientPage() {
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
</div> </div>
<span className="text-sm text-[#666]">2024 Route Commerce. All rights reserved.</span> <span className="text-sm text-[#666]">&copy; {new Date().getFullYear()} Route Commerce. All rights reserved.</span>
</div> </div>
<nav className="flex items-center gap-6 text-sm text-[#888]" aria-label="Footer navigation"> <nav className="flex items-center gap-6 text-sm text-[#888]" aria-label="Footer navigation">
<Link href="/privacy-policy" className="hover:text-[#1a4d2e] transition-colors">Privacy</Link> <Link href="/privacy-policy" className="hover:text-[#1a4d2e] transition-colors">Privacy</Link>
+2 -2
View File
@@ -325,7 +325,7 @@ function LoginForm() {
</svg> </svg>
</div> </div>
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}> <span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
© 2025 Route Commerce © {new Date().getFullYear()} Route Commerce
</span> </span>
</div> </div>
<nav className="flex items-center gap-6"> <nav className="flex items-center gap-6">
@@ -436,7 +436,7 @@ function DemoMode() {
</svg> </svg>
</div> </div>
<span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}> <span className="text-xs" style={{ fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", color: "#b5b0a8" }}>
© 2025 Route Commerce © {new Date().getFullYear()} Route Commerce
</span> </span>
</div> </div>
</div> </div>
+1 -1
View File
@@ -126,7 +126,7 @@ export default function PrivacyPolicyPage() {
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
</div> </div>
<span className="text-sm text-[#666]">2024 Route Commerce. All rights reserved.</span> <span className="text-sm text-[#666]">&copy; {new Date().getFullYear()} Route Commerce. All rights reserved.</span>
</div> </div>
<div className="flex items-center gap-6 text-sm text-[#888]"> <div className="flex items-center gap-6 text-sm text-[#888]">
<Link href="/privacy-policy" className="hover:text-[#1a4d2e] transition-colors">Privacy</Link> <Link href="/privacy-policy" className="hover:text-[#1a4d2e] transition-colors">Privacy</Link>
+1 -1
View File
@@ -246,7 +246,7 @@ export default function RoadmapPage() {
{/* Footer */} {/* Footer */}
<footer className="border-t border-[#e5e5e5] py-8"> <footer className="border-t border-[#e5e5e5] py-8">
<div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]"> <div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]">
© 2025 Route Commerce. All rights reserved. © {new Date().getFullYear()} Route Commerce. All rights reserved.
</div> </div>
</footer> </footer>
</div> </div>
+1 -1
View File
@@ -257,7 +257,7 @@ export default function SecurityPage() {
{/* Footer */} {/* Footer */}
<footer className="border-t border-[#e5e5e5] py-8 bg-white"> <footer className="border-t border-[#e5e5e5] py-8 bg-white">
<div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]"> <div className="max-w-6xl mx-auto px-6 text-center text-sm text-[#888]">
© 2025 Route Commerce. All rights reserved. © {new Date().getFullYear()} Route Commerce. All rights reserved.
</div> </div>
</footer> </footer>
</div> </div>
+1 -1
View File
@@ -128,7 +128,7 @@ export default function TermsPage() {
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
</div> </div>
<span className="text-sm text-[#666]">2024 Route Commerce. All rights reserved.</span> <span className="text-sm text-[#666]">&copy; {new Date().getFullYear()} Route Commerce. All rights reserved.</span>
</div> </div>
<div className="flex items-center gap-6 text-sm text-[#888]"> <div className="flex items-center gap-6 text-sm text-[#888]">
<Link href="/privacy-policy" className="hover:text-[#1a4d2e] transition-colors">Privacy</Link> <Link href="/privacy-policy" className="hover:text-[#1a4d2e] transition-colors">Privacy</Link>
+33 -19
View File
@@ -2,8 +2,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { motion, useInView, AnimatePresence } from "framer-motion"; import { motion, useInView } from "framer-motion";
import { useCart } from "@/context/CartContext";
import TuxedoVideoHero from "@/components/storefront/TuxedoVideoHero"; import TuxedoVideoHero from "@/components/storefront/TuxedoVideoHero";
import CinematicShowcase from "@/components/storefront/CinematicShowcase"; import CinematicShowcase from "@/components/storefront/CinematicShowcase";
import LayoutContainer from "@/components/layout/LayoutContainer"; import LayoutContainer from "@/components/layout/LayoutContainer";
@@ -618,8 +617,6 @@ export default function TuxedoPage() {
return () => ctx.revert(); return () => ctx.revert();
}, []); }, []);
const featuredProducts = products.slice(0, 3);
return ( return (
<div ref={pageScopeRef} className="min-h-screen bg-stone-50"> <div ref={pageScopeRef} className="min-h-screen bg-stone-50">
<BrandStylesProvider <BrandStylesProvider
@@ -665,19 +662,6 @@ export default function TuxedoPage() {
onSecondaryClick={scrollToStory} onSecondaryClick={scrollToStory}
/> />
{/* ─── CINEMATIC PRODUCT SHOWCASE ────────────────────────────────── */}
<CinematicShowcase
products={featuredProducts.map((p) => ({
id: p.id,
name: p.name,
description: p.description ?? "",
price: `$${p.price}`,
type: p.type,
imageUrl: p.image_url,
}))}
brandSlug="tuxedo"
/>
<WhyTuxedoCorn /> <WhyTuxedoCorn />
<section id="stops" className="relative bg-gradient-to-b from-stone-100 to-stone-50 scroll-mt-20"> <section id="stops" className="relative bg-gradient-to-b from-stone-100 to-stone-50 scroll-mt-20">
@@ -724,8 +708,33 @@ export default function TuxedoPage() {
</div> </div>
<FadeOnScroll from="up" delay={0.3}> <FadeOnScroll from="up" delay={0.3}>
{stops.length === 0 ? ( {stops.length === 0 ? (
<div className="rounded-3xl bg-white p-14 text-center ring-1 ring-stone-200/60"> <div className="rounded-3xl bg-white p-12 sm:p-16 text-center ring-1 ring-stone-200/60">
<p className="text-stone-500">No upcoming stops scheduled. Check back soon!</p> <div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-stone-100">
<svg className="h-7 w-7 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<p className="text-lg font-semibold text-stone-800">No stops on the calendar just yet</p>
<p className="mx-auto mt-2 max-w-md text-stone-500">
The harvest is right around the corner new pickup stops go live weekly. You can still preorder below and pick a stop once they're announced.
</p>
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/tuxedo/stops"
className="inline-flex items-center gap-2 rounded-2xl bg-stone-950 px-5 py-2.5 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-900 transition-colors"
>
View All Stops
</Link>
{showSchedulePdf && (
<a
href="/api/tuxedo/schedule-pdf"
download
className="inline-flex items-center gap-2 rounded-2xl bg-white px-5 py-2.5 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
>
Download Schedule
</a>
)}
</div>
</div> </div>
) : ( ) : (
<PaginatedStops stops={stops} brandSlug="tuxedo" brandAccent="green" /> <PaginatedStops stops={stops} brandSlug="tuxedo" brandAccent="green" />
@@ -774,8 +783,13 @@ export default function TuxedoPage() {
price: `$${p.price}`, price: `$${p.price}`,
type: p.type, type: p.type,
imageUrl: p.image_url, imageUrl: p.image_url,
brand_id: p.brand_id,
brand_slug: "tuxedo",
is_taxable: p.is_taxable,
pickup_type: p.pickup_type,
}))} }))}
brandSlug="tuxedo" brandSlug="tuxedo"
brandName={brand?.name ?? "Tuxedo Corn"}
/> />
{/* Mobile-friendly fallback grid for smaller screens */} {/* Mobile-friendly fallback grid for smaller screens */}
+20 -3
View File
@@ -55,14 +55,31 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props)
</div> </div>
{upcomingStops.length === 0 ? ( {upcomingStops.length === 0 ? (
<div className="max-w-4xl mx-auto text-center py-16"> <div className="max-w-2xl mx-auto text-center py-16">
<div className="w-20 h-20 rounded-2xl bg-stone-100 flex items-center justify-center mx-auto mb-6"> <div className="w-20 h-20 rounded-2xl bg-stone-100 flex items-center justify-center mx-auto mb-6">
<svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="w-10 h-10 text-stone-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg> </svg>
</div> </div>
<h2 className="text-2xl font-bold text-stone-800 mb-2">No Upcoming Stops</h2> <h2 className="text-2xl font-bold text-stone-800 mb-2">No Upcoming Stops Yet</h2>
<p className="text-stone-500">Check back soon for new pickup locations!</p> <p className="text-stone-500 mb-8 max-w-md mx-auto">
The corn is still ripening in the field. New pickup stops are added every week check back soon, or browse the full season schedule.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/tuxedo"
className="inline-flex items-center gap-2 rounded-2xl bg-stone-950 px-6 py-3 text-sm font-semibold text-white hover:bg-stone-800 active:bg-stone-900 transition-colors"
>
Back to Tuxedo Corn
</Link>
<a
href="/api/tuxedo/schedule-pdf"
download
className="inline-flex items-center gap-2 rounded-2xl bg-white px-6 py-3 text-sm font-semibold text-stone-700 ring-1 ring-stone-200 hover:bg-stone-50 transition-colors"
>
Download Full Schedule
</a>
</div>
</div> </div>
) : ( ) : (
<div className="max-w-4xl mx-auto space-y-3"> <div className="max-w-4xl mx-auto space-y-3">
+1 -1
View File
@@ -118,7 +118,7 @@ export default function WaitlistPage() {
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
</div> </div>
<span className="text-sm text-[#666]">© 2025 Route Commerce. All rights reserved.</span> <span className="text-sm text-[#666]">&copy; {new Date().getFullYear()} Route Commerce. All rights reserved.</span>
</div> </div>
<nav className="flex items-center gap-6 text-sm text-[#888]"> <nav className="flex items-center gap-6 text-sm text-[#888]">
<a href="/privacy-policy" className="hover:text-[#1a4d2e]">Privacy</a> <a href="/privacy-policy" className="hover:text-[#1a4d2e]">Privacy</a>
+13 -2
View File
@@ -15,6 +15,17 @@ export function Providers({ children }: { children: React.ReactNode }) {
const isBrandRoute = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct"); const isBrandRoute = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct");
const isLandingPage = pathname === "/" || pathname === "/brands"; const isLandingPage = pathname === "/" || pathname === "/brands";
const isAuthPage = pathname === "/login" || pathname === "/admin/login"; const isAuthPage = pathname === "/login" || pathname === "/admin/login";
// Admin routes have their own AdminSidebar + design-system shell; suppress
// the public SiteHeader/SiteFooter to avoid a duplicate header on every
// admin page.
const isAdminRoute = pathname?.startsWith("/admin");
// Cart + checkout + wholesale pages render their own StorefrontHeader/Footer
// (or wholesale-specific layout) — skip the public chrome to avoid doubles.
const isStandalonePage =
pathname?.startsWith("/cart") ||
pathname?.startsWith("/checkout") ||
pathname?.startsWith("/wholesale") ||
pathname?.startsWith("/water");
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
@@ -24,9 +35,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false} disableTransitionOnChange> <ThemeProvider attribute="class" defaultTheme="light" enableSystem={false} disableTransitionOnChange>
<CartProvider> <CartProvider>
{!isBrandRoute && !isLandingPage && !isAuthPage && <SiteHeader />} {!isBrandRoute && !isLandingPage && !isAuthPage && !isAdminRoute && !isStandalonePage && <SiteHeader />}
{mounted ? children : <div style={{ visibility: 'hidden' }}>{children}</div>} {mounted ? children : <div style={{ visibility: 'hidden' }}>{children}</div>}
{!isBrandRoute && !isLandingPage && !isAuthPage && <SiteFooter />} {!isBrandRoute && !isLandingPage && !isAuthPage && !isAdminRoute && !isStandalonePage && <SiteFooter />}
<CartToast /> <CartToast />
<CartRestoredToast /> <CartRestoredToast />
</CartProvider> </CartProvider>
+3 -1
View File
@@ -98,6 +98,7 @@ export default function CommunicationsPage({
initialSegments = [], initialSegments = [],
initialAnalytics = [], initialAnalytics = [],
editCampaignId, editCampaignId,
initialTab,
}: { }: {
campaigns: Campaign[]; campaigns: Campaign[];
templates: Template[]; templates: Template[];
@@ -110,8 +111,9 @@ export default function CommunicationsPage({
initialSegments?: Segment[]; initialSegments?: Segment[];
initialAnalytics?: CampaignAnalytics[]; initialAnalytics?: CampaignAnalytics[];
editCampaignId?: string; editCampaignId?: string;
initialTab?: Tab;
}) { }) {
const [currentTab, setCurrentTab] = useState<Tab>("campaigns"); const [currentTab, setCurrentTab] = useState<Tab>(initialTab ?? "campaigns");
const [isComposing, setIsComposing] = useState(false); const [isComposing, setIsComposing] = useState(false);
return ( return (
+30 -6
View File
@@ -161,11 +161,17 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{/* Email */} {/* Email */}
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Email</label> <label htmlFor="create-user-email" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Email
</label>
<input <input
id="create-user-email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required
aria-required="true"
autoComplete="email"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]" className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="user@example.com" placeholder="user@example.com"
/> />
@@ -173,12 +179,19 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{/* Password */} {/* Password */}
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Password</label> <label htmlFor="create-user-password" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
<p className="text-xs text-[var(--admin-text-muted)] mb-1.5">Minimum 6 characters.</p> Password
</label>
<p id="create-user-password-help" className="text-xs text-[var(--admin-text-muted)] mb-1.5">Minimum 6 characters.</p>
<input <input
id="create-user-password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required
aria-required="true"
aria-describedby="create-user-password-help"
autoComplete="new-password"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]" className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="Choose a password" placeholder="Choose a password"
minLength={6} minLength={6}
@@ -188,23 +201,31 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{/* Display Name & Phone - 2 columns on larger screens */} {/* Display Name & Phone - 2 columns on larger screens */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Display Name</label> <label htmlFor="create-user-display-name" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Display Name
</label>
<input <input
id="create-user-display-name"
type="text" type="text"
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
autoComplete="name"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]" className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="Kyle Martinez" placeholder="Kyle Martinez"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Phone Number</label> <label htmlFor="create-user-phone" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Phone Number
</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-1.5">Optional.</p> <p className="text-xs text-[var(--admin-text-muted)] mb-1.5">Optional.</p>
<input <input
id="create-user-phone"
type="tel" type="tel"
value={phoneNumber} value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)} onChange={(e) => setPhoneNumber(e.target.value)}
autoComplete="tel"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]" className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20 placeholder:text-[var(--admin-text-muted)]"
placeholder="+1 (555) 000-0000" placeholder="+1 (555) 000-0000"
/> />
@@ -237,10 +258,13 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{/* Brand */} {/* Brand */}
{showBrandSelect && ( {showBrandSelect && (
<div> <div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Brand</label> <label htmlFor="create-user-brand" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Brand</label>
<select <select
id="create-user-brand"
value={brandId ?? ""} value={brandId ?? ""}
onChange={(e) => setBrandId(e.target.value || null)} onChange={(e) => setBrandId(e.target.value || null)}
required
aria-required="true"
className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20" className="w-full rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card-bg)] px-3 py-2.5 text-sm text-[var(--admin-text-primary)] outline-none focus:border-[var(--admin-accent)] focus:ring-2 focus:ring-[var(--admin-accent)]/20"
> >
<option value="">Select a brand</option> <option value="">Select a brand</option>
+11 -2
View File
@@ -360,7 +360,12 @@ export default function DashboardClient({
</div> </div>
</div> </div>
{/* Active Products */} {/* Active Products uses plan-aware usage.products so this card
always matches the "Products 0/25" usage bar below and the
billing page's invoice/usage row. Previously this came from a
separate query (`active=eq.true` only) and could disagree with
the plan limit usage, producing e.g. "1 vs 0/25" in the same
dashboard. */}
<div <div
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5" className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
style={{ style={{
@@ -379,7 +384,11 @@ export default function DashboardClient({
Active Products Active Products
</p> </p>
<p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}> <p className="text-2xl font-bold mt-0.5" style={{ color: "var(--admin-text-primary)" }}>
{isLoadingStats ? "—" : stats?.activeProducts ?? 0} {/* usage.products comes from the server-rendered prop (via
getBillingOverview), so it's available immediately and
is guaranteed to match the "Products X/25" usage bar
and the billing page. */}
{usage.products}
</p> </p>
</div> </div>
</div> </div>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect } from "react";
import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns"; import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns";
import { type Template } from "@/actions/communications/templates"; import { type Template } from "@/actions/communications/templates";
import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments"; import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments";
@@ -323,10 +323,54 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
// Audience preview (visible count + sample contacts)
const [previewCount, setPreviewCount] = useState<number | null>(null);
const [previewSamples, setPreviewSamples] = useState<string[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId); const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
const selectedSegment = segments.find((s) => s.id === selectedSegmentId); const selectedSegment = segments.find((s) => s.id === selectedSegmentId);
// Load audience preview when entering the Audience step or changing the segment
useEffect(() => {
if (step !== 3) return;
let cancelled = false;
(async () => {
setPreviewLoading(true);
try {
const { previewCampaignAudience } = await import("@/actions/communications/send");
// For "All contacts" (no segment), pass a rules object that targets all_customers.
// For a chosen segment, use the segment's rules. The action's rules type
// accepts a permissive object, so we cast through unknown.
const rules = (selectedSegment?.rules ?? { target: "all_customers" }) as unknown as Parameters<typeof previewCampaignAudience>[1];
const result = await previewCampaignAudience(brandId, rules);
if (cancelled) return;
if (result) {
setPreviewCount(result.count ?? 0);
setPreviewSamples(
(result.sample_customers ?? [])
.map((c) => c.email)
.filter((e): e is string => Boolean(e))
.slice(0, 5),
);
} else {
setPreviewCount(0);
setPreviewSamples([]);
}
} catch (err) {
if (!cancelled) {
setPreviewCount(0);
setPreviewSamples([]);
}
} finally {
if (!cancelled) setPreviewLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [step, selectedSegment, brandId]);
const handleTemplateSelect = useCallback((template: Template) => { const handleTemplateSelect = useCallback((template: Template) => {
setSelectedTemplateId(template.id); setSelectedTemplateId(template.id);
setSubject(template.subject ?? ""); setSubject(template.subject ?? "");
@@ -550,6 +594,44 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
)} )}
</div> </div>
{/* Audience Preview — always-visible count + sample */}
<div
className="rounded-xl border border-emerald-200 bg-emerald-50 p-4"
data-testid="audience-preview"
aria-live="polite"
aria-busy={previewLoading}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-emerald-700">
Audience Preview
</p>
{previewLoading ? (
<p className="text-sm text-emerald-700 mt-1">Counting recipients</p>
) : previewCount === null ? (
<p className="text-sm text-emerald-700 mt-1">Calculating</p>
) : (
<p className="text-sm text-emerald-900 mt-1">
<span className="text-2xl font-bold text-emerald-700">
{previewCount.toLocaleString()}
</span>{" "}
<span className="text-stone-700">
{previewCount === 1 ? "contact" : "contacts"} will receive this campaign
</span>
</p>
)}
</div>
</div>
{!previewLoading && previewSamples.length > 0 && (
<div className="mt-3 pt-3 border-t border-emerald-200">
<p className="text-xs font-semibold text-emerald-700 mb-1">Sample recipients:</p>
<p className="text-xs text-emerald-800 break-words">
{previewSamples.join(", ")}
</p>
</div>
)}
</div>
{/* Content preview */} {/* Content preview */}
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200"> <div className="p-4 bg-stone-50 rounded-xl border border-stone-200">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-2">Content Summary</p> <p className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-2">Content Summary</p>
+23 -2
View File
@@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
const BRAND_NAMES: Record<string, string> = { const BRAND_NAMES: Record<string, string> = {
tuxedo: "Tuxedo Corn", tuxedo: "Tuxedo Corn",
@@ -10,6 +11,26 @@ const BRAND_NAMES: Record<string, string> = {
export default function SiteHeader() { export default function SiteHeader() {
const pathname = usePathname(); const pathname = usePathname();
const [isAdmin, setIsAdmin] = useState(false);
// Only show the Admin link to users who are actually authenticated as admins.
// This prevents leaking the Admin entry point on public marketing pages
// (e.g. /, /pricing, /contact, /security, /roadmap, /changelog, /blog, etc.).
useEffect(() => {
let cancelled = false;
(async () => {
try {
const mod = await import("@/actions/admin-user");
const user = await mod.getCurrentAdminUser();
if (!cancelled) setIsAdmin(!!user);
} catch {
if (!cancelled) setIsAdmin(false);
}
})();
return () => {
cancelled = true;
};
}, [pathname]);
const showBrandName = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct"); const showBrandName = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct");
const brandKey = pathname?.split("/")[1]; const brandKey = pathname?.split("/")[1];
@@ -83,8 +104,8 @@ export default function SiteHeader() {
</> </>
)} )}
{/* Admin link */} {/* Admin link — only rendered for users who are signed in as admins. */}
{!isAdminRoute && ( {!isAdminRoute && isAdmin && (
<Link <Link
href="/admin" href="/admin"
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70" className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { gsap } from "gsap"; import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger"; import { ScrollTrigger } from "gsap/ScrollTrigger";
import Image from "next/image"; import Image from "next/image";
import { useCart } from "@/context/CartContext";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger); gsap.registerPlugin(ScrollTrigger);
@@ -16,11 +17,16 @@ interface Product {
price: string; price: string;
type: string; type: string;
imageUrl: string | null; imageUrl: string | null;
brand_id?: string;
brand_slug?: string;
is_taxable?: boolean;
pickup_type?: "scheduled_stop" | "shed";
} }
interface CinematicShowcaseProps { interface CinematicShowcaseProps {
products: Product[]; products: Product[];
brandSlug?: string; brandSlug?: string;
brandName?: string;
} }
// Single product card with scroll-driven morphing // Single product card with scroll-driven morphing
@@ -117,10 +123,41 @@ function MorphingProductCard({
export default function CinematicShowcase({ export default function CinematicShowcase({
products, products,
brandSlug = "tuxedo", brandSlug = "tuxedo",
brandName = "Tuxedo Corn",
}: CinematicShowcaseProps) { }: CinematicShowcaseProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const [scrollProgress, setScrollProgress] = useState(0); const [scrollProgress, setScrollProgress] = useState(0);
const [addedId, setAddedId] = useState<string | null>(null);
const { addToCart } = useCart();
const activeProduct = products[activeIndex];
const isPickupOnly = activeProduct?.type === "Pickup";
const isShippingOnly = activeProduct?.type === "Shipping";
const isBoth = activeProduct?.type === "Pickup & Shipping";
const canAdd = !!activeProduct && !!activeProduct.brand_id;
function handleAddActive() {
if (!activeProduct || !canAdd) return;
const baseItem = {
id: activeProduct.id,
name: activeProduct.name,
price: activeProduct.price,
brand_id: activeProduct.brand_id ?? "",
brand_slug: activeProduct.brand_slug ?? brandSlug,
is_taxable: activeProduct.is_taxable ?? true,
pickup_type: activeProduct.pickup_type ?? "scheduled_stop",
description: activeProduct.description ?? "",
};
if (isPickupOnly) addToCart(baseItem, "pickup");
else if (isShippingOnly) addToCart(baseItem, "ship");
else addToCart(baseItem, "pickup");
setAddedId(activeProduct.id);
window.setTimeout(() => {
setAddedId((cur) => (cur === activeProduct?.id ? null : cur));
}, 1800);
}
useEffect(() => { useEffect(() => {
if (typeof window === "undefined" || !containerRef.current) return; if (typeof window === "undefined" || !containerRef.current) return;
@@ -282,6 +319,47 @@ export default function CinematicShowcase({
<p className="text-xs text-white/30 max-w-md"> <p className="text-xs text-white/30 max-w-md">
{products[activeIndex]?.description} {products[activeIndex]?.description}
</p> </p>
{activeProduct && (
<div className="mt-5 flex items-center gap-3">
<button
type="button"
onClick={handleAddActive}
disabled={!canAdd}
aria-label={`Add ${activeProduct.name} to cart`}
className={`inline-flex items-center gap-2 rounded-2xl px-6 py-3 text-sm font-bold tracking-wider transition-all duration-200 ${
!canAdd
? "bg-white/10 text-white/40 cursor-not-allowed"
: addedId === activeProduct.id
? "bg-emerald-500 text-white"
: "bg-white text-stone-950 hover:bg-stone-100 active:bg-stone-200"
}`}
>
{addedId === activeProduct.id ? (
<>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Added
</>
) : isBoth ? (
"Add to Cart"
) : isShippingOnly ? (
"Add — Ships"
) : (
"Add to Cart"
)}
</button>
<span className="text-xs text-white/40 hidden sm:inline">
{isBoth
? "Preorder for pickup or shipping"
: isShippingOnly
? "Shipped after season"
: isPickupOnly
? "Pickup at a scheduled stop"
: ""}
</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,82 @@
-- 203_plan_usage_active_products.sql
-- Reconcile plan usage product count with the dashboard "Active Products" stat.
--
-- Context:
-- Migration 091's get_brand_plan_info counted `products` using
-- deleted_at IS NULL
-- while the dashboard's getDashboardStats server action counted
-- active = true
-- These two filters can disagree (a product can be `active=false` but
-- not soft-deleted, or `active=true` and soft-deleted in some flows),
-- producing "Active Products 1" in the dashboard stats while the
-- billing/usage bar said "Products 0/25".
--
-- This migration updates get_brand_plan_info to count products that are
-- active AND not soft-deleted — the natural meaning of "products you sell
-- that count against the plan limit". The same filter is also applied to
-- the stop count (active AND not soft-deleted AND in the current month)
-- to keep the usage semantics consistent across all three counters.
--
-- Uses CREATE OR REPLACE FUNCTION so re-running is safe.
CREATE OR REPLACE FUNCTION public.get_brand_plan_info(p_brand_id UUID)
RETURNS JSONB AS $$
DECLARE
v_result JSONB;
v_user_count BIGINT;
v_active_stops BIGINT;
v_product_count BIGINT;
v_brand RECORD;
BEGIN
SELECT plan_tier, max_users, max_stops_monthly, max_products, stripe_customer_id, name
INTO v_brand
FROM brands WHERE id = p_brand_id;
IF v_brand IS NULL THEN
RETURN NULL;
END IF;
-- Count current usage with consistent semantics:
-- users: not soft-deleted
-- stops: active AND not soft-deleted AND in the current month
-- products: active AND not soft-deleted (matches "Active Products"
-- stat used in the admin dashboard, so plan usage and
-- dashboard stats never disagree)
SELECT COUNT(*) INTO v_user_count
FROM admin_users
WHERE brand_id = p_brand_id
AND deleted_at IS NULL;
SELECT COUNT(*) INTO v_active_stops
FROM stops
WHERE brand_id = p_brand_id
AND active = true
AND deleted_at IS NULL
AND date >= date_trunc('month', now());
SELECT COUNT(*) INTO v_product_count
FROM products
WHERE brand_id = p_brand_id
AND active = true
AND deleted_at IS NULL;
SELECT jsonb_build_object(
'plan_tier', v_brand.plan_tier,
'max_users', v_brand.max_users,
'max_stops_monthly', v_brand.max_stops_monthly,
'max_products', v_brand.max_products,
'stripe_customer_id', v_brand.stripe_customer_id,
'brand_name', v_brand.name,
'usage', jsonb_build_object(
'users', v_user_count,
'stops_this_month', v_active_stops,
'products', v_product_count
)
) INTO v_result;
RETURN v_result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Reload PostgREST schema cache so the change is picked up immediately.
NOTIFY pgrst, 'reload schema';