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
+3 -1
View File
@@ -98,6 +98,7 @@ export default function CommunicationsPage({
initialSegments = [],
initialAnalytics = [],
editCampaignId,
initialTab,
}: {
campaigns: Campaign[];
templates: Template[];
@@ -110,8 +111,9 @@ export default function CommunicationsPage({
initialSegments?: Segment[];
initialAnalytics?: CampaignAnalytics[];
editCampaignId?: string;
initialTab?: Tab;
}) {
const [currentTab, setCurrentTab] = useState<Tab>("campaigns");
const [currentTab, setCurrentTab] = useState<Tab>(initialTab ?? "campaigns");
const [isComposing, setIsComposing] = useState(false);
return (
+30 -6
View File
@@ -161,11 +161,17 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{/* Email */}
<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
id="create-user-email"
type="email"
value={email}
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)]"
placeholder="user@example.com"
/>
@@ -173,12 +179,19 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{/* Password */}
<div>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Password</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-1.5">Minimum 6 characters.</p>
<label htmlFor="create-user-password" className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Password
</label>
<p id="create-user-password-help" className="text-xs text-[var(--admin-text-muted)] mb-1.5">Minimum 6 characters.</p>
<input
id="create-user-password"
type="password"
value={password}
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)]"
placeholder="Choose a password"
minLength={6}
@@ -188,23 +201,31 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{/* Display Name & Phone - 2 columns on larger screens */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
<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
id="create-user-display-name"
type="text"
value={displayName}
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)]"
placeholder="Kyle Martinez"
/>
</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>
<input
id="create-user-phone"
type="tel"
value={phoneNumber}
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)]"
placeholder="+1 (555) 000-0000"
/>
@@ -237,10 +258,13 @@ export default function CreateUserModal({ isOpen, onClose, onSuccess, brands, cu
{/* Brand */}
{showBrandSelect && (
<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
id="create-user-brand"
value={brandId ?? ""}
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"
>
<option value="">Select a brand</option>
+13 -4
View File
@@ -360,10 +360,15 @@ export default function DashboardClient({
</div>
</div>
{/* Active Products */}
<div
{/* 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
className="rounded-xl border p-5 transition-all hover:shadow-md hover:-translate-y-0.5"
style={{
style={{
backgroundColor: "var(--admin-card-bg)",
borderColor: "var(--admin-border)"
}}
@@ -379,7 +384,11 @@ export default function DashboardClient({
Active Products
</p>
<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>
</div>
</div>
@@ -1,6 +1,6 @@
"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 Template } from "@/actions/communications/templates";
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 [error, setError] = useState("");
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 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) => {
setSelectedTemplateId(template.id);
setSubject(template.subject ?? "");
@@ -550,6 +594,44 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se
)}
</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 */}
<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>