feat: initial commit — storefront, convex backend, and shared packages

Completes the first milestone of The Pet Loft ecommerce platform:
- apps/storefront: full customer-facing Next.js app with HeroUI (cart,
  checkout, orders, wishlist, product detail, shop, search, auth)
- convex/: serverless backend with schema, queries, mutations, actions,
  HTTP routes, Stripe/Shippo integrations, and co-located tests
- packages/types, packages/utils, packages/convex: shared workspace packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 09:31:18 +03:00
commit cc15338ad9
361 changed files with 45005 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Account",
robots: { index: false, follow: false },
};
export default function AccountLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="w-full max-w-full px-4 py-8 md:px-6 lg:mx-auto lg:max-w-5xl lg:px-8">
{children}
</main>
);
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { OrderDetailPageView } from "@/components/orders/OrderDetailPageView";
type Props = {
params: Promise<{ orderId: string }>;
};
export const metadata: Metadata = {
title: "Order Details",
};
export default async function OrderDetailPage({ params }: Props) {
const { orderId } = await params;
return (
<main className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<OrderDetailPageView orderId={orderId} />
</main>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import { Skeleton } from "@heroui/react";
import { OrdersSkeleton } from "@/components/orders/state/OrdersSkeleton";
export default function OrdersLoading() {
return (
<div className="space-y-6">
{/* Page heading skeleton */}
<div className="space-y-2">
<Skeleton className="h-8 w-40 rounded-md" />
<Skeleton className="h-4 w-56 rounded-md" />
</div>
<OrdersSkeleton />
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { SectionHeading } from "@/utils/common/heading/section_heading";
import { OrdersPageView } from "@/components/orders/OrdersPageView";
export default function OrdersPage() {
return (
<div className="space-y-6">
<div>
<SectionHeading title="My Orders" as="h1" />
<p className="mt-1 text-sm text-gray-500">
View and manage your order history.
</p>
</div>
<OrdersPageView />
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useConvexAuth, useQuery } from "convex/react";
import Link from "next/link";
import { api } from "../../../../../convex/_generated/api";
const sections = [
{
title: "Order History",
description: "View your past orders and track current ones.",
href: "/account/orders",
cta: "View orders",
},
{
title: "Wishlist",
description: "View and manage your saved items.",
href: "/wishlist",
cta: "Coming soon",
},
{
title: "Addresses",
description: "Manage your shipping and billing addresses.",
href: "/account/addresses",
cta: "Coming soon",
},
{
title: "Profile Settings",
description: "Update your name, email, and preferences.",
href: "/account/settings",
cta: "Coming soon",
},
] as const;
export default function AccountPage() {
const { isAuthenticated, isLoading: authLoading } = useConvexAuth();
const user = useQuery(
api.users.current,
isAuthenticated ? {} : "skip",
);
if (authLoading || (isAuthenticated && user === undefined)) {
return <AccountSkeleton />;
}
if (!isAuthenticated || user === null) {
return (
<div className="py-16 text-center">
<p className="text-gray-500">You need to sign in to view your account.</p>
<Link
href="/sign-in"
className="mt-4 inline-block rounded-md bg-[#236f6b] px-6 py-2 text-sm font-medium text-white hover:bg-[#1b5955] transition-colors"
>
Sign In
</Link>
</div>
);
}
return (
<div className="space-y-8">
<div>
<h1 className="font-serif text-2xl font-semibold text-gray-900 md:text-3xl">
My Account
</h1>
<p className="mt-1 text-sm text-gray-500">
Welcome back, {user.name}
</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6">
<h2 className="text-sm font-medium uppercase tracking-wide text-gray-500">
Account Details
</h2>
<dl className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<dt className="text-sm text-gray-500">Name</dt>
<dd className="mt-1 text-sm font-medium text-gray-900">{user.name}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Email</dt>
<dd className="mt-1 text-sm font-medium text-gray-900">{user.email}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Role</dt>
<dd className="mt-1 text-sm font-medium text-gray-900 capitalize">{user.role}</dd>
</div>
</dl>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{sections.map((section) => (
<Link
key={section.href}
href={section.href}
className="group rounded-lg border border-gray-200 bg-white p-6 transition-shadow hover:shadow-md"
>
<h3 className="font-medium text-gray-900 group-hover:text-[#236f6b]">
{section.title}
</h3>
<p className="mt-1 text-sm text-gray-500">{section.description}</p>
<span className="mt-3 inline-block text-sm font-medium text-[#236f6b]">
{section.cta} &rarr;
</span>
</Link>
))}
</div>
</div>
);
}
function AccountSkeleton() {
return (
<div className="animate-pulse space-y-8">
<div>
<div className="h-8 w-40 rounded bg-gray-200" />
<div className="mt-2 h-4 w-56 rounded bg-gray-200" />
</div>
<div className="rounded-lg border border-gray-200 bg-white p-6">
<div className="h-4 w-32 rounded bg-gray-200" />
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i}>
<div className="h-3 w-16 rounded bg-gray-200" />
<div className="mt-2 h-4 w-36 rounded bg-gray-200" />
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="h-5 w-28 rounded bg-gray-200" />
<div className="mt-2 h-4 w-full rounded bg-gray-200" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { CartPageLayout } from "@/components/cart/containers/CartPageLayout";
import { CartContent } from "@/components/cart/content/CartContent";
import { CartContentSkeleton } from "@/components/cart/state/CartContentSkeleton";
import { CartErrorState } from "@/components/cart/state/CartErrorState";
import { useCart } from "@/lib/cart/useCart";
import { useCartMutations } from "@/lib/cart/useCartMutations";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
/**
* Cart page: wires Convex data and mutations to shared CartContent.
* Shows skeleton while loading, error state on failure, otherwise CartContent with real data.
*/
export function CartPageView() {
const sessionId = useCartSessionId();
const { items, subtotal, isLoading, error } = useCart(sessionId);
const { updateItem, removeItem } = useCartMutations(sessionId);
if (isLoading) {
return (
<CartPageLayout itemCount={0}>
<CartContentSkeleton />
</CartPageLayout>
);
}
if (error) {
return (
<CartPageLayout itemCount={0}>
<CartErrorState
message={error.message ?? "Something went wrong loading your cart."}
onRetry={() => window.location.reload()}
/>
</CartPageLayout>
);
}
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
return (
<CartPageLayout itemCount={itemCount}>
<CartContent
items={items}
subtotal={subtotal}
onUpdateQuantity={updateItem}
onRemove={removeItem}
isEmpty={items.length === 0}
onViewFullCart={undefined}
hideHeading
layout="page"
/>
</CartPageLayout>
);
}

View File

@@ -0,0 +1,18 @@
import type { Metadata } from "next";
/**
* Cart layout: noindex/nofollow and title (Phase 6 rules compliance).
* See docs/development/ui-implementation-rules/07-cart-checkout.md.
*/
export const metadata: Metadata = {
title: "Cart",
robots: { index: false, follow: false },
};
export default function CartLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,13 @@
import { CartPageLayout } from "@/components/cart/containers/CartPageLayout";
import { CartContentSkeleton } from "@/components/cart/state/CartContentSkeleton";
/**
* Cart page loading state: heading + card-based skeleton.
*/
export default function CartLoading() {
return (
<CartPageLayout>
<CartContentSkeleton />
</CartPageLayout>
);
}

View File

@@ -0,0 +1,10 @@
import { CartPageView } from "./CartPageView";
/**
* Cart page: full-page cart with shared content.
* Meta (noindex, title) in layout.tsx. Loading state in loading.tsx.
* Visible h1 and item count come from CartPageLayout + CartContent.
*/
export default function CartPage() {
return <CartPageView />;
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
import { useCartValidation, useShippingAddresses } from "@/lib/checkout";
import type { CheckoutStep } from "@/lib/checkout/constants";
import type { CheckoutSelectedShippingRate } from "@/lib/checkout/types";
import { CheckoutShell } from "@/components/checkout/CheckoutShell";
import { CartValidationStep } from "@/components/checkout/steps/CartValidationStep";
import { ShippingAddressStep } from "@/components/checkout/steps/ShippingAddressStep";
import { OrderReviewStep } from "@/components/checkout/steps/OrderReviewStep";
import { PaymentStep } from "@/components/checkout/steps/PaymentStep";
import { CheckoutSkeleton } from "@/components/checkout/state/CheckoutSkeleton";
import { CheckoutErrorState } from "@/components/checkout/state/CheckoutErrorState";
export function CheckoutPageView() {
const sessionId = useCartSessionId();
const { result, isLoading } = useCartValidation(sessionId);
const { addresses, isLoading: isLoadingAddresses } = useShippingAddresses();
const router = useRouter();
const [currentStep, setCurrentStep] = useState<CheckoutStep>("validation");
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(null);
const [shipmentObjectId, setShipmentObjectId] = useState<string | null>(null);
const [selectedShippingRate, setSelectedShippingRate] = useState<CheckoutSelectedShippingRate | null>(null);
const isEmpty = !isLoading && result !== null && result.items.length === 0;
useEffect(() => {
if (isEmpty) router.replace("/cart");
}, [isEmpty, router]);
if (isLoading) {
return (
<CheckoutShell currentStep="validation">
<CheckoutSkeleton />
</CheckoutShell>
);
}
if (!result) {
return (
<CheckoutShell currentStep="validation">
<CheckoutErrorState
message="Something went wrong loading your checkout."
onRetry={() => window.location.reload()}
/>
</CheckoutShell>
);
}
if (result.items.length === 0) return null;
return (
<CheckoutShell currentStep={currentStep}>
{currentStep === "validation" && (
<CartValidationStep
result={result}
onProceed={() => setCurrentStep("shipping-address")}
onBackToCart={() => router.push("/cart")}
/>
)}
{currentStep === "shipping-address" && (
<ShippingAddressStep
addresses={addresses}
isLoadingAddresses={isLoadingAddresses}
onProceed={(addressId) => {
setSelectedAddressId(addressId);
setCurrentStep("review");
}}
onBack={() => setCurrentStep("validation")}
/>
)}
{currentStep === "review" && selectedAddressId && result && (
<OrderReviewStep
addressId={selectedAddressId}
addresses={addresses}
cartResult={result}
sessionId={sessionId}
onProceed={(shipmentId, shippingRate) => {
setShipmentObjectId(shipmentId);
setSelectedShippingRate(shippingRate);
setCurrentStep("payment");
}}
onBack={() => setCurrentStep("shipping-address")}
/>
)}
{currentStep === "payment" &&
selectedAddressId &&
shipmentObjectId &&
selectedShippingRate && (
<PaymentStep
shipmentObjectId={shipmentObjectId}
selectedShippingRate={selectedShippingRate}
addressId={selectedAddressId}
sessionId={sessionId}
onBack={() => setCurrentStep("review")}
/>
)}
</CheckoutShell>
);
}

View File

@@ -0,0 +1,18 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Checkout",
robots: { index: false, follow: false },
};
export default function CheckoutLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="w-full max-w-full px-4 py-6 md:px-6 lg:mx-auto lg:max-w-5xl lg:px-8">
{children}
</main>
);
}

View File

@@ -0,0 +1,5 @@
import { CheckoutSkeleton } from "@/components/checkout/state/CheckoutSkeleton";
export default function CheckoutLoading() {
return <CheckoutSkeleton />;
}

View File

@@ -0,0 +1,7 @@
"use client";
import { CheckoutPageView } from "./CheckoutPageView";
export default function CheckoutPage() {
return <CheckoutPageView />;
}

View File

@@ -0,0 +1,253 @@
"use client";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAction } from "convex/react";
import { Button, Card, Link, Spinner } from "@heroui/react";
import { api } from "../../../../../../convex/_generated/api";
type PageState =
| { phase: "loading" }
| { phase: "complete"; email: string | null }
| { phase: "incomplete" }
| { phase: "error"; message: string };
// ─── Main Content (inside Suspense) ──────────────────────────────────────────
function SuccessContent() {
const searchParams = useSearchParams();
const sessionId = searchParams.get("session_id");
const getStatus = useAction(api.stripeActions.getCheckoutSessionStatus);
const [state, setState] = useState<PageState>({ phase: "loading" });
const fetchRef = useRef(0);
const fetchStatus = useCallback(async () => {
if (!sessionId) {
setState({
phase: "error",
message:
"No payment session found. If you completed a payment, please check your email for confirmation.",
});
return;
}
const id = ++fetchRef.current;
setState({ phase: "loading" });
try {
const result = await getStatus({ sessionId });
if (id !== fetchRef.current) return;
if (result.status === "complete") {
setState({ phase: "complete", email: result.customerEmail });
} else {
setState({ phase: "incomplete" });
}
} catch {
if (id !== fetchRef.current) return;
setState({
phase: "error",
message:
"Unable to retrieve payment status. Please check your email for confirmation or contact support.",
});
}
}, [sessionId, getStatus]);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
if (state.phase === "loading") {
return <LoadingState />;
}
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Card className="w-full max-w-md rounded-xl p-6 md:p-8">
<Card.Content className="flex flex-col items-center gap-5 p-0 text-center">
{state.phase === "complete" && (
<CompleteView email={state.email} />
)}
{state.phase === "incomplete" && <IncompleteView />}
{state.phase === "error" && <ErrorView message={state.message} />}
</Card.Content>
</Card>
</div>
);
}
// ─── State Views ─────────────────────────────────────────────────────────────
function CompleteView({ email }: { email: string | null }) {
return (
<>
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-50">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-8 text-[#236f6b]"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div className="space-y-2">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-semibold">
Payment successful!
</h1>
{email && (
<p className="text-sm text-default-500">
A confirmation has been sent to{" "}
<span className="font-medium text-foreground">{email}</span>.
</p>
)}
<p className="text-sm text-default-500">
Your order has been placed and you&apos;ll receive a confirmation
email shortly.
</p>
</div>
<div className="flex w-full flex-col gap-2 pt-2">
<Button
as={Link}
href="/account/orders"
color="primary"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
>
View your orders
</Button>
<Button as={Link} href="/shop" variant="ghost" className="w-full">
Continue shopping
</Button>
</div>
</>
);
}
function IncompleteView() {
return (
<>
<div className="flex size-16 items-center justify-center rounded-full bg-amber-50">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-8 text-amber-600"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
</div>
<div className="space-y-2">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-semibold">
Payment was not completed
</h1>
<p className="text-sm text-default-500">
Your payment was not completed. No charges have been made. You can
return to checkout to try again.
</p>
</div>
<div className="flex w-full flex-col gap-2 pt-2">
<Button
as={Link}
href="/checkout"
color="primary"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
>
Return to checkout
</Button>
<Button as={Link} href="/shop" variant="ghost" className="w-full">
Continue shopping
</Button>
</div>
</>
);
}
function ErrorView({ message }: { message: string }) {
return (
<>
<div className="flex size-16 items-center justify-center rounded-full bg-red-50">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-8 text-danger"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
/>
</svg>
</div>
<div className="space-y-2">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-semibold">
Something went wrong
</h1>
<p className="text-sm text-default-500">{message}</p>
</div>
<div className="flex w-full flex-col gap-2 pt-2">
<Button
as={Link}
href="/checkout"
color="primary"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
>
Return to checkout
</Button>
<Button as={Link} href="/" variant="ghost" className="w-full">
Go to homepage
</Button>
</div>
</>
);
}
// ─── Loading State ───────────────────────────────────────────────────────────
function LoadingState() {
return (
<div
className="flex min-h-[60vh] flex-col items-center justify-center gap-4"
role="status"
>
<Spinner size="lg" />
<p className="text-sm text-default-500">Verifying your payment</p>
</div>
);
}
// ─── Page Export ──────────────────────────────────────────────────────────────
export default function CheckoutSuccessPage() {
return (
<Suspense fallback={<LoadingState />}>
<SuccessContent />
</Suspense>
);
}

View File

@@ -0,0 +1,196 @@
@import "tailwindcss";
@import "@heroui/styles";
/*
* The Pet Loft Brand Theme — mapped to HeroUI CSS variables
*
* Primary Teal: #38a99f (Brand) · #236f6b (Deep) · #8dd5d1 (Soft) · #e8f7f6 (Mist)
* Accent Warm: #f4a13a (Amber) · #fde8c8 (Cream)
* Accent Coral: #f2705a (Coral) · #fce0da (Blush)
* Neutrals: #1a2e2d (Forest) · #3d5554 (Moss) · #8aa9a8 (Sage) · #f0f8f7 (Ice)
*/
:root,
.light,
.default,
[data-theme="light"],
[data-theme="default"] {
/* The Pet Loft brand palette — beyond HeroUI variables */
--brand: #38a99f;
--brand-dark: #236f6b;
--brand-light: #8dd5d1;
--brand-mist: #e8f7f6;
--warm: #f4a13a;
--warm-light: #fde8c8;
--coral: #f2705a;
--coral-light: #fce0da;
--neutral-900: #1a2e2d;
--neutral-700: #3d5554;
--neutral-400: #8aa9a8;
--neutral-100: #f0f8f7;
/* Brand Teal #38a99f — primary actions, links, focus */
--accent: oklch(66.96% 0.1009 186.67);
--accent-foreground: oklch(100.00% 0 0);
/* Ice White #f0f8f7 — page background */
--background: oklch(97.28% 0.0086 188.66);
/* Sage Mist #8aa9a8 — borders */
--border: oklch(71.13% 0.0341 194.09);
--danger: oklch(65.32% 0.2335 17.37);
--danger-foreground: oklch(99.11% 0 0);
/* Teal Mist #e8f7f6 — default/neutral chip & tag fills */
--default: oklch(96.47% 0.0159 192.37);
--default-foreground: oklch(28.38% 0.0260 191.82);
/* White #ffffff — input backgrounds */
--field-background: oklch(100.00% 0.0001 263.28);
--field-foreground: oklch(28.38% 0.0260 191.82);
/* Sage Mist #8aa9a8 — placeholders */
--field-placeholder: oklch(71.13% 0.0341 194.09);
/* Brand Teal — focus rings */
--focus: oklch(66.96% 0.1009 186.67);
/* Forest Black #1a2e2d — body text */
--foreground: oklch(28.38% 0.0260 191.82);
/* Moss Grey #3d5554 — muted/secondary text */
--muted: oklch(42.97% 0.0292 192.97);
/* White — overlays */
--overlay: oklch(100.00% 0.0001 263.28);
--overlay-foreground: oklch(28.38% 0.0260 191.82);
--scrollbar: oklch(71.13% 0.0341 194.09);
/* White — segmented controls */
--segment: oklch(100.00% 0.0001 263.28);
--segment-foreground: oklch(28.38% 0.0260 191.82);
--separator: oklch(96.47% 0.0159 192.37);
--success: oklch(73.29% 0.1941 142.44);
--success-foreground: oklch(21.03% 0.0059 142.44);
/* White — card surfaces */
--surface: oklch(100.00% 0.0001 263.28);
--surface-foreground: oklch(28.38% 0.0260 191.82);
/* Teal Mist #e8f7f6 — secondary surfaces */
--surface-secondary: oklch(96.47% 0.0159 192.37);
--surface-secondary-foreground: oklch(28.38% 0.0260 191.82);
/* Ice White #f0f8f7 — tertiary surfaces */
--surface-tertiary: oklch(97.28% 0.0086 188.66);
--surface-tertiary-foreground: oklch(28.38% 0.0260 191.82);
--warning: oklch(78.19% 0.1590 63.96);
--warning-foreground: oklch(21.03% 0.0059 63.96);
--radius: 0.75rem;
--field-radius: 0.75rem;
/* Fonts: DM Sans (body) + Fraunces (headings) */
--font-sans: var(--font-dm-sans);
--font-serif: var(--font-fraunces);
/* Design system: 8px grid (spatial system) */
--spacing-1: 0.25rem; /* 4px — tight */
--spacing-2: 0.5rem; /* 8px — base unit */
--spacing-3: 0.75rem; /* 12px — compact */
--spacing-4: 1rem; /* 16px — standard */
--spacing-6: 1.5rem; /* 24px — comfortable */
--spacing-8: 2rem; /* 32px — generous */
--spacing-12: 3rem; /* 48px — section spacing */
--spacing-16: 4rem; /* 64px — large sections */
/* Design system: transition speeds */
--transition-fast: 150ms; /* color changes, small UI updates */
--transition-base: 250ms; /* standard interactions */
--transition-slow: 350ms; /* complex animations */
--transition-bounce: 400ms; /* playful hover states */
--transition-spring: 600ms; /* entrances/exits */
/* Design system: breakpoints (use in media queries; values in px) */
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
}
/*
* Base layer — PetPaws branding + Design System (DESIGN_SYSTEM_DOCS.md, petpaws-branding).
* Body: DM Sans (--font-sans), Forest Black text (--foreground), Ice White page bg (--background).
* Borders: Sage Mist (--border). Headings use Fraunces via .font-serif / font-[family-name:var(--font-serif)].
* Smooth scroll; 8px grid spacing via --spacing-*; transitions via --transition-*.
*/
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
border-color: var(--color-border);
}
html {
overflow-x: hidden;
scroll-behavior: smooth;
max-width: 100%;
}
body {
background: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 1rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
max-width: 100%;
overflow-x: hidden;
}
}
/* Design system: carousel / marquee (use duration with var(--transition-spring) or var(--transition-base)) */
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
/* Continuous right-to-left marquee for utility bar */
.animate-marquee {
animation: scroll 25s linear infinite;
}
/* Playful micro-animation (design system: bouncy hover feel) */
@keyframes sparkle {
0%, 100% {
opacity: 0.3;
transform: translateY(0) scale(1);
}
50% {
opacity: 0.8;
transform: translateY(-10px) scale(1.2);
}
}
/* Hide scrollbar for carousel / horizontal scroll areas */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

View File

