From 0245aa29ccf1fa1b08fd349c5fa9b773ecd356f1 Mon Sep 17 00:00:00 2001 From: default Date: Wed, 3 Jun 2026 16:39:19 +0000 Subject: [PATCH] fix(buyer/billing/comms/a11y): Codex review pass round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/actions/billing/billing-overview.ts | 236 +++++++++++ src/app/admin/communications/compose/page.tsx | 10 +- src/app/admin/me/AdminMeClient.tsx | 19 +- src/app/admin/page.tsx | 60 ++- src/app/admin/products/import/page.tsx | 8 +- src/app/admin/sales/import/page.tsx | 9 +- src/app/admin/settings/ai/AIClient.tsx | 34 +- .../settings/billing/BillingClientPage.tsx | 385 ++++++++++++------ src/app/admin/settings/billing/page.tsx | 52 +-- .../integrations/IntegrationsClient.tsx | 42 +- .../water-log/headgates/HeadgatesManager.tsx | 22 +- src/app/admin/water-log/settings/page.tsx | 10 +- src/app/admin/wholesale/WholesaleClient.tsx | 55 +-- src/app/blog/page.tsx | 2 +- src/app/brands/page.tsx | 2 +- src/app/cart/CartClient.tsx | 16 +- src/app/changelog/page.tsx | 2 +- src/app/contact/ContactClientPage.tsx | 23 +- src/app/login/LoginClient.tsx | 4 +- src/app/privacy-policy/page.tsx | 2 +- src/app/roadmap/page.tsx | 2 +- src/app/security/page.tsx | 2 +- src/app/terms-and-conditions/page.tsx | 2 +- src/app/tuxedo/page.tsx | 52 ++- src/app/tuxedo/stops/TuxedoStopsList.tsx | 23 +- src/app/waitlist/page.tsx | 2 +- src/components/Providers.tsx | 15 +- src/components/admin/CommunicationsPage.tsx | 4 +- src/components/admin/CreateUserModal.tsx | 36 +- src/components/admin/DashboardClient.tsx | 17 +- .../HarvestReach/CampaignComposerPage.tsx | 84 +++- src/components/layout/SiteHeader.tsx | 25 +- .../storefront/CinematicShowcase.tsx | 78 ++++ .../203_plan_usage_active_products.sql | 82 ++++ 34 files changed, 1122 insertions(+), 295 deletions(-) create mode 100644 src/actions/billing/billing-overview.ts create mode 100644 supabase/migrations/203_plan_usage_active_products.sql diff --git a/src/actions/billing/billing-overview.ts b/src/actions/billing/billing-overview.ts new file mode 100644 index 0000000..9e12ff7 --- /dev/null +++ b/src/actions/billing/billing-overview.ts @@ -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; + // 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, + 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}` }; + } +} diff --git a/src/app/admin/communications/compose/page.tsx b/src/app/admin/communications/compose/page.tsx index 66fc3b9..017e3e1 100644 --- a/src/app/admin/communications/compose/page.tsx +++ b/src/app/admin/communications/compose/page.tsx @@ -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" /> ); -} \ No newline at end of file +} diff --git a/src/app/admin/me/AdminMeClient.tsx b/src/app/admin/me/AdminMeClient.tsx index ed6a37f..ed15cf9 100644 --- a/src/app/admin/me/AdminMeClient.tsx +++ b/src/app/admin/me/AdminMeClient.tsx @@ -153,13 +153,14 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) { {editing && (
- + 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) { />
- + 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" />
@@ -228,18 +231,22 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) { ) : (
- + 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" />
{emailError && ( diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ecf73a6..7b43773 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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} /> ); -} \ No newline at end of file +} diff --git a/src/app/admin/products/import/page.tsx b/src/app/admin/products/import/page.tsx index 497081e..a8cdf3c 100644 --- a/src/app/admin/products/import/page.tsx +++ b/src/app/admin/products/import/page.tsx @@ -106,8 +106,9 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE, {/* Brand ID */}
- + setBrandId(e.target.value)} @@ -118,8 +119,9 @@ Citrus Gift Box,Seasonal citrus assortment,34.99,Shipping,TRUE, {/* File upload */}
- + Or paste CSV content below:

+