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:
@@ -11,6 +11,12 @@ export const metadata: Metadata = {
|
||||
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() {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser || !adminUser.can_manage_messages) {
|
||||
@@ -31,7 +37,7 @@ export default async function ComposePage() {
|
||||
templates={templatesResult.success ? templatesResult.templates : []}
|
||||
brandId={effectiveBrandId}
|
||||
initialSegments={segmentsResult.success ? segmentsResult.segments : []}
|
||||
editMode="new"
|
||||
initialTab="compose"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,13 +153,14 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
{editing && (
|
||||
<form onSubmit={handleSaveProfile} className="mt-6 space-y-4">
|
||||
<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
|
||||
id="me-display-name"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
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)",
|
||||
color: "var(--admin-text-primary)",
|
||||
backgroundColor: "white"
|
||||
@@ -168,18 +169,20 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
/>
|
||||
</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
|
||||
id="me-phone"
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
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)",
|
||||
color: "var(--admin-text-primary)",
|
||||
backgroundColor: "white"
|
||||
}}
|
||||
placeholder="+1 (555) 000-0000"
|
||||
autoComplete="tel"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
@@ -228,18 +231,22 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
) : (
|
||||
<form onSubmit={handleEmailChange} className="mt-4 space-y-3">
|
||||
<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
|
||||
id="me-new-email"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
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"
|
||||
style={{
|
||||
style={{
|
||||
borderColor: "var(--admin-border)",
|
||||
color: "var(--admin-text-primary)",
|
||||
backgroundColor: "white"
|
||||
}}
|
||||
placeholder="new@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
{emailError && (
|
||||
|
||||
+45
-15
@@ -1,7 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
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";
|
||||
|
||||
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 brandDisplayName = "Admin";
|
||||
|
||||
if (adminUser?.brand_id) {
|
||||
for (const key of ["harvest_reach", "wholesale_portal", "water_log", "ai_tools", "sms_campaigns", "square_sync", "route_trace"] as const) {
|
||||
enabledAddons[key] = await isFeatureEnabled(adminUser.brand_id, key);
|
||||
// For platform_admin in dev mode, adminUser.brand_id is null. To keep
|
||||
// the dashboard's "Active Products" stat in sync with the billing page,
|
||||
// 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;
|
||||
}
|
||||
const planResult = await getBrandPlanInfo(adminUser.brand_id);
|
||||
if (planResult.success && planResult.data) {
|
||||
planTier = planResult.data.plan_tier ?? "starter";
|
||||
usage = planResult.data.usage ?? usage;
|
||||
limits = {
|
||||
max_users: planResult.data.max_users ?? 1,
|
||||
max_stops_monthly: planResult.data.max_stops_monthly ?? 10,
|
||||
max_products: planResult.data.max_products ?? 25,
|
||||
};
|
||||
if (planResult.data.brand_name) brandDisplayName = planResult.data.brand_name;
|
||||
}
|
||||
|
||||
if (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,4 +127,4 @@ export default async function AdminPage() {
|
||||
limits={limits}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +106,9 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
|
||||
{/* Brand ID */}
|
||||
<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
|
||||
id="import-products-brand"
|
||||
type="text"
|
||||
value={brandId}
|
||||
onChange={(e) => setBrandId(e.target.value)}
|
||||
@@ -118,8 +119,9 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE,
|
||||
|
||||
{/* File upload */}
|
||||
<div className="mb-4 rounded-2xl bg-white p-6 shadow-xl shadow-stone-200/50">
|
||||
<label 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
|
||||
id="import-products-csv"
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
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">
|
||||
Or paste CSV content below:
|
||||
</p>
|
||||
<label htmlFor="import-products-text" className="sr-only">CSV text</label>
|
||||
<textarea
|
||||
id="import-products-text"
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
rows={6}
|
||||
|
||||
@@ -90,8 +90,9 @@ John Doe,john@example.com,555-5678,{STOP_ID},{PRODUCT_ID},1,shipping
|
||||
</div>
|
||||
|
||||
<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
|
||||
id="import-orders-brand"
|
||||
type="text"
|
||||
value={brandId}
|
||||
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 className="mb-4 card p-6">
|
||||
<label className="block text-sm font-medium text-stone-600 mb-2">Upload CSV</label>
|
||||
<input ref={fileRef} type="file" accept=".csv" onChange={handleFile} className="w-full text-sm text-stone-500" />
|
||||
<label htmlFor="import-orders-csv" className="block text-sm font-medium text-stone-600 mb-2">Upload CSV</label>
|
||||
<input id="import-orders-csv" ref={fileRef} type="file" accept=".csv" onChange={handleFile} className="w-full text-sm text-stone-500" />
|
||||
<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
|
||||
id="import-orders-text"
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
rows={6}
|
||||
|
||||
@@ -514,8 +514,9 @@ function CampaignWriterTool({ brandId, brandName }: { brandId: string; brandName
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
id="ai-cw-topic"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
rows={3}
|
||||
@@ -589,20 +590,22 @@ function ProductWriterTool({ brandId }: { brandId: string }) {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Product Name *</label>
|
||||
<input type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" className={inputBaseClass} style={inputStyle} />
|
||||
<label htmlFor="ai-pw-product-name" className="block text-sm font-medium mb-1" style={labelStyle}>
|
||||
Product Name <span className="text-red-400 ml-0.5" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="ai-pw-product-name" type="text" value={productName} onChange={(e) => setProductName(e.target.value)} placeholder="Sweet Corn" required aria-required="true" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label 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} />
|
||||
<label htmlFor="ai-pw-category" className="block text-sm font-medium mb-1" style={labelStyle}>Category</label>
|
||||
<input id="ai-pw-category" type="text" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Vegetables" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label 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} />
|
||||
<label htmlFor="ai-pw-price" className="block text-sm font-medium mb-1" style={labelStyle}>Price</label>
|
||||
<input id="ai-pw-price" type="text" value={price} onChange={(e) => setPrice(e.target.value)} placeholder="$4.50" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label 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} />
|
||||
<label htmlFor="ai-pw-unit" className="block text-sm font-medium mb-1" style={labelStyle}>Unit</label>
|
||||
<input id="ai-pw-unit" type="text" value={unit} onChange={(e) => setUnit(e.target.value)} placeholder="per dozen" className={inputBaseClass} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -694,8 +697,9 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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
|
||||
id="ai-rep-type"
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value)}
|
||||
className={inputBaseClass}
|
||||
@@ -707,8 +711,9 @@ function ReportExplainerTool({ brandId }: { brandId: string }) {
|
||||
</select>
|
||||
</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
|
||||
id="ai-rep-range"
|
||||
type="text"
|
||||
value={dateRange}
|
||||
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.
|
||||
</p>
|
||||
<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
|
||||
id="ai-pa-product-name"
|
||||
type="text"
|
||||
value={productName}
|
||||
onChange={(e) => setProductName(e.target.value)}
|
||||
placeholder="Sweet Corn"
|
||||
required
|
||||
aria-required="true"
|
||||
className={inputBaseClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
@@ -1,88 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import PlanUpgradeButton from "./PlanUpgradeButton";
|
||||
import AddPaymentMethodButton from "./AddPaymentMethodButton";
|
||||
import AddAddonButton from "./AddAddonButton";
|
||||
import RemoveAddonButton from "./RemoveAddonButton";
|
||||
import BillingCycleToggle from "./BillingCycleToggle";
|
||||
import StripePortalButton from "./StripePortalButton";
|
||||
import { PLAN_TIERS, ADDONS } from "@/lib/pricing";
|
||||
import { AdminButton } from "@/components/admin/design-system";
|
||||
import { PLAN_TIERS } from "@/lib/pricing";
|
||||
import type { BillingOverview, BillingSubscriptionStatus } from "@/actions/billing/billing-overview";
|
||||
|
||||
type BillingCycle = "monthly" | "annual";
|
||||
|
||||
type AddonData = { key: string; label: string; icon: string; monthlyPrice: number; annualPrice: number };
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
planTier: string;
|
||||
brandName: string | null;
|
||||
hasStripeCustomer: boolean;
|
||||
enabledAddons: Record<string, boolean>;
|
||||
isPlatformAdmin: boolean;
|
||||
subscriptionStatus: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
overview: BillingOverview;
|
||||
};
|
||||
|
||||
export default function BillingClientPage({
|
||||
brandId,
|
||||
planTier,
|
||||
brandName,
|
||||
hasStripeCustomer,
|
||||
enabledAddons,
|
||||
isPlatformAdmin,
|
||||
subscriptionStatus,
|
||||
currentPeriodEnd,
|
||||
}: Props) {
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>("annual");
|
||||
/**
|
||||
* Status badge class for the subscription state pill.
|
||||
* Returns null for "none" so we can hide it entirely instead of showing
|
||||
* a generic "Inactive" pill that contradicts the "Active" add-on badges.
|
||||
*/
|
||||
function subscriptionStatusBadgeClass(status: BillingSubscriptionStatus | null): {
|
||||
label: string;
|
||||
cls: string;
|
||||
show: boolean;
|
||||
} {
|
||||
if (!status || status === "none" || status === "inactive") {
|
||||
return { label: "No active subscription", cls: "bg-stone-100 text-stone-600", show: true };
|
||||
}
|
||||
switch (status) {
|
||||
case "active":
|
||||
return { label: "Active", cls: "bg-[var(--admin-success)]/10 text-[var(--admin-success)]", show: true };
|
||||
case "trialing":
|
||||
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:
|
||||
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 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 {
|
||||
brandId,
|
||||
planTier,
|
||||
hasStripeCustomer,
|
||||
hasActiveSubscription,
|
||||
subscriptionStatus,
|
||||
currentPeriodEnd,
|
||||
isPlatformAdmin,
|
||||
addons,
|
||||
limits,
|
||||
usage,
|
||||
} = overview;
|
||||
|
||||
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 currentPlan = PLAN_TIERS[planTier] ?? PLAN_TIERS.starter;
|
||||
|
||||
const allAddonKeys = Object.keys(ADDONS) as Array<keyof typeof ADDONS>;
|
||||
// ── 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";
|
||||
|
||||
const addonsMonthlyTotal = enabledAddonsList.reduce((sum, a) => {
|
||||
return sum + (billingCycle === "annual" ? Math.round(a.annualPrice / 12) : a.monthlyPrice);
|
||||
}, 0);
|
||||
// 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 totalMonthly = planMonthly + addonsMonthlyTotal;
|
||||
const displayedTotal =
|
||||
billingCycle === "annual"
|
||||
? planDisplayPrice + addonsAnnualTotal
|
||||
: planDisplayPrice + addonsMonthlyTotal;
|
||||
|
||||
const annualSavings = enabledAddonsList.reduce(
|
||||
(s, a) => s + (a.monthlyPrice * 12 - a.annualPrice),
|
||||
(billingCycle === "annual" ? (currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0) : 0)
|
||||
);
|
||||
return { planDisplayPrice, addonsMonthlyTotal, addonsAnnualTotal, displayedTotal, planDisplayLabel };
|
||||
}, [billingCycle, currentPlan, addons, hasActiveSubscription]);
|
||||
|
||||
const getStatusBadgeClass = (status: string | null) => {
|
||||
if (!status) return "bg-stone-100 text-stone-600";
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-[var(--admin-success)]/10 text-[var(--admin-success)]";
|
||||
case "past_due":
|
||||
return "bg-amber-100 text-amber-700";
|
||||
case "canceled":
|
||||
return "bg-red-100 text-red-600";
|
||||
case "trialing":
|
||||
return "bg-blue-100 text-blue-600";
|
||||
default:
|
||||
return "bg-stone-100 text-stone-600";
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<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="flex items-center justify-between flex-wrap gap-6">
|
||||
<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}`}>
|
||||
{currentPlan.label}
|
||||
</span>
|
||||
{subscriptionStatus && (
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${getStatusBadgeClass(subscriptionStatus)}`}>
|
||||
{subscriptionStatus.replace("_", " ")}
|
||||
{statusBadge.show && (
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-bold uppercase ${statusBadge.cls}`}>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{hasActiveSubscription && periodEndLabel && (
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
renews {periodEndLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-3xl font-bold text-[var(--admin-text-primary)]">
|
||||
${totalMonthly}
|
||||
<span className="text-base font-normal text-[var(--admin-text-muted)]">/mo</span>
|
||||
{hasActiveSubscription ? `$${displayedTotal}` : `$${currentPlan.monthlyPrice}`}
|
||||
<span className="text-base font-normal text-[var(--admin-text-muted)]">
|
||||
{hasActiveSubscription ? planDisplayLabel : "/mo"}
|
||||
</span>
|
||||
</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)]">
|
||||
<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" />
|
||||
@@ -112,10 +163,21 @@ export default function BillingClientPage({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--admin-text-muted)] mt-1">
|
||||
{currentPeriodEnd ? (
|
||||
<>Next billing: <span className="font-medium text-[var(--admin-text-primary)]">{new Date(currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</span></>
|
||||
{hasActiveSubscription ? (
|
||||
<>
|
||||
{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"}</>
|
||||
) : (
|
||||
<>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"
|
||||
<>No active subscription. Add a payment method to start.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -124,7 +186,7 @@ export default function BillingClientPage({
|
||||
</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">
|
||||
{/* Current plan card */}
|
||||
<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>
|
||||
</div>
|
||||
<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 className="text-xs text-[var(--admin-text-muted)] mb-5">
|
||||
{billingCycle === "annual" && currentPlan.annualPrice
|
||||
? `$${Math.round(currentPlan.annualPrice / 12)}/mo equivalent — saves $${Math.round(((currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0)))}/yr`
|
||||
: "billed monthly"}
|
||||
{hasActiveSubscription ? (
|
||||
billingCycle === "annual" && currentPlan.annualPrice
|
||||
? `$${Math.round(currentPlan.annualPrice / 12)}/mo equivalent — saves $${Math.round(((currentPlan.monthlyPrice ?? 0) * 12 - (currentPlan.annualPrice ?? 0)))}/yr`
|
||||
: "billed monthly"
|
||||
) : (
|
||||
"Not yet subscribed"
|
||||
)}
|
||||
</p>
|
||||
<ul className="space-y-2 mb-5">
|
||||
{(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>
|
||||
<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>
|
||||
<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 className="space-y-3">
|
||||
{allAddonKeys.map((key) => {
|
||||
const addon = ADDONS[key];
|
||||
const isEnabled = !!enabledAddons[key];
|
||||
const displayPrice = billingCycle === "annual"
|
||||
? Math.round(addon.annualPrice / 12)
|
||||
: addon.monthlyPrice;
|
||||
{addons.map((addon) => {
|
||||
const displayPrice =
|
||||
billingCycle === "annual"
|
||||
? Math.round(addon.annualPrice / 12)
|
||||
: 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 (
|
||||
<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">
|
||||
<span className="text-xl leading-none">{addon.icon}</span>
|
||||
<div>
|
||||
@@ -195,18 +289,29 @@ export default function BillingClientPage({
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-sm font-semibold text-[var(--admin-text-secondary)]">+${displayPrice}/mo</span>
|
||||
{isEnabled ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" />
|
||||
Active
|
||||
</span>
|
||||
{isPlatformAdmin && (
|
||||
<RemoveAddonButton brandId={brandId} addonKey={key} onRemoved={() => window.location.reload()} />
|
||||
)}
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1 rounded-full text-xs px-2.5 py-1 font-medium ${stateBadge.cls}`}>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
stateBadge.label === "Active"
|
||||
? "bg-[var(--admin-success)]"
|
||||
: stateBadge.label === "Pending"
|
||||
? "bg-amber-500"
|
||||
: "bg-stone-400"
|
||||
}`}
|
||||
/>
|
||||
{stateBadge.label}
|
||||
</span>
|
||||
{addon.removable ? (
|
||||
<RemoveAddonButton brandId={brandId} addonKey={addon.key} onRemoved={() => window.location.reload()} />
|
||||
) : hasActiveSubscription ? (
|
||||
<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>
|
||||
@@ -216,13 +321,13 @@ export default function BillingClientPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 3. Compare plans (collapsible) ───────────────────────────────────── */}
|
||||
{/* ── 3. Compare plans (collapsible) ──────────────────────────────── */}
|
||||
{compareOpen && isPlatformAdmin && (
|
||||
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] overflow-hidden">
|
||||
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-bg)] px-6 py-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-[var(--admin-text-primary)]">Plan Comparison</h3>
|
||||
<button
|
||||
onClick={() => setCompareOpen(false)}
|
||||
<button
|
||||
onClick={() => setCompareOpen(false)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -312,8 +417,8 @@ export default function BillingClientPage({
|
||||
Current
|
||||
</span>
|
||||
) : tier === "enterprise" ? (
|
||||
<a
|
||||
href="mailto:team@cielohermosa.com?subject=Enterprise+Plan"
|
||||
<a
|
||||
href="mailto:team@cielohermosa.com?subject=Enterprise+Plan"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--admin-accent)] bg-white px-3 py-1.5 text-sm font-medium text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
@@ -334,7 +439,7 @@ export default function BillingClientPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 4. Payment + Invoices (two-column) ──────────────────────────────── */}
|
||||
{/* ── 4. Payment + Invoices (two-column) ───────────────────────────── */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Payment method */}
|
||||
<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 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">
|
||||
<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>
|
||||
</div>
|
||||
<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-xs text-[var(--admin-text-muted)]">Expires 12/26</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)]">
|
||||
{hasActiveSubscription
|
||||
? "Billed automatically per the cycle you selected"
|
||||
: "On file — no active subscription yet"}
|
||||
</p>
|
||||
</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 className="w-1.5 h-1.5 rounded-full bg-[var(--admin-success)]" />
|
||||
Active
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full text-xs px-2.5 py-1 font-medium shrink-0 ${
|
||||
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>
|
||||
</div>
|
||||
<StripePortalButton brandId={brandId} variant="secondary" label="Manage in Stripe Portal" />
|
||||
@@ -389,6 +508,11 @@ export default function BillingClientPage({
|
||||
{/* Invoice history */}
|
||||
<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>
|
||||
{!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">
|
||||
<table className="w-full text-sm">
|
||||
<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-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"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{[
|
||||
{ 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) => (
|
||||
{placeholderInvoices.map((inv) => (
|
||||
<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-right font-semibold text-[var(--admin-text-primary)]">${inv.amount}</td>
|
||||
<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">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{inv.status}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium capitalize ${
|
||||
inv.isSample
|
||||
? "bg-stone-100 text-stone-600"
|
||||
: "bg-[var(--admin-success)]/10 text-[var(--admin-success)]"
|
||||
}`}
|
||||
>
|
||||
{inv.isSample ? "—" : inv.status}
|
||||
</span>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -431,6 +557,21 @@ export default function BillingClientPage({
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
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 BillingClientPage from "./BillingClientPage";
|
||||
|
||||
@@ -49,24 +49,25 @@ export default async function BillingPage({ params }: Props) {
|
||||
|
||||
if (!resolvedBrandId) return <AdminAccessDenied />;
|
||||
|
||||
const [planResult, addons] = await Promise.all([
|
||||
getBrandPlanInfo(resolvedBrandId),
|
||||
getEnabledAddons(resolvedBrandId),
|
||||
]);
|
||||
// Single source of truth for everything the billing client needs.
|
||||
const overviewRes = await getBillingOverview(resolvedBrandId);
|
||||
if (!overviewRes.success || !overviewRes.data) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">Billing Unavailable</h1>
|
||||
<p className="mt-2 text-[var(--admin-text-muted)]">{overviewRes.error ?? "Could not load billing information."}</p>
|
||||
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
|
||||
Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const planData = (planResult.success && planResult.data && typeof planResult.data === "object")
|
||||
? planResult.data as Record<string, any>
|
||||
: {} as Record<string, any>;
|
||||
|
||||
const planTier = planData.plan_tier ?? "starter";
|
||||
|
||||
const { data: brand } = await supabase
|
||||
.from("brands")
|
||||
.select("name, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_current_period_end")
|
||||
.eq("id", resolvedBrandId)
|
||||
.single();
|
||||
|
||||
const hasStripeCustomer = !!brand?.stripe_customer_id;
|
||||
const overview = overviewRes.data;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
||||
@@ -94,21 +95,12 @@ export default async function BillingPage({ params }: Props) {
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)]">Billing & Subscription</h1>
|
||||
<p className="mt-1 text-[var(--admin-text-muted)]">
|
||||
Manage your Route Commerce subscription for {brand?.name ?? "your brand"}.
|
||||
Manage your Route Commerce subscription for {overview.brandName ?? "your brand"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BillingClientPage
|
||||
brandId={resolvedBrandId}
|
||||
planTier={planTier}
|
||||
brandName={brand?.name ?? null}
|
||||
hasStripeCustomer={hasStripeCustomer}
|
||||
enabledAddons={addons}
|
||||
isPlatformAdmin={isPlatformAdmin}
|
||||
subscriptionStatus={brand?.stripe_subscription_status ?? null}
|
||||
currentPeriodEnd={brand?.stripe_current_period_end ?? null}
|
||||
/>
|
||||
<BillingClientPage overview={overview} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type CredentialField = {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
isSecret?: boolean;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type SyncOption = {
|
||||
@@ -346,22 +347,38 @@ function IntegrationCard({
|
||||
{/* Credentials */}
|
||||
<div className="space-y-3 mb-5">
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Credentials</p>
|
||||
{integration.credentials.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">{field.label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecrets[field.key] ? "text" : "password"}
|
||||
value={credentials[field.key] ?? ""}
|
||||
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full rounded-lg border border-zinc-600 bg-zinc-800 px-3 py-2 text-sm pr-16 text-zinc-100 outline-none focus:border-violet-500"
|
||||
/>
|
||||
{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}>
|
||||
<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">
|
||||
<input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
value={credentials[field.key] ?? ""}
|
||||
onChange={(e) => setCredentials((p) => ({ ...p, [field.key]: e.target.value }))}
|
||||
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"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||
{field.isSecret && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{showSecrets[field.key] ? "Hide" : "Show"}
|
||||
@@ -370,7 +387,8 @@ function IntegrationCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<form onSubmit={handleAdd} className="flex gap-3 items-end">
|
||||
<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
|
||||
id="headgate-add-name"
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
@@ -231,11 +232,13 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
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)]"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
</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
|
||||
id="headgate-add-unit"
|
||||
value={newUnit}
|
||||
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)]"
|
||||
@@ -371,19 +374,22 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
</div>
|
||||
<form onSubmit={handleEditSave} className="p-5 space-y-4">
|
||||
<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
|
||||
id="headgate-edit-name"
|
||||
type="text"
|
||||
value={editName}
|
||||
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)]"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
id="headgate-edit-unit"
|
||||
value={editUnit}
|
||||
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)]"
|
||||
@@ -395,15 +401,16 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<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
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
id="headgate-edit-high"
|
||||
type="number"
|
||||
step="any"
|
||||
value={editHigh}
|
||||
@@ -413,8 +420,9 @@ export default function HeadgatesManager({ initialHeadgates, brandId }: Props) {
|
||||
/>
|
||||
</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
|
||||
id="headgate-edit-low"
|
||||
type="number"
|
||||
step="any"
|
||||
value={editLow}
|
||||
|
||||
@@ -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>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
id="water-new-pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -147,8 +148,9 @@ export default function WaterLogSettingsPage() {
|
||||
/>
|
||||
</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
|
||||
id="water-confirm-pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -225,13 +227,15 @@ export default function WaterLogSettingsPage() {
|
||||
|
||||
{alertsEnabled && (
|
||||
<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
|
||||
id="water-alert-phone"
|
||||
type="tel"
|
||||
value={alertPhone}
|
||||
onChange={(e) => setAlertPhone(e.target.value)}
|
||||
placeholder="+1234567890"
|
||||
className="w-full rounded-xl border border-zinc-600 px-4 py-3 text-base outline-none focus:border-stone-900"
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label 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 }))}
|
||||
<label htmlFor="ws-company-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Company Name</label>
|
||||
<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)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label 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 }))}
|
||||
<label htmlFor="ws-contact-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Contact Name</label>
|
||||
<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)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label 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 }))}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
<label htmlFor="ws-email" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Email</label>
|
||||
<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)]" autoComplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label 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 }))}
|
||||
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
<label htmlFor="ws-phone" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Phone</label>
|
||||
<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)]" autoComplete="tel" />
|
||||
</div>
|
||||
<div>
|
||||
<label 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 }))}
|
||||
<label htmlFor="ws-account-status" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Status</label>
|
||||
<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)]">
|
||||
<option value="active">Active</option>
|
||||
<option value="on_hold">On Hold</option>
|
||||
@@ -648,8 +648,8 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [],
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label 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) }))}
|
||||
<label htmlFor="ws-credit-limit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Credit Limit ($)</label>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -665,13 +665,13 @@ function CustomersTab({ customers, products, brandId, onMsg, registrations = [],
|
||||
{form.depositsEnabled && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label 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 }))}
|
||||
<label htmlFor="ws-deposit-threshold" className="block text-xs text-[var(--admin-text-muted)] mb-1">Threshold ($ order total to trigger)</label>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<label 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 }))}
|
||||
<label htmlFor="ws-deposit-pct" className="block text-xs text-[var(--admin-text-muted)] mb-1">Deposit %</label>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1008,19 +1008,22 @@ function PriceSheetModal({
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<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
|
||||
id="ws-price-subject"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
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)]"
|
||||
/>
|
||||
</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>
|
||||
</label>
|
||||
<textarea
|
||||
id="ws-price-note"
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
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>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label 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 }))}
|
||||
<label htmlFor="ws-prod-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Product Name</label>
|
||||
<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)]" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label 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 }))}
|
||||
<label htmlFor="ws-prod-desc" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Description</label>
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
<label 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 }))}
|
||||
<label htmlFor="ws-prod-unit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit Type</label>
|
||||
<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)]">
|
||||
{["each", "48-count box", "bag", "pallet", "bin", "load", "custom"].map(u => (
|
||||
<option key={u} value={u}>{u}</option>
|
||||
|
||||
Reference in New Issue
Block a user