916ad39176
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
507 lines
23 KiB
TypeScript
507 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import Link from "next/link";
|
||
import { useCart } from "@/context/CartContext";
|
||
import type { StopInfo } from "@/context/CartContext";
|
||
import { createRetailStripeCheckoutSession } from "@/actions/billing/retail-checkout";
|
||
import { getPublicStopsForBrand } from "@/actions/checkout";
|
||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||
import StripeExpressCheckout, {
|
||
type CheckoutItem as ExpressItem,
|
||
} from "@/components/storefront/StripeExpressCheckout";
|
||
|
||
export default function CheckoutClient() {
|
||
const cartContext = useCart();
|
||
const cart = cartContext.cart;
|
||
const subtotal = cartContext.subtotal;
|
||
const selectedStop: StopInfo | null = cartContext.selectedStop;
|
||
const setSelectedStop = cartContext.setSelectedStop;
|
||
const cartBrandId = cartContext.cartBrandId;
|
||
const router = useRouter();
|
||
const [stops, setStops] = useState<StopInfo[]>([]);
|
||
|
||
// Controlled form state — passed down to StripeExpressCheckout so the
|
||
// Express + Card paths know who the buyer is and where to ship / pick up.
|
||
const [customerName, setCustomerName] = useState("");
|
||
const [customerEmail, setCustomerEmail] = useState("");
|
||
const [customerPhone, setCustomerPhone] = useState("");
|
||
const [shippingState, setShippingState] = useState("");
|
||
const [shippingPostal, setShippingPostal] = useState("");
|
||
const [shippingCity, setShippingCity] = useState("");
|
||
|
||
// Hosted-checkout (legacy Stripe Checkout) fallback state
|
||
const [hostedLoading, setHostedLoading] = useState(false);
|
||
const [hostedError, setHostedError] = useState<string | null>(null);
|
||
|
||
// Stable idempotency key per checkout session — survives retries
|
||
const [idempotencyKey, setIdempotencyKey] = useState<string>("");
|
||
|
||
useEffect(() => {
|
||
// Initialize idempotency key in useEffect, not during render
|
||
const init = async () => {
|
||
if (typeof window !== "undefined" && !idempotencyKey) {
|
||
setIdempotencyKey(crypto.randomUUID());
|
||
}
|
||
};
|
||
init();
|
||
}, [idempotencyKey]);
|
||
|
||
useEffect(() => {
|
||
if (!cartBrandId) return;
|
||
getPublicStopsForBrand(cartBrandId)
|
||
.then((data) => setStops(data ?? []))
|
||
.catch(() => setStops([]));
|
||
}, [cartBrandId]);
|
||
|
||
const hasPickupItems = cart.some((i) => i.fulfillment === "pickup");
|
||
const hasShedPickupItems = cart.some((i) => i.fulfillment === "pickup" && i.pickup_type === "shed");
|
||
const hasStopPickupItems = cart.some(
|
||
(i) => i.fulfillment === "pickup" && i.pickup_type !== "shed"
|
||
);
|
||
const hasShipItems = cart.some((i) => i.fulfillment === "ship");
|
||
|
||
/**
|
||
* Legacy "Use secure hosted checkout" fallback. Creates a Stripe
|
||
* Checkout Session and redirects — this is the path used when the
|
||
* embedded Stripe Elements can't load (no publishable key, browser
|
||
* incompatibility, etc.) or when the buyer prefers Stripe's hosted page.
|
||
*/
|
||
const handleHostedCheckout = useCallback(async () => {
|
||
if (cart.length === 0) return;
|
||
|
||
if (selectedStop?.brand_id && cartBrandId && selectedStop.brand_id !== cartBrandId) {
|
||
setSelectedStop(null);
|
||
router.replace("/cart");
|
||
return;
|
||
}
|
||
|
||
if (!customerEmail.trim()) {
|
||
setHostedError("Please enter your email before continuing to checkout.");
|
||
return;
|
||
}
|
||
if (hasStopPickupItems && !selectedStop) {
|
||
setHostedError("Please select a pickup location.");
|
||
return;
|
||
}
|
||
|
||
setHostedLoading(true);
|
||
setHostedError(null);
|
||
|
||
const items = cart.map((item) => ({
|
||
id: item.id,
|
||
name: item.name,
|
||
price: Number(item.price.replace("$", "")),
|
||
quantity: item.quantity,
|
||
fulfillment: item.fulfillment ?? "pickup",
|
||
}));
|
||
|
||
const siteUrl =
|
||
typeof window !== "undefined" ? window.location.origin : "https://route-commerce-platform.vercel.app";
|
||
const successUrl = `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`;
|
||
const cancelUrl = `${siteUrl}/checkout?status=cancelled`;
|
||
|
||
if (typeof sessionStorage !== "undefined") {
|
||
sessionStorage.setItem(
|
||
"pending_checkout",
|
||
JSON.stringify({
|
||
customerName,
|
||
customerEmail,
|
||
customerPhone,
|
||
stopId: selectedStop?.id ?? null,
|
||
items: items.map((item) => ({
|
||
...item,
|
||
fulfillment: item.fulfillment as "pickup" | "ship",
|
||
})),
|
||
cartBrandId,
|
||
shippingAddress: hasShipItems
|
||
? { state: shippingState, postal_code: shippingPostal, city: shippingCity }
|
||
: undefined,
|
||
idempotencyKey: idempotencyKey,
|
||
})
|
||
);
|
||
}
|
||
|
||
const stripeResult = await createRetailStripeCheckoutSession(
|
||
items,
|
||
"",
|
||
cartBrandId ?? "unknown",
|
||
successUrl,
|
||
cancelUrl
|
||
);
|
||
if (!stripeResult.success || !stripeResult.url) {
|
||
setHostedError(stripeResult.error ?? "Failed to initiate payment. Please try again.");
|
||
setHostedLoading(false);
|
||
return;
|
||
}
|
||
|
||
window.location.href = stripeResult.url;
|
||
}, [
|
||
cart,
|
||
customerName,
|
||
customerEmail,
|
||
customerPhone,
|
||
selectedStop,
|
||
shippingState,
|
||
shippingPostal,
|
||
shippingCity,
|
||
cartBrandId,
|
||
hasShipItems,
|
||
hasStopPickupItems,
|
||
router,
|
||
setSelectedStop,
|
||
]);
|
||
|
||
if (cart.length === 0) {
|
||
return (
|
||
<div className="min-h-screen bg-stone-50">
|
||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||
<main className="px-6 py-12">
|
||
<div className="mx-auto max-w-4xl">
|
||
<h1 className="text-3xl font-bold text-stone-900">Your cart is empty</h1>
|
||
<Link
|
||
href="/"
|
||
className="mt-4 inline-block text-stone-600 hover:text-stone-900"
|
||
>
|
||
← Back to storefront
|
||
</Link>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Map cart → StripeExpressCheckout item shape
|
||
const expressItems: ExpressItem[] = cart.map((item) => ({
|
||
id: item.id,
|
||
name: item.name,
|
||
price: item.price,
|
||
quantity: item.quantity,
|
||
fulfillment: (item.fulfillment ?? "pickup") as "pickup" | "ship",
|
||
}));
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-stone-50 via-white to-emerald-50/30">
|
||
<StorefrontHeader brandName="Checkout" brandSlug="tuxedo" />
|
||
<main className="px-6 py-12">
|
||
<div className="mx-auto grid max-w-6xl gap-8 md:grid-cols-[1fr_400px]">
|
||
{/* LEFT — Form + Embedded Stripe Elements */}
|
||
<div>
|
||
<h1 className="text-4xl font-bold text-slate-900">Checkout</h1>
|
||
<p className="mt-3 text-slate-600">
|
||
Tap to pay with Apple Pay, Google Pay, or card. Your details stay secure — we never see them.
|
||
</p>
|
||
|
||
{/* Error alert (shared across both payment paths) */}
|
||
{(hostedError) && (
|
||
<div className="mt-6 rounded-xl bg-red-50 border border-red-200 p-4 text-red-700 text-sm shadow-sm" role="alert">
|
||
<div className="flex items-start gap-3">
|
||
<svg className="h-5 w-5 text-red-500 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<span>{hostedError}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-8 space-y-6">
|
||
{/* Customer Information — controlled inputs feed the embedded
|
||
Stripe Elements above. The wallet (Apple Pay) will overwrite
|
||
name/email at confirm time, but we still collect them here
|
||
so the order row has them in case the wallet omits them. */}
|
||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||
<legend className="text-xl font-bold text-slate-900">Your details</legend>
|
||
<p className="mt-1 text-xs text-slate-500">
|
||
Used for the order confirmation. Apple Pay / Google Pay will fill these in for you.
|
||
</p>
|
||
|
||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div className="sm:col-span-2">
|
||
<label htmlFor="customer_name" className="block text-sm font-medium text-slate-700">
|
||
Full Name
|
||
</label>
|
||
<input
|
||
id="customer_name"
|
||
name="customer_name"
|
||
autoComplete="name"
|
||
value={customerName}
|
||
onChange={(e) => setCustomerName(e.target.value)}
|
||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||
placeholder="Jane Smith"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="customer_email" className="block text-sm font-medium text-slate-700">
|
||
Email <span className="text-red-500" aria-hidden="true">*</span>
|
||
</label>
|
||
<input
|
||
id="customer_email"
|
||
name="customer_email"
|
||
type="email"
|
||
autoComplete="email"
|
||
required
|
||
value={customerEmail}
|
||
onChange={(e) => setCustomerEmail(e.target.value)}
|
||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||
placeholder="jane@example.com"
|
||
aria-required="true"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="customer_phone" className="block text-sm font-medium text-slate-700">
|
||
Phone <span className="text-slate-400 text-xs">(optional)</span>
|
||
</label>
|
||
<input
|
||
id="customer_phone"
|
||
name="customer_phone"
|
||
type="tel"
|
||
autoComplete="tel"
|
||
value={customerPhone}
|
||
onChange={(e) => setCustomerPhone(e.target.value)}
|
||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||
placeholder="(555) 555-5555"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
|
||
{/* Shed Pickup notice */}
|
||
{hasShedPickupItems && (
|
||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-emerald-200 border border-emerald-100">
|
||
<legend className="text-xl font-bold text-slate-900">Shed Pickup</legend>
|
||
<p className="mt-2 text-sm text-slate-600">
|
||
{cart.filter((i) => i.fulfillment === "pickup" && i.pickup_type === "shed").map((i) => i.description || i.name).join(", ")}
|
||
</p>
|
||
<p className="mt-1 text-xs text-slate-500">Pickup location details are included in your order confirmation.</p>
|
||
</fieldset>
|
||
)}
|
||
|
||
{/* Stop Pickup */}
|
||
{hasStopPickupItems && (
|
||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||
<legend className="text-xl font-bold text-slate-900">
|
||
Pickup Location <span className="text-red-500" aria-hidden="true">*</span>
|
||
</legend>
|
||
|
||
{selectedStop ? (
|
||
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||
<p className="font-medium text-emerald-900">
|
||
<svg className="inline h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||
</svg>
|
||
{selectedStop.city}, {selectedStop.state}
|
||
</p>
|
||
<p className="mt-1 text-sm text-emerald-700">
|
||
{selectedStop.date} · {selectedStop.location}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedStop(null)}
|
||
className="mt-2 text-xs text-emerald-600 hover:text-emerald-800 underline transition-colors"
|
||
aria-label="Change pickup stop"
|
||
>
|
||
Change pickup stop
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="mt-4">
|
||
<label htmlFor="stop_id" className="block text-sm font-medium text-slate-700 mb-2">
|
||
Select pickup stop <span className="text-red-500" aria-hidden="true">*</span>
|
||
</label>
|
||
<select
|
||
id="stop_id"
|
||
name="stop_id"
|
||
required
|
||
value={(selectedStop as StopInfo | null)?.id ?? ""}
|
||
onChange={(e) => {
|
||
const found = stops.find((s) => s.id === e.target.value) ?? null;
|
||
setSelectedStop(found as StopInfo | null);
|
||
}}
|
||
className="w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all appearance-none bg-white"
|
||
aria-required="true"
|
||
>
|
||
<option value="">Select a stop...</option>
|
||
{stops.map((stop) => (
|
||
<option key={stop.id} value={stop.id}>
|
||
{stop.city}, {stop.state} — {stop.date} @ {stop.location}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</fieldset>
|
||
)}
|
||
|
||
{/* Shipping Address */}
|
||
{hasShipItems && (
|
||
<fieldset className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100">
|
||
<legend className="text-xl font-bold text-slate-900">Shipping Address</legend>
|
||
<p className="mt-1 text-sm text-slate-500">
|
||
Enter your shipping details for delivery.
|
||
</p>
|
||
|
||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||
<div className="sm:col-span-3">
|
||
<label htmlFor="shipping_address" className="block text-sm font-medium text-slate-700">
|
||
Street Address
|
||
</label>
|
||
<input
|
||
id="shipping_address"
|
||
name="shipping_address"
|
||
autoComplete="street-address"
|
||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||
placeholder="123 Main St"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="shipping_city" className="block text-sm font-medium text-slate-700">
|
||
City
|
||
</label>
|
||
<input
|
||
id="shipping_city"
|
||
name="shipping_city"
|
||
autoComplete="address-level2"
|
||
value={shippingCity}
|
||
onChange={(e) => setShippingCity(e.target.value)}
|
||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="shipping_state" className="block text-sm font-medium text-slate-700">
|
||
State
|
||
</label>
|
||
<input
|
||
id="shipping_state"
|
||
name="shipping_state"
|
||
autoComplete="address-level1"
|
||
maxLength={2}
|
||
value={shippingState}
|
||
onChange={(e) => setShippingState(e.target.value.toUpperCase())}
|
||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all uppercase"
|
||
placeholder="NC"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="shipping_postal_code" className="block text-sm font-medium text-slate-700">
|
||
ZIP Code
|
||
</label>
|
||
<input
|
||
id="shipping_postal_code"
|
||
name="shipping_postal_code"
|
||
autoComplete="postal-code"
|
||
value={shippingPostal}
|
||
onChange={(e) => setShippingPostal(e.target.value)}
|
||
className="mt-1 w-full rounded-xl border border-slate-300 px-4 py-3 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 transition-all"
|
||
placeholder="28147"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</fieldset>
|
||
)}
|
||
|
||
{/* Embedded Stripe Elements (Express + Card) — real checkout */}
|
||
<div data-testid="stripe-express-checkout">
|
||
<StripeExpressCheckout
|
||
items={expressItems}
|
||
brandId={cartBrandId}
|
||
customerName={customerName}
|
||
customerEmail={customerEmail}
|
||
customerPhone={customerPhone}
|
||
selectedStop={selectedStop as StopInfo | null}
|
||
shippingState={shippingState}
|
||
shippingPostal={shippingPostal}
|
||
shippingCity={shippingCity}
|
||
checkoutSessionKey={idempotencyKey || undefined}
|
||
onHostedCheckout={() => void handleHostedCheckout()}
|
||
/>
|
||
</div>
|
||
|
||
{/* Hosted-checkout fallback (legacy Stripe Checkout) */}
|
||
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50/60 p-4">
|
||
<p className="text-xs text-slate-500 mb-2 text-center">
|
||
Prefer Stripe’s hosted page? Tap below.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleHostedCheckout()}
|
||
disabled={hostedLoading}
|
||
className="w-full rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-800 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||
>
|
||
{hostedLoading ? (
|
||
<>
|
||
<span className="h-4 w-4 rounded-full border-2 border-slate-300 border-t-slate-700 animate-spin" />
|
||
Redirecting to Stripe…
|
||
</>
|
||
) : (
|
||
<>Use secure hosted checkout →</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Order Summary */}
|
||
<aside aria-label="Order summary">
|
||
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 border border-slate-100 sticky top-6">
|
||
<h2 className="text-xl font-bold text-slate-900">Order Summary</h2>
|
||
|
||
<div className="mt-4 space-y-3">
|
||
{cart.map((item) => (
|
||
<div key={item.id} className="flex justify-between text-sm">
|
||
<span className="text-slate-600">
|
||
{item.name} × {item.quantity}
|
||
{item.fulfillment === "ship" && (
|
||
<span className="ml-1 text-xs text-blue-500">(ship)</span>
|
||
)}
|
||
</span>
|
||
<span className="font-medium text-slate-900">
|
||
${(Number(item.price.replace("$", "")) * item.quantity).toFixed(2)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-4 border-t border-slate-200 pt-4 space-y-2">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-slate-600">Subtotal</span>
|
||
<span className="text-slate-900">${subtotal.toFixed(2)}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-slate-600">Estimated Tax</span>
|
||
<span className="text-slate-900 text-xs italic">Calculated at payment</span>
|
||
</div>
|
||
<div className="flex justify-between text-xl font-bold text-slate-900 pt-2 border-t border-slate-200">
|
||
<span>Total</span>
|
||
<span>${subtotal.toFixed(2)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Trust badges */}
|
||
<div className="mt-4 space-y-1.5">
|
||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||
</svg>
|
||
<span>Secured by Stripe</span>
|
||
</div>
|
||
<div className="flex items-center justify-center gap-2 text-xs text-slate-500">
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||
</svg>
|
||
<span>100% Sweetness Guarantee</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</main>
|
||
|
||
<StorefrontFooter brandName="Tuxedo Corn" brandSlug="tuxedo" />
|
||
</div>
|
||
);
|
||
}
|