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:
@@ -15,6 +15,17 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const isBrandRoute = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct");
|
||||
const isLandingPage = pathname === "/" || pathname === "/brands";
|
||||
const isAuthPage = pathname === "/login" || pathname === "/admin/login";
|
||||
// Admin routes have their own AdminSidebar + design-system shell; suppress
|
||||
// the public SiteHeader/SiteFooter to avoid a duplicate header on every
|
||||
// admin page.
|
||||
const isAdminRoute = pathname?.startsWith("/admin");
|
||||
// Cart + checkout + wholesale pages render their own StorefrontHeader/Footer
|
||||
// (or wholesale-specific layout) — skip the public chrome to avoid doubles.
|
||||
const isStandalonePage =
|
||||
pathname?.startsWith("/cart") ||
|
||||
pathname?.startsWith("/checkout") ||
|
||||
pathname?.startsWith("/wholesale") ||
|
||||
pathname?.startsWith("/water");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,9 +35,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false} disableTransitionOnChange>
|
||||
<CartProvider>
|
||||
{!isBrandRoute && !isLandingPage && !isAuthPage && <SiteHeader />}
|
||||
{!isBrandRoute && !isLandingPage && !isAuthPage && !isAdminRoute && !isStandalonePage && <SiteHeader />}
|
||||
{mounted ? children : <div style={{ visibility: 'hidden' }}>{children}</div>}
|
||||
{!isBrandRoute && !isLandingPage && !isAuthPage && <SiteFooter />}
|
||||
{!isBrandRoute && !isLandingPage && !isAuthPage && !isAdminRoute && !isStandalonePage && <SiteFooter />}
|
||||
<CartToast />
|
||||
<CartRestoredToast />
|
||||
</CartProvider>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const BRAND_NAMES: Record<string, string> = {
|
||||
tuxedo: "Tuxedo Corn",
|
||||
@@ -10,6 +11,26 @@ const BRAND_NAMES: Record<string, string> = {
|
||||
|
||||
export default function SiteHeader() {
|
||||
const pathname = usePathname();
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
// Only show the Admin link to users who are actually authenticated as admins.
|
||||
// This prevents leaking the Admin entry point on public marketing pages
|
||||
// (e.g. /, /pricing, /contact, /security, /roadmap, /changelog, /blog, etc.).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const mod = await import("@/actions/admin-user");
|
||||
const user = await mod.getCurrentAdminUser();
|
||||
if (!cancelled) setIsAdmin(!!user);
|
||||
} catch {
|
||||
if (!cancelled) setIsAdmin(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
const showBrandName = pathname?.startsWith("/tuxedo") || pathname?.startsWith("/indian-river-direct");
|
||||
const brandKey = pathname?.split("/")[1];
|
||||
@@ -83,8 +104,8 @@ export default function SiteHeader() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Admin link */}
|
||||
{!isAdminRoute && (
|
||||
{/* Admin link — only rendered for users who are signed in as admins. */}
|
||||
{!isAdminRoute && isAdmin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-xs sm:text-sm font-medium uppercase tracking-wider transition-colors hover:opacity-70"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import Image from "next/image";
|
||||
import { useCart } from "@/context/CartContext";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
@@ -16,11 +17,16 @@ interface Product {
|
||||
price: string;
|
||||
type: string;
|
||||
imageUrl: string | null;
|
||||
brand_id?: string;
|
||||
brand_slug?: string;
|
||||
is_taxable?: boolean;
|
||||
pickup_type?: "scheduled_stop" | "shed";
|
||||
}
|
||||
|
||||
interface CinematicShowcaseProps {
|
||||
products: Product[];
|
||||
brandSlug?: string;
|
||||
brandName?: string;
|
||||
}
|
||||
|
||||
// Single product card with scroll-driven morphing
|
||||
@@ -117,10 +123,41 @@ function MorphingProductCard({
|
||||
export default function CinematicShowcase({
|
||||
products,
|
||||
brandSlug = "tuxedo",
|
||||
brandName = "Tuxedo Corn",
|
||||
}: CinematicShowcaseProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const [addedId, setAddedId] = useState<string | null>(null);
|
||||
const { addToCart } = useCart();
|
||||
|
||||
const activeProduct = products[activeIndex];
|
||||
const isPickupOnly = activeProduct?.type === "Pickup";
|
||||
const isShippingOnly = activeProduct?.type === "Shipping";
|
||||
const isBoth = activeProduct?.type === "Pickup & Shipping";
|
||||
const canAdd = !!activeProduct && !!activeProduct.brand_id;
|
||||
|
||||
function handleAddActive() {
|
||||
if (!activeProduct || !canAdd) return;
|
||||
const baseItem = {
|
||||
id: activeProduct.id,
|
||||
name: activeProduct.name,
|
||||
price: activeProduct.price,
|
||||
brand_id: activeProduct.brand_id ?? "",
|
||||
brand_slug: activeProduct.brand_slug ?? brandSlug,
|
||||
is_taxable: activeProduct.is_taxable ?? true,
|
||||
pickup_type: activeProduct.pickup_type ?? "scheduled_stop",
|
||||
description: activeProduct.description ?? "",
|
||||
};
|
||||
if (isPickupOnly) addToCart(baseItem, "pickup");
|
||||
else if (isShippingOnly) addToCart(baseItem, "ship");
|
||||
else addToCart(baseItem, "pickup");
|
||||
|
||||
setAddedId(activeProduct.id);
|
||||
window.setTimeout(() => {
|
||||
setAddedId((cur) => (cur === activeProduct?.id ? null : cur));
|
||||
}, 1800);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !containerRef.current) return;
|
||||
@@ -282,6 +319,47 @@ export default function CinematicShowcase({
|
||||
<p className="text-xs text-white/30 max-w-md">
|
||||
{products[activeIndex]?.description}
|
||||
</p>
|
||||
{activeProduct && (
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddActive}
|
||||
disabled={!canAdd}
|
||||
aria-label={`Add ${activeProduct.name} to cart`}
|
||||
className={`inline-flex items-center gap-2 rounded-2xl px-6 py-3 text-sm font-bold tracking-wider transition-all duration-200 ${
|
||||
!canAdd
|
||||
? "bg-white/10 text-white/40 cursor-not-allowed"
|
||||
: addedId === activeProduct.id
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-white text-stone-950 hover:bg-stone-100 active:bg-stone-200"
|
||||
}`}
|
||||
>
|
||||
{addedId === activeProduct.id ? (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Added
|
||||
</>
|
||||
) : isBoth ? (
|
||||
"Add to Cart"
|
||||
) : isShippingOnly ? (
|
||||
"Add — Ships"
|
||||
) : (
|
||||
"Add to Cart"
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs text-white/40 hidden sm:inline">
|
||||
{isBoth
|
||||
? "Preorder for pickup or shipping"
|
||||
: isShippingOnly
|
||||
? "Shipped after season"
|
||||
: isPickupOnly
|
||||
? "Pickup at a scheduled stop"
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user