@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { DM_Sans, Fraunces } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import { ConvexClientProvider } from "@repo/convex";
import { CartUIProvider } from "../components/cart/CartUIProvider";
import { Header } from "../components/layout/header/Header";
import { SessionCartMerge } from "../lib/session/SessionCartMerge";
import { StoreUserSync } from "../lib/session/StoreUserSync";
import { Footer } from "../components/layout/footer/Footer";
import { ToastProvider } from "../components/layout/ToastProvider";
import "./globals.css";
const dmSans = DM_Sans({
subsets: ["latin"],
weight: ["300", "400", "500"],
variable: "--font-dm-sans",
});
const fraunces = Fraunces({
subsets: ["latin"],
weight: ["400", "600", "700"],
variable: "--font-fraunces",
});
export const metadata: Metadata = {
title: {
template: "%s | The Pet Loft",
default: "The Pet Loft — Pet Supplies & More",
},
description: "Your one-stop shop for premium pet supplies",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body
className={`${dmSans.variable} ${fraunces.variable} font-sans flex min-h-screen max-w-full flex-col overflow-x-hidden`}
>
<ClerkProvider>
<ConvexClientProvider>
<SessionCartMerge />
<StoreUserSync />
<CartUIProvider>
<Header />
{children}
<Footer />
<ToastProvider />
</CartUIProvider>
</ConvexClientProvider>
</ClerkProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,46 @@
import Image from "next/image";
import Link from "next/link";
import { SectionHeading } from "../utils/common/heading/section_heading";
export default function NotFound() {
return (
<main className="flex flex-1 items-center justify-center bg-[var(--background)] px-4 py-16 md:px-6 md:py-24">
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-8 md:flex-row md:gap-12">
<div className="flex w-full flex-col items-center text-center md:w-1/2 md:items-start md:text-left">
<SectionHeading title="Page Not Found" as="h1" className="text-3xl md:text-4xl" />
<p className="mt-4 max-w-md text-base leading-relaxed text-[var(--muted)] md:text-lg">
Sorry, we couldn&apos;t find the page you&apos;re looking for. It
may have been moved, renamed, or no longer exists.
</p>
<Link
href="/"
className="mt-8 inline-flex items-center rounded-full bg-[#f4a13a] px-8 py-3 text-sm font-medium text-[#1a2e2d] transition-opacity hover:opacity-90 md:w-auto"
>
Back to Home
</Link>
</div>
<div className="order-first flex w-full max-w-xs flex-col items-center gap-2 md:order-last md:w-1/2 md:max-w-none">
<Image
src="/content/illustrations/404_not_found.svg"
alt="Page not found illustration"
width={500}
height={500}
priority
className="h-auto w-full"
/>
<a
href="https://storyset.com/online"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-[var(--muted)] hover:underline"
>
Online illustrations by Storyset
</a>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { CtaSection } from "../components/sections/hompepage/cta/CtaSection";
import { CategorySection } from "../components/sections/hompepage/category/CategorySection";
import { NewsletterSection } from "../components/sections/hompepage/newsletter/NewsletterSection";
import { RecentlyAddedSection } from "../components/sections/hompepage/products-sections/recently-added/RecentlyAddedSection";
import { SpecialOffersSection } from "../components/sections/hompepage/products-sections/special-offers/SpecialOffersSection";
import { TopPicksSection } from "../components/sections/hompepage/products-sections/top-picks/TopPicsSection";
import { WishlistSection } from "../components/sections/hompepage/wishlist/WishlistSection";
import { Toast } from "@heroui/react";
export default function HomePage() {
return (
<main className="min-h-screen min-w-0 max-w-full overflow-x-hidden bg-background px-4 py-8 text-foreground md:px-6">
<Toast.Provider placement="top" className="bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm" />
<CtaSection />
<CategorySection />
<WishlistSection />
<RecentlyAddedSection />
<SpecialOffersSection />
<TopPicksSection />
<NewsletterSection />
</main>
);
}

View File

@@ -0,0 +1,57 @@
/**
* Page-level loading fallback for /shop/[category]/[subCategory]/[slug].
* Mirrors the new PDP layout: hero grid + tab navigation + content skeleton.
* Uses Tailwind only so this stays a Server Component (HeroUI Skeleton is client-only).
*/
export default function ProductDetailLoading() {
return (
<main className="mx-auto w-full max-w-7xl px-4 py-6 md:py-8">
{/* Breadcrumb */}
<div className="mb-6 h-4 w-2/3 max-w-sm animate-pulse rounded bg-[var(--muted)] md:mb-8" />
{/* Hero grid */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-10">
{/* Gallery */}
<div className="flex flex-col gap-3">
<div className="aspect-square w-full animate-pulse rounded-xl bg-[var(--muted)] md:aspect-[4/3]" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-16 w-16 shrink-0 animate-pulse rounded-lg bg-[var(--muted)]"
/>
))}
</div>
</div>
{/* Buy box */}
<div className="flex flex-col gap-5">
<div className="h-6 w-20 animate-pulse rounded-full bg-[var(--muted)]" />
<div className="h-8 w-full max-w-sm animate-pulse rounded bg-[var(--muted)]" />
<div className="h-4 w-32 animate-pulse rounded bg-[var(--muted)]" />
<div className="h-7 w-28 animate-pulse rounded bg-[var(--muted)]" />
<div className="h-6 w-20 animate-pulse rounded-full bg-[var(--muted)]" />
<div className="flex flex-col gap-4 md:flex-row">
<div className="h-10 w-full animate-pulse rounded-lg bg-[var(--muted)] md:w-36" />
<div className="h-12 w-full animate-pulse rounded-lg bg-[var(--muted)] md:w-44" />
</div>
</div>
</div>
{/* Tab navigation + first section skeleton */}
<div className="mt-8 md:mt-12">
<div className="flex gap-4 border-b border-[var(--muted)]">
<div className="h-10 w-28 animate-pulse rounded-t bg-[var(--muted)]" />
<div className="h-10 w-20 animate-pulse rounded-t bg-[var(--muted)]" />
<div className="h-10 w-24 animate-pulse rounded-t bg-[var(--muted)]" />
</div>
<div className="space-y-2 py-6 md:py-8">
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-[var(--muted)]" />
<div className="h-4 w-full max-w-2xl animate-pulse rounded bg-[var(--muted)]" />
<div className="h-4 w-full max-w-2xl animate-pulse rounded bg-[var(--muted)]" />
<div className="h-4 w-3/4 max-w-xl animate-pulse rounded bg-[var(--muted)]" />
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,25 @@
import Link from "next/link";
/**
* Product-specific 404 when slug is invalid or product not found / not active
* at /shop/[category]/[subCategory]/[slug].
*/
export default function ProductNotFound() {
return (
<div className="mx-auto max-w-2xl px-4 py-16 text-center">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[var(--foreground)] md:text-3xl">
Product not found
</h1>
<p className="mt-3 text-[var(--muted)]">
This product doesn&apos;t exist or is no longer available. Try browsing
the shop.
</p>
<Link
href="/shop"
className="mt-6 inline-block rounded-lg bg-[var(--brand-dark)] px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--brand-hover)] w-full md:w-auto"
>
Back to Shop
</Link>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { fetchQuery } from "convex/nextjs";
import { api } from "../../../../../../../../convex/_generated/api";
import { ProductDetailContent } from "@/components/product-detail/ProductDetailContent";
import type { ProductDetailParams } from "@/lib/product-detail/constants";
import { getProductDetailPath } from "@/lib/product-detail/constants";
import { isPetCategorySlug } from "@/lib/shop/constants";
import { ProductDetailStructuredData } from "@/components/product-detail/ProductDetailStructuredData";
// PPR: when using Next.js canary, uncomment so PDP uses Partial Prerendering:
// export const experimental_ppr = true;
type Props = {
params: Promise<ProductDetailParams>;
};
function isValidSlug(slug: string): boolean {
return typeof slug === "string" && slug.length > 0 && !slug.includes("/");
}
/** Strip HTML tags for plain-text meta description. */
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
/** Meta description: 150160 chars per SEO rule. */
function metaDescription(
seoDescription: string | undefined | null,
rawDescription: string | undefined | null,
): string | undefined {
if (seoDescription && seoDescription.trim()) {
return seoDescription.slice(0, 160);
}
if (rawDescription && rawDescription.trim()) {
return stripHtml(rawDescription).slice(0, 160);
}
return undefined;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { category, subCategory, slug } = await params;
if (!isPetCategorySlug(category) || !isValidSlug(slug)) {
return { title: "Not Found" };
}
const resolved = await fetchQuery(api.categories.getByPath, {
categorySlug: category,
subCategorySlug: subCategory,
});
if (!resolved) {
return { title: "Not Found" };
}
const product = await fetchQuery(api.products.getBySlug, { slug });
if (!product) {
return { title: "Not Found" };
}
const title = product.seoTitle ?? product.name;
const description = metaDescription(
product.seoDescription,
product.description,
);
const canonicalPath = getProductDetailPath(category, subCategory, slug);
const baseUrl =
process.env.NEXT_PUBLIC_APP_URL ?? process.env.NEXT_PUBLIC_STOREFRONT_URL ?? "";
const canonical = baseUrl ? `${baseUrl.replace(/\/$/, "")}${canonicalPath}` : undefined;
const firstImage =
product.images && product.images.length > 0 ? product.images[0] : null;
const openGraphImages =
firstImage?.url != null
? [{ url: firstImage.url, alt: firstImage.alt ?? product.name }]
: undefined;
return {
title,
description,
alternates: canonical ? { canonical } : undefined,
openGraph: {
title,
description: description ?? undefined,
url: canonical,
images: openGraphImages,
type: "website",
},
twitter: {
card: "summary_large_image",
title,
description: description ?? undefined,
images: firstImage?.url != null ? [firstImage.url] : undefined,
},
};
}
export default async function ProductDetailPage({ params }: Props) {
const { category, subCategory, slug } = await params;
if (!isPetCategorySlug(category)) {
notFound();
}
const resolved = await fetchQuery(api.categories.getByPath, {
categorySlug: category,
subCategorySlug: subCategory,
});
if (!resolved) {
notFound();
}
if (!isValidSlug(slug)) {
notFound();
}
const product = await fetchQuery(api.products.getBySlug, { slug });
if (!product) {
notFound();
}
return (
<>
<ProductDetailStructuredData
category={category}
subCategory={subCategory}
slug={slug}
productName={product.name}
subCategoryName={resolved.name}
/>
<ProductDetailContent
category={category}
subCategory={subCategory}
slug={slug}
categoryId={product.categoryId}
/>
</>
);
}

View File

@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { fetchQuery } from "convex/nextjs";
import { api } from "../../../../../../../convex/_generated/api";
import type { Id } from "../../../../../../../convex/_generated/dataModel";
import { isPetCategorySlug } from "@/lib/shop/constants";
import { SubCategoryPageContent } from "@/components/shop/SubCategoryPageContent";
type Props = {
params: Promise<{ category: string; subCategory: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { category, subCategory } = await params;
if (!isPetCategorySlug(category)) {
return { title: "Not Found" };
}
const resolved = await fetchQuery(api.categories.getByPath, {
categorySlug: category,
subCategorySlug: subCategory,
});
if (!resolved) {
return { title: "Not Found" };
}
const title = resolved.seoTitle ?? resolved.name;
const description =
resolved.seoDescription ?? resolved.description ?? `Shop ${resolved.name}`;
return { title, description };
}
export default async function ShopSubCategoryPage({ params }: Props) {
const { category, subCategory } = await params;
if (!isPetCategorySlug(category)) {
notFound();
}
const resolved = await fetchQuery(api.categories.getByPath, {
categorySlug: category,
subCategorySlug: subCategory,
});
if (!resolved) {
notFound();
}
const subCategoryData = {
_id: resolved._id as Id<"categories">,
name: resolved.name,
description: resolved.description,
slug: resolved.slug,
parentId: resolved.parentId as Id<"categories">,
};
return (
<SubCategoryPageContent
categorySlug={category}
subCategorySlug={subCategory}
subCategory={subCategoryData}
/>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { PetCategoryPage } from "@/components/shop/PetCategoryPage";
export const metadata: Metadata = {
title: "Birds",
description: "Shop products for birds",
};
export default function ShopBirdsPage() {
return <PetCategoryPage slug="birds" />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
export const metadata: Metadata = {
title: "Bowl",
description: "Shop bowls and feeders for dogs, cats, birds, and more",
};
export default function ShopBowlPage() {
return <TopCategoryPage slug="bowl" />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
export const metadata: Metadata = {
title: "Carrier",
description: "Shop carriers and travel gear for dogs, cats, birds, and more",
};
export default function ShopCarrierPage() {
return <TopCategoryPage slug="carrier" />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { PetCategoryPage } from "@/components/shop/PetCategoryPage";
export const metadata: Metadata = {
title: "Cats",
description: "Shop products for cats",
};
export default function ShopCatsPage() {
return <PetCategoryPage slug="cats" />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { PetCategoryPage } from "@/components/shop/PetCategoryPage";
export const metadata: Metadata = {
title: "Dogs",
description: "Shop products for dogs",
};
export default function ShopDogsPage() {
return <PetCategoryPage slug="dogs" />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
export const metadata: Metadata = {
title: "Food",
description: "Shop pet food for dogs, cats, birds, and more",
};
export default function ShopFoodPage() {
return <TopCategoryPage slug="food" />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
export const metadata: Metadata = {
title: "Litter",
description: "Shop litter and litter supplies for cats and small pets",
};
export default function ShopLitterPage() {
return <TopCategoryPage slug="litter" />;
}

View File

@@ -0,0 +1,25 @@
import Link from "next/link";
/**
* Shop-specific 404 when an invalid category or sub-category path is requested
* (e.g. unknown [category] or [subCategory] in /shop/[category]/[subCategory]).
*/
export default function ShopNotFound() {
return (
<div className="mx-auto max-w-2xl px-4 py-16 text-center">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[var(--foreground)] md:text-3xl">
Page not found
</h1>
<p className="mt-3 text-[var(--muted)]">
This shop category or page doesn&apos;t exist. Try browsing from the
shop home.
</p>
<Link
href="/shop"
className="mt-6 inline-block rounded-lg bg-[var(--brand-dark)] px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--brand-hover)]"
>
Back to Shop
</Link>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { ShopIndexContent } from "@/components/shop/ShopIndexContent";
export const metadata: Metadata = {
title: "Shop",
description: "Browse all products",
};
export default function ShopPage() {
return <ShopIndexContent />;
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { RecentlyAddedPage } from "@/components/shop/RecentlyAddedPage";
export const metadata: Metadata = {
title: "Recently Added | The Pet Loft",
description:
"Discover the newest products added to our store in the last 30 days.",
};
export default function ShopRecentlyAddedPage() {
return <RecentlyAddedPage />;
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { TagShopPage } from "@/components/shop/TagShopPage";
export const metadata: Metadata = {
title: "Special Offers | The Pet Loft",
description:
"Browse current deals and discounts on pet food, toys, treats, and accessories.",
};
export default function ShopSalePage() {
return (
<TagShopPage
tag="sale"
title="Special Offers"
subtitle="Great deals on your pet's favorites"
breadcrumbLabel="Special Offers"
/>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { PetCategoryPage } from "@/components/shop/PetCategoryPage";
export const metadata: Metadata = {
title: "Small Pets",
description: "Shop products for small pets",
};
export default function ShopSmallPetsPage() {
return <PetCategoryPage slug="small-pets" />;
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { TagShopPage } from "@/components/shop/TagShopPage";
export const metadata: Metadata = {
title: "Top Picks | The Pet Loft",
description:
"Our curated selection of the best products for your furry, feathered, or scaled friend.",
};
export default function ShopTopPicksPage() {
return (
<TagShopPage
tag="top-picks"
title="Top Picks"
subtitle="Curated favorites loved by pet parents"
breadcrumbLabel="Top Picks"
/>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
export const metadata: Metadata = {
title: "Toys",
description: "Shop pet toys for dogs, cats, birds, and more",
};
export default function ShopToysPage() {
return <TopCategoryPage slug="toys" />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
export const metadata: Metadata = {
title: "Treats",
description: "Shop pet treats for dogs, cats, birds, and more",
};
export default function ShopTreatsPage() {
return <TopCategoryPage slug="treats" />;
}

View File

@@ -0,0 +1,9 @@
import { SignIn } from "@clerk/nextjs";
export default function SignInPage() {
return (
<main className="flex min-h-[60vh] items-center justify-center px-4 py-12">
<SignIn />
</main>
);
}

View File

@@ -0,0 +1,9 @@
import { SignUp } from "@clerk/nextjs";
export default function SignUpPage() {
return (
<main className="flex min-h-[60vh] items-center justify-center px-4 py-12">
<SignUp />
</main>
);
}

View File

@@ -0,0 +1,18 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Wishlist",
robots: { index: false, follow: false },
};
export default function WishlistLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="w-full max-w-full px-4 py-8 md:px-6 lg:mx-auto lg:max-w-6xl lg:px-8">
{children}
</main>
);
}

View File

@@ -0,0 +1,5 @@
import { WishlistSkeleton } from "@/components/wishlist/state/WishlistSkeleton";
export default function WishlistLoading() {
return <WishlistSkeleton />;
}

View File

@@ -0,0 +1,13 @@
"use client";
import { WishlistPageView } from "@/components/wishlist/WishlistPageView";
import { SectionHeading } from "@/utils/common/heading/section_heading";
export default function WishlistPage() {
return (
<div className="space-y-6">
<SectionHeading title="My Wishlist" as="h1" />
<WishlistPageView />
</div>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import { createContext, useContext } from "react";
export type CartUIContextValue = {
isOpen: boolean;
openCart: (triggerElement?: HTMLElement | null) => void;
closeCart: () => void;
};
export const CartUIContext = createContext<CartUIContextValue | null>(null);
export function useCartUI(): CartUIContextValue {
const ctx = useContext(CartUIContext);
if (!ctx) {
throw new Error("useCartUI must be used within CartUIProvider");
}
return ctx;
}

View File

@@ -0,0 +1,39 @@
"use client";
import { useCallback, useRef, useState, type ReactNode } from "react";
import { CartUIContext } from "./CartUIContext";
import { CartSideDrawer } from "./containers/CartSideDrawer";
import { CartBottomSheet } from "./containers/CartBottomSheet";
/**
* Provides cart overlay state (drawer on lg+, sheet on smaller in Phase 4).
* Open/close state is React state (single source of truth); Phase 5 can add
* cart item count for badge. Only one of drawer vs sheet is shown per breakpoint.
*/
export function CartUIProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLElement | null>(null);
const openCart = useCallback((triggerElement?: HTMLElement | null) => {
if (triggerElement) triggerRef.current = triggerElement;
setIsOpen(true);
}, []);
const closeCart = useCallback(() => {
const trigger = triggerRef.current;
triggerRef.current = null;
setIsOpen(false);
// Return focus to trigger after overlay unmounts (Phase 6 a11y)
if (trigger?.focus) {
setTimeout(() => trigger.focus({ preventScroll: true }), 0);
}
}, []);
return (
<CartUIContext.Provider value={{ isOpen, openCart, closeCart }}>
{children}
<CartSideDrawer />
<CartBottomSheet />
</CartUIContext.Provider>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { Chip, Modal, ScrollShadow, Separator } from "@heroui/react";
import { useCartUI } from "../CartUIContext";
import { CartContent } from "../content/CartContent";
import { CartContentSkeleton } from "../state/CartContentSkeleton";
import { CartErrorState } from "../state/CartErrorState";
import { useCart } from "@/lib/cart/useCart";
import { useMediaQuery } from "@/lib/cart/useMediaQuery";
import { useCartMutations } from "@/lib/cart/useCartMutations";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
/**
* Bottom sheet for cart on mobile and tablet (< lg: 1024px).
* Uses HeroUI Modal with placement="bottom" for native overlay behavior.
* Includes drag handle visual cue, scrollable body, and sticky footer.
*/
export function CartBottomSheet() {
const { isOpen, closeCart } = useCartUI();
const isLg = useMediaQuery("(min-width: 1024px)");
const sessionId = useCartSessionId();
const { items, subtotal, isLoading, error } = useCart(sessionId);
const { updateItem, removeItem } = useCartMutations(sessionId);
const show = isOpen && !isLg;
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
return (
<Modal.Backdrop
isOpen={show}
onOpenChange={(open) => {
if (!open) closeCart();
}}
variant="opaque"
>
<Modal.Container placement="bottom" size="full" scroll="inside">
<Modal.Dialog
className="max-h-[85vh] rounded-t-2xl"
aria-label="Shopping cart"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
{/* Drag handle */}
<div className="flex shrink-0 justify-center pt-3 pb-1">
<span
className="h-1 w-10 rounded-full bg-[var(--border)]"
aria-hidden="true"
/>
</div>
{/* Header */}
<Modal.Header className="px-4 pb-2 pt-0">
<div className="flex w-full items-center gap-2">
<Modal.Heading className="text-lg font-semibold font-[family-name:var(--font-fraunces)]">
Cart
</Modal.Heading>
{!isLoading && itemCount > 0 && (
<Chip
size="sm"
variant="soft"
className="bg-[#e8f7f6] text-[var(--foreground)]"
>
{itemCount}
</Chip>
)}
</div>
<Modal.CloseTrigger />
</Modal.Header>
<Separator />
{/* Body */}
<Modal.Body className="px-4 py-3">
<ScrollShadow className="flex-1">
{isLoading && <CartContentSkeleton />}
{error && (
<CartErrorState
message={
error.message ??
"Something went wrong loading your cart."
}
onRetry={() => window.location.reload()}
/>
)}
{!isLoading && !error && (
<CartContent
items={items}
subtotal={subtotal}
onUpdateQuantity={updateItem}
onRemove={removeItem}
isEmpty={items.length === 0}
onCheckout={closeCart}
onViewFullCart={closeCart}
onClose={closeCart}
hideHeading
layout="overlay"
/>
)}
</ScrollShadow>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import type { ReactNode } from "react";
import { Chip } from "@heroui/react";
/**
* Wraps shared cart content for the full cart page.
* Full-width main with consistent padding and max-width; mobile-first single column.
* Optional itemCount shows visible "Shopping cart" heading with item count Chip.
*/
export function CartPageLayout({
children,
itemCount,
}: {
children: ReactNode;
itemCount?: number;
}) {
return (
<main
className="w-full max-w-full px-4 py-6 md:px-6 lg:mx-auto lg:max-w-5xl lg:px-8"
aria-label="Shopping cart"
>
{itemCount !== undefined && (
<div className="mb-6 flex items-center gap-3">
<h1 className="text-2xl font-semibold font-[family-name:var(--font-fraunces)] md:text-3xl">
Shopping Cart
</h1>
<Chip
size="md"
variant="soft"
className="bg-[#e8f7f6] text-[var(--foreground)]"
>
{itemCount} {itemCount === 1 ? "item" : "items"}
</Chip>
</div>
)}
{children}
</main>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import { Chip, Modal, ScrollShadow, Separator } from "@heroui/react";
import { useCartUI } from "../CartUIContext";
import { CartContent } from "../content/CartContent";
import { CartContentSkeleton } from "../state/CartContentSkeleton";
import { CartErrorState } from "../state/CartErrorState";
import { useCart } from "@/lib/cart/useCart";
import { useMediaQuery } from "@/lib/cart/useMediaQuery";
import { useCartMutations } from "@/lib/cart/useCartMutations";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
/**
* Side drawer for cart on desktop (lg: 1024px+).
* Uses HeroUI Modal with CSS overrides to dock the dialog to the right edge.
*
* Override chain:
* - Backdrop: `!justify-end` overrides default `justify-center` so children align right.
* - Container: `sm:!w-auto !p-0 sm:!p-0` removes padding so the dialog touches the edge.
* - Dialog: full viewport height, left-only border radius, fixed width.
*/
export function CartSideDrawer() {
const { isOpen, closeCart } = useCartUI();
const isLg = useMediaQuery("(min-width: 1024px)");
const sessionId = useCartSessionId();
const { items, subtotal, isLoading, error } = useCart(sessionId);
const { updateItem, removeItem } = useCartMutations(sessionId);
const show = isOpen && isLg;
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
return (
<Modal.Backdrop
isOpen={show}
onOpenChange={(open) => {
if (!open) closeCart();
}}
variant="blur"
className="!justify-end"
>
<Modal.Container
scroll="inside"
className="!p-0 sm:!p-0 sm:!w-auto"
>
<Modal.Dialog
className="!my-0 !ml-auto !mr-0 !h-full !max-h-full !w-full !max-w-md !rounded-none !rounded-l-2xl !shadow-xl lg:!w-[420px]"
aria-label="Shopping cart"
>
{/* Header */}
<Modal.Header className="px-5 py-4">
<div className="flex w-full items-center gap-2">
<Modal.Heading className="text-lg font-semibold font-[family-name:var(--font-fraunces)]">
Cart
</Modal.Heading>
{!isLoading && itemCount > 0 && (
<Chip
size="sm"
variant="soft"
className="bg-[#e8f7f6] text-[var(--foreground)]"
>
{itemCount}
</Chip>
)}
</div>
<Modal.CloseTrigger />
</Modal.Header>
<Separator />
{/* Body */}
<Modal.Body className="px-5 py-4">
<ScrollShadow className="flex-1">
{isLoading && <CartContentSkeleton />}
{error && (
<CartErrorState
message={
error.message ??
"Something went wrong loading your cart."
}
onRetry={() => window.location.reload()}
/>
)}
{!isLoading && !error && (
<CartContent
items={items}
subtotal={subtotal}
onUpdateQuantity={updateItem}
onRemove={removeItem}
isEmpty={items.length === 0}
onCheckout={closeCart}
onViewFullCart={closeCart}
onClose={closeCart}
hideHeading
layout="overlay"
/>
)}
</ScrollShadow>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import Link from "next/link";
import { CART_PAGE_PATH, CHECKOUT_PAGE_PATH } from "@/lib/cart/constants";
const SHOP_PATH = "/shop";
type CartActionsProps = {
onCheckout?: () => void;
onViewFullCart?: () => void;
/** Dismiss the overlay on any navigation. */
onClose?: () => void;
/** "page" = full cart page; "overlay" = drawer/sheet. */
layout?: "page" | "overlay";
};
/**
* Cart CTA buttons. Uses Next.js Link for all internal routes per hero-ui-usage rules.
* Styled to match PetPaws branding: Amber checkout CTA, outline/ghost secondaries.
* Every link calls onClose so the overlay dismisses on navigation.
*/
export function CartActions({
onCheckout,
onViewFullCart,
onClose,
layout = "overlay",
}: CartActionsProps) {
const handleCheckout = () => {
onCheckout?.();
onClose?.();
};
const handleViewFullCart = () => {
onViewFullCart?.();
onClose?.();
};
return (
<div className="flex flex-col gap-2">
{/* Primary CTA: Amber checkout */}
<Link
href={CHECKOUT_PAGE_PATH}
onClick={handleCheckout}
className="flex h-11 w-full items-center justify-center rounded-full bg-[#f4a13a] px-6 font-medium text-[#1a2e2d] transition-colors hover:bg-[#e8932e] md:w-auto"
aria-label="Proceed to checkout"
>
Checkout
</Link>
{/* View Full Cart: overlay only */}
{layout === "overlay" && onViewFullCart !== undefined && (
<Link
href={CART_PAGE_PATH}
onClick={handleViewFullCart}
className="flex h-10 w-full items-center justify-center rounded-lg border border-[var(--border)] px-6 text-sm font-medium text-[var(--foreground)] transition-colors hover:bg-[var(--default)] md:w-auto"
aria-label="View full cart page"
>
View Full Cart
</Link>
)}
{/* Continue Shopping: ghost-style link */}
<Link
href={SHOP_PATH}
onClick={() => onClose?.()}
className="flex h-10 w-full items-center justify-center rounded-lg px-6 text-sm font-medium text-[var(--muted)] transition-colors hover:text-[var(--foreground)] hover:bg-[var(--default)] md:w-auto"
aria-label="Continue shopping"
>
Continue Shopping
</Link>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { Chip, Separator } from "@heroui/react";
import type { CartContentProps } from "@/lib/cart/types";
import { CartLineItems } from "./CartLineItems";
import { CartSummaryCard } from "./CartOrderSummary";
import { CartActions } from "./CartActions";
import { CartEmptyState } from "./CartEmptyState";
/**
* Shared cart content composed from CartLineItems, CartSummaryCard, and CartActions.
* Used in cart page, side drawer (lg+), and bottom sheet (< lg).
* Presentational: receives items and callbacks; data/mutations live at container level.
*/
export function CartContent({
items,
subtotal,
onUpdateQuantity,
onRemove,
isEmpty,
onCheckout,
onViewFullCart,
onClose,
hideHeading = false,
layout = "overlay",
}: CartContentProps) {
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
if (isEmpty) {
return (
<div className="flex flex-col gap-6">
{!hideHeading && (
<header className="flex items-center gap-2">
<h2 className="text-xl font-semibold font-[family-name:var(--font-fraunces)]">
Cart
</h2>
<Chip size="sm" variant="soft">
0
</Chip>
</header>
)}
<CartEmptyState onClose={onClose} />
</div>
);
}
const heading = !hideHeading && (
<header className="flex items-center gap-2">
<h2 className="text-xl font-semibold font-[family-name:var(--font-fraunces)]">
Cart
</h2>
<Chip size="sm" variant="soft" className="bg-[#e8f7f6] text-[var(--foreground)]">
{itemCount}
</Chip>
</header>
);
const lineItems = (
<CartLineItems
items={items}
onUpdateQuantity={onUpdateQuantity}
onRemove={onRemove}
variant={layout === "page" ? "full" : "compact"}
/>
);
const isOverlay = layout === "overlay";
if (layout === "page") {
return (
<div className="flex flex-col gap-6">
{heading}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr,360px] lg:items-start">
<div>{lineItems}</div>
<div className="lg:sticky lg:top-8 lg:self-start">
<div className="flex flex-col gap-4">
<CartSummaryCard subtotal={subtotal} display="card" />
<CartActions
onCheckout={onCheckout}
onViewFullCart={onViewFullCart}
onClose={onClose}
layout="page"
/>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{heading}
{lineItems}
<Separator className="my-1" />
<CartSummaryCard subtotal={subtotal} display="inline" />
<CartActions
onCheckout={onCheckout}
onViewFullCart={isOverlay ? onViewFullCart : undefined}
onClose={onClose}
layout="overlay"
/>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
const SHOP_PATH = "/shop";
/**
* Empty cart state with shopping bag icon, message, and CTA.
* Mobile-first: full-width CTA on mobile, auto on tablet+.
*/
export function CartEmptyState({ onClose }: { onClose?: () => void }) {
return (
<section
aria-labelledby="cart-empty-heading"
className="flex flex-col items-center justify-center gap-5 py-10 text-center"
>
<div className="flex size-20 items-center justify-center rounded-full bg-[#e8f7f6]">
<ShoppingBagIcon />
</div>
<div className="flex flex-col gap-1">
<h2
id="cart-empty-heading"
className="text-lg font-semibold font-[family-name:var(--font-fraunces)] text-[var(--foreground)]"
>
Your cart is empty
</h2>
<p className="text-sm text-[var(--muted)]">
Looks like you haven&apos;t added anything yet.
</p>
</div>
<Link
href={SHOP_PATH}
onClick={() => onClose?.()}
className="flex h-11 w-full items-center justify-center rounded-full bg-[#f4a13a] px-8 font-medium text-[#1a2e2d] transition-colors hover:bg-[#e8932e] md:w-auto"
aria-label="Browse products to add to cart"
>
Browse Products
</Link>
</section>
);
}
function ShoppingBagIcon() {
return (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="#38a99f"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { Button, Card, Chip, NumberField } from "@heroui/react";
import Image from "next/image";
import Link from "next/link";
import { formatPrice } from "@repo/utils";
import { getCartItemProductUrl } from "@/lib/cart/constants";
import type { CartEnrichedItem } from "@/lib/cart/types";
const PLACEHOLDER_IMAGE = "/images/placeholder-product.png";
type CartItemCardProps = {
item: CartEnrichedItem;
onUpdateQuantity: (variantId: string, quantity: number) => void;
onRemove: (variantId: string) => void;
variant?: "compact" | "full";
};
export function CartItemCard({
item,
onUpdateQuantity,
onRemove,
variant = "compact",
}: CartItemCardProps) {
const lineTotal = item.priceSnapshot * item.quantity;
const productUrl = getCartItemProductUrl(item);
const maxQty =
item.stockQuantity != null && item.stockQuantity > 0
? item.stockQuantity
: 99;
const imgSize = variant === "full" ? 80 : 56;
return (
<Card variant="transparent" className="gap-0 p-0 rounded-xs p-2">
<Card.Content className="p-0">
<div className="flex items-start gap-3 md:gap-4">
{/* Product image */}
<Link
href={productUrl}
className="relative block shrink-0 overflow-hidden rounded-xl bg-[var(--surface)]"
style={{ width: imgSize, height: imgSize }}
>
<Image
src={item.imageUrl ?? PLACEHOLDER_IMAGE}
alt=""
width={imgSize}
height={imgSize}
className="object-cover"
unoptimized={!(item.imageUrl ?? "").startsWith("http")}
/>
</Link>
{/* Details + controls */}
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
{/* Top row: name + remove */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={productUrl}
className="line-clamp-2 text-sm font-medium text-[var(--foreground)] hover:underline md:text-base"
>
{item.productName}
</Link>
{item.variantName && (
<Chip
size="sm"
variant="soft"
className="mt-1 bg-[#e8f7f6] text-[var(--foreground)]"
>
{item.variantName}
</Chip>
)}
</div>
<Button
isIconOnly
size="sm"
variant="ghost"
className="shrink-0 text-[var(--destructive)] hover:bg-[#fce0da]"
aria-label={`Remove ${item.productName} from cart`}
onPress={() => onRemove(item.variantId)}
>
<TrashIcon />
</Button>
</div>
{/* Bottom row: price + quantity + line total */}
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
<span className="text-sm font-semibold text-[#236f6b]">
{formatPrice(item.priceSnapshot)}
</span>
<div className="flex items-center gap-3">
<NumberField
aria-label={`Quantity for ${item.productName}`}
value={item.quantity}
minValue={1}
maxValue={maxQty}
onChange={(val) => {
if (val !== undefined && val >= 1) {
onUpdateQuantity(item.variantId, val);
}
}}
>
<NumberField.Group className="h-8 text-sm">
<NumberField.DecrementButton className="w-8" />
<NumberField.Input className="w-10 text-center" />
<NumberField.IncrementButton className="w-8" />
</NumberField.Group>
</NumberField>
{variant === "full" && (
<span className="text-sm font-semibold text-[#236f6b]">
{formatPrice(lineTotal)}
</span>
)}
</div>
</div>
</div>
</div>
</Card.Content>
</Card>
);
}
function TrashIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { Separator } from "@heroui/react";
import type { CartLineItemsProps } from "@/lib/cart/types";
import { CartItemCard } from "./CartItemCard";
/**
* Card-based list of cart line items. Replaces the previous table layout.
* Used inside CartContent for both page and overlay layouts.
*/
export function CartLineItems({
items,
onUpdateQuantity,
onRemove,
variant = "compact",
}: CartLineItemsProps & { variant?: "compact" | "full" }) {
if (items.length === 0) return null;
return (
<div className="flex flex-col" role="list" aria-label="Cart items">
{items.map((item, idx) => (
<div key={item.variantId} role="listitem">
<CartItemCard
item={item}
onUpdateQuantity={onUpdateQuantity}
onRemove={onRemove}
variant={variant}
/>
{idx < items.length - 1 && <Separator className="my-3" />}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { Card, Separator } from "@heroui/react";
import { formatPrice } from "@repo/utils";
import type { CartOrderSummaryProps } from "@/lib/cart/types";
type CartSummaryCardProps = CartOrderSummaryProps & {
/** "card" wraps in a HeroUI Card (full page); "inline" renders bare (overlays). */
display?: "card" | "inline";
};
export function CartSummaryCard({
subtotal,
display = "card",
}: CartSummaryCardProps) {
const content = (
<div className="flex flex-col gap-3">
{display === "card" && (
<h2 className="text-lg font-semibold font-[family-name:var(--font-fraunces)]">
Order Summary
</h2>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-[var(--muted)]">Subtotal</span>
<span className="font-semibold text-[var(--foreground)]">
{formatPrice(subtotal)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-[var(--muted)]">Shipping</span>
<span className="text-[var(--muted)]">Calculated at checkout</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-medium text-[var(--foreground)]">Total</span>
<span className="text-lg font-bold text-[#236f6b]">
{formatPrice(subtotal)}
</span>
</div>
</div>
);
if (display === "inline") {
return (
<section aria-label="Order summary">
{content}
</section>
);
}
return (
<Card className="p-4 rounded-lg">
<Card.Content className="p-0">
<section aria-label="Order summary">
{content}
</section>
</Card.Content>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { Separator, Skeleton } from "@heroui/react";
/**
* Cart content skeleton matching the new card-based layout.
* Renders heading skeleton + 3 item card skeletons + summary skeleton.
*/
export function CartContentSkeleton() {
return (
<div className="flex flex-col gap-4">
{/* Heading skeleton */}
<div className="flex items-center gap-2">
<Skeleton className="h-7 w-16 rounded" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
{/* Item cards */}
<div className="flex flex-col">
{[0, 1, 2].map((i) => (
<div key={i}>
<CartItemSkeleton />
{i < 2 && <Separator className="my-3" />}
</div>
))}
</div>
<Separator className="my-1" />
{/* Summary skeleton */}
<div className="flex flex-col gap-3">
<div className="flex justify-between">
<Skeleton className="h-4 w-16 rounded" />
<Skeleton className="h-4 w-14 rounded" />
</div>
<div className="flex justify-between">
<Skeleton className="h-4 w-16 rounded" />
<Skeleton className="h-4 w-28 rounded" />
</div>
<Separator />
<div className="flex justify-between">
<Skeleton className="h-5 w-12 rounded" />
<Skeleton className="h-5 w-16 rounded" />
</div>
</div>
{/* CTA skeleton */}
<Skeleton className="h-11 w-full rounded-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
);
}
function CartItemSkeleton() {
return (
<div className="flex items-start gap-3">
<Skeleton className="size-14 shrink-0 rounded-xl" />
<div className="flex flex-1 flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="size-8 shrink-0 rounded-lg" />
</div>
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-14 rounded" />
<Skeleton className="h-8 w-24 rounded-lg" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { Alert, Button } from "@heroui/react";
/**
* Error state for cart using HeroUI Alert.
* Shown when cart query or mutations fail.
*/
export function CartErrorState({
message = "Something went wrong loading your cart.",
onRetry,
}: {
message?: string;
onRetry?: () => void;
}) {
return (
<Alert status="danger" className="w-full">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Cart error</Alert.Title>
<Alert.Description>{message}</Alert.Description>
{onRetry && (
<Button
size="sm"
variant="danger"
className="mt-2"
onPress={onRetry}
>
Try again
</Button>
)}
</Alert.Content>
</Alert>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { CHECKOUT_STEPS, type CheckoutStep } from "@/lib/checkout/constants";
const STEP_LABELS: Record<CheckoutStep, string> = {
validation: "Review cart",
"shipping-address": "Address",
review: "Order review",
payment: "Payment",
};
type CheckoutShellProps = {
currentStep: CheckoutStep;
children: React.ReactNode;
};
export function CheckoutShell({ currentStep, children }: CheckoutShellProps) {
const currentIndex = CHECKOUT_STEPS.indexOf(currentStep);
const stepNumber = currentIndex + 1;
return (
<div className="flex flex-col gap-6">
{/* Mobile: compact step indicator */}
<nav aria-label="Checkout progress" className="md:hidden">
<p className="text-sm font-medium text-default-500">
Step {stepNumber} of {CHECKOUT_STEPS.length}
<span className="mx-1.5">&mdash;</span>
<span className="text-foreground">{STEP_LABELS[currentStep]}</span>
</p>
</nav>
{/* Tablet+: horizontal stepper bar */}
<nav
aria-label="Checkout progress"
className="hidden md:block"
>
<ol className="flex items-center gap-1">
{CHECKOUT_STEPS.map((step, i) => {
const isActive = i === currentIndex;
const isCompleted = i < currentIndex;
return (
<li
key={step}
className="flex items-center gap-1"
aria-current={isActive ? "step" : undefined}
>
<span
className={`
flex size-7 items-center justify-center rounded-full text-xs font-semibold
${isActive ? "bg-[#236f6b] text-white" : ""}
${isCompleted ? "bg-[#e8f7f6] text-[#236f6b]" : ""}
${!isActive && !isCompleted ? "bg-default-100 text-default-400" : ""}
`}
>
{isCompleted ? (
<CheckIcon />
) : (
i + 1
)}
</span>
<span
className={`
text-sm
${isActive ? "font-semibold text-foreground" : ""}
${isCompleted ? "font-medium text-default-600" : ""}
${!isActive && !isCompleted ? "text-default-400" : ""}
`}
>
{STEP_LABELS[step]}
</span>
{i < CHECKOUT_STEPS.length - 1 && (
<span
className={`mx-1 h-px w-4 lg:w-6 ${
i < currentIndex ? "bg-[#236f6b]" : "bg-default-200"
}`}
aria-hidden="true"
/>
)}
</li>
);
})}
</ol>
</nav>
{children}
</div>
);
}
function CheckIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}

View File

@@ -0,0 +1,306 @@
"use client";
import { useState, useCallback } from "react";
import {
Alert,
Button,
Description,
Input,
InputGroup,
Label,
Spinner,
TextField,
} from "@heroui/react";
import type { AddressFormData } from "@/lib/checkout/types";
const UK_POSTCODE_REGEX = /^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/i;
type AddressFormProps = {
initialData?: AddressFormData;
onSubmit: (data: AddressFormData) => void;
onCancel?: () => void;
isSubmitting: boolean;
validationError?: string | null;
};
type FieldErrors = Partial<Record<keyof AddressFormData, string>>;
export function AddressForm({
initialData,
onSubmit,
onCancel,
isSubmitting,
validationError,
}: AddressFormProps) {
const [firstName, setFirstName] = useState(initialData?.firstName ?? "");
const [lastName, setLastName] = useState(initialData?.lastName ?? "");
const [phone, setPhone] = useState(initialData?.phone ?? "");
const [addressLine1, setAddressLine1] = useState(initialData?.addressLine1 ?? "");
const [additionalInformation, setAdditionalInformation] = useState(
initialData?.additionalInformation ?? "",
);
const [city, setCity] = useState(initialData?.city ?? "");
const [postalCode, setPostalCode] = useState(initialData?.postalCode ?? "");
const [errors, setErrors] = useState<FieldErrors>({});
const validateForm = useCallback((): boolean => {
const next: FieldErrors = {};
if (!firstName.trim()) next.firstName = "First name is required";
if (!lastName.trim()) next.lastName = "Last name is required";
if (!phone.trim()) next.phone = "Phone number is required";
if (!addressLine1.trim()) next.addressLine1 = "Address is required";
if (!city.trim()) next.city = "City / Town is required";
if (!postalCode.trim()) {
next.postalCode = "Postcode is required";
} else if (!UK_POSTCODE_REGEX.test(postalCode.trim())) {
next.postalCode = "Enter a valid UK postcode (e.g. SW1A 2AA)";
}
setErrors(next);
return Object.keys(next).length === 0;
}, [firstName, lastName, phone, addressLine1, city, postalCode]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
onSubmit({
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
addressLine1: addressLine1.trim(),
additionalInformation: additionalInformation.trim() || undefined,
city: city.trim(),
postalCode: postalCode.trim().toUpperCase(),
country: "GB",
});
},
[validateForm, onSubmit, firstName, lastName, phone, addressLine1, additionalInformation, city, postalCode],
);
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
{validationError && (
<Alert status="danger" role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Validation error</Alert.Title>
<Alert.Description>{validationError}</Alert.Description>
</Alert.Content>
</Alert>
)}
{/* First name + Last name — stacked on mobile, side-by-side on md: */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1">
<Label htmlFor="addr-firstName">First name</Label>
<Input
id="addr-firstName"
name="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Jane"
required
autoComplete="given-name"
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.firstName || undefined}
aria-describedby={errors.firstName ? "addr-firstName-err" : undefined}
/>
{errors.firstName && (
<p id="addr-firstName-err" className="text-xs text-danger">
{errors.firstName}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="addr-lastName">Last name</Label>
<Input
id="addr-lastName"
name="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Doe"
required
autoComplete="family-name"
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.lastName || undefined}
aria-describedby={errors.lastName ? "addr-lastName-err" : undefined}
/>
{errors.lastName && (
<p id="addr-lastName-err" className="text-xs text-danger">
{errors.lastName}
</p>
)}
</div>
</div>
{/* Phone with +44 prefix */}
<div className="flex flex-col gap-1">
<TextField name="phone" isRequired isDisabled={isSubmitting}>
<Label htmlFor="addr-phone">Phone</Label>
<InputGroup>
<InputGroup.Prefix>+44</InputGroup.Prefix>
<InputGroup.Input
id="addr-phone"
type="tel"
value={phone}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPhone(e.target.value)}
placeholder="7911 123456"
autoComplete="tel-national"
aria-required="true"
aria-invalid={!!errors.phone || undefined}
aria-describedby={
[errors.phone ? "addr-phone-err" : null, "addr-phone-desc"]
.filter(Boolean)
.join(" ") || undefined
}
/>
</InputGroup>
<Description id="addr-phone-desc">
We will only use this to contact you about your order
</Description>
</TextField>
{errors.phone && (
<p id="addr-phone-err" className="text-xs text-danger">
{errors.phone}
</p>
)}
</div>
{/* Address */}
<div className="flex flex-col gap-1">
<Label htmlFor="addr-line1">Address</Label>
<Input
id="addr-line1"
name="addressLine1"
value={addressLine1}
onChange={(e) => setAddressLine1(e.target.value)}
placeholder="10 Downing Street"
required
autoComplete="address-line1"
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.addressLine1 || undefined}
aria-describedby={errors.addressLine1 ? "addr-line1-err" : undefined}
/>
{errors.addressLine1 && (
<p id="addr-line1-err" className="text-xs text-danger">
{errors.addressLine1}
</p>
)}
</div>
{/* Additional information (optional) */}
<div className="flex flex-col gap-1">
<Label htmlFor="addr-additional">
Apartment, suite, floor, unit, etc. <span className="text-default-400">(optional)</span>
</Label>
<Input
id="addr-additional"
name="additionalInformation"
value={additionalInformation}
onChange={(e) => setAdditionalInformation(e.target.value)}
placeholder="Flat 4B"
autoComplete="address-line2"
disabled={isSubmitting}
/>
</div>
{/* City / Town + Postcode — stacked on mobile, side-by-side on md: */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1">
<Label htmlFor="addr-city">City / Town</Label>
<Input
id="addr-city"
name="city"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="London"
required
autoComplete="address-level2"
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.city || undefined}
aria-describedby={errors.city ? "addr-city-err" : undefined}
/>
{errors.city && (
<p id="addr-city-err" className="text-xs text-danger">
{errors.city}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="addr-postcode">Postcode</Label>
<Input
id="addr-postcode"
name="postalCode"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
placeholder="SW1A 2AA"
required
autoComplete="postal-code"
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.postalCode || undefined}
aria-describedby={errors.postalCode ? "addr-postcode-err" : undefined}
/>
{errors.postalCode && (
<p id="addr-postcode-err" className="text-xs text-danger">
{errors.postalCode}
</p>
)}
</div>
</div>
{/* Country (UK only) */}
<div className="flex flex-col gap-1">
<Label htmlFor="addr-country">Country</Label>
<Input
id="addr-country"
name="country"
value="United Kingdom"
readOnly
disabled
aria-describedby="addr-country-note"
/>
<p id="addr-country-note" className="text-xs text-default-400">
We currently ship to the United Kingdom only.
</p>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 pt-2 md:flex-row md:justify-end">
{onCancel && (
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onCancel}
isDisabled={isSubmitting}
>
Cancel
</Button>
)}
<Button
type="submit"
variant="primary"
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
isDisabled={isSubmitting}
isPending={isSubmitting}
>
{({ isPending }) => (
<>
{isPending ? <Spinner color="current" size="sm" /> : null}
Validate &amp; save address
</>
)}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { Button, Chip, RadioGroup, Radio, Label, Skeleton } from "@heroui/react";
import type { CheckoutAddress } from "@/lib/checkout/types";
type AddressSelectorProps = {
addresses: CheckoutAddress[];
selectedId: string | null;
onSelect: (id: string) => void;
onAddNew: () => void;
isLoading: boolean;
};
export function AddressSelector({
addresses,
selectedId,
onSelect,
onAddNew,
isLoading,
}: AddressSelectorProps) {
if (isLoading) {
return <AddressSelectorSkeleton />;
}
if (addresses.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-4">
<RadioGroup
aria-label="Select shipping address"
value={selectedId ?? undefined}
onChange={(value) => onSelect(value)}
>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{addresses.map((address) => (
<Radio key={address.id} value={address.id}>
{({ isSelected }) => (
<AddressCard address={address} isSelected={isSelected} />
)}
</Radio>
))}
</div>
</RadioGroup>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onAddNew}
>
+ Add new address
</Button>
</div>
);
}
function AddressCard({
address,
isSelected,
}: {
address: CheckoutAddress;
isSelected: boolean;
}) {
return (
<div
className={`
flex cursor-pointer flex-col gap-2 rounded-lg border-2 p-4 transition-colors
${isSelected ? "border-[#236f6b] bg-[#e8f7f6]/40" : "border-default-200 bg-background hover:border-default-400"}
`}
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">{address.fullName}</p>
<div className="flex items-center gap-1.5">
{address.isDefault && (
<Chip size="sm" variant="soft">
Default
</Chip>
)}
{address.isValidated ? (
<Chip size="sm" variant="soft" color="success">
Verified
</Chip>
) : (
<Chip size="sm" variant="soft" color="warning">
Not verified
</Chip>
)}
</div>
</div>
<div className="text-sm text-default-600">
<p>{address.addressLine1}</p>
{address.additionalInformation && <p>{address.additionalInformation}</p>}
<p>{address.city}</p>
<p>{address.postalCode}</p>
</div>
{address.phone && (
<p className="text-xs text-default-400">{address.phone}</p>
)}
</div>
);
}
function AddressSelectorSkeleton() {
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex flex-col gap-3 rounded-lg border-2 border-default-200 p-4">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-28 rounded-md" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3 w-40 rounded-md" />
<Skeleton className="h-3 w-32 rounded-md" />
<Skeleton className="h-3 w-20 rounded-md" />
</div>
<Skeleton className="h-3 w-24 rounded-md" />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,250 @@
"use client";
import { Alert, Button, Chip } from "@heroui/react";
import type { CheckoutAddressValidationResult } from "@/lib/checkout/types";
type AddressValidationFeedbackProps = {
result: CheckoutAddressValidationResult;
onAcceptRecommended: () => void;
onKeepOriginal: () => void;
onEditAddress: () => void;
};
const CONFIDENCE_COLOR: Record<string, "success" | "warning" | "danger"> = {
high: "success",
medium: "warning",
low: "danger",
};
const ADDRESS_TYPE_LABELS: Record<string, string> = {
residential: "Residential",
commercial: "Commercial",
po_box: "PO Box",
military: "Military",
unknown: "Unknown",
};
export function AddressValidationFeedback({
result,
onAcceptRecommended,
onKeepOriginal,
onEditAddress,
}: AddressValidationFeedbackProps) {
if (result.isValid && !result.recommendedAddress) {
return <ValidNoCorrections result={result} />;
}
if (result.recommendedAddress) {
return (
<RecommendedCorrections
result={result}
onAcceptRecommended={onAcceptRecommended}
onKeepOriginal={onKeepOriginal}
/>
);
}
return (
<InvalidAddress
result={result}
onEditAddress={onEditAddress}
onKeepOriginal={onKeepOriginal}
/>
);
}
function ValidNoCorrections({ result }: { result: CheckoutAddressValidationResult }) {
return (
<div className="flex flex-col gap-4" role="alert">
<Alert status="success">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Address verified successfully</Alert.Title>
<Alert.Description>Your address has been validated and is ready to use.</Alert.Description>
</Alert.Content>
</Alert>
<AddressSummaryCard
label="Verified address"
address={result.originalAddress}
addressType={result.addressType}
/>
</div>
);
}
function RecommendedCorrections({
result,
onAcceptRecommended,
onKeepOriginal,
}: {
result: CheckoutAddressValidationResult;
onAcceptRecommended: () => void;
onKeepOriginal: () => void;
}) {
const rec = result.recommendedAddress!;
const confidence = rec.confidenceScore;
const alertStatus = confidence === "high" ? "accent" : "warning";
const changedSet = new Set(result.changedAttributes);
return (
<div className="flex flex-col gap-4">
<Alert status={alertStatus} role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>We found a more accurate version of your address</Alert.Title>
<Alert.Description>{rec.confidenceDescription}</Alert.Description>
</Alert.Content>
</Alert>
<div className="flex items-center gap-2">
<Chip size="sm" variant="soft" color={CONFIDENCE_COLOR[confidence] ?? "default"}>
{confidence.charAt(0).toUpperCase() + confidence.slice(1)} confidence
</Chip>
{result.addressType !== "unknown" && (
<Chip size="sm" variant="soft">
{ADDRESS_TYPE_LABELS[result.addressType] ?? result.addressType}
</Chip>
)}
</div>
{/* Side-by-side on md+, stacked on mobile */}
<div
className="grid grid-cols-1 gap-4 md:grid-cols-2"
aria-label="Address comparison"
>
<AddressSummaryCard
label="You entered"
address={result.originalAddress}
/>
<AddressSummaryCard
label="Suggested"
address={{
addressLine1: rec.addressLine1,
additionalInformation: rec.additionalInformation,
city: rec.city,
postalCode: rec.postalCode,
country: rec.country,
}}
highlightedFields={changedSet}
/>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<Button
variant="primary"
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
onPress={onAcceptRecommended}
>
Use suggested address
</Button>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onKeepOriginal}
>
Keep my address
</Button>
</div>
</div>
);
}
function InvalidAddress({
result,
onEditAddress,
onKeepOriginal,
}: {
result: CheckoutAddressValidationResult;
onEditAddress: () => void;
onKeepOriginal: () => void;
}) {
return (
<div className="flex flex-col gap-4">
<Alert status="danger" role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>We couldn&apos;t verify this address</Alert.Title>
<Alert.Description>Please review the issues below and correct your address.</Alert.Description>
</Alert.Content>
</Alert>
{result.reasons.length > 0 && (
<ul className="list-inside list-disc space-y-1 text-sm text-default-600">
{result.reasons.map((reason, i) => (
<li key={i}>{reason.description}</li>
))}
</ul>
)}
<div className="flex flex-col gap-2 md:flex-row">
<Button
variant="primary"
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
onPress={onEditAddress}
>
Edit address
</Button>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onKeepOriginal}
>
Save anyway
</Button>
</div>
<p className="text-xs text-warning-600">
Unverified addresses may cause delivery failures or surcharges.
</p>
</div>
);
}
function AddressSummaryCard({
label,
address,
addressType,
highlightedFields,
}: {
label: string;
address: {
addressLine1: string;
additionalInformation?: string;
city: string;
postalCode: string;
country: string;
};
addressType?: string;
highlightedFields?: Set<string>;
}) {
const hl = (field: string, text: string) => {
if (highlightedFields?.has(field)) {
return <span className="font-semibold text-[#236f6b]" aria-label={`${field} changed`}>{text}</span>;
}
return text;
};
return (
<div className="rounded-lg border border-default-200 p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-default-400">
{label}
</p>
<div className="text-sm text-foreground">
<p>{hl("address_line_1", address.addressLine1)}</p>
{address.additionalInformation && (
<p>{hl("address_line_2", address.additionalInformation)}</p>
)}
<p>{hl("city_locality", address.city)}</p>
<p>{hl("postal_code", address.postalCode)}</p>
<p>{hl("country_code", address.country)}</p>
</div>
{addressType && addressType !== "unknown" && (
<div className="mt-2">
<Chip size="sm" variant="soft">
{ADDRESS_TYPE_LABELS[addressType] ?? addressType}
</Chip>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
import { Chip, Separator } from "@heroui/react";
import Image from "next/image";
import Link from "next/link";
import { formatPrice } from "@repo/utils";
import { getCartItemProductUrl } from "@/lib/cart/constants";
import type {
CheckoutValidatedItem,
CheckoutItemIssue,
} from "@/lib/checkout/types";
const PLACEHOLDER_IMAGE = "/images/placeholder-product.png";
type CheckoutLineItemsProps = {
items: CheckoutValidatedItem[];
issues: CheckoutItemIssue[];
};
function getItemIssues(
variantId: string,
productId: string,
issues: CheckoutItemIssue[],
): CheckoutItemIssue[] {
return issues.filter((issue) => {
if ("variantId" in issue) return issue.variantId === variantId;
if ("productId" in issue) return issue.productId === productId;
return false;
});
}
function isItemUnavailable(itemIssues: CheckoutItemIssue[]): boolean {
return itemIssues.some(
(i) =>
i.type === "out_of_stock" ||
i.type === "variant_inactive" ||
i.type === "variant_not_found" ||
i.type === "product_not_found",
);
}
export function CheckoutLineItems({ items, issues }: CheckoutLineItemsProps) {
if (items.length === 0) return null;
return (
<>
{/* Mobile: stacked card layout */}
<div className="flex flex-col gap-0 md:hidden" role="list" aria-label="Order items">
{items.map((item, i) => (
<div key={item.variantId} role="listitem">
<MobileItemCard
item={item}
itemIssues={getItemIssues(item.variantId, item.productId, issues)}
/>
{i < items.length - 1 && <Separator className="my-3" />}
</div>
))}
</div>
{/* Tablet+: table layout */}
<table className="hidden w-full md:table">
<caption className="sr-only">Order items</caption>
<thead>
<tr className="border-b border-default-200 text-left text-sm text-default-500">
<th scope="col" className="pb-3 font-medium">
Product
</th>
<th scope="col" className="pb-3 font-medium">
Price
</th>
<th scope="col" className="pb-3 text-center font-medium">
Qty
</th>
<th scope="col" className="pb-3 text-right font-medium">
Total
</th>
<th scope="col" className="pb-3 text-right font-medium">
Status
</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<TableRow
key={item.variantId}
item={item}
itemIssues={getItemIssues(
item.variantId,
item.productId,
issues,
)}
/>
))}
</tbody>
</table>
</>
);
}
function MobileItemCard({
item,
itemIssues,
}: {
item: CheckoutValidatedItem;
itemIssues: CheckoutItemIssue[];
}) {
const unavailable = isItemUnavailable(itemIssues);
const productUrl = getCartItemProductUrl(item);
const lineTotal = item.unitPrice * item.quantity;
return (
<div className={`flex items-start gap-3 py-1 ${unavailable ? "opacity-50" : ""}`}>
<Link
href={productUrl}
className="relative block size-16 shrink-0 overflow-hidden rounded-xl bg-default-100"
>
<Image
src={item.imageUrl ?? PLACEHOLDER_IMAGE}
alt=""
width={64}
height={64}
className="object-cover"
unoptimized={!(item.imageUrl ?? "").startsWith("http")}
/>
</Link>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="min-w-0">
<Link
href={productUrl}
className="line-clamp-2 text-sm font-medium text-foreground hover:underline"
>
{item.productName}
</Link>
{item.variantName && (
<Chip
size="sm"
variant="soft"
className="mt-1 bg-[#e8f7f6] text-foreground"
>
{item.variantName}
</Chip>
)}
</div>
<IssueBadges issues={itemIssues} />
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm text-default-500">
{formatPrice(item.unitPrice)} &times; {item.quantity}
</span>
<span className="text-sm font-semibold text-[#236f6b]">
{formatPrice(lineTotal)}
</span>
</div>
</div>
</div>
);
}
function TableRow({
item,
itemIssues,
}: {
item: CheckoutValidatedItem;
itemIssues: CheckoutItemIssue[];
}) {
const unavailable = isItemUnavailable(itemIssues);
const productUrl = getCartItemProductUrl(item);
const lineTotal = item.unitPrice * item.quantity;
return (
<tr
className={`border-b border-default-100 ${unavailable ? "opacity-50" : ""}`}
>
{/* Product */}
<td className="py-4 pr-4">
<div className="flex items-center gap-3">
<Link
href={productUrl}
className="relative block size-14 shrink-0 overflow-hidden rounded-xl bg-default-100"
>
<Image
src={item.imageUrl ?? PLACEHOLDER_IMAGE}
alt=""
width={56}
height={56}
className="object-cover"
unoptimized={!(item.imageUrl ?? "").startsWith("http")}
/>
</Link>
<div className="min-w-0">
<Link
href={productUrl}
className="line-clamp-2 text-sm font-medium text-foreground hover:underline"
>
{item.productName}
</Link>
{item.variantName && (
<Chip
size="sm"
variant="soft"
className="mt-1 bg-[#e8f7f6] text-foreground"
>
{item.variantName}
</Chip>
)}
</div>
</div>
</td>
{/* Price */}
<td className="py-4 text-sm text-foreground">
{formatPrice(item.unitPrice)}
</td>
{/* Qty */}
<td className="py-4 text-center text-sm text-foreground">
{item.quantity}
</td>
{/* Line total */}
<td className="py-4 text-right text-sm font-semibold text-[#236f6b]">
{formatPrice(lineTotal)}
</td>
{/* Status */}
<td className="py-4 text-right">
<IssueBadges issues={itemIssues} />
</td>
</tr>
);
}
function IssueBadges({ issues }: { issues: CheckoutItemIssue[] }) {
if (issues.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
{issues.map((issue, i) => (
<IssueBadge key={`${issue.type}-${i}`} issue={issue} />
))}
</div>
);
}
function IssueBadge({ issue }: { issue: CheckoutItemIssue }) {
switch (issue.type) {
case "out_of_stock":
return (
<Chip size="sm" variant="soft" className="bg-danger-50 text-danger">
Out of stock
</Chip>
);
case "insufficient_stock":
return (
<Chip size="sm" variant="soft" className="bg-warning-50 text-warning-600">
Only {issue.available} left
</Chip>
);
case "price_changed":
return (
<Chip size="sm" variant="soft" className="bg-primary-50 text-primary">
Price updated: {formatPrice(issue.oldPrice)} &rarr;{" "}
{formatPrice(issue.newPrice)}
</Chip>
);
case "variant_inactive":
case "variant_not_found":
case "product_not_found":
return (
<Chip size="sm" variant="soft" className="bg-danger-50 text-danger">
Unavailable
</Chip>
);
}
}

View File

@@ -0,0 +1,43 @@
"use client";
import { Separator } from "@heroui/react";
import { formatPrice } from "@repo/utils";
type CheckoutOrderSummaryProps = {
subtotal: number;
itemCount: number;
};
export function CheckoutOrderSummary({
subtotal,
itemCount,
}: CheckoutOrderSummaryProps) {
return (
<section aria-label="Order summary">
<h2 className="mb-3 text-lg font-semibold font-[family-name:var(--font-fraunces)]">
Summary
</h2>
<dl className="flex flex-col gap-3">
<div className="flex items-center justify-between text-sm">
<dt className="text-default-500">
Items ({itemCount})
</dt>
<dd className="font-semibold text-foreground">
{formatPrice(subtotal)}
</dd>
</div>
<div className="flex items-center justify-between text-sm">
<dt className="text-default-500">Shipping</dt>
<dd className="text-default-400">Calculated at next step</dd>
</div>
<Separator />
<div className="flex items-center justify-between">
<dt className="font-medium text-foreground">Subtotal</dt>
<dd className="text-lg font-bold text-[#236f6b]">
{formatPrice(subtotal)}
</dd>
</div>
</dl>
</section>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { Button, Link } from "@heroui/react";
/**
* Checkout error state: error message + retry button + back-to-cart link.
* Phase 3 will refine styling; this provides a functional error boundary UI.
*/
export function CheckoutErrorState({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<p className="text-lg font-medium text-danger">{message}</p>
<div className="flex flex-col gap-2 w-full md:w-auto md:flex-row">
<Button color="primary" onPress={onRetry} className="w-full md:w-auto">
Try again
</Button>
<Button
as={Link}
href="/cart"
variant="flat"
className="w-full md:w-auto"
>
Back to cart
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { Separator, Skeleton } from "@heroui/react";
export function CheckoutSkeleton() {
return (
<div className="flex flex-col gap-6">
{/* Stepper skeleton — compact on mobile, bar on md+ */}
<div>
<Skeleton className="h-5 w-44 rounded md:hidden" />
<div className="hidden items-center gap-2 md:flex">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-1">
<Skeleton className="size-7 rounded-full" />
<Skeleton className="h-4 w-16 rounded" />
{i < 4 && <Skeleton className="mx-1 h-px w-4 lg:w-6" />}
</div>
))}
</div>
</div>
{/* Two-column on lg: line items + summary sidebar */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr,340px] lg:items-start">
{/* Line items */}
<div className="flex flex-col gap-0">
{[0, 1, 2].map((i) => (
<div key={i}>
<CheckoutItemSkeleton />
{i < 2 && <Separator className="my-3" />}
</div>
))}
</div>
{/* Summary sidebar */}
<div className="flex flex-col gap-4 rounded-lg border border-default-200 p-4">
<Skeleton className="h-5 w-24 rounded" />
<div className="flex justify-between">
<Skeleton className="h-4 w-20 rounded" />
<Skeleton className="h-4 w-16 rounded" />
</div>
<div className="flex justify-between">
<Skeleton className="h-4 w-28 rounded" />
<Skeleton className="h-4 w-32 rounded" />
</div>
<Separator />
<div className="flex justify-between">
<Skeleton className="h-5 w-20 rounded" />
<Skeleton className="h-5 w-20 rounded" />
</div>
<Skeleton className="h-11 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
</div>
</div>
);
}
function CheckoutItemSkeleton() {
return (
<div className="flex items-start gap-3 py-1">
<Skeleton className="size-16 shrink-0 rounded-xl" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-36 rounded" />
<Skeleton className="h-3 w-20 rounded" />
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-4 w-16 rounded" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { Alert, Button, Card } from "@heroui/react";
import type { CheckoutValidationResult } from "@/lib/checkout/types";
import { CheckoutLineItems } from "../content/CheckoutLineItems";
import { CheckoutOrderSummary } from "../content/CheckoutOrderSummary";
type CartValidationStepProps = {
result: CheckoutValidationResult;
onProceed: () => void;
onBackToCart: () => void;
};
export function CartValidationStep({
result,
onProceed,
onBackToCart,
}: CartValidationStepProps) {
const hasBlockingIssues = !result.valid;
const hasPriceWarnings =
result.valid &&
result.issues.some((i) => i.type === "price_changed");
const itemCount = result.items.reduce((sum, i) => sum + i.quantity, 0);
const issueBannerId = "checkout-issue-banner";
return (
<div className="flex flex-col gap-6">
{/* Issue banners */}
{hasBlockingIssues && (
<Alert id={issueBannerId} status="danger" role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Some items need attention</Alert.Title>
<Alert.Description>
Please return to your cart to resolve the issues below, then
try again.
</Alert.Description>
</Alert.Content>
</Alert>
)}
{hasPriceWarnings && (
<Alert id={issueBannerId} status="accent">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Prices updated</Alert.Title>
<Alert.Description>
Some prices have changed since you added items. The current
prices are shown below.
</Alert.Description>
</Alert.Content>
</Alert>
)}
{/* Two-column on lg: items + sticky summary/actions */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr,340px] lg:items-start">
{/* Line items */}
<div>
<CheckoutLineItems items={result.items} issues={result.issues} />
</div>
{/* Summary + actions sidebar */}
<div className="lg:sticky lg:top-8 lg:self-start">
<Card className="rounded-lg p-4">
<Card.Content className="flex flex-col gap-4 p-0">
<CheckoutOrderSummary
subtotal={result.subtotal}
itemCount={itemCount}
/>
<div className="flex flex-col gap-2">
<Button
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
onPress={onProceed}
isDisabled={hasBlockingIssues}
aria-disabled={hasBlockingIssues || undefined}
aria-describedby={
hasBlockingIssues ? issueBannerId : undefined
}
>
Continue to shipping
</Button>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onBackToCart}
>
Back to cart
</Button>
</div>
</Card.Content>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,341 @@
"use client";
import { Alert, Button, Card, Separator, Spinner } from "@heroui/react";
import { useEffect, useRef } from "react";
import { useShippingRate } from "@/lib/checkout";
import type {
CheckoutAddress,
CheckoutValidationResult,
CheckoutSelectedShippingRate,
CheckoutShippingRateResult,
} from "@/lib/checkout/types";
import { formatPrice } from "@repo/utils";
import { CheckoutLineItems } from "../content/CheckoutLineItems";
type OrderReviewStepProps = {
addressId: string;
addresses: CheckoutAddress[];
cartResult: CheckoutValidationResult;
sessionId: string | undefined;
onProceed: (
shipmentObjectId: string,
shippingRate: CheckoutSelectedShippingRate,
) => void;
onBack: () => void;
};
function formatShippingAmount(amount: number, currency: string): string {
return new Intl.NumberFormat("en-GB", {
style: "currency",
currency: currency || "GBP",
minimumFractionDigits: 2,
}).format(amount);
}
export function OrderReviewStep({
addressId,
addresses,
cartResult,
sessionId,
onProceed,
onBack,
}: OrderReviewStepProps) {
const { result, isLoading, error, retry } = useShippingRate(
addressId,
sessionId,
);
const prevSubtotalRef = useRef(cartResult.subtotal);
useEffect(() => {
if (
result &&
!isLoading &&
cartResult.subtotal !== result.cartSubtotal
) {
prevSubtotalRef.current = cartResult.subtotal;
retry();
}
}, [cartResult.subtotal, result, isLoading, retry]);
const selectedAddress = addresses.find((a) => a.id === addressId);
const itemCount = cartResult.items.reduce((sum, i) => sum + i.quantity, 0);
const canProceed = !!result && !isLoading && !error;
const handleContinue = () => {
if (!result) return;
onProceed(result.shipmentObjectId, result.selectedRate);
};
const loadingMessageId = "shipping-rate-loading";
const errorMessageId = "shipping-rate-error";
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr,340px] lg:items-start">
{/* Left column: shipping rate + address + line items */}
<div className="flex flex-col gap-6">
<ShippingRateCard
result={result}
isLoading={isLoading}
error={error}
onRetry={retry}
loadingMessageId={loadingMessageId}
errorMessageId={errorMessageId}
/>
{selectedAddress && (
<section aria-label="Shipping address">
<h3 className="mb-2 text-sm font-semibold text-foreground">
Shipping address
</h3>
<AddressPreview address={selectedAddress} />
</section>
)}
<section aria-label="Order items">
<h3 className="mb-3 text-sm font-semibold text-foreground">
Items ({itemCount})
</h3>
<CheckoutLineItems
items={cartResult.items}
issues={cartResult.issues}
/>
</section>
</div>
{/* Right column: sticky order summary */}
<div className="lg:sticky lg:top-8 lg:self-start">
<Card className="rounded-lg p-4">
<Card.Content className="flex flex-col gap-4 p-0">
<OrderSummary
cartSubtotal={cartResult.subtotal}
itemCount={itemCount}
result={result}
isLoading={isLoading}
/>
<div className="flex flex-col gap-2">
<Button
color="primary"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
onPress={handleContinue}
isDisabled={!canProceed}
aria-disabled={!canProceed || undefined}
aria-describedby={
isLoading
? loadingMessageId
: error
? errorMessageId
: undefined
}
>
Continue to payment
</Button>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onBack}
>
Back to address
</Button>
</div>
</Card.Content>
</Card>
</div>
</div>
</div>
);
}
// ─── Shipping Rate Card ──────────────────────────────────────────────────────
function ShippingRateCard({
result,
isLoading,
error,
onRetry,
loadingMessageId,
errorMessageId,
}: {
result: CheckoutShippingRateResult | null;
isLoading: boolean;
error: string | null;
onRetry: () => void;
loadingMessageId: string;
errorMessageId: string;
}) {
if (isLoading) {
return (
<Card className="rounded-lg p-4">
<Card.Content
className="flex items-center gap-3 p-0"
role="status"
id={loadingMessageId}
>
<Spinner size="sm" />
<p className="text-sm text-default-500">
Calculating best shipping rate
</p>
</Card.Content>
</Card>
);
}
if (error) {
return (
<div id={errorMessageId}>
<Alert status="danger" role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Unable to calculate shipping</Alert.Title>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
<Button
variant="ghost"
className="mt-3 w-full md:w-auto"
onPress={onRetry}
>
Retry
</Button>
</div>
);
}
if (!result) return null;
const { selectedRate } = result;
return (
<Card className="rounded-lg p-4">
<Card.Content className="flex flex-col gap-1 p-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TruckIcon />
<span className="text-sm font-semibold text-foreground">
{selectedRate.provider}
</span>
</div>
<span className="text-sm font-semibold text-[#236f6b]">
{formatShippingAmount(selectedRate.amount, selectedRate.currency)}
</span>
</div>
<p className="text-sm text-default-500">
{selectedRate.serviceName}
{selectedRate.durationTerms && (
<span> · {selectedRate.durationTerms}</span>
)}
</p>
{selectedRate.estimatedDays > 0 && (
<p className="text-xs text-default-400">
Est. {selectedRate.estimatedDays} business{" "}
{selectedRate.estimatedDays === 1 ? "day" : "days"}
</p>
)}
</Card.Content>
</Card>
);
}
// ─── Order Summary ───────────────────────────────────────────────────────────
function OrderSummary({
cartSubtotal,
itemCount,
result,
isLoading,
}: {
cartSubtotal: number;
itemCount: number;
result: CheckoutShippingRateResult | null;
isLoading: boolean;
}) {
const currency = result?.selectedRate.currency ?? "GBP";
const shippingAmount = result?.shippingTotal ?? null;
const orderTotal =
shippingAmount !== null ? cartSubtotal / 100 + shippingAmount : null;
return (
<section aria-label="Order summary">
<h2 className="mb-3 font-[family-name:var(--font-fraunces)] text-lg font-semibold">
Summary
</h2>
<dl className="flex flex-col gap-3">
<div className="flex items-center justify-between text-sm">
<dt className="text-default-500">Items ({itemCount})</dt>
<dd className="font-semibold text-foreground">
{formatPrice(cartSubtotal)}
</dd>
</div>
<div className="flex items-center justify-between text-sm">
<dt className="text-default-500">Shipping</dt>
<dd
className={
shippingAmount !== null
? "font-semibold text-foreground"
: "text-default-400"
}
>
{isLoading
? "Calculating…"
: shippingAmount !== null
? formatShippingAmount(shippingAmount, currency)
: "—"}
</dd>
</div>
<Separator />
<div className="flex items-center justify-between">
<dt className="font-medium text-foreground">Total</dt>
<dd className="text-lg font-bold text-[#236f6b]">
{orderTotal !== null
? formatShippingAmount(orderTotal, currency)
: "—"}
</dd>
</div>
</dl>
</section>
);
}
// ─── Address Preview ─────────────────────────────────────────────────────────
function AddressPreview({ address }: { address: CheckoutAddress }) {
return (
<div className="rounded-md border border-default-200 p-3 text-sm text-default-600">
<p className="font-medium text-foreground">{address.fullName}</p>
<p>{address.addressLine1}</p>
{address.additionalInformation && <p>{address.additionalInformation}</p>}
<p>{address.city}</p>
<p>{address.postalCode}</p>
</div>
);
}
// ─── Icons ───────────────────────────────────────────────────────────────────
function TruckIcon() {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#236f6b]"
aria-hidden="true"
>
<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2" />
<path d="M15 18H9" />
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14" />
<circle cx="17" cy="18" r="2" />
<circle cx="7" cy="18" r="2" />
</svg>
);
}

View File

@@ -0,0 +1,275 @@
"use client";
import { useAction } from "convex/react";
import { ConvexError } from "convex/values";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Button, Card, Separator, Spinner } from "@heroui/react";
import {
CheckoutProvider,
PaymentElement,
useCheckout,
} from "@stripe/react-stripe-js/checkout";
import { api } from "../../../../../../convex/_generated/api";
import type { Id } from "../../../../../../convex/_generated/dataModel";
import { stripePromise } from "@/lib/stripe";
import type { PaymentStepProps } from "@/lib/checkout/types";
type PaymentState =
| { phase: "initialising" }
| { phase: "ready"; clientSecret: string }
| { phase: "error"; message: string };
export function PaymentStep({
shipmentObjectId,
selectedShippingRate,
addressId,
sessionId,
onBack,
}: PaymentStepProps) {
const createSession = useAction(api.stripeActions.createCheckoutSession);
const [state, setState] = useState<PaymentState>({ phase: "initialising" });
const fetchIdRef = useRef(0);
const initSession = useCallback(async () => {
const id = ++fetchIdRef.current;
setState({ phase: "initialising" });
try {
const result = await createSession({
addressId: addressId as Id<"addresses">,
shipmentObjectId,
shippingRate: selectedShippingRate,
sessionId,
});
if (id !== fetchIdRef.current) return;
setState({ phase: "ready", clientSecret: result.clientSecret });
} catch (err) {
if (id !== fetchIdRef.current) return;
let message: string;
if (err instanceof ConvexError && typeof err.data === "string") {
message = err.data;
} else if (err instanceof Error) {
message = err.message;
} else {
message = "Unable to prepare payment. Please try again.";
}
setState({ phase: "error", message });
}
}, [createSession, addressId, shipmentObjectId, selectedShippingRate, sessionId]);
useEffect(() => {
initSession();
}, [initSession]);
if (state.phase === "initialising") {
return <PaymentSkeleton />;
}
if (state.phase === "error") {
return (
<div className="flex flex-col gap-4">
<Alert status="danger" role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Payment setup failed</Alert.Title>
<Alert.Description>{state.message}</Alert.Description>
</Alert.Content>
</Alert>
<div className="flex flex-col gap-2 md:flex-row">
<Button
color="primary"
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
onPress={initSession}
>
Retry
</Button>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onBack}
>
Back to review
</Button>
</div>
</div>
);
}
return (
<CheckoutProvider
stripe={stripePromise}
options={{ clientSecret: state.clientSecret }}
>
<CheckoutForm onBack={onBack} onSessionExpired={initSession} />
</CheckoutProvider>
);
}
// ─── Checkout Form (inside CheckoutProvider) ────────────────────────────────
function CheckoutForm({
onBack,
onSessionExpired,
}: {
onBack: () => void;
onSessionExpired: () => void;
}) {
const checkoutState = useCheckout();
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
if (checkoutState.type === "loading") {
return <PaymentSkeleton />;
}
if (checkoutState.type === "error") {
const isExpiry =
checkoutState.error.message?.toLowerCase().includes("expir") ?? false;
return (
<div className="flex flex-col gap-4">
<Alert status="danger" role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>
{isExpiry ? "Session expired" : "Payment session error"}
</Alert.Title>
<Alert.Description>
{isExpiry
? "Your payment session has expired. Please try again to start a new secure session."
: (checkoutState.error.message ||
"Something went wrong loading the payment form. Please try again.")}
</Alert.Description>
</Alert.Content>
</Alert>
<div className="flex flex-col gap-2 md:flex-row">
<Button
color="primary"
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
onPress={onSessionExpired}
>
Try again
</Button>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onBack}
>
Back to review
</Button>
</div>
</div>
);
}
const { checkout } = checkoutState;
const handleSubmit = async () => {
setIsSubmitting(true);
setErrorMessage(null);
const result = await checkout.confirm();
if (result.type === "error") {
setErrorMessage(result.error.message);
}
setIsSubmitting(false);
};
const subtotal = checkout.total.subtotal;
const shippingAmount = checkout.total.shippingRate;
const total = checkout.total.total;
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr,340px] lg:items-start">
{/* Left column: payment form */}
<div className="flex flex-col gap-4">
<h3 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold">
Payment details
</h3>
<PaymentElement />
{errorMessage && (
<Alert status="danger" role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Description>{errorMessage}</Alert.Description>
</Alert.Content>
</Alert>
)}
</div>
{/* Right column: order summary */}
<div className="lg:sticky lg:top-8 lg:self-start">
<Card className="rounded-lg p-4">
<Card.Content className="flex flex-col gap-4 p-0">
<section aria-label="Order summary">
<h2 className="mb-3 font-[family-name:var(--font-fraunces)] text-lg font-semibold">
Summary
</h2>
<dl className="flex flex-col gap-3">
<div className="flex items-center justify-between text-sm">
<dt className="text-default-500">
Items ({checkout.lineItems.length})
</dt>
<dd className="font-semibold text-foreground">
{subtotal.amount}
</dd>
</div>
<div className="flex items-center justify-between text-sm">
<dt className="text-default-500">Shipping</dt>
<dd className="font-semibold text-foreground">
{shippingAmount.amount}
</dd>
</div>
<Separator />
<div className="flex items-center justify-between">
<dt className="font-medium text-foreground">Total</dt>
<dd className="text-lg font-bold text-[#236f6b]">
{total.amount}
</dd>
</div>
</dl>
</section>
<div className="flex flex-col gap-2">
<Button
color="primary"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
onPress={handleSubmit}
isDisabled={isSubmitting || !checkout.canConfirm}
isLoading={isSubmitting}
>
{isSubmitting ? "Processing…" : `Pay ${total.amount}`}
</Button>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onBack}
isDisabled={isSubmitting}
>
Back to review
</Button>
</div>
</Card.Content>
</Card>
</div>
</div>
</div>
);
}
// ─── Payment Skeleton ─────────────────────────────────────────────────────────
function PaymentSkeleton() {
return (
<div className="flex flex-col items-center gap-4 py-12" role="status">
<Spinner size="lg" />
<p className="text-sm text-default-500">Preparing secure payment</p>
</div>
);
}

View File

@@ -0,0 +1,395 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Alert, Button, Card, Spinner } from "@heroui/react";
import { useAddressValidation, useAddressMutations } from "@/lib/checkout";
import type {
CheckoutAddress,
CheckoutAddressValidationResult,
AddressFormData,
} from "@/lib/checkout/types";
import { AddressSelector } from "../content/AddressSelector";
import { AddressForm } from "../content/AddressForm";
import { AddressValidationFeedback } from "../content/AddressValidationFeedback";
type ShippingAddressStepProps = {
addresses: CheckoutAddress[];
isLoadingAddresses: boolean;
onProceed: (selectedAddressId: string) => void;
onBack: () => void;
};
type StepMode = "select" | "form" | "feedback";
export function ShippingAddressStep({
addresses,
isLoadingAddresses,
onProceed,
onBack,
}: ShippingAddressStepProps) {
const { validate, isValidating, error: validationError, reset: resetValidation } = useAddressValidation();
const { addAddress, markValidated } = useAddressMutations();
const defaultAddr = addresses.find((a) => a.isDefault) ?? addresses[0];
const [selectedId, setSelectedId] = useState<string | null>(defaultAddr?.id ?? null);
const [mode, setMode] = useState<StepMode>(
isLoadingAddresses || addresses.length > 0 ? "select" : "form",
);
const [formData, setFormData] = useState<AddressFormData | null>(null);
const [validationResult, setValidationResult] = useState<CheckoutAddressValidationResult | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [revalidatingAddressId, setRevalidatingAddressId] = useState<string | null>(null);
const [shippoFailed, setShippoFailed] = useState(false);
useEffect(() => {
if (!isLoadingAddresses) {
if (addresses.length > 0) {
setMode((prev) => (prev === "form" && formData ? prev : "select"));
if (!selectedId || !addresses.some((a) => a.id === selectedId)) {
const def = addresses.find((a) => a.isDefault) ?? addresses[0];
setSelectedId(def?.id ?? null);
}
if (revalidatingAddressId && !addresses.some((a) => a.id === revalidatingAddressId)) {
setRevalidatingAddressId(null);
setValidationResult(null);
setMode("select");
}
} else if (mode === "select") {
setMode("form");
}
}
}, [addresses, isLoadingAddresses]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSelectAddress = useCallback((id: string) => {
setSelectedId(id);
setShippoFailed(false);
setActionError(null);
}, []);
const handleContinue = useCallback(async () => {
if (!selectedId) return;
const addr = addresses.find((a) => a.id === selectedId);
if (!addr) return;
if (addr.isValidated) {
onProceed(selectedId);
return;
}
setActionError(null);
setShippoFailed(false);
const result = await validate({
firstName: addr.firstName,
lastName: addr.lastName,
phone: addr.phone,
addressLine1: addr.addressLine1,
additionalInformation: addr.additionalInformation,
city: addr.city,
postalCode: addr.postalCode,
country: addr.country,
});
if (result) {
if (result.isValid && !result.recommendedAddress) {
setIsSaving(true);
try {
await markValidated(selectedId, true);
onProceed(selectedId);
} catch {
setActionError("Failed to update address. Please try again.");
} finally {
setIsSaving(false);
}
return;
}
setRevalidatingAddressId(selectedId);
setFormData({
firstName: addr.firstName,
lastName: addr.lastName,
phone: addr.phone,
addressLine1: addr.addressLine1,
additionalInformation: addr.additionalInformation,
city: addr.city,
postalCode: addr.postalCode,
country: addr.country,
});
setValidationResult(result);
setMode("feedback");
} else {
setActionError(
"We couldn\u2019t verify your address right now. You can continue \u2014 we\u2019ll verify it later.",
);
setShippoFailed(true);
}
}, [selectedId, addresses, onProceed, validate, markValidated]);
const handleFormSubmit = useCallback(
async (data: AddressFormData) => {
setFormData(data);
setActionError(null);
setShippoFailed(false);
const result = await validate(data);
if (result) {
setRevalidatingAddressId(null);
setValidationResult(result);
setMode("feedback");
} else {
setShippoFailed(true);
}
},
[validate],
);
const handleSaveWithoutValidation = useCallback(async () => {
if (selectedId && mode === "select") {
onProceed(selectedId);
return;
}
if (!formData) return;
setIsSaving(true);
setActionError(null);
try {
const newId = await addAddress({
...formData,
isValidated: false,
});
setSelectedId(newId);
onProceed(newId);
} catch {
setActionError("Failed to save address. Please try again.");
} finally {
setIsSaving(false);
}
}, [selectedId, mode, formData, addAddress, onProceed]);
const handleAcceptRecommended = useCallback(async () => {
if (!validationResult?.recommendedAddress) return;
const rec = validationResult.recommendedAddress;
setIsSaving(true);
setActionError(null);
try {
const newId = await addAddress({
firstName: formData?.firstName ?? "",
lastName: formData?.lastName ?? "",
phone: formData?.phone ?? "",
addressLine1: rec.addressLine1,
additionalInformation: rec.additionalInformation,
city: rec.city,
postalCode: rec.postalCode,
country: rec.country,
isValidated: true,
});
setSelectedId(newId);
onProceed(newId);
} catch {
setActionError("Failed to save address. Please try again.");
} finally {
setIsSaving(false);
}
}, [validationResult, formData, addAddress, onProceed]);
const handleKeepOriginal = useCallback(async () => {
const isValid =
validationResult?.validationValue === "valid" ||
validationResult?.validationValue === "partially_valid";
if (revalidatingAddressId) {
setIsSaving(true);
setActionError(null);
try {
await markValidated(revalidatingAddressId, isValid);
onProceed(revalidatingAddressId);
} catch {
setActionError("Failed to update address. Please try again.");
} finally {
setIsSaving(false);
}
return;
}
if (!formData) return;
setIsSaving(true);
setActionError(null);
try {
const newId = await addAddress({
...formData,
isValidated: isValid,
});
setSelectedId(newId);
onProceed(newId);
} catch {
setActionError("Failed to save address. Please try again.");
} finally {
setIsSaving(false);
}
}, [formData, validationResult, addAddress, onProceed, revalidatingAddressId, markValidated]);
const handleEditAddress = useCallback(() => {
setMode("form");
setValidationResult(null);
setRevalidatingAddressId(null);
resetValidation();
}, [resetValidation]);
const handleAddNew = useCallback(() => {
setFormData(null);
setValidationResult(null);
setRevalidatingAddressId(null);
setShippoFailed(false);
setActionError(null);
resetValidation();
setMode("form");
}, [resetValidation]);
const handleCancelForm = useCallback(() => {
if (addresses.length > 0) {
setFormData(null);
setValidationResult(null);
setRevalidatingAddressId(null);
setShippoFailed(false);
setActionError(null);
resetValidation();
setMode("select");
}
}, [addresses.length, resetValidation]);
const isProcessing = isValidating || isSaving;
return (
<div className="flex flex-col gap-6">
{actionError && (
<Alert status={shippoFailed ? "warning" : "danger"} role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>
{shippoFailed ? "Address verification unavailable" : "Something went wrong"}
</Alert.Title>
<Alert.Description>{actionError}</Alert.Description>
</Alert.Content>
</Alert>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr,340px] lg:items-start">
{/* Main content area */}
<div className="relative">
{isProcessing && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60">
<div className="flex flex-col items-center gap-2">
<Spinner size="lg" />
<p className="text-sm text-default-500">
{isValidating ? "Validating address\u2026" : "Saving address\u2026"}
</p>
</div>
</div>
)}
{mode === "select" && (
<AddressSelector
addresses={addresses}
selectedId={selectedId}
onSelect={handleSelectAddress}
onAddNew={handleAddNew}
isLoading={isLoadingAddresses}
/>
)}
{mode === "form" && (
<>
<AddressForm
initialData={formData ?? undefined}
onSubmit={handleFormSubmit}
onCancel={addresses.length > 0 ? handleCancelForm : undefined}
isSubmitting={isProcessing}
validationError={shippoFailed ? null : validationError}
/>
{shippoFailed && formData && (
<div className="mt-4 rounded-lg border border-warning-200 bg-warning-50 p-4">
<p className="text-sm text-warning-700">
We couldn&apos;t verify your address right now. You can save it and
continue &mdash; we&apos;ll verify it later.
</p>
<Button
variant="ghost"
className="mt-3 w-full md:w-auto"
onPress={handleSaveWithoutValidation}
isDisabled={isSaving}
>
Save without verification
</Button>
</div>
)}
</>
)}
{mode === "feedback" && validationResult && (
<AddressValidationFeedback
result={validationResult}
onAcceptRecommended={handleAcceptRecommended}
onKeepOriginal={handleKeepOriginal}
onEditAddress={handleEditAddress}
/>
)}
</div>
{/* Sticky sidebar with navigation */}
<div className="lg:sticky lg:top-8 lg:self-start">
<Card className="rounded-lg p-4">
<Card.Content className="flex flex-col gap-3 p-0">
<p className="text-sm font-semibold text-foreground">Shipping address</p>
{selectedId && mode === "select" && (
<SelectedAddressPreview
address={addresses.find((a) => a.id === selectedId)}
/>
)}
<div className="flex flex-col gap-2">
{mode === "select" && (
<Button
variant="primary"
className="w-full bg-[#236f6b] font-medium text-white"
size="lg"
onPress={shippoFailed ? handleSaveWithoutValidation : handleContinue}
isDisabled={!selectedId || isProcessing}
>
{shippoFailed ? "Continue without verification" : "Continue to review"}
</Button>
)}
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onBack}
isDisabled={isProcessing}
>
Back to review
</Button>
</div>
</Card.Content>
</Card>
</div>
</div>
</div>
);
}
function SelectedAddressPreview({ address }: { address: CheckoutAddress | undefined }) {
if (!address) return null;
return (
<div className="rounded-md border border-default-200 p-3 text-sm text-default-600">
<p className="font-medium text-foreground">{address.fullName}</p>
<p>{address.addressLine1}</p>
{address.additionalInformation && <p>{address.additionalInformation}</p>}
<p>{address.city}</p>
<p>{address.postalCode}</p>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import Image from "next/image";
import Link from "next/link";
interface BrandLogoProps {
size?: number;
textClassName?: string;
}
export function BrandLogo({ size = 28, textClassName }: BrandLogoProps) {
return (
<Link href="/" className="flex shrink-0 items-center gap-2">
<Image
src="/branding/logo.svg"
alt=""
width={size}
height={size}
className="shrink-0"
priority
/>
<span
className={
textClassName ??
"font-bold font-[family-name:var(--font-fraunces)] text-2xl tracking-tight text-[#236f6b]"
}
>
<span className="font-medium lowercase">the</span>{" "}
<span className="">Pet</span>
<span className="text-[#f4a13a]"> Loft</span>
</span>
</Link>
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import { Toast } from "@heroui/react";
export function ToastProvider() {
return <Toast.Provider placement="bottom end" />;
}

View File

@@ -0,0 +1,252 @@
import { BrandLogo } from "@/components/layout/BrandLogo";
const linkClasses =
"text-sm text-[#3d5554] transition-colors hover:text-[#38a99f]";
function FacebookIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
);
}
function InstagramIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
);
}
function TwitterIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}
const shopLinks = [
{ label: "All Products", href: "/shop" },
{ label: "Dog Food", href: "/shop/dogs/dry-food" },
{ label: "Cat Food", href: "/shop/cats/dry-food" },
{ label: "Treats & Snacks", href: "/shop/dogs/treats" },
{ label: "Toys", href: "/shop/dogs/toys" },
{ label: "Beds & Baskets", href: "/shop/dogs/beds-and-baskets" },
{ label: "Grooming & Care", href: "/shop/dogs/grooming-and-care" },
{ label: "Leads & Collars", href: "/shop/dogs/leads-and-collars" },
{ label: "Bowls & Feeders", href: "/shop/dogs/bowls-and-feeders" },
{ label: "Flea & Worming", href: "/shop/dogs/flea-tick-and-worming-treatments" },
{ label: "Clothing", href: "/shop/dogs/clothing" },
];
const specialtyGroups = [
{
heading: "Brands",
links: [
{ label: "Almo Nature", href: "/brands/almo-nature" },
{ label: "Applaws", href: "/brands/applaws" },
{ label: "Arden Grange", href: "/brands/arden-grange" },
{ label: "Shop All", href: "/shop" },
],
},
{
heading: "Accessories",
links: [
{ label: "Crates & Travel", href: "/shop/dogs/crates-and-travel-accessories" },
{ label: "Kennels & Gates", href: "/shop/dogs/kennels-flaps-and-gates" },
{ label: "Trees & Scratching", href: "/shop/cats/trees-and-scratching-posts" },
],
},
];
const engagementGroups = [
{
heading: "Community",
links: [
{ label: "Adopt a Pet", href: "/community/adopt" },
{ label: "Pet Pharmacy", href: "/pharmacy" },
{ label: "Pet Services", href: "/services" },
],
},
{
heading: "Promotions",
links: [
{ label: "Special Offers", href: "/shop/sale" },
{ label: "Top Picks", href: "/shop/top-picks" },
{ label: "What's New", href: "/shop/recently-added" },
],
},
];
const utilityGroups = [
{
heading: "Content",
links: [
{ label: "Blog", href: "/blog" },
{ label: "Tips & Tricks", href: "/tips" },
{ label: "Pet Guides", href: "/guides" },
],
},
{
heading: "Support",
links: [
{ label: "Order Tracking", href: "/account/orders" },
{ label: "Shipping Info", href: "/support/shipping" },
{ label: "Returns & Refunds", href: "/support/returns" },
{ label: "FAQs", href: "/support/faqs" },
],
},
{
heading: "Company",
links: [
{ label: "About Us", href: "/about" },
{ label: "Contact Us", href: "/contact" },
{ label: "Careers", href: "/careers" },
],
},
];
function FooterColumn({
groups,
}: {
groups: { heading: string; links: { label: string; href: string }[] }[];
}) {
return (
<div className="space-y-5">
{groups.map((group) => (
<div key={group.heading}>
<h4 className="mb-2 text-sm font-semibold text-[#236f6b]">
{group.heading}
</h4>
<ul className="space-y-1.5">
{group.links.map((link) => (
<li key={link.label}>
<a href={link.href} className={linkClasses}>
{link.label}
</a>
</li>
))}
</ul>
</div>
))}
</div>
);
}
export function Footer() {
return (
<footer className="mt-auto w-full max-w-full overflow-x-hidden">
{/* Main footer */}
<div className="border-t border-[#e8f7f6] bg-white">
<div className="mx-auto grid min-w-0 max-w-[1400px] grid-cols-1 gap-10 px-4 py-12 sm:grid-cols-2 lg:grid-cols-[240px_1fr_1fr_1fr_1fr] lg:px-6">
{/* Brand & Social */}
<div className="space-y-6">
<div>
<BrandLogo
size={30}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
<p className="mt-3 text-sm leading-relaxed text-[#3d5554]">
Your trusted partner for premium pet supplies. Healthy pets,
happy homes from nutrition to play, we&apos;ve got it all.
</p>
</div>
<div className="flex items-center gap-3" suppressHydrationWarning>
<a
suppressHydrationWarning
href="https://facebook.com"
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
className="flex h-9 w-9 items-center justify-center rounded-full bg-[#f0f8f7] text-[#3d5554] transition-colors hover:bg-[#e8f7f6] hover:text-[#236f6b]"
>
<FacebookIcon />
</a>
<a
suppressHydrationWarning
href="https://instagram.com"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
className="flex h-9 w-9 items-center justify-center rounded-full bg-[#f0f8f7] text-[#3d5554] transition-colors hover:bg-[#e8f7f6] hover:text-[#236f6b]"
>
<InstagramIcon />
</a>
<a
suppressHydrationWarning
href="https://twitter.com"
target="_blank"
rel="noopener noreferrer"
aria-label="Twitter / X"
className="flex h-9 w-9 items-center justify-center rounded-full bg-[#f0f8f7] text-[#3d5554] transition-colors hover:bg-[#e8f7f6] hover:text-[#236f6b]"
>
<TwitterIcon />
</a>
</div>
</div>
{/* Column 1 — Shop */}
<div>
<h4 className="mb-3 text-sm font-semibold text-[#236f6b]">Shop</h4>
<ul className="space-y-1.5">
{shopLinks.map((link) => (
<li key={link.label}>
<a href={link.href} className={linkClasses}>
{link.label}
</a>
</li>
))}
</ul>
<a
href="/special-offers"
className="mt-3 inline-block text-sm font-semibold text-[#f2705a] transition-colors hover:text-[#e05a42]"
>
Sale
</a>
</div>
{/* Column 2 — Specialty */}
<FooterColumn groups={specialtyGroups} />
{/* Column 3 — Engagement */}
<FooterColumn groups={engagementGroups} />
{/* Column 4 — Utility */}
<FooterColumn groups={utilityGroups} />
</div>
</div>
{/* Copyright bar */}
<div className="w-full max-w-full bg-[#236f6b]">
<div className="mx-auto flex min-w-0 max-w-[1400px] flex-wrap items-center justify-between gap-3 px-4 py-4 lg:px-6">
<p className="text-xs text-white/80">
&copy; {new Date().getFullYear()} The Pet Loft. All rights reserved.
</p>
<div className="flex items-center gap-6">
<a
href="/terms"
className="text-xs text-white/80 transition-colors hover:text-white"
>
Terms of Use
</a>
<a
href="/privacy"
className="text-xs text-white/80 transition-colors hover:text-white"
>
Privacy Policy
</a>
<a
href="/sitemap"
className="text-xs text-white/80 transition-colors hover:text-white"
>
Site Map
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { DesktopHeader } from "./desktop/DesktopHeader";
import { MobileHeader } from "./mobile/MobileHeader";
export function Header() {
return (
<>
{/* Desktop Header - hidden on mobile/tablet */}
<div className="hidden lg:block">
<DesktopHeader />
</div>
{/* Mobile Header - shown on mobile and tablet */}
<div className="block w-full max-w-full min-w-0 overflow-x-clip lg:hidden">
<MobileHeader />
</div>
</>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
useProductSearch,
useClickOutside,
SEARCH_CATEGORIES,
MIN_SEARCH_LENGTH,
} from "@/lib/search";
import type { SearchCategory } from "@/lib/search";
import { SearchResultsPanel } from "@/components/search/SearchResultsPanel";
import { getProductUrl } from "@/lib/shop/productMapper";
interface HeaderSearchBarProps {
variant: "desktop" | "mobile";
}
export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
const router = useRouter();
const search = useProductSearch();
const searchBarRef = useRef<HTMLDivElement>(null);
// Desktop-only: custom category dropdown state
const [selectedCategory, setSelectedCategory] = useState<SearchCategory>(
SEARCH_CATEGORIES[0],
);
const [dropdownOpen, setDropdownOpen] = useState(false);
useClickOutside(
[searchBarRef, search.panelRef],
search.close,
search.isOpen,
);
function handleCategorySelect(cat: SearchCategory) {
setSelectedCategory(cat);
search.setCategorySlug(cat.slug);
setDropdownOpen(false);
}
function handleSearchButtonClick() {
if (search.query.length >= MIN_SEARCH_LENGTH) {
router.push(`/shop?search=${encodeURIComponent(search.query)}`);
search.close();
} else {
search.open();
}
}
const isDesktop = variant === "desktop";
return (
<div
ref={searchBarRef}
className={
isDesktop
? "relative flex w-full max-w-2xl items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
: "relative flex items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] p-1.5 shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
}
>
{/* Category picker */}
{isDesktop ? (
/* Desktop: custom dropdown */
<div className="relative">
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
className="flex items-center gap-1.5 rounded-l-full py-3 pl-5 pr-3 text-sm text-[#3d5554] transition-colors hover:text-[#1a2e2d]"
>
<span className="max-w-[110px] truncate">{selectedCategory.label}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform ${dropdownOpen ? "rotate-180" : ""}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{dropdownOpen && (
<div className="absolute left-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-xl border border-[#e8f7f6] bg-white py-1 shadow-lg">
{SEARCH_CATEGORIES.map((cat) => (
<button
key={cat.label}
onClick={() => handleCategorySelect(cat)}
className={`block w-full px-4 py-2 text-left text-sm transition-colors hover:bg-[#e8f7f6] ${
selectedCategory.label === cat.label
? "font-medium text-[#236f6b]"
: "text-[#3d5554]"
}`}
>
{cat.label}
</button>
))}
</div>
)}
</div>
) : (
/* Mobile: native select leverages OS picker */
<div className="flex items-center gap-1 border-r border-[#d9e8e7] pl-3 pr-2">
<select
value={search.categorySlug ?? ""}
onChange={(e) => search.setCategorySlug(e.target.value || undefined)}
className="max-w-[80px] appearance-none truncate bg-transparent text-xs font-medium text-[#3d5554] outline-none"
aria-label="Search category"
>
{SEARCH_CATEGORIES.map((cat) => (
<option key={cat.label} value={cat.slug ?? ""}>
{cat.label}
</option>
))}
</select>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 text-[#8aa9a8]"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
)}
{/* Divider — desktop only, between category picker and input */}
{isDesktop && <div className="h-6 w-px bg-[#d9e8e7]" />}
{/* Input */}
<input
ref={search.inputRef}
type="text"
value={search.query}
onChange={(e) => search.setQuery(e.target.value)}
onFocus={() => search.open()}
onKeyDown={search.handleKeyDown}
onBlur={search.handleBlur}
placeholder={
isDesktop
? "Search for products, brands, and more..."
: "Search for food, toys, etc."
}
className={
isDesktop
? "flex-1 bg-transparent py-3 pl-4 pr-3 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8]"
: "flex-1 border-none bg-transparent pl-3 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
}
role="combobox"
aria-expanded={search.isOpen && search.showResults}
aria-controls="search-results-panel"
aria-activedescendant={
search.selectedIndex >= 0
? `search-result-${search.selectedIndex}`
: undefined
}
aria-autocomplete="list"
autoComplete="off"
/>
{/* Search Button */}
<button
onClick={handleSearchButtonClick}
aria-label="Search"
className={`${isDesktop ? "mr-1.5 " : ""}flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#236f6b] text-white transition-colors hover:bg-[#38a99f]`}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
{/* Results Panel */}
{(search.showResults || search.showMinCharsHint) && (
<SearchResultsPanel
results={search.results}
isLoading={search.isLoading}
isStalled={search.isStalled}
isEmpty={search.isEmpty}
query={search.query}
selectedIndex={search.selectedIndex}
onItemSelect={(item) => {
router.push(getProductUrl(item));
search.close();
}}
onItemHover={search.setSelectedIndex}
showMinCharsHint={search.showMinCharsHint}
panelRef={search.panelRef}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html class="light" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Pet Store - Modern Home</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,400&amp;family=DM+Sans:ital,wght@0,300;0,400;0,500;0,700;1,400&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style type="text/tailwindcss">
:root {
--deep-teal: #236f6b;
--accent: #38a99f;
--soft-teal: #8dd5d1;
--default: #e8f7f6;
--surface-secondary: #e8f7f6;
--sunny-amber: #f4a13a;
--amber-cream: #fde8c8;
--playful-coral: #f2705a;
--coral-blush: #fce0da;
--foreground: #1a2e2d;
--muted: #3d5554;
--border: #8aa9a8;
--field-placeholder: #8aa9a8;
--background: #f0f8f7;
--surface: #ffffff;
--font-fraunces: 'Fraunces', serif;
--radius: 0.75rem;
}
body {
font-family: 'DM Sans', sans-serif;
background-color: var(--background);
color: var(--foreground);
}
.font-display {
font-family: var(--font-fraunces);
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.product-card-shadow {
box-shadow: 0 4px 6px -1px rgba(56, 169, 159, 0.1), 0 2px 4px -1px rgba(56, 169, 159, 0.06);
}
.product-card-shadow:hover {
box-shadow: 0 10px 15px -3px rgba(56, 169, 159, 0.2);
transform: translateY(-4px);
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary": "#38a99f",
},
fontFamily: {
"sans": ["DM Sans", "sans-serif"],
"serif": ["Fraunces", "serif"]
},
borderRadius: {
"DEFAULT": "0.75rem",
"lg": "1rem",
"xl": "1.5rem",
"full": "9999px"
},
},
},
}
</script>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="min-h-screen flex flex-col bg-[#f0f8f7]">
<div class="flex items-center justify-between px-4 py-2 bg-white border-b border-[#8aa9a8]/20">
<span class="text-[10px] font-medium tracking-widest text-[#3d5554] uppercase font-sans">petstore.com</span>
<div class="flex items-center gap-3">
<span class="bg-[#fce0da] text-[#f2705a] text-[10px] font-bold px-2 py-0.5 rounded-sm font-sans uppercase">10% OFF</span>
<span class="material-symbols-outlined text-[#3d5554] text-lg cursor-pointer">call</span>
</div>
</div>
<header class="bg-[#236f6b] px-4 pt-6 pb-2 sticky top-0 z-50">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-2">
<div class="w-9 h-9 bg-white/10 rounded-lg flex items-center justify-center">
<span class="material-symbols-outlined text-[#38a99f] text-xl font-bold">pets</span>
</div>
<h1 class="text-xl font-bold tracking-tight text-white font-serif">
Pet<span class="text-[#f4a13a]">Store</span>
</h1>
</div>
<div class="flex items-center gap-4">
<button class="relative p-1">
<span class="material-symbols-outlined text-white/75 hover:text-white transition-colors">favorite</span>
<span class="absolute top-1 right-1 w-2 h-2 bg-[#f2705a] rounded-full"></span>
</button>
<button class="p-1">
<span class="material-symbols-outlined text-white/75 hover:text-white transition-colors">shopping_bag</span>
</button>
<button class="w-8 h-8 rounded-full bg-white/10 overflow-hidden border border-white/20">
<img alt="Profile" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCnapMxzVZKxuHUBEXawVka0boJ9Y2sdWkYbTpigfEw5NtyPo2JtMNF9ZHQ-A8sgRebAnjVWXT1NyccaPWF0obFuiOd3VzwV1G29SsDZccSf-Wigc__vfnooY2SW0grPY9c0oXCwdDXuaSOIFEQ06STsebuvIcHT3w6tTrLTuEoKYEdM_8lrm7K8vecZZVULNcUxnSD32eeSyUBEVEuwo15MpIlji74Voi0wmnB1V46KPwR_S3zCiP7B4jiJA9JHrslgwXE7Az54hxW"/>
</button>
</div>
</div>
<div class="relative flex items-center bg-white rounded-[0.75rem] p-1.5 shadow-sm border border-transparent mb-4">
<div class="flex items-center gap-1 pl-3 pr-2 border-r border-[#8aa9a8]/30">
<span class="text-xs font-medium text-[#3d5554] truncate max-w-[80px] font-sans">All Categories</span>
<span class="material-symbols-outlined text-sm text-[#8aa9a8]">expand_more</span>
</div>
<input class="flex-1 bg-transparent border-none focus:ring-0 text-sm text-[#1a2e2d] placeholder:text-[#8aa9a8] font-sans" placeholder="Search for food, toys, etc." type="text"/>
<button class="w-10 h-10 bg-[#38a99f] hover:brightness-110 transition-colors rounded-[0.75rem] flex items-center justify-center text-white">
<span class="material-symbols-outlined">search</span>
</button>
</div>
</header>
<main class="flex-1 overflow-y-auto pb-24">
<div class="bg-white border-b border-[#8aa9a8]/10">
<div class="flex items-center gap-2 overflow-x-auto px-4 py-3 hide-scrollbar">
<button class="flex items-center gap-1.5 shrink-0 px-4 py-2 bg-[#e8f7f6] text-[#1a2e2d] rounded-full font-medium text-xs border border-[#8dd5d1]/30 font-sans">
<span class="material-symbols-outlined text-sm text-[#38a99f]">pets</span>
Dogs
<span class="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
<button class="flex items-center gap-1.5 shrink-0 px-4 py-2 text-[#3d5554] rounded-full font-medium text-xs hover:bg-[#e8f7f6] font-sans transition-colors">
<span class="material-symbols-outlined text-sm">pets</span>
Cats
<span class="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
<button class="flex items-center gap-1.5 shrink-0 px-4 py-2 text-[#3d5554] rounded-full font-medium text-xs hover:bg-[#e8f7f6] font-sans transition-colors">
<span class="material-symbols-outlined text-sm text-[#8dd5d1]">medical_services</span>
Pharmacy
<span class="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
<button class="flex items-center gap-1.5 shrink-0 px-4 py-2 text-[#3d5554] rounded-full font-medium text-xs hover:bg-[#e8f7f6] font-sans transition-colors">
<span class="material-symbols-outlined text-sm">verified</span>
Brands
<span class="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
</div>
</div>
<section class="bg-[#e8f7f6]">
<div class="grid grid-cols-2 gap-3 px-4 py-6">
<a class="flex flex-col items-start gap-2 p-4 bg-white rounded-[0.75rem] border border-[#8aa9a8]/10 transition-all product-card-shadow" href="#">
<div class="w-10 h-10 bg-[#fce0da] rounded-full flex items-center justify-center text-[#f2705a]">
<span class="material-symbols-outlined">sell</span>
</div>
<div>
<p class="font-serif font-semibold text-[#1a2e2d] leading-tight">Promotions</p>
<p class="text-[10px] text-[#f2705a] font-bold uppercase tracking-wider font-sans">Flash Sales</p>
</div>
</a>
<a class="flex flex-col items-start gap-2 p-4 bg-white rounded-[0.75rem] border border-[#8aa9a8]/10 transition-all product-card-shadow" href="#">
<div class="w-10 h-10 bg-[#fde8c8] rounded-full flex items-center justify-center text-[#f4a13a]">
<span class="material-symbols-outlined">tips_and_updates</span>
</div>
<div>
<p class="font-serif font-semibold text-[#1a2e2d] leading-tight">Tips &amp; Tricks</p>
<p class="text-[10px] text-[#f4a13a] font-bold uppercase tracking-wider font-sans">Expert Advice</p>
</div>
</a>
</div>
</section>
<div class="px-4 py-6 bg-white">
<div class="relative h-56 w-full rounded-xl overflow-hidden shadow-xl group">
<img alt="Happy Dog" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBEuRo5WgBTT4osxjXazPDRQuBOEsKqmqbBo_ghuBKWOCISBAAcwZGC56tc-QIakBAGhc77KDs3ISzKgBz-Cuuuh3SlGmPT6Y_dFs5WzPLhMbIqeriwQoMG1MqSsRI8zom4nlFAjlPbeQT5jfVA-6UtfDwt6cybeEo5NI8t4nTB3FWUstGEKcKlqEW3INs3nZVQev8W82DTdwZ7ubWhJg7AITqLuwrWIuZqmTdn-J32trGHQV4CmzeacPm3-K4Z7R28CVi-btxx-nWn"/>
<div class="absolute inset-0 bg-gradient-to-r from-[#236f6b] via-[#38a99f]/60 to-transparent flex flex-col justify-center px-8">
<span class="bg-[#fce0da] text-[#f2705a] text-[10px] font-bold px-2 py-0.5 rounded w-fit mb-2 font-sans uppercase">LIMITED TIME</span>
<h2 class="text-white text-2xl font-serif font-bold mb-1">Premium Kibble</h2>
<p class="text-white/82 text-sm font-light mb-5 font-sans">Up to 30% off on Royal Canin</p>
<button class="bg-[#f4a13a] text-[#1a2e2d] font-bold px-6 py-2.5 rounded-full text-xs w-fit hover:brightness-110 transition-all font-sans">Shop Now</button>
</div>
</div>
</div>
<section class="bg-[#e8f7f6] px-4 py-8">
<div class="flex items-center justify-between mb-6">
<h3 class="font-serif font-bold text-lg text-[#1a2e2d]">Popular Categories</h3>
<span class="text-sm font-semibold text-[#38a99f] font-sans">View All</span>
</div>
<div class="grid grid-cols-4 gap-3">
<div class="flex flex-col items-center gap-2">
<div class="w-14 h-14 bg-white rounded-full border border-[#8aa9a8]/20 flex items-center justify-center shadow-sm">
<span class="material-symbols-outlined text-[#38a99f]">restaurant</span>
</div>
<span class="text-[10px] font-bold text-[#3d5554] uppercase tracking-tight font-sans">Food</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="w-14 h-14 bg-white rounded-full border border-[#8aa9a8]/20 flex items-center justify-center shadow-sm">
<span class="material-symbols-outlined text-[#38a99f]">toys</span>
</div>
<span class="text-[10px] font-bold text-[#3d5554] uppercase tracking-tight font-sans">Toys</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="w-14 h-14 bg-white rounded-full border border-[#8aa9a8]/20 flex items-center justify-center shadow-sm">
<span class="material-symbols-outlined text-[#38a99f]">bed</span>
</div>
<span class="text-[10px] font-bold text-[#3d5554] uppercase tracking-tight font-sans">Beds</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="w-14 h-14 bg-white rounded-full border border-[#8aa9a8]/20 flex items-center justify-center shadow-sm">
<span class="material-symbols-outlined text-[#38a99f]">stroller</span>
</div>
<span class="text-[10px] font-bold text-[#3d5554] uppercase tracking-tight font-sans">Travel</span>
</div>
</div>
</section>
</main>
<nav class="fixed bottom-0 left-0 right-0 bg-[#236f6b] border-t border-white/10 px-4 pb-6 pt-3 z-50">
<div class="flex items-center justify-around max-w-md mx-auto">
<a class="flex flex-col items-center gap-1 group" href="#">
<div class="text-[#f4a13a] p-1">
<span class="material-symbols-outlined text-[26px] font-variation-fill">home</span>
</div>
<span class="text-[10px] font-medium text-white tracking-tight font-sans">Home</span>
</a>
<a class="flex flex-col items-center gap-1 group" href="#">
<div class="text-white/75 group-hover:text-white transition-colors p-1">
<span class="material-symbols-outlined text-[26px]">storefront</span>
</div>
<span class="text-[10px] font-medium text-white/75 group-hover:text-white tracking-tight transition-colors font-sans">Shop</span>
</a>
<a class="flex flex-col items-center gap-1 group" href="#">
<div class="text-white/75 group-hover:text-white transition-colors p-1">
<span class="material-symbols-outlined text-[26px]">receipt_long</span>
</div>
<span class="text-[10px] font-medium text-white/75 group-hover:text-white tracking-tight transition-colors font-sans">Orders</span>
</a>
<a class="flex flex-col items-center gap-1 group" href="#">
<div class="text-white/75 group-hover:text-white transition-colors p-1">
<span class="material-symbols-outlined text-[26px]">settings</span>
</div>
<span class="text-[10px] font-medium text-white/75 group-hover:text-white tracking-tight transition-colors font-sans">Settings</span>
</a>
</div>
</nav>
</body></html>

View File

@@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import { Link } from "@heroui/react";
import {
navCategories,
navCategoryOrder,
navCategoryLabels,
type NavSubcategory,
} from "@/lib/shop/navCategories";
const primaryLinks = navCategoryOrder.map((key) => ({
key,
label: navCategoryLabels[key],
href: `/shop/${navCategories[key].slug}`,
hasDropdown: true,
}));
const highlightedLinks = [
{ label: "Special Offers", href: "/shop/sale" },
{ label: "Tips & Tricks", href: "/tips" },
];
function ChevronDown({ open }: { open?: boolean }) {
return (
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform duration-200 ${open ? "rotate-180" : ""}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
function NavDropdown({
label,
href,
categorySlug,
subcategories,
isOpen,
onOpen,
onClose,
}: {
label: string;
href: string;
categorySlug: string;
subcategories: NavSubcategory[];
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
}) {
const colCount =
subcategories.length <= 6 ? 2 : subcategories.length <= 12 ? 3 : 4;
return (
<div className="relative" onMouseEnter={onOpen} onMouseLeave={onClose}>
<button
className={`flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-medium transition-colors ${
isOpen
? "bg-[#e8f7f6] text-[#236f6b]"
: "text-[#3d5554] hover:bg-[#e8f7f6] hover:text-[#236f6b]"
}`}
>
<span>{label}</span>
<ChevronDown open={isOpen} />
</button>
<div
className={`absolute left-0 top-full z-50 pt-1 transition-all duration-150 ${
isOpen
? "pointer-events-auto visible translate-y-0 opacity-100"
: "pointer-events-none invisible -translate-y-1 opacity-0"
}`}
>
<div className="w-max rounded-xl border border-[#e8f7f6] bg-white px-5 pb-4 pt-5 shadow-xl">
<div
className="grid gap-x-5"
style={{
gridTemplateColumns: `repeat(${colCount}, max-content)`,
}}
>
{subcategories.map((sub) => (
<Link
key={sub.slug}
href={`/shop/${categorySlug}/${sub.slug}`}
className="whitespace-nowrap rounded-md px-2.5 py-1.5 text-sm text-[#3d5554] no-underline transition-colors hover:bg-[#e8f7f6] hover:text-[#236f6b]"
>
{sub.name}
</Link>
))}
</div>
<div className="mt-3.5 border-t border-[#e8f7f6] pt-3">
<Link
href={href}
className="inline-flex items-center gap-1.5 text-sm font-medium text-[#236f6b] no-underline transition-colors hover:text-[#38a99f]"
>
Browse all {label}
<Link.Icon />
</Link>
</div>
</div>
</div>
</div>
);
}
export function BottomNav() {
const [openMenu, setOpenMenu] = useState<string | null>(null);
return (
<nav className="relative w-full border-b border-[#e8f7f6] bg-white">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6">
<ul className="flex items-center gap-1">
{primaryLinks.map((link) => {
const category = navCategories[link.key];
const subcategories = category.subcategories;
return (
<li key={link.key}>
<NavDropdown
label={link.label}
href={link.href}
categorySlug={category.slug}
subcategories={subcategories}
isOpen={openMenu === link.key}
onOpen={() => setOpenMenu(link.key)}
onClose={() => setOpenMenu(null)}
/>
</li>
);
})}
</ul>
<ul className="flex items-center gap-1">
{highlightedLinks.map((link) => (
<li key={link.label}>
<a
href={link.href}
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-medium text-[#f2705a] transition-colors hover:bg-[#fce0da]/50"
>
{link.label === "Special Offers" && (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 12 20 22 4 22 4 12" />
<rect x="2" y="7" width="20" height="5" />
<line x1="12" y1="22" x2="12" y2="7" />
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z" />
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z" />
</svg>
)}
{link.label === "Tips & Tricks" && (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
)}
<span>{link.label}</span>
</a>
</li>
))}
</ul>
</div>
</nav>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useRef } from "react";
import { useCartUI } from "@/components/cart/CartUIContext";
import { useCart } from "@/lib/cart/useCart";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
import { useWishlistCount } from "@/lib/wishlist/useWishlistCount";
import Link from "next/link";
import { HeaderUserAction } from "./HeaderUserAction";
import { BrandLogo } from "@/components/layout/BrandLogo";
import { HeaderSearchBar } from "@/components/layout/header/HeaderSearchBar";
export function CoreBrandBar() {
const { isOpen: isCartOpen, openCart, closeCart } = useCartUI();
const cartButtonRef = useRef<HTMLButtonElement>(null);
const sessionId = useCartSessionId();
const { items } = useCart(sessionId);
const cartItemCount = items.reduce((sum, i) => sum + i.quantity, 0);
const wishlistCount = useWishlistCount();
return (
<div className="w-full bg-white">
<div className="mx-auto flex max-w-[1400px] items-center justify-between gap-8 px-6 py-4">
{/* Logo */}
<BrandLogo size={32} />
{/* Search Bar */}
<HeaderSearchBar variant="desktop" />
{/* User Actions */}
<div className="flex shrink-0 items-center gap-6">
<HeaderUserAction />
{/* Wishlist */}
<Link
href="/wishlist"
className="group relative flex flex-col items-center gap-1"
aria-label={`Wishlist${wishlistCount > 0 ? `, ${wishlistCount} items` : ""}`}
>
<div className="relative">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#3d5554] transition-colors group-hover:text-[#f2705a]"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{wishlistCount > 0 && (
<span className="absolute -right-2 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f2705a] text-[9px] font-bold text-white">
{wishlistCount > 99 ? "99+" : wishlistCount}
</span>
)}
</div>
<span className="text-[10px] font-medium tracking-wide text-[#3d5554] transition-colors group-hover:text-[#f2705a]">
Wishlist
</span>
</Link>
{/* Cart */}
<button
ref={cartButtonRef}
type="button"
onClick={() =>
isCartOpen
? closeCart()
: openCart(cartButtonRef.current ?? undefined)
}
className="group relative flex flex-col items-center gap-1"
aria-label={`Cart${cartItemCount > 0 ? `, ${cartItemCount} items` : ""}`}
>
<div className="relative">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#3d5554] transition-colors group-hover:text-[#236f6b]"
>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
{cartItemCount > 0 && (
<span className="absolute -right-2 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f2705a] text-[9px] font-bold text-white">
{cartItemCount > 99 ? "99+" : cartItemCount}
</span>
)}
</div>
<span className="text-[10px] font-medium tracking-wide text-[#3d5554] transition-colors group-hover:text-[#236f6b]">
Cart
</span>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { TopUtilityBar } from "./TopUtilityBar";
import { CoreBrandBar } from "./CoreBrandBar";
import { BottomNav } from "./BottomNav";
export function DesktopHeader() {
return (
<header className="sticky top-0 z-50 w-full shadow-sm">
<TopUtilityBar />
<CoreBrandBar />
<BottomNav />
</header>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useConvexAuth } from "convex/react";
import { UserButton } from "@clerk/nextjs";
function OrdersIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
);
}
export function HeaderUserAction() {
const { isLoading, isAuthenticated } = useConvexAuth();
if (isLoading || !isAuthenticated) {
return (
<a
href="/sign-in"
className="group flex flex-col items-center gap-1"
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#3d5554] transition-colors group-hover:text-[#236f6b]"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span className="text-[10px] font-medium tracking-wide text-[#3d5554] transition-colors group-hover:text-[#236f6b]">
Sign In
</span>
</a>
);
}
return (
<div className="flex flex-col items-center gap-1">
<UserButton
afterSignOutUrl="/"
appearance={{
elements: {
avatarBox: "w-[22px] h-[22px]",
},
}}
>
<UserButton.MenuItems>
<UserButton.Link
label="My Orders"
labelIcon={<OrdersIcon />}
href="/account/orders"
/>
</UserButton.MenuItems>
</UserButton>
<span className="text-[10px] font-medium tracking-wide text-[#3d5554]">
Account
</span>
</div>
);
}

View File

@@ -0,0 +1,74 @@
export function TopUtilityBar() {
return (
<div className="w-full bg-[#f5f5f5] border-b border-[#e8e8e8]">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-1.5 text-xs">
{/* Domain */}
<span className="tracking-wide text-[#3d5554]">www.thepetloft.com</span>
{/* Promo */}
<div className="flex items-center gap-1.5 font-medium text-[#f2705a]">
<span><strong className="text-[13px]"> 10% </strong>
off your first order</span>
<span></span>
<span><strong className="text-[13px]"> 5% </strong>
off on all Re-orders over <strong>£30</strong></span>
<span></span>
<span>Free shipping on orders over <strong>£40</strong></span>
</div>
{/* Utility links */}
<div className="flex items-center gap-5 text-[#3d5554]">
<button className="flex items-center gap-1.5 transition-colors hover:text-[#1a2e2d]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
<path d="M14.05 2a9 9 0 0 1 8 7.94" />
<path d="M14.05 6A5 5 0 0 1 18 10" />
</svg>
<span>Contact</span>
</button>
<div className="h-3 w-px bg-[#ccc]" />
<button className="flex items-center gap-1.5 transition-colors hover:text-[#1a2e2d]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<span>EN</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useRef } from "react";
import { useCartUI } from "@/components/cart/CartUIContext";
import { useCart } from "@/lib/cart/useCart";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
import { useWishlistCount } from "@/lib/wishlist/useWishlistCount";
import Link from "next/link";
import { MobileHeaderUserAction } from "./MobileHeaderUserAction";
import { BrandLogo } from "@/components/layout/BrandLogo";
import { HeaderSearchBar } from "@/components/layout/header/HeaderSearchBar";
export function MobileCoreBrandBar() {
const { isOpen: isCartOpen, openCart, closeCart } = useCartUI();
const cartButtonRef = useRef<HTMLButtonElement>(null);
const sessionId = useCartSessionId();
const { items } = useCart(sessionId);
const cartItemCount = items.reduce((sum, i) => sum + i.quantity, 0);
const wishlistCount = useWishlistCount();
return (
<div className="bg-white px-4 py-4">
{/* Logo and Actions Row */}
<div className="mb-4 flex items-center justify-between">
{/* Logo */}
<BrandLogo
size={26}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
{/* Actions */}
<div className="flex items-center gap-4">
<Link
href="/wishlist"
className="relative p-1 text-[#3d5554] transition-colors hover:text-[#f2705a]"
aria-label={`Wishlist${wishlistCount > 0 ? `, ${wishlistCount} items` : ""}`}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{wishlistCount > 0 && (
<span className="absolute -right-1 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f2705a] text-[9px] font-bold text-white">
{wishlistCount > 99 ? "99+" : wishlistCount}
</span>
)}
</Link>
<button
ref={cartButtonRef}
type="button"
onClick={() =>
isCartOpen
? closeCart()
: openCart(cartButtonRef.current ?? undefined)
}
className="relative p-1 text-[#3d5554] transition-colors hover:text-[#236f6b]"
aria-label={`Cart${cartItemCount > 0 ? `, ${cartItemCount} items` : ""}`}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
{cartItemCount > 0 && (
<span className="absolute -right-1 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f2705a] text-[9px] font-bold text-white">
{cartItemCount > 99 ? "99+" : cartItemCount}
</span>
)}
</button>
<MobileHeaderUserAction />
</div>
</div>
{/* Search Bar */}
<HeaderSearchBar variant="mobile" />
</div>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { useRef } from "react";
import { MobileUtilityBar } from "./MobileUtilityBar";
import { MobileNavButtons } from "./MobileNavButtons";
import { MobileHeaderUserAction } from "./MobileHeaderUserAction";
import Link from "next/link";
import { useCartUI } from "@/components/cart/CartUIContext";
import { useCart } from "@/lib/cart/useCart";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
import { useWishlistCount } from "@/lib/wishlist/useWishlistCount";
import { BrandLogo } from "@/components/layout/BrandLogo";
import { HeaderSearchBar } from "@/components/layout/header/HeaderSearchBar";
export function MobileHeader() {
const { openCart } = useCartUI();
const cartButtonRef = useRef<HTMLButtonElement>(null);
const sessionId = useCartSessionId();
const { items } = useCart(sessionId);
const cartItemCount = items.reduce((sum, i) => sum + i.quantity, 0);
const wishlistCount = useWishlistCount();
return (
<>
{/* In-flow: utility bar + logo row scroll away with the page */}
<div className="w-full max-w-full min-w-0 overflow-x-hidden bg-white">
<MobileUtilityBar />
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-[#e8f7f6] px-4 py-3">
<BrandLogo
size={26}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
<div className="flex shrink-0 items-center gap-3">
<Link
href="/wishlist"
className="relative p-1 text-[#3d5554] transition-colors hover:text-[#f2705a]"
aria-label={`Wishlist${wishlistCount > 0 ? `, ${wishlistCount} items` : ""}`}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{wishlistCount > 0 && (
<span className="absolute -right-1 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f2705a] text-[9px] font-bold text-white">
{wishlistCount > 99 ? "99+" : wishlistCount}
</span>
)}
</Link>
<button
ref={cartButtonRef}
type="button"
onClick={() => openCart(cartButtonRef.current)}
className="relative flex min-h-[44px] min-w-[44px] items-center justify-center p-1 text-[#3d5554] transition-colors hover:text-[#236f6b]"
aria-label={cartItemCount > 0 ? `Cart, ${cartItemCount} items` : "Cart"}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
{cartItemCount > 0 && (
<span className="absolute right-0 top-0 flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-[#f2705a] px-1 text-[10px] font-bold text-white">
{cartItemCount > 99 ? "99+" : cartItemCount}
</span>
)}
</button>
<MobileHeaderUserAction />
</div>
</div>
</div>
{/* Sticky: search bar + nav stay at top once they reach the viewport */}
{/* overflow-x-clip (not hidden) so the absolute results panel can overflow below */}
<header className="sticky top-0 z-50 w-full max-w-full min-w-0 overflow-x-clip bg-white shadow-sm">
<div className="min-w-0 px-4 py-2">
<HeaderSearchBar variant="mobile" />
</div>
<MobileNavButtons />
</header>
</>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useConvexAuth } from "convex/react";
import { UserButton } from "@clerk/nextjs";
function OrdersIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
);
}
export function MobileHeaderUserAction() {
const { isLoading, isAuthenticated } = useConvexAuth();
if (isLoading || !isAuthenticated) {
return (
<a
href="/sign-in"
className="flex h-8 w-8 items-center justify-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb]"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#3d5554]"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
);
}
return (
<UserButton
afterSignOutUrl="/"
appearance={{
elements: {
avatarBox: "w-8 h-8",
},
}}
>
<UserButton.MenuItems>
<UserButton.Link
label="My Orders"
labelIcon={<OrdersIcon />}
href="/account/orders"
/>
</UserButton.MenuItems>
</UserButton>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import {
navCategories,
navCategoryOrder,
navCategoryLabels,
} from "@/lib/shop/navCategories";
/** Nav items from single source: navCategories.ts */
const navItems = navCategoryOrder.map((key) => ({
key,
label: navCategoryLabels[key],
href: `/shop/${navCategories[key].slug}`,
subcategories: navCategories[key].subcategories,
}));
function ChevronDown({ open }: { open?: boolean }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`shrink-0 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
aria-hidden
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
export function MobileNavButtons() {
const [openLabel, setOpenLabel] = useState<string | null>(null);
const [dropdownTop, setDropdownTop] = useState<number | null>(null);
const triggerRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const containerRef = useRef<HTMLDivElement>(null);
const openItem = openLabel ? navItems.find((i) => i.label === openLabel) : null;
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
const target = e.target as HTMLElement;
if (!target.closest("[role='menu']")) setOpenLabel(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (!openLabel) {
setDropdownTop(null);
return;
}
const el = triggerRefs.current[openLabel];
if (el) {
const r = el.getBoundingClientRect();
setDropdownTop(r.bottom + 4);
}
}, [openLabel]);
return (
<div ref={containerRef} className="border-b border-[#e8f7f6] bg-white">
<div className="scrollbar-hide flex items-center gap-2 overflow-x-auto px-4 py-3">
{navItems.map((item) => {
const isOpen = openLabel === item.label;
return (
<button
key={item.label}
ref={(el) => {
triggerRefs.current[item.label] = el;
}}
type="button"
onClick={() => setOpenLabel(isOpen ? null : item.label)}
className={`shrink-0 flex items-center gap-1.5 rounded-full border px-4 py-2 font-sans text-xs font-medium transition-colors ${
isOpen
? "border-[#38a99f]/30 bg-[#e8f7f6] text-[#236f6b]"
: "border-transparent text-[#3d5554] hover:bg-[#e8f7f6] hover:text-[#236f6b]"
}`}
aria-expanded={isOpen}
aria-haspopup="true"
aria-controls={isOpen ? `nav-dropdown-${item.label}` : undefined}
id={`nav-trigger-${item.label}`}
>
{item.label}
<ChevronDown open={isOpen} />
</button>
);
})}
</div>
{openItem &&
dropdownTop != null &&
typeof document !== "undefined" &&
createPortal(
<div
id={`nav-dropdown-${openItem.label}`}
role="menu"
className="fixed z-[100] max-h-[60vh] overflow-y-auto rounded-xl border border-[#e8f7f6] bg-white py-2 shadow-lg"
style={{
top: dropdownTop,
left: 16,
right: 16,
}}
>
{openItem.subcategories.map((sub) => (
<Link
key={sub.slug}
href={`/shop/${navCategories[openItem.key].slug}/${sub.slug}`}
role="menuitem"
className="block px-4 py-2.5 font-sans text-sm text-[#3d5554] no-underline transition-colors hover:bg-[#e8f7f6] hover:text-[#236f6b]"
onClick={() => setOpenLabel(null)}
>
{sub.name}
</Link>
))}
<div className="mt-2 border-t border-[#e8f7f6] pt-2">
<Link
href={openItem.href}
className="block px-4 py-2 font-sans text-sm font-medium text-[#236f6b] no-underline transition-colors hover:bg-[#e8f7f6]"
onClick={() => setOpenLabel(null)}
>
Browse all {openItem.label}
</Link>
</div>
</div>,
document.body
)}
</div>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
const PROMO_TEXT =
"☞ 10% off your first order ★ ☞ 5% off on all Re-orders over £30 ★ Free shipping on orders over £40 ★ ";
export function MobileUtilityBar() {
return (
<div className="relative flex min-h-[2.5rem] w-full max-w-full items-center overflow-hidden border-b border-[#e8e8e8] bg-[#f5f5f5]">
<div className="absolute inset-0 flex items-center overflow-hidden" aria-hidden>
<div className="flex animate-marquee whitespace-nowrap">
<span className="inline-block pr-8 font-sans text-[10px] font-medium text-[#f2705a]">
{PROMO_TEXT}
</span>
<span className="inline-block pr-8 font-sans text-[10px] font-medium text-[#f2705a]">
{PROMO_TEXT}
</span>
</div>
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import { Breadcrumbs, toast } from "@heroui/react";
import Link from "next/link";
import { ORDERS_PATH, useOrderDetail, useOrderActions } from "@/lib/orders";
import { useCartSession } from "@/lib/session";
import { OrderHeader } from "./detail/OrderHeader";
import { OrderLineItems } from "./detail/OrderLineItems";
import { OrderPriceSummary } from "./detail/OrderPriceSummary";
import { OrderAddresses } from "./detail/OrderAddresses";
import { OrderTrackingInfo } from "./detail/OrderTrackingInfo";
import { OrderActions } from "./detail/OrderActions";
import { CancelOrderDialog } from "./actions/CancelOrderDialog";
import { ReorderConfirmDialog } from "./actions/ReorderConfirmDialog";
import { OrderDetailSkeleton } from "./state/OrderDetailSkeleton";
interface Props {
orderId: string;
}
export function OrderDetailPageView({ orderId }: Props) {
const { order, isLoading } = useOrderDetail(orderId);
const { cancelOrder, isCancelling, reorderItems, isReordering } =
useOrderActions();
const { sessionId } = useCartSession();
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [reorderDialogOpen, setReorderDialogOpen] = useState(false);
if (isLoading) return <OrderDetailSkeleton />;
if (!order) {
return (
<div className="py-16 text-center">
<p className="text-gray-500">Order not found.</p>
<Link
href={ORDERS_PATH}
className="mt-4 inline-block text-sm font-medium text-[#38a99f] hover:text-[#236f6b]"
>
Back to Orders
</Link>
</div>
);
}
const handleConfirmCancel = async () => {
const result = await cancelOrder(orderId);
setCancelDialogOpen(false);
if (result.success) {
toast.success("Order cancelled successfully.");
} else {
toast.danger("Failed to cancel order. Please try again.");
}
};
const handleConfirmReorder = async () => {
const { added, skipped } = await reorderItems(order.items, sessionId);
setReorderDialogOpen(false);
const total = order.items.length;
if (skipped === 0) {
toast.success(
`All ${added} ${added === 1 ? "item" : "items"} added to your cart.`,
);
} else if (added === 0) {
toast.danger(
"No items could be added — they are no longer available.",
);
} else {
toast(`${added} of ${total} items added to cart.`, {
description: `${skipped} ${skipped === 1 ? "item was" : "items were"} no longer available.`,
});
}
};
return (
<div className="space-y-6">
{/* Breadcrumbs */}
<Breadcrumbs>
<Breadcrumbs.Item href="/account">Account</Breadcrumbs.Item>
<Breadcrumbs.Item href={ORDERS_PATH}>Orders</Breadcrumbs.Item>
<Breadcrumbs.Item>Order {order.orderNumber}</Breadcrumbs.Item>
</Breadcrumbs>
{/* Header */}
<OrderHeader
orderNumber={order.orderNumber}
status={order.status}
paymentStatus={order.paymentStatus}
createdAt={order.createdAt}
paidAt={order.paidAt}
/>
{/* Main grid — items/tracking/addresses left, summary right */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<OrderLineItems items={order.items} currency={order.currency} />
<OrderTrackingInfo
carrier={order.carrier}
shippingMethod={order.shippingMethod}
trackingNumber={order.trackingNumber}
trackingUrl={order.trackingUrl}
estimatedDelivery={order.estimatedDelivery}
shippedAt={order.shippedAt}
actualDelivery={order.actualDelivery}
status={order.status}
/>
<OrderAddresses
shippingAddress={order.shippingAddressSnapshot}
billingAddress={order.billingAddressSnapshot}
/>
</div>
<div>
<OrderPriceSummary
subtotal={order.subtotal}
shipping={order.shipping}
tax={order.tax}
discount={order.discount}
total={order.total}
currency={order.currency}
/>
</div>
</div>
{/* Actions */}
<OrderActions
order={order}
onCancel={() => setCancelDialogOpen(true)}
isCancelling={isCancelling}
onReorder={() => setReorderDialogOpen(true)}
isReordering={isReordering}
/>
{/* Dialogs */}
<CancelOrderDialog
isOpen={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
onConfirm={handleConfirmCancel}
isCancelling={isCancelling}
orderNumber={order.orderNumber}
/>
<ReorderConfirmDialog
isOpen={reorderDialogOpen}
onClose={() => setReorderDialogOpen(false)}
onConfirm={handleConfirmReorder}
isReordering={isReordering}
itemCount={order.items.length}
/>
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useOrders, ORDER_TAB_FILTERS, ORDERS_PATH } from "@/lib/orders";
import { OrderStatusFilter } from "./list/OrderStatusFilter";
import { OrderCardList } from "./list/OrderCardList";
import { OrdersSkeleton } from "./state/OrdersSkeleton";
import { OrdersEmptyState } from "./state/OrdersEmptyState";
export function OrdersPageView() {
const [activeFilter, setActiveFilter] = useState("all");
const router = useRouter();
const tab = ORDER_TAB_FILTERS.find((f) => f.id === activeFilter);
const statusFilter = tab?.statuses?.map(String);
const { orders, isLoading, hasMore, loadMore, status } =
useOrders(statusFilter);
if (isLoading) {
return <OrdersSkeleton />;
}
return (
<div className="space-y-6">
<OrderStatusFilter
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
/>
{orders.length === 0 ? (
<OrdersEmptyState
message={
activeFilter === "all"
? "When you place an order, it will appear here."
: "No orders match this filter."
}
/>
) : (
<OrderCardList
orders={orders}
hasMore={hasMore}
isLoadingMore={status === "LoadingMore"}
onLoadMore={loadMore}
onViewDetails={(id) => router.push(`${ORDERS_PATH}/${id}`)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { AlertDialog, Button, Spinner } from "@heroui/react";
interface Props {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
isCancelling: boolean;
orderNumber: string;
}
export function CancelOrderDialog({
isOpen,
onClose,
onConfirm,
isCancelling,
orderNumber,
}: Props) {
return (
<AlertDialog>
<AlertDialog.Backdrop isOpen={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
<AlertDialog.Container>
<AlertDialog.Dialog>
<AlertDialog.Header>
<AlertDialog.Icon status="danger" />
<AlertDialog.Heading>Cancel Order?</AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body>
<p className="text-sm text-gray-600">
Are you sure you want to cancel order{" "}
<span className="font-medium text-[#1a2e2d]">{orderNumber}</span>?{" "}
This action cannot be undone.
</p>
</AlertDialog.Body>
<AlertDialog.Footer>
<Button variant="outline" slot="close" isDisabled={isCancelling}>
Keep Order
</Button>
<Button
variant="danger"
onPress={onConfirm}
isDisabled={isCancelling}
>
{isCancelling ? (
<>
<Spinner size="sm" />
Cancelling
</>
) : (
"Yes, Cancel Order"
)}
</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
</AlertDialog>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { Modal, Button, Spinner } from "@heroui/react";
interface Props {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
isReordering: boolean;
itemCount: number;
}
export function ReorderConfirmDialog({
isOpen,
onClose,
onConfirm,
isReordering,
itemCount,
}: Props) {
return (
<Modal>
<Modal.Backdrop isOpen={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
<Modal.Container size="sm">
<Modal.Dialog>
<Modal.Header>
<Modal.Heading>Add items to cart?</Modal.Heading>
</Modal.Header>
<Modal.Body>
<p className="text-sm text-gray-600">
This will add{" "}
<span className="font-medium text-[#1a2e2d]">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>{" "}
from this order to your cart. Some items may no longer be
available.
</p>
</Modal.Body>
<Modal.Footer>
<Button variant="outline" slot="close" isDisabled={isReordering}>
Cancel
</Button>
<Button
onPress={onConfirm}
isDisabled={isReordering}
className="bg-[#f4a13a] text-white hover:bg-[#e08c28]"
>
{isReordering ? (
<>
<Spinner size="sm" />
Adding
</>
) : (
"Add to Cart"
)}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { Button, Spinner } from "@heroui/react";
import Link from "next/link";
import { CANCELLABLE_STATUSES, ORDERS_PATH } from "@/lib/orders";
import type { OrderDetail } from "@/lib/orders";
interface Props {
order: OrderDetail;
onCancel: () => void;
isCancelling: boolean;
onReorder: () => void;
isReordering: boolean;
}
const REORDERABLE_STATUSES = ["delivered", "cancelled", "refunded"] as const;
export function OrderActions({
order,
onCancel,
isCancelling,
onReorder,
isReordering,
}: Props) {
const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(
order.status,
);
const canReorder = (REORDERABLE_STATUSES as readonly string[]).includes(
order.status,
);
return (
<div className="flex w-full flex-col gap-3 md:flex-row md:flex-wrap">
{canCancel && (
<Button
variant="danger"
className="w-full md:w-auto"
onPress={onCancel}
isDisabled={isCancelling}
>
{isCancelling ? (
<>
<Spinner size="sm" />
Cancelling
</>
) : (
"Cancel Order"
)}
</Button>
)}
{canReorder && (
<Button
variant="outline"
className="w-full md:w-auto"
onPress={onReorder}
isDisabled={isReordering}
>
{isReordering ? (
<>
<Spinner size="sm" />
Adding to cart
</>
) : (
"Reorder"
)}
</Button>
)}
<Link
href={ORDERS_PATH}
className="inline-flex w-full items-center justify-center rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 md:w-auto"
>
Back to Orders
</Link>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { Card } from "@heroui/react";
import type { OrderDetail } from "@/lib/orders";
interface Props {
shippingAddress: OrderDetail["shippingAddressSnapshot"];
billingAddress: OrderDetail["billingAddressSnapshot"];
}
function AddressLines({
address,
}: {
address: Props["shippingAddress"] | Props["billingAddress"];
}) {
const fullName =
"fullName" in address
? address.fullName
: `${address.firstName} ${address.lastName}`;
return (
<address className="space-y-0.5 text-sm not-italic text-gray-600">
<p className="font-medium text-[#1a2e2d]">{fullName}</p>
<p>{address.addressLine1}</p>
{"additionalInformation" in address && address.additionalInformation && (
<p>{address.additionalInformation}</p>
)}
<p>
{address.city}, {address.postalCode}
</p>
<p>{address.country}</p>
{"phone" in address && address.phone && <p>{address.phone}</p>}
</address>
);
}
export function OrderAddresses({ shippingAddress, billingAddress }: Props) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<Card.Header>
<Card.Title className="text-sm font-semibold uppercase tracking-wide text-gray-500">
Shipping Address
</Card.Title>
</Card.Header>
<Card.Content>
<AddressLines address={shippingAddress} />
</Card.Content>
</Card>
<Card>
<Card.Header>
<Card.Title className="text-sm font-semibold uppercase tracking-wide text-gray-500">
Billing Address
</Card.Title>
</Card.Header>
<Card.Content>
<AddressLines address={billingAddress} />
</Card.Content>
</Card>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { Chip } from "@heroui/react";
import { ORDER_STATUS_CONFIG, PAYMENT_STATUS_CONFIG } from "@/lib/orders";
import type { OrderStatus, PaymentStatus } from "@/lib/orders";
interface Props {
orderNumber: string;
status: OrderStatus;
paymentStatus: PaymentStatus;
createdAt: number;
paidAt?: number;
}
function formatTimestamp(ts: number): string {
return new Intl.DateTimeFormat("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(ts));
}
export function OrderHeader({
orderNumber,
status,
paymentStatus,
createdAt,
paidAt,
}: Props) {
const statusCfg = ORDER_STATUS_CONFIG[status];
const paymentCfg = PAYMENT_STATUS_CONFIG[paymentStatus];
return (
<div className="space-y-3">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h1 className="font-[family-name:var(--font-fraunces)] text-xl font-semibold text-[#1a2e2d] md:text-2xl">
Order {orderNumber}
</h1>
<div className="flex flex-wrap gap-2">
<Chip size="sm" variant="soft" className={statusCfg.colorClass}>
{statusCfg.label}
</Chip>
<Chip size="sm" variant="soft" className={paymentCfg.colorClass}>
{paymentCfg.label}
</Chip>
</div>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-500">
<p>
<span className="font-medium text-[#1a2e2d]">Placed on</span>{" "}
{formatTimestamp(createdAt)}
</p>
{paidAt && (
<p>
<span className="font-medium text-[#1a2e2d]">Paid on</span>{" "}
{formatTimestamp(paidAt)}
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import Image from "next/image";
import { Card, ScrollShadow } from "@heroui/react";
import { formatPrice } from "@repo/utils";
import type { OrderLineItem } from "@/lib/orders";
interface Props {
items: OrderLineItem[];
currency: string;
}
function LineItem({
item,
currency,
}: {
item: OrderLineItem;
currency: string;
}) {
return (
<div className="flex gap-3 py-3 first:pt-0 last:pb-0">
{/* Product image */}
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-md border border-gray-100 bg-gray-50 md:h-20 md:w-20">
{item.imageUrl ? (
<Image
src={item.imageUrl}
alt={item.productName}
fill
className="object-cover"
sizes="(max-width: 768px) 64px, 80px"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-2xl text-gray-300">
🐾
</div>
)}
</div>
{/* Item details */}
<div className="flex flex-1 flex-col justify-between gap-1 md:flex-row md:items-start">
<div className="space-y-0.5">
<p className="text-sm font-medium text-[#1a2e2d]">
{item.productName}
</p>
<p className="text-xs text-gray-500">
{item.variantName}
</p>
<p className="text-xs text-gray-400">SKU: {item.sku}</p>
</div>
<div className="flex items-center gap-2 text-sm md:flex-col md:items-end">
<span className="text-gray-500">
{item.quantity} × {formatPrice(item.unitPrice, currency)}
</span>
<span className="font-semibold text-[#236f6b]">
{formatPrice(item.totalPrice, currency)}
</span>
</div>
</div>
</div>
);
}
export function OrderLineItems({ items, currency }: Props) {
const scrollable = items.length > 4;
return (
<Card>
<Card.Header>
<Card.Title className="text-base">
Items ({items.length})
</Card.Title>
</Card.Header>
<Card.Content className="p-0">
{scrollable ? (
<ScrollShadow className="max-h-[400px] px-4 pb-4" hideScrollBar>
<div className="divide-y divide-gray-100">
{items.map((item) => (
<LineItem key={item._id} item={item} currency={currency} />
))}
</div>
</ScrollShadow>
) : (
<div className="divide-y divide-gray-100 px-4 pb-4">
{items.map((item) => (
<LineItem key={item._id} item={item} currency={currency} />
))}
</div>
)}
</Card.Content>
</Card>
);
}

View File

@@ -0,0 +1,69 @@
import { Card, Separator } from "@heroui/react";
import { formatPrice } from "@repo/utils";
interface Props {
subtotal: number;
shipping: number;
tax: number;
discount: number;
total: number;
currency: string;
}
export function OrderPriceSummary({
subtotal,
shipping,
tax,
discount,
total,
currency,
}: Props) {
return (
<Card>
<Card.Header>
<Card.Title className="text-base">Order Summary</Card.Title>
</Card.Header>
<Card.Content>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-500">Subtotal</dt>
<dd className="font-medium text-[#1a2e2d]">
{formatPrice(subtotal, currency)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Shipping</dt>
<dd className="font-medium text-[#1a2e2d]">
{shipping === 0 ? "Free" : formatPrice(shipping, currency)}
</dd>
</div>
{tax > 0 && (
<div className="flex justify-between">
<dt className="text-gray-500">Tax</dt>
<dd className="font-medium text-[#1a2e2d]">
{formatPrice(tax, currency)}
</dd>
</div>
)}
{discount > 0 && (
<div className="flex justify-between">
<dt className="text-gray-500">Discount</dt>
<dd className="font-medium text-[#f2705a]">
-{formatPrice(discount, currency)}
</dd>
</div>
)}
<Separator className="my-2" />
<div className="flex justify-between pt-1">
<dt className="text-base font-semibold text-[#1a2e2d]">Total</dt>
<dd className="text-base font-bold text-[#236f6b]">
{formatPrice(total, currency)}
</dd>
</div>
</dl>
</Card.Content>
</Card>
);
}

View File

@@ -0,0 +1,108 @@
import { Card } from "@heroui/react";
import Link from "next/link";
import type { OrderStatus } from "@/lib/orders";
interface Props {
carrier: string;
shippingMethod: string;
trackingNumber?: string;
trackingUrl?: string;
estimatedDelivery?: number;
shippedAt?: number;
actualDelivery?: number;
status: OrderStatus;
}
function formatTimestamp(ts: number): string {
return new Intl.DateTimeFormat("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(ts));
}
export function OrderTrackingInfo({
carrier,
shippingMethod,
trackingNumber,
trackingUrl,
estimatedDelivery,
shippedAt,
actualDelivery,
status,
}: Props) {
const isPending = status === "pending" || status === "confirmed";
return (
<Card>
<Card.Header>
<Card.Title className="text-base">Shipping & Tracking</Card.Title>
</Card.Header>
<Card.Content className="space-y-2 text-sm">
{carrier && (
<p>
<span className="font-medium text-[#1a2e2d]">Carrier:</span>{" "}
<span className="text-gray-600">
{carrier}
{shippingMethod ? ` · ${shippingMethod}` : ""}
</span>
</p>
)}
{isPending && !trackingNumber ? (
<p className="text-gray-500 italic">
Tracking information will be available once your order ships.
</p>
) : (
<>
{trackingNumber && (
<p>
<span className="font-medium text-[#1a2e2d]">
Tracking number:
</span>{" "}
{trackingUrl ? (
<Link
href={trackingUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[#38a99f] underline underline-offset-2 hover:text-[#236f6b]"
>
{trackingNumber}
</Link>
) : (
<span className="text-gray-600">{trackingNumber}</span>
)}
</p>
)}
{shippedAt && (
<p>
<span className="font-medium text-[#1a2e2d]">Shipped on:</span>{" "}
<span className="text-gray-600">{formatTimestamp(shippedAt)}</span>
</p>
)}
{estimatedDelivery && !actualDelivery && (
<p>
<span className="font-medium text-[#1a2e2d]">
Estimated delivery:
</span>{" "}
<span className="text-gray-600">
{formatTimestamp(estimatedDelivery)}
</span>
</p>
)}
{actualDelivery && (
<p>
<span className="font-medium text-[#1a2e2d]">
Delivered on:
</span>{" "}
<span className="text-gray-600">
{formatTimestamp(actualDelivery)}
</span>
</p>
)}
</>
)}
</Card.Content>
</Card>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { Card, Chip, Button } from "@heroui/react";
import { formatPrice } from "@repo/utils";
import { ORDER_STATUS_CONFIG, PAYMENT_STATUS_CONFIG } from "@/lib/orders";
import type { OrderSummary } from "@/lib/orders";
interface Props {
order: OrderSummary;
onViewDetails: (orderId: string) => void;
}
function formatTimestamp(ts: number): string {
return new Intl.DateTimeFormat("en-GB", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(ts));
}
export function OrderCard({ order, onViewDetails }: Props) {
const statusCfg = ORDER_STATUS_CONFIG[order.status];
const paymentCfg = PAYMENT_STATUS_CONFIG[order.paymentStatus];
return (
<article>
<Card className="w-full">
<Card.Header className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h3 className="font-[family-name:var(--font-fraunces)] text-base font-semibold text-[#1a2e2d] md:text-lg">
{order.orderNumber}
</h3>
<div className="flex flex-wrap gap-2">
<Chip
size="sm"
variant="soft"
className={statusCfg.colorClass}
>
{statusCfg.label}
</Chip>
<Chip
size="sm"
variant="soft"
className={paymentCfg.colorClass}
>
{paymentCfg.label}
</Chip>
</div>
</Card.Header>
<Card.Content className="space-y-1 text-sm text-[#1a2e2d]/70">
<p>
<span className="font-medium text-[#1a2e2d]">Placed:</span>{" "}
{formatTimestamp(order.createdAt)}
</p>
<p>
<span className="font-medium text-[#1a2e2d]">Ship to:</span>{" "}
{order.shippingAddressSnapshot.city},{" "}
{order.shippingAddressSnapshot.country}
</p>
{order.carrier && (
<p>
<span className="font-medium text-[#1a2e2d]">Carrier:</span>{" "}
{order.carrier}
{order.shippingMethod ? ` · ${order.shippingMethod}` : ""}
</p>
)}
<p className="pt-1 text-base font-semibold text-[#236f6b]">
{formatPrice(order.total, order.currency)}
</p>
</Card.Content>
<Card.Footer>
<Button
variant="outline"
size="sm"
onPress={() => onViewDetails(order._id)}
>
View Details
</Button>
</Card.Footer>
</Card>
</article>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { Button, Spinner } from "@heroui/react";
import { OrderCard } from "./OrderCard";
import type { OrderSummary } from "@/lib/orders";
interface Props {
orders: OrderSummary[];
hasMore: boolean;
isLoadingMore: boolean;
onLoadMore: () => void;
onViewDetails: (orderId: string) => void;
}
export function OrderCardList({
orders,
hasMore,
isLoadingMore,
onLoadMore,
onViewDetails,
}: Props) {
return (
<div className="space-y-4">
{orders.map((order) => (
<OrderCard key={order._id} order={order} onViewDetails={onViewDetails} />
))}
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
onPress={onLoadMore}
isPending={isLoadingMore}
>
{isLoadingMore ? (
<>
<Spinner size="sm" />
Loading
</>
) : (
"Load more orders"
)}
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
import { Tabs } from "@heroui/react";
import { ORDER_TAB_FILTERS } from "@/lib/orders";
import type { Key } from "react";
interface Props {
activeFilter: string;
onFilterChange: (filterId: string) => void;
}
export function OrderStatusFilter({ activeFilter, onFilterChange }: Props) {
return (
<Tabs
selectedKey={activeFilter}
onSelectionChange={(key: Key) => onFilterChange(String(key))}
>
<Tabs.ListContainer>
<Tabs.List aria-label="Filter orders by status">
{ORDER_TAB_FILTERS.map((tab) => (
<Tabs.Tab key={tab.id} id={tab.id}>
{tab.label}
<Tabs.Indicator className="rounded-full bg-white shadow-sm" />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
);
}

View File

@@ -0,0 +1,93 @@
import { Skeleton } from "@heroui/react";
export function OrderDetailSkeleton() {
return (
<div className="space-y-6">
{/* Breadcrumbs */}
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-16 rounded" />
<span className="text-gray-300">/</span>
<Skeleton className="h-4 w-16 rounded" />
<span className="text-gray-300">/</span>
<Skeleton className="h-4 w-28 rounded" />
</div>
{/* Header */}
<div className="space-y-3">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<Skeleton className="h-8 w-52 rounded" />
<div className="flex gap-2">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-16 rounded-full" />
</div>
</div>
<Skeleton className="h-4 w-44 rounded" />
</div>
{/* Main grid */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left column */}
<div className="space-y-6 lg:col-span-2">
{/* Items card */}
<div className="rounded-xl border border-gray-100 bg-white p-4">
<Skeleton className="mb-4 h-5 w-24 rounded" />
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-3 py-3">
<Skeleton className="h-16 w-16 shrink-0 rounded-md" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-3/4 rounded" />
<Skeleton className="h-3 w-1/2 rounded" />
<Skeleton className="h-3 w-1/3 rounded" />
</div>
</div>
))}
</div>
{/* Tracking card */}
<div className="space-y-3 rounded-xl border border-gray-100 bg-white p-4">
<Skeleton className="h-5 w-36 rounded" />
<Skeleton className="h-4 w-1/2 rounded" />
<Skeleton className="h-4 w-2/3 rounded" />
</div>
{/* Addresses */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{[0, 1].map((i) => (
<div
key={i}
className="space-y-2 rounded-xl border border-gray-100 bg-white p-4"
>
<Skeleton className="h-4 w-28 rounded" />
<Skeleton className="h-4 w-36 rounded" />
<Skeleton className="h-4 w-40 rounded" />
<Skeleton className="h-4 w-24 rounded" />
</div>
))}
</div>
</div>
{/* Right column — price summary */}
<div className="space-y-3 rounded-xl border border-gray-100 bg-white p-4">
<Skeleton className="h-5 w-28 rounded" />
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex justify-between">
<Skeleton className="h-4 w-16 rounded" />
<Skeleton className="h-4 w-20 rounded" />
</div>
))}
<div className="flex justify-between pt-2">
<Skeleton className="h-5 w-12 rounded" />
<Skeleton className="h-5 w-24 rounded" />
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3">
<Skeleton className="h-10 w-32 rounded-lg" />
<Skeleton className="h-10 w-28 rounded-lg" />
<Skeleton className="h-10 w-32 rounded-lg" />
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import Link from "next/link";
interface Props {
message?: string;
}
export function OrdersEmptyState({
message = "When you place an order, it will appear here.",
}: Props) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-4 text-5xl" aria-hidden="true">
🛍
</div>
<h2 className="font-[family-name:var(--font-fraunces)] text-xl font-semibold text-[#1a2e2d]">
No orders yet
</h2>
<p className="mt-2 max-w-xs text-sm text-gray-500">{message}</p>
<Link
href="/shop"
className="mt-6 inline-flex items-center rounded-md bg-[#f4a13a] px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-[#db8d2e]"
>
Start Shopping
</Link>
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { Button } from "@heroui/react";
import Link from "next/link";
interface Props {
message?: string;
onRetry: () => void;
}
export function OrdersErrorState({
message = "Something went wrong while loading your orders.",
onRetry,
}: Props) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-4 text-5xl" aria-hidden="true">
</div>
<h2 className="font-[family-name:var(--font-fraunces)] text-xl font-semibold text-[#1a2e2d]">
Something went wrong
</h2>
<p className="mt-2 max-w-xs text-sm text-gray-500">{message}</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<Button variant="primary" onPress={onRetry}>
Try Again
</Button>
<Link
href="/account"
className="inline-flex items-center justify-center rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Back to Account
</Link>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More