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,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>
);
}

View File

@@ -0,0 +1,44 @@
import { Skeleton } from "@heroui/react";
function OrderCardSkeleton() {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
{/* Header row */}
<div className="mb-3 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<Skeleton className="h-5 w-36 rounded-md" />
<div className="flex gap-2">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
</div>
{/* Content rows */}
<div className="space-y-2">
<Skeleton className="h-4 w-40 rounded-md" />
<Skeleton className="h-4 w-52 rounded-md" />
<Skeleton className="h-4 w-32 rounded-md" />
<Skeleton className="h-5 w-20 rounded-md" />
</div>
{/* Footer */}
<div className="mt-4">
<Skeleton className="h-8 w-28 rounded-md" />
</div>
</div>
);
}
export function OrdersSkeleton() {
return (
<div className="space-y-4">
{/* Tab bar skeleton */}
<div className="flex gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-24 rounded-full" />
))}
</div>
{/* Card skeletons */}
{Array.from({ length: 3 }).map((_, i) => (
<OrderCardSkeleton key={i} />
))}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { Suspense } from "react";
import type { Id } from "../../../../../convex/_generated/dataModel";
import { ProductDetailRelatedSection } from "./sections/ProductDetailRelatedSection";
import { ProductDetailSectionsWrapper } from "./sections/ProductDetailSectionsWrapper";
import { ProductDetailHeroSkeleton } from "./state/ProductDetailHeroSkeleton";
import { ProductDetailRelatedSkeleton } from "./state/ProductDetailRelatedSkeleton";
type ProductDetailContentProps = {
category: string;
subCategory: string;
slug: string;
categoryId: Id<"categories">;
};
/**
* Composes PDP sections: Hero + Tabs (Description, Details, Reviews) → Related.
* Reviews stream independently via a nested Suspense inside the tabs wrapper.
*/
export function ProductDetailContent({
category,
subCategory,
slug,
categoryId,
}: ProductDetailContentProps) {
return (
<main className="mx-auto w-full max-w-7xl px-4 py-6 md:py-8">
<Suspense fallback={<ProductDetailHeroSkeleton />}>
<ProductDetailSectionsWrapper
category={category}
subCategory={subCategory}
slug={slug}
/>
</Suspense>
<Suspense fallback={<ProductDetailRelatedSkeleton />}>
<ProductDetailRelatedSection categoryId={categoryId} slug={slug} />
</Suspense>
</main>
);
}

View File

@@ -0,0 +1,75 @@
import { getProductDetailPath } from "@/lib/product-detail/constants";
type BreadcrumbListItem = {
"@type": "ListItem";
position: number;
name: string;
item?: string;
};
type ProductDetailStructuredDataProps = {
category: string;
subCategory: string;
slug: string;
productName: string;
subCategoryName: string;
};
/**
* Renders BreadcrumbList JSON-LD for the PDP (Home → Shop → Category → Subcategory → Product).
* Server-rendered only; validate at https://search.google.com/test/rich-results
*/
export function ProductDetailStructuredData({
category,
subCategory,
slug,
productName,
subCategoryName,
}: ProductDetailStructuredDataProps) {
const baseUrl =
process.env.NEXT_PUBLIC_APP_URL ??
process.env.NEXT_PUBLIC_STOREFRONT_URL ??
"";
const base = baseUrl ? baseUrl.replace(/\/$/, "") : "";
const items: BreadcrumbListItem[] = [
{ "@type": "ListItem", position: 1, name: "Home", item: base ? `${base}/` : undefined },
{ "@type": "ListItem", position: 2, name: "Shop", item: base ? `${base}/shop` : undefined },
{
"@type": "ListItem",
position: 3,
name: formatCategoryLabel(category),
item: base ? `${base}/shop/${category}` : undefined,
},
{
"@type": "ListItem",
position: 4,
name: subCategoryName,
item: base ? `${base}/shop/${category}/${subCategory}` : undefined,
},
{
"@type": "ListItem",
position: 5,
name: productName,
item: base ? `${base}${getProductDetailPath(category, subCategory, slug)}` : undefined,
},
];
const breadcrumbList = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items,
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbList) }}
/>
);
}
function formatCategoryLabel(slug: string): string {
if (slug === "other-pets") return "Other Pets";
return slug.charAt(0).toUpperCase() + slug.slice(1);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { useQuery } from "convex/react";
import { api } from "../../../../../../convex/_generated/api";
import type { Id } from "../../../../../../convex/_generated/dataModel";
import { ReviewSortOption } from "@/lib/product-detail/types";
import { ReviewSortBar } from "./ReviewSortBar";
import { ReviewList } from "./ReviewList";
import { ReviewForm } from "./ReviewForm";
import { ProductDetailErrorState } from "../state/ProductDetailErrorState";
import { ProductDetailReviewsSkeleton } from "../state/ProductDetailReviewsSkeleton";
type Props = {
productId: string;
initialRating?: number;
initialReviewCount?: number;
};
const LIMIT = 10;
export function ProductDetailReviewsPanel({ productId, initialRating, initialReviewCount }: Props) {
const [sortBy, setSortBy] = useState<ReviewSortOption>("newest");
const [offset, setOffset] = useState(0);
const result = useQuery(api.reviews.listByProductSorted, {
productId: productId as Id<"products">,
sortBy,
limit: offset + LIMIT,
offset: 0, // In this pattern, we increase limit to fetch more pages without resetting offset, so previously fetched array grows
});
if (result === undefined) return <ProductDetailReviewsSkeleton />;
if (result === null) return <ProductDetailErrorState message="Could not load reviews" />;
const { page, total, hasMore } = result;
const handleLoadMore = () => {
setOffset((prev) => prev + LIMIT);
};
const handleSortChange = (newSort: ReviewSortOption) => {
setSortBy(newSort);
setOffset(0);
};
const actualReviewCount = initialReviewCount ?? total;
const hasEverHadReviews = actualReviewCount > 0 || total > 0;
return (
<div className="flex flex-col">
{hasEverHadReviews ? (
<>
<ReviewSortBar
averageRating={initialRating}
reviewCount={actualReviewCount}
sortBy={sortBy}
onSortChange={handleSortChange}
/>
<ReviewList
reviews={page as any}
total={total}
hasMore={hasMore}
isLoading={result === undefined}
onLoadMore={handleLoadMore}
/>
</>
) : (
<div className="rounded-lg border border-[var(--separator)] bg-[var(--surface-secondary)]/50 p-6 text-center">
<p className="text-[var(--muted)] mb-2">No reviews yet.</p>
<p className="text-sm text-[var(--muted)]">Be the first to share your experience!</p>
</div>
)}
<ReviewForm productId={productId as Id<"products">} />
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { ProductDetailReview } from "@/lib/product-detail/types";
import { StarRatingDisplay } from "./StarRatingDisplay";
export function ReviewCard({ review }: { review: ProductDetailReview }) {
// A relative time formatter
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
const formatTime = (ts: number) => {
const daysDiff = Math.round((ts - Date.now()) / (1000 * 60 * 60 * 24));
if (daysDiff === 0) return "today";
return rtf.format(daysDiff, 'day');
};
return (
<article className="rounded-lg border border-[var(--separator)] bg-[var(--surface)] p-4 md:p-5">
<h3 className="text-sm font-semibold text-[var(--foreground)] md:text-base">
{review.title}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-2">
<StarRatingDisplay value={review.rating} size="sm" />
{review.userName && (
<span className="text-sm text-[var(--muted)]">
{review.userName}
</span>
)}
<span className="text-sm text-[var(--muted)]">
· {formatTime(review.createdAt)}
</span>
{review.verifiedPurchase && (
<span className="text-xs font-medium text-[var(--success)] before:content-['·'] before:mr-2 before:text-[var(--muted)]">
Verified purchase
</span>
)}
</div>
<p className="mt-2 whitespace-pre-wrap text-sm text-[var(--foreground)]/80">
{review.content}
</p>
{review.helpfulCount > 0 && (
<p className="mt-2 text-xs text-[var(--muted)]">
{review.helpfulCount} {review.helpfulCount === 1 ? "person" : "people"} found this helpful
</p>
)}
</article>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { useAction, useQuery, useConvexAuth } from "convex/react";
import { api } from "../../../../../../convex/_generated/api";
import type { Id } from "../../../../../../convex/_generated/dataModel";
import { Form, TextField, Label, Input, TextArea, FieldError, Button, Spinner, toast } from "@heroui/react";
import { StarRatingInput } from "./StarRatingInput";
import Link from "next/link";
export function ReviewForm({ productId }: { productId: Id<"products"> }) {
const { isAuthenticated } = useConvexAuth();
const hasReviewed = useQuery(api.reviews.hasUserReviewed, { productId });
const submitAndRecalculate = useAction(api.reviews.submitAndRecalculate);
const [rating, setRating] = useState<number>(0);
const [isSubmitting, setIsSubmitting] = useState(false);
// Loading auth state
if (isAuthenticated === undefined || (isAuthenticated && hasReviewed === undefined)) {
return <div className="mt-8 h-32 animate-pulse rounded-lg bg-[var(--surface-secondary)]" />;
}
// Not signed in
if (!isAuthenticated) {
return (
<div className="mt-8 border-t border-[var(--separator)] pt-8">
<h3 className="text-lg font-bold font-[family-name:var(--font-fraunces)] text-[var(--foreground)] mb-4">
Write a Review
</h3>
<p className="mb-4 text-sm text-[var(--muted)]">
Please sign in to share your experience with this product.
</p>
<Link
href="/sign-in"
className="inline-flex items-center justify-center rounded-lg bg-[var(--brand-dark)] px-4 py-2 text-sm font-medium text-white w-full md:w-auto"
>
Sign In
</Link>
</div>
);
}
// Already reviewed
if (hasReviewed) {
return (
<div className="mt-8 border-t border-[var(--separator)] pt-8">
<h3 className="text-lg font-bold font-[family-name:var(--font-fraunces)] text-[var(--foreground)] mb-2">
You&apos;ve already reviewed this product
</h3>
<p className="text-sm text-[var(--muted)]">
Thank you for sharing your experience! Your review is helping other pet parents.
</p>
</div>
);
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (rating === 0) {
toast.danger("Please select a rating to submit.");
return;
}
const formData = new FormData(e.currentTarget);
const title = formData.get("title") as string;
const content = formData.get("content") as string;
setIsSubmitting(true);
try {
await submitAndRecalculate({ productId, rating, title, content });
toast.success("Thanks for your review! It will appear after moderation.");
e.currentTarget.reset();
setRating(0);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to submit review.";
toast.danger(message);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mt-8 border-t border-[var(--separator)] pt-8">
<h3 className="text-lg font-bold font-[family-name:var(--font-fraunces)] text-[var(--foreground)] mb-6">
Write a Review
</h3>
<Form onSubmit={handleSubmit} className="flex flex-col gap-5 max-w-2xl w-full">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[var(--foreground)]">
Your rating <span className="text-danger">*</span>
</label>
<StarRatingInput value={rating} onChange={setRating} />
</div>
{/* TextField wraps Label + Input + FieldError — isRequired/validate live on TextField, not Input */}
<TextField
isRequired
name="title"
minLength={3}
maxLength={100}
validate={(val: string) => {
if (val && val.length < 3) return "Title must be at least 3 characters";
}}
className="flex flex-col gap-1"
>
<Label className="text-sm font-medium text-[var(--foreground)]">Review title</Label>
<Input placeholder="Summarize your experience" className="bg-[var(--surface)]" />
<FieldError className="text-xs text-danger" />
</TextField>
{/* TextArea is a bare primitive — wrap in TextField for validation/label support */}
<TextField
isRequired
name="content"
minLength={10}
maxLength={2000}
validate={(val: string) => {
if (val && val.length < 10) return "Review must be at least 10 characters";
}}
className="flex flex-col gap-1"
>
<Label className="text-sm font-medium text-[var(--foreground)]">Your review</Label>
<TextArea
rows={4}
placeholder="What did you like or dislike?"
className="bg-[var(--surface)]"
/>
<FieldError className="text-xs text-danger" />
</TextField>
{/* isPending (not isLoading); spinner via render-prop children */}
<Button
type="submit"
isPending={isSubmitting}
className="bg-[var(--brand-dark)] text-white w-full md:w-auto md:self-start mt-2"
>
{({ isPending }: { isPending: boolean }) => (
<>
{isPending ? <Spinner color="current" size="sm" /> : null}
Submit Review
</>
)}
</Button>
</Form>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { Button } from "@heroui/react";
import { ProductDetailReview } from "@/lib/product-detail/types";
import { ReviewCard } from "./ReviewCard";
import { ProductDetailReviewsSkeleton } from "../state/ProductDetailReviewsSkeleton";
type Props = {
reviews: ProductDetailReview[] | undefined;
total: number;
hasMore: boolean;
isLoading: boolean;
onLoadMore: () => void;
};
export function ReviewList({ reviews, total, hasMore, isLoading, onLoadMore }: Props) {
if (reviews === undefined) {
return <ProductDetailReviewsSkeleton />;
}
if (reviews.length === 0) {
return null; // Empty state managed by panel
}
return (
<div className="space-y-6">
<ul className="m-0 list-none space-y-4 p-0">
{reviews.map((review) => (
<li key={review._id}>
<ReviewCard review={review} />
</li>
))}
</ul>
{hasMore && (
<div className="flex flex-col items-center gap-3 pt-2">
<p className="text-sm text-[var(--muted)]">
Showing {reviews.length} of {total} reviews
</p>
<Button
variant="ghost"
onPress={onLoadMore}
isLoading={isLoading}
className="w-full md:w-auto text-[var(--foreground)] border border-[var(--separator)]"
>
Show more reviews
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { Select, ListBox } from "@heroui/react";
import { StarRatingDisplay } from "./StarRatingDisplay";
import { ReviewSortOption } from "@/lib/product-detail/types";
type Props = {
averageRating?: number;
reviewCount: number;
sortBy: ReviewSortOption;
onSortChange: (value: ReviewSortOption) => void;
};
export function ReviewSortBar({ averageRating, reviewCount, sortBy, onSortChange }: Props) {
const options = [
{ key: "newest", label: "Most Recent" },
{ key: "oldest", label: "Oldest First" },
{ key: "highest", label: "Highest Rated" },
{ key: "lowest", label: "Lowest Rated" },
{ key: "helpful", label: "Most Helpful" },
];
return (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
<div className="flex flex-wrap items-center gap-2 gap-y-1">
{averageRating != null && reviewCount > 0 ? (
<>
<StarRatingDisplay value={averageRating} />
<span className="text-sm font-medium text-[var(--foreground)]">
{averageRating.toFixed(1)} average
</span>
<span className="text-sm text-[var(--muted)]">
· {reviewCount} {reviewCount === 1 ? "review" : "reviews"}
</span>
</>
) : (
<span className="text-sm text-[var(--muted)]">
{reviewCount} {reviewCount === 1 ? "review" : "reviews"}
</span>
)}
</div>
<div className="w-full md:w-64">
<Select
aria-label="Sort reviews"
selectedKey={sortBy}
onSelectionChange={(key) => {
if (key) onSortChange(key as ReviewSortOption);
}}
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover>
<ListBox>
{options.map((opt) => (
<ListBox.Item key={opt.key} id={opt.key} textValue={opt.label}>
{opt.label}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { useId } from "react";
function StarIcon({ filled, half, gradientId }: { filled?: boolean; half?: boolean; gradientId?: string }) {
if (half && gradientId) {
return (
<svg className="size-[18px] text-[var(--warm)]" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<defs>
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
<stop offset="50%" stopColor="currentColor" />
<stop offset="50%" stopColor="transparent" />
</linearGradient>
</defs>
<path fill={`url(#${gradientId})`} d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
);
}
return (
<svg className="size-[18px] text-[var(--warm)]" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5" aria-hidden>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
);
}
export function StarRatingDisplay({ value, size = "md" }: { value: number; size?: "sm" | "md" }) {
const id = useId();
const full = Math.floor(value);
const hasHalf = value % 1 >= 0.25 && value % 1 < 0.75;
const stars = [];
for (let i = 0; i < 5; i++) {
if (i < full) {
stars.push(<StarIcon key={i} filled />);
} else if (i === full && hasHalf) {
stars.push(<StarIcon key={i} half gradientId={id.replace(/:/g, "")} />);
} else {
stars.push(<StarIcon key={i} />);
}
}
const wrapperClass = size === "sm" ? "scale-90 origin-left" : "";
return (
<div className={`flex items-center gap-0.5 ${wrapperClass}`} aria-label={`${value.toFixed(1)} out of 5 stars`}>
{stars}
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
function StarIcon({ filled, onMouseEnter, onClick }: { filled: boolean; onMouseEnter: () => void; onClick: () => void }) {
return (
<button
type="button"
onMouseEnter={onMouseEnter}
onClick={onClick}
className={`relative rounded-sm outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 p-0.5 text-[var(--warm)] transition-opacity ${!filled && "opacity-40"}`}
>
<svg className="size-6" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5" aria-hidden>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</button>
);
}
export function StarRatingInput({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const [hoverValue, setHoverValue] = useState(0);
return (
<div
className="flex items-center gap-1"
onMouseLeave={() => setHoverValue(0)}
>
{[1, 2, 3, 4, 5].map((star) => (
<StarIcon
key={star}
filled={star <= (hoverValue || value)}
onMouseEnter={() => setHoverValue(star)}
onClick={() => onChange(star)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import type { ProductDetailProductBase } from "@/lib/product-detail/types";
type ProductAttributes = ProductDetailProductBase["attributes"];
type ProductDetailAttributesSectionProps = {
attributes?: ProductAttributes | null;
};
const ATTRIBUTE_LABELS: Record<keyof NonNullable<ProductAttributes>, string> = {
petSize: "Pet size",
ageRange: "Age range",
specialDiet: "Special diet",
material: "Material",
flavor: "Flavor",
};
function formatAttributeValue(
value: string | string[] | undefined,
): string | null {
if (value == null) return null;
if (Array.isArray(value)) {
const filtered = value.filter((v) => v != null && String(v).trim() !== "");
return filtered.length === 0 ? null : filtered.join(", ");
}
const s = String(value).trim();
return s === "" ? null : s;
}
/**
* Attributes/specs section for PDP tabs.
* Uses <table> with <caption> and <th scope="row"> per SEO rule.
* Renders inside a parent <section> provided by ProductDetailTabsSection.
*/
export function ProductDetailAttributesSection({
attributes,
}: ProductDetailAttributesSectionProps) {
if (!attributes) {
return (
<p className="text-sm text-[var(--muted)]">
No product details available.
</p>
);
}
const entries: Array<{
key: keyof NonNullable<ProductAttributes>;
label: string;
value: string;
}> = [];
for (const key of Object.keys(ATTRIBUTE_LABELS) as Array<
keyof NonNullable<ProductAttributes>
>) {
const value = formatAttributeValue(attributes[key]);
if (value != null) {
entries.push({ key, label: ATTRIBUTE_LABELS[key], value });
}
}
if (entries.length === 0) {
return (
<p className="text-sm text-[var(--muted)]">
No product details available.
</p>
);
}
return (
<table className="w-full max-w-xl text-sm">
<caption className="sr-only">Product specifications</caption>
<tbody>
{entries.map(({ key, label, value }) => (
<tr
key={key}
className="border-b border-[var(--separator)] last:border-b-0"
>
<th
scope="row"
className="w-1/3 py-3 pr-4 text-left font-medium text-[var(--muted)]"
>
{label}
</th>
<td className="py-3 text-[var(--foreground)]">{value}</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,25 @@
type ProductDetailDescriptionSectionProps = {
/** Product description (HTML or plain text); server-rendered in initial HTML per SEO. */
description?: string | null;
};
/**
* Description content for the PDP tabs section.
* Renders inside a parent <section> provided by ProductDetailTabsSection.
* If empty, shows a short fallback message.
*/
export function ProductDetailDescriptionSection({
description,
}: ProductDetailDescriptionSectionProps) {
const hasContent =
typeof description === "string" && description.trim().length > 0;
return hasContent ? (
<div
className="max-w-3xl text-sm leading-relaxed text-[var(--foreground)]/80 [&_ol]:mb-3 [&_ol]:list-inside [&_ol]:list-decimal [&_p:last-child]:mb-0 [&_p]:mb-3 [&_ul]:mb-3 [&_ul]:list-inside [&_ul]:list-disc"
dangerouslySetInnerHTML={{ __html: description!.trim() }}
/>
) : (
<p className="text-sm text-[var(--muted)]">No description available.</p>
);
}

View File

@@ -0,0 +1,595 @@
"use client";
import { Alert, Button, Chip, Label, NumberField, Spinner } from "@heroui/react";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { ShopBreadcrumbBar } from "@/components/shop/ShopBreadcrumbBar";
import { useCartUI } from "@/components/cart/CartUIContext";
import { trackAddToCart } from "@/lib/cart/analytics";
import { getAddToCartErrorMessage } from "@/lib/cart/addToCartErrors";
import { useCartMutations } from "@/lib/cart/useCartMutations";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
import { formatPrice } from "@repo/utils";
import {
PRODUCT_DETAIL_SECTION_IDS,
getProductDetailBreadcrumbItems,
} from "@/lib/product-detail/constants";
import type {
ProductDetailImage,
ProductDetailProduct,
ProductDetailVariant,
} from "@/lib/product-detail/types";
type ProductDetailHeroSectionProps = {
product: ProductDetailProduct;
category: string;
subCategory: string;
};
function CartIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<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>
);
}
function TruckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 6.75C2 5.23 3.23 4 4.75 4h8.5C14.51 4 15.57 4.85 15.9 6h2.05c.9 0 1.75.44 2.26 1.18l1.3 1.88c.32.46.49 1 .49 1.56v3.66c0 1.25-.85 2.31-2 2.62A3.25 3.25 0 0 1 16.75 20a3.25 3.25 0 0 1-3.24-3h-3.02a3.25 3.25 0 0 1-3.24 3 3.25 3.25 0 0 1-3.25-3.08A2.49 2.49 0 0 1 2 14.36V6.75ZM4.28 15.44A3.24 3.24 0 0 1 7.25 13.5c1.35 0 2.51.83 3 2h3.5c.17-.42.43-.79.75-1.1V6.75c0-.69-.56-1.25-1.25-1.25h-8.5c-.69 0-1.25.56-1.25 1.25v7.61c0 .5.32.93.78 1.08ZM16 7.5v6.09c.24-.06.49-.09.75-.09 1.32 0 2.46.79 2.97 1.92.46-.17.78-.62.78-1.14v-3.66c0-.25-.08-.5-.22-.71l-1.3-1.87A1.25 1.25 0 0 0 17.95 7.5H16ZM7.25 15a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5Zm9.5 0a1.75 1.75 0 1 0 0 3.5 1.75 1.75 0 0 0 0-3.5Z"
/>
</svg>
);
}
function ReturnIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.84 3.5a1.75 1.75 0 0 1 1.65-1h7.32c.66 0 1.28.33 1.65 1l2.27 3.4c.35.36.57.85.57 1.4v1.23a.75.75 0 0 1-1.5 0V8.3c0-.13-.05-.24-.13-.33a.58.58 0 0 0-.35-.15H3.98a.58.58 0 0 0-.35.15.46.46 0 0 0-.13.33v8.63c0 .27.22.48.48.48h4.94a.75.75 0 0 1 0 1.5H3.98A1.98 1.98 0 0 1 2 16.93V8.3c0-.54.22-1.04.57-1.4L4.84 3.5Zm-.07 2.82h4.63V4.12H6.49a.25.25 0 0 0-.2.12L4.77 6.32Zm6.13-2.2v2.2h4.63l-1.32-1.98a.25.25 0 0 0-.2-.12h-2.91v-.1Zm4.71 7.35a.75.75 0 0 1 0 1.06l-1.19 1.19h.67c3.82 0 6.92 3.1 6.92 6.92a.75.75 0 0 1-1.5 0 5.42 5.42 0 0 0-5.42-5.42h-.67l1.19 1.19a.75.75 0 0 1-1.06 1.06l-2.47-2.47a.75.75 0 0 1 0-1.06l2.47-2.47a.75.75 0 0 1 1.06 0Z"
/>
</svg>
);
}
function ShieldIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
function StarIcon({ filled }: { filled?: boolean }) {
return (
<svg
className="size-4 text-[var(--warm)]"
viewBox="0 0 24 24"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.5"
aria-hidden
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
);
}
function StarRating({ value }: { value: number }) {
const full = Math.floor(value);
return (
<span
className="inline-flex items-center gap-0.5"
aria-label={`${value} out of 5 stars`}
>
{[1, 2, 3, 4, 5].map((star) => (
<StarIcon key={star} filled={star <= full} />
))}
</span>
);
}
type AttributeDimension = "size" | "flavor" | "color";
function getAttributeDimensions(
variants: ProductDetailVariant[],
): AttributeDimension[] {
const dims: AttributeDimension[] = [];
if (variants.some((v) => v.attributes?.size != null)) dims.push("size");
if (variants.some((v) => v.attributes?.flavor != null)) dims.push("flavor");
if (variants.some((v) => v.attributes?.color != null)) dims.push("color");
return dims;
}
function getUniqueValues(
variants: ProductDetailVariant[],
dimension: AttributeDimension,
): string[] {
const set = new Set<string>();
for (const v of variants) {
const val = v.attributes?.[dimension];
if (val != null && val !== "") set.add(val);
}
return Array.from(set).sort();
}
function findVariantBySelection(
variants: ProductDetailVariant[],
selection: Partial<Record<AttributeDimension, string>>,
): ProductDetailVariant | null {
return (
variants.find((v) =>
(["size", "flavor", "color"] as const).every((dim) => {
const sel = selection[dim];
if (sel == null) return true;
return v.attributes?.[dim] === sel;
}),
) ?? null
);
}
function getStockMessage(variant: ProductDetailVariant): string {
if (variant.stockQuantity <= 0) return "Out of stock";
if (variant.stockQuantity <= 5) return `Only ${variant.stockQuantity} left`;
return "In stock";
}
function getStockColor(
variant: ProductDetailVariant,
): "success" | "warning" | "danger" {
if (variant.stockQuantity <= 0) return "danger";
if (variant.stockQuantity <= 5) return "warning";
return "success";
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
function firstThreeSentences(text: string): string {
const sentences = text.match(/[^.!?]*[.!?]+/g);
if (!sentences) return text;
return sentences.slice(0, 3).join("").trim();
}
export function ProductDetailHeroSection({
product,
category,
subCategory,
}: ProductDetailHeroSectionProps) {
const sessionId = useCartSessionId();
const { addItem } = useCartMutations(sessionId);
const { openCart } = useCartUI();
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [attributeSelection, setAttributeSelection] = useState<
Partial<Record<AttributeDimension, string>>
>({});
const [quantity, setQuantity] = useState(1);
const [isAdding, setIsAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const images: ProductDetailImage[] = product.images ?? [];
const variants: ProductDetailVariant[] = product.variants ?? [];
const mainImage = images[selectedImageIndex] ?? images[0];
const dimensions = useMemo(
() => getAttributeDimensions(variants),
[variants],
);
const selectedVariant = useMemo(() => {
if (variants.length === 0) return null;
if (variants.length === 1) return variants[0];
return (
findVariantBySelection(variants, attributeSelection) ?? variants[0]
);
}, [variants, attributeSelection]);
const breadcrumbItems = useMemo(
() =>
getProductDetailBreadcrumbItems({
categorySlug: category,
subCategorySlug: subCategory,
subCategoryName: product.category?.name ?? subCategory,
productName: product.name,
}),
[category, subCategory, product.category?.name, product.name],
);
const shortDescriptionText = useMemo(() => {
const raw = product.shortDescription?.trim() || (product.description?.trim() ? stripHtml(product.description) : null);
if (!raw) return null;
return firstThreeSentences(raw);
}, [product.shortDescription, product.description]);
const canAddToCart =
selectedVariant != null &&
selectedVariant.stockQuantity > 0 &&
quantity > 0;
const addToCartButtonLabel =
quantity > 1
? isAdding
? "Adding to cart, quantity " + quantity
: "Add to cart, quantity " + quantity
: isAdding
? "Adding to cart"
: "Add to cart";
const addToCartErrorId = "pdp-add-to-cart-error";
useEffect(() => {
setAddError(null);
}, [quantity, selectedVariant?._id]);
function handleAddToCart() {
if (!canAddToCart || !selectedVariant) return;
setAddError(null);
setIsAdding(true);
addItem(selectedVariant._id, quantity)
.then(() => {
setIsAdding(false);
trackAddToCart({
variantId: selectedVariant._id,
quantity,
source: "pdp",
});
openCart();
})
.catch((err: unknown) => {
setIsAdding(false);
setAddError(getAddToCartErrorMessage(err));
});
}
return (
<section
id={PRODUCT_DETAIL_SECTION_IDS.hero}
aria-label="Product overview"
className="flex flex-col gap-6 md:gap-8"
>
<ShopBreadcrumbBar items={breadcrumbItems} />
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-10 lg:items-start">
{/* Gallery — sticky on desktop so it stays visible alongside a tall buy box */}
<div className="flex flex-col gap-3 lg:sticky lg:top-24">
<div className="relative aspect-square w-full overflow-hidden rounded-xl bg-[var(--surface-secondary)] shadow-sm md:aspect-[4/3]">
{mainImage ? (
<Image
src={mainImage.url}
alt={mainImage.alt ?? product.name}
fill
priority
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
) : (
<div
className="absolute inset-0 flex items-center justify-center text-[var(--muted)]"
aria-hidden
>
No image
</div>
)}
</div>
{images.length > 1 && (
<>
{/* Mobile: horizontal scrollable thumbnail strip */}
<div className="flex gap-2 overflow-x-auto scrollbar-hide py-1 md:hidden">
{images.map((img, i) => (
<button
key={img._id}
type="button"
onClick={() => setSelectedImageIndex(i)}
className="relative h-16 w-16 shrink-0 overflow-hidden rounded-lg border-2 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
style={{
borderColor:
selectedImageIndex === i
? "var(--accent)"
: "transparent",
}}
aria-label={`View image ${i + 1}`}
>
<Image
src={img.url}
alt={img.alt ?? `${product.name} ${i + 1}`}
fill
className="object-cover"
sizes="64px"
/>
</button>
))}
</div>
{/* Tablet+: flex wrap thumbnails */}
<div className="hidden md:flex flex-wrap gap-2 p-2">
{images.map((img, i) => (
<button
key={img._id}
type="button"
onClick={() => setSelectedImageIndex(i)}
className="relative h-16 w-16 shrink-0 overflow-hidden rounded-lg border-2 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
style={{
borderColor:
selectedImageIndex === i
? "var(--accent)"
: "transparent",
}}
aria-label={`View image ${i + 1}`}
>
<Image
src={img.url}
alt={img.alt ?? `${product.name} ${i + 1}`}
fill
className="object-cover"
sizes="64px"
/>
</button>
))}
</div>
</>
)}
</div>
{/* Buy box */}
<div className="flex flex-col gap-4">
{product.brand && (
<Chip color="accent" variant="soft" size="sm" className="w-fit">
{product.brand}
</Chip>
)}
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-semibold tracking-tight text-[var(--foreground)] md:text-3xl">
{product.name}
</h1>
{product.averageRating != null &&
product.reviewCount != null &&
product.reviewCount > 0 && (
<a
href={`#${PRODUCT_DETAIL_SECTION_IDS.reviews}`}
className="inline-flex w-fit items-center gap-2 text-sm text-[var(--muted)] transition-colors hover:text-[var(--foreground)]"
>
<StarRating value={product.averageRating} />
<span className="font-sans">
{product.averageRating.toFixed(1)} ({product.reviewCount}{" "}
{product.reviewCount === 1 ? "review" : "reviews"})
</span>
</a>
)}
{/* Short description — 3-line clamp for quick product overview */}
{shortDescriptionText && (
<p className="line-clamp-3 text-sm leading-relaxed text-[var(--muted)]">
{shortDescriptionText}
</p>
)}
{/* Variant selector — real <form> + <fieldset> + <legend> + <input type="radio"> per SEO rule */}
{variants.length > 1 && dimensions.length > 0 && (
<form
onSubmit={(e) => e.preventDefault()}
className="space-y-4"
aria-label="Variant selection"
>
{dimensions.map((dim) => {
const options = getUniqueValues(variants, dim);
if (options.length <= 1) return null;
const label = dim.charAt(0).toUpperCase() + dim.slice(1);
const name = `pdp-variant-${dim}`;
return (
<fieldset key={dim} className="space-y-2">
<legend className="text-sm font-medium text-[var(--foreground)]">
{label}
</legend>
<div className="flex flex-wrap gap-2">
{options.map((value) => (
<label
key={value}
className="flex cursor-pointer items-center rounded-full border border-[var(--border)] px-4 py-2 text-sm transition-all has-[:checked]:border-[var(--accent)] has-[:checked]:bg-[var(--brand-mist)] has-[:checked]:font-medium has-[:checked]:text-[var(--brand-dark)] hover:border-[var(--accent)]/50"
>
<input
type="radio"
name={name}
value={value}
checked={
(attributeSelection[dim] ?? null) === value
}
onChange={() =>
setAttributeSelection((prev) => ({
...prev,
[dim]: value,
}))
}
className="sr-only"
/>
<span>{value}</span>
</label>
))}
</div>
</fieldset>
);
})}
</form>
)}
{selectedVariant && (
<>
{/* Price block */}
<div className="flex flex-wrap items-baseline gap-3">
<span className="text-2xl font-bold text-[var(--brand-dark)]">
{formatPrice(selectedVariant.price)}
</span>
{selectedVariant.compareAtPrice != null &&
selectedVariant.compareAtPrice > selectedVariant.price && (
<>
<span className="text-sm text-[var(--muted)] line-through">
{formatPrice(selectedVariant.compareAtPrice)}
</span>
<Chip color="danger" variant="soft" size="sm">
Save{" "}
{Math.round(
((selectedVariant.compareAtPrice -
selectedVariant.price) /
selectedVariant.compareAtPrice) *
100,
)}
%
</Chip>
</>
)}
</div>
{/* Stock status + SKU */}
<div className="flex items-center gap-3">
<Chip
color={getStockColor(selectedVariant)}
variant="soft"
size="sm"
>
{getStockMessage(selectedVariant)}
</Chip>
{selectedVariant.sku && (
<span className="text-xs text-[var(--muted)]">
SKU: {selectedVariant.sku}
</span>
)}
</div>
{/* Quantity + Add to Cart */}
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<NumberField
value={quantity}
onChange={(val) => setQuantity(val ?? 1)}
minValue={1}
maxValue={selectedVariant.stockQuantity || 99}
name="pdp-quantity"
className="w-full md:w-auto"
>
<Label className="text-sm font-medium text-[var(--foreground)]">
Quantity
</Label>
<NumberField.Group>
<NumberField.DecrementButton />
<NumberField.Input className="w-16 text-center" />
<NumberField.IncrementButton />
</NumberField.Group>
</NumberField>
<Button
variant="primary"
size="lg"
onPress={handleAddToCart}
isDisabled={!canAddToCart || isAdding}
isPending={isAdding}
className="w-full md:w-auto min-w-[180px] text-sm font-medium"
aria-label={addToCartButtonLabel}
aria-busy={isAdding}
aria-describedby={addError ? addToCartErrorId : undefined}
>
{isAdding ? (
<>
<Spinner
size="sm"
color="current"
className="shrink-0"
/>
Adding
</>
) : (
<>
<CartIcon className="size-5 shrink-0" />
Add to cart
</>
)}
</Button>
</div>
{/* Delivery & trust info */}
<div className="mt-1 flex flex-col gap-3 border-t border-[var(--separator)] pt-5">
<div className="flex items-start gap-3 text-sm text-[var(--muted)]">
<TruckIcon className="mt-0.5 size-5 shrink-0 text-[var(--brand)]" />
<span>
Free delivery on orders over £50
</span>
</div>
<div className="flex items-start gap-3 text-sm text-[var(--muted)]">
<ReturnIcon className="mt-0.5 size-5 shrink-0 text-[var(--brand)]" />
<span>
Easy 30-day returns
</span>
</div>
<div className="flex items-start gap-3 text-sm text-[var(--muted)]">
<ShieldIcon className="mt-0.5 size-5 shrink-0 text-[var(--brand)]" />
<span>
Secure checkout
</span>
</div>
</div>
{addError && (
<Alert
id={addToCartErrorId}
status="danger"
className="w-full"
role="alert"
aria-live="polite"
>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Could not add to cart</Alert.Title>
<Alert.Description>{addError}</Alert.Description>
<button
type="button"
onClick={() => setAddError(null)}
className="mt-2 font-medium underline focus:outline-none focus:ring-2 focus:ring-offset-2 rounded"
>
Dismiss
</button>
</Alert.Content>
</Alert>
)}
</>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,31 @@
import { fetchQuery } from "convex/nextjs";
import { notFound } from "next/navigation";
import { api } from "../../../../../../convex/_generated/api";
import { ProductDetailHeroSection } from "./ProductDetailHeroSection";
type ProductDetailHeroSectionWrapperProps = {
category: string;
subCategory: string;
slug: string;
};
/**
* Async server component: fetches product by slug and renders the hero section.
* Used inside Suspense so the hero can show ProductDetailHeroSkeleton while loading.
*/
export async function ProductDetailHeroSectionWrapper({
category,
subCategory,
slug,
}: ProductDetailHeroSectionWrapperProps) {
const product = await fetchQuery(api.products.getBySlug, { slug });
if (!product) notFound();
return (
<ProductDetailHeroSection
product={product as any}
category={category}
subCategory={subCategory}
/>
);
}

View File

@@ -0,0 +1,58 @@
import { fetchQuery } from "convex/nextjs";
import { api } from "../../../../../../convex/_generated/api";
import type { Id } from "../../../../../../convex/_generated/dataModel";
import { ShopProductGrid } from "@/components/shop/ShopProductGrid";
import { PRODUCT_DETAIL_SECTION_IDS } from "@/lib/product-detail/constants";
import {
enrichedProductToCardProps,
type EnrichedProduct,
} from "@/lib/shop/productMapper";
const RELATED_LIMIT = 8;
type ProductDetailRelatedSectionProps = {
categoryId: Id<"categories">;
slug: string;
};
/**
* Related products from the same category (excluding current product).
* Product links use the product object as single source of truth.
*/
export async function ProductDetailRelatedSection({
categoryId,
slug,
}: ProductDetailRelatedSectionProps) {
const list = await fetchQuery(api.products.listActive, {
categoryId,
limit: RELATED_LIMIT + 1,
});
const others = (list ?? [])
.filter((p: { slug: string }) => p.slug !== slug)
.slice(0, RELATED_LIMIT) as EnrichedProduct[];
if (others.length === 0) return null;
const products = others.map((p) => ({
...enrichedProductToCardProps(p),
id: p._id,
}));
return (
<section
id={PRODUCT_DETAIL_SECTION_IDS.related}
aria-label="Related products"
className="py-6 md:py-8"
>
<div
className="mb-6 h-px w-full bg-[var(--separator)] md:mb-8"
role="separator"
/>
<h2 className="mb-4 font-[family-name:var(--font-fraunces)] text-lg font-semibold tracking-tight text-[var(--foreground)] md:mb-6 md:text-xl">
You might also like
</h2>
<ShopProductGrid products={products} />
</section>
);
}

View File

@@ -0,0 +1,185 @@
import { Suspense } from "react";
import Link from "next/link";
import { fetchQuery } from "convex/nextjs";
import { api } from "../../../../../../convex/_generated/api";
import type { Id } from "../../../../../../convex/_generated/dataModel";
import { PRODUCT_DETAIL_SECTION_IDS } from "@/lib/product-detail/constants";
import { ProductDetailReviewsSkeleton } from "../state/ProductDetailReviewsSkeleton";
import { ProductDetailErrorState } from "../state/ProductDetailErrorState";
import { getProductDetailPath } from "@/lib/product-detail/constants";
const REVIEWS_INITIAL_LIMIT = 10;
function StarRating({ value }: { value: number }) {
const full = Math.floor(value);
return (
<span
className="inline-flex items-center gap-0.5"
aria-label={`${value} out of 5 stars`}
>
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={
star <= full ? "text-[var(--warm)]" : "text-[var(--border)]"
}
aria-hidden
>
</span>
))}
</span>
);
}
/**
* @deprecated Use ProductDetailReviewsPanel instead for interactivity, sorting, and user submissions.
* Reviews content — async server component that fetches and renders reviews.
* Used as `reviewsContent` prop inside ProductDetailTabsSection.
*/
export async function ProductDetailReviewsContent({
slug,
category,
subCategory,
}: {
slug: string;
category: string;
subCategory: string;
}) {
let product;
try {
product = await fetchQuery(api.products.getBySlug, { slug });
} catch {
return <ProductDetailErrorState message="Unable to load reviews." />;
}
if (!product) return null;
let reviews: Awaited<
ReturnType<typeof fetchQuery<typeof api.reviews.listByProduct>>
>["page"];
let total: number;
let hasMore: boolean;
try {
const productId = product._id as Id<"products">;
const result = await fetchQuery(api.reviews.listByProduct, {
productId,
limit: REVIEWS_INITIAL_LIMIT,
offset: 0,
});
reviews = result.page;
total = result.total;
hasMore = result.hasMore;
} catch {
return <ProductDetailErrorState message="Unable to load reviews." />;
}
const averageRating = product.averageRating;
const reviewCount = product.reviewCount ?? total;
const hasReviews = reviews.length > 0;
return (
<div className="space-y-6">
{/* Aggregate rating */}
<div className="flex flex-wrap items-center gap-2 gap-y-1">
{averageRating != null && reviewCount != null && reviewCount > 0 ? (
<>
<StarRating value={averageRating} />
<span className="text-sm text-[var(--muted)]">
{averageRating.toFixed(1)} · {reviewCount}{" "}
{reviewCount === 1 ? "review" : "reviews"}
</span>
</>
) : hasReviews ? (
<span className="text-sm text-[var(--muted)]">
{total} {total === 1 ? "review" : "reviews"}
</span>
) : null}
</div>
{!hasReviews ? (
<div className="rounded-lg border border-[var(--separator)] bg-[var(--surface-secondary)]/50 p-6 text-center">
<p className="text-[var(--muted)]">No reviews yet.</p>
<Link
href={`${getProductDetailPath(category, subCategory, slug)}#${PRODUCT_DETAIL_SECTION_IDS.reviews}`}
className="mt-3 inline-block w-full rounded-lg bg-[var(--brand-dark)] px-4 py-2.5 text-sm font-medium text-white transition hover:opacity-90 md:w-auto"
>
Be the first to review
</Link>
</div>
) : (
<ul className="m-0 list-none space-y-4 p-0">
{reviews.map((review) => (
<li key={review._id}>
<article className="rounded-lg border border-[var(--separator)] bg-[var(--surface)] p-4 md:p-5">
<h3 className="text-sm font-semibold text-[var(--foreground)] md:text-base">
{review.title}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-2">
<StarRating value={review.rating} />
{review.userName && (
<span className="text-sm text-[var(--muted)]">
{review.userName}
</span>
)}
{review.verifiedPurchase && (
<span className="text-xs font-medium text-[var(--success)]">
Verified purchase
</span>
)}
</div>
<p className="mt-2 whitespace-pre-wrap text-sm text-[var(--foreground)]/80">
{review.content}
</p>
{review.helpfulCount > 0 && (
<p className="mt-2 text-xs text-[var(--muted)]">
{review.helpfulCount}{" "}
{review.helpfulCount === 1 ? "person" : "people"} found this
helpful
</p>
)}
</article>
</li>
))}
</ul>
)}
{hasMore && hasReviews && (
<p className="text-sm text-[var(--muted)]">
Showing {reviews.length} of {total} reviews.
</p>
)}
</div>
);
}
type ProductDetailReviewsSectionProps = {
slug: string;
category: string;
subCategory: string;
};
/**
* Standalone reviews section with its own Suspense boundary.
* Used when reviews are rendered outside the tabs (backward compat).
*/
export function ProductDetailReviewsSection({
slug,
category,
subCategory,
}: ProductDetailReviewsSectionProps) {
return (
<section
id={PRODUCT_DETAIL_SECTION_IDS.reviews}
aria-label="Customer reviews"
className="scroll-mt-6"
>
<Suspense fallback={<ProductDetailReviewsSkeleton />}>
<ProductDetailReviewsContent
slug={slug}
category={category}
subCategory={subCategory}
/>
</Suspense>
</section>
);
}

View File

@@ -0,0 +1,64 @@
import { Suspense } from "react";
import { fetchQuery } from "convex/nextjs";
import { notFound } from "next/navigation";
import { api } from "../../../../../../convex/_generated/api";
import { ProductDetailHeroSection } from "./ProductDetailHeroSection";
import { ProductDetailTabsSection } from "./ProductDetailTabsSection";
import { ProductDetailDescriptionSection } from "./ProductDetailDescriptionSection";
import { ProductDetailAttributesSection } from "./ProductDetailAttributesSection";
import { ProductDetailReviewsPanel } from "../reviews/ProductDetailReviewsPanel";
import { ProductDetailErrorState } from "../state/ProductDetailErrorState";
import type { ProductDetailProduct } from "@/lib/product-detail/types";
type ProductDetailSectionsWrapperProps = {
category: string;
subCategory: string;
slug: string;
};
/**
* Async server component: fetches product by slug once and renders
* Hero + Tabs (Description, Details, Reviews).
* Reviews load independently via a nested Suspense boundary.
*/
export async function ProductDetailSectionsWrapper({
category,
subCategory,
slug,
}: ProductDetailSectionsWrapperProps) {
let product;
try {
product = await fetchQuery(api.products.getBySlug, { slug });
} catch {
return (
<ProductDetailErrorState message="Unable to load product details. Please try again." />
);
}
if (!product) notFound();
return (
<>
<ProductDetailHeroSection
product={product as ProductDetailProduct}
category={category}
subCategory={subCategory}
/>
<ProductDetailTabsSection
descriptionContent={
<ProductDetailDescriptionSection description={product.description} />
}
detailsContent={
<ProductDetailAttributesSection attributes={product.attributes} />
}
reviewsContent={
<ProductDetailReviewsPanel
productId={product._id}
initialRating={product.averageRating}
initialReviewCount={product.reviewCount}
/>
}
reviewCount={product.reviewCount}
/>
</>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import type { ReactNode } from "react";
import { Tabs } from "@heroui/react";
import { PRODUCT_DETAIL_SECTION_IDS } from "@/lib/product-detail/constants";
type ProductDetailTabsSectionProps = {
descriptionContent: ReactNode;
detailsContent: ReactNode;
reviewsContent: ReactNode;
reviewCount?: number;
};
/**
* HeroUI Tabs for PDP content sections (Description, Details, Reviews).
* Only the active panel is visible — tab labels serve as headings.
*/
export function ProductDetailTabsSection({
descriptionContent,
detailsContent,
reviewsContent,
reviewCount,
}: ProductDetailTabsSectionProps) {
const reviewsLabel =
reviewCount != null && reviewCount > 0
? `Reviews (${reviewCount})`
: "Reviews";
return (
<div className="mt-8 md:mt-12">
<Tabs
variant="secondary"
defaultSelectedKey={PRODUCT_DETAIL_SECTION_IDS.description}
className="w-full"
>
<Tabs.ListContainer className="sticky top-0 z-10 bg-[var(--background)]">
<Tabs.List aria-label="Product information sections">
<Tabs.Tab id={PRODUCT_DETAIL_SECTION_IDS.description}>
Description
<Tabs.Indicator />
</Tabs.Tab>
<Tabs.Tab id={PRODUCT_DETAIL_SECTION_IDS.attributes}>
Details
<Tabs.Indicator />
</Tabs.Tab>
<Tabs.Tab id={PRODUCT_DETAIL_SECTION_IDS.reviews}>
{reviewsLabel}
<Tabs.Indicator />
</Tabs.Tab>
</Tabs.List>
</Tabs.ListContainer>
<Tabs.Panel
id={PRODUCT_DETAIL_SECTION_IDS.description}
className="py-6 md:py-8"
>
{descriptionContent}
</Tabs.Panel>
<Tabs.Panel
id={PRODUCT_DETAIL_SECTION_IDS.attributes}
className="py-6 md:py-8"
>
{detailsContent}
</Tabs.Panel>
<Tabs.Panel
id={PRODUCT_DETAIL_SECTION_IDS.reviews}
className="py-6 md:py-8"
>
{reviewsContent}
</Tabs.Panel>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { Skeleton } from "@heroui/react";
/**
* Attributes section skeleton: short list of skeleton rows.
* Used as Suspense fallback if attributes are ever loaded asynchronously.
*/
export function ProductDetailAttributesSkeleton() {
return (
<section
aria-label="Product details (loading)"
className="flex flex-col gap-2 py-6 md:py-8"
>
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex flex-col gap-2 border-b border-default-200 py-2 sm:flex-row sm:items-center"
>
<Skeleton className="h-4 w-24 rounded" />
<Skeleton className="h-4 w-32 rounded" />
</div>
))}
</section>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { Skeleton } from "@heroui/react";
/**
* Description section skeleton: a few lines to match approximate description height.
* Used as Suspense fallback if description is ever loaded asynchronously.
*/
export function ProductDetailDescriptionSkeleton() {
return (
<section
aria-label="Product description (loading)"
className="flex flex-col gap-3 py-6 md:py-8"
>
<Skeleton className="h-4 w-full max-w-2xl rounded" />
<Skeleton className="h-4 w-full max-w-2xl rounded" />
<Skeleton className="h-4 w-4/5 max-w-xl rounded" />
<Skeleton className="h-4 w-full max-w-2xl rounded" />
</section>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useRouter } from "next/navigation";
import { Button } from "@heroui/react";
type ProductDetailErrorStateProps = {
/** Section-specific message, e.g. "Unable to load reviews" */
message?: string;
/** Optional retry; defaults to router.refresh() */
onRetry?: () => void;
};
/**
* Section-level error state for PDP. Use when a section's data fetch fails
* (e.g. Convex error) so the rest of the page still works.
*/
export function ProductDetailErrorState({
message = "Something went wrong loading this section.",
onRetry,
}: ProductDetailErrorStateProps) {
const router = useRouter();
const handleRetry = onRetry ?? (() => router.refresh());
return (
<div
className="rounded-lg border border-red-200 bg-red-50/50 p-6 text-center"
role="alert"
aria-live="polite"
>
<p className="text-red-800">{message}</p>
<Button
variant="primary"
onPress={handleRetry}
className="mt-4 w-full md:w-auto"
>
Try again
</Button>
</div>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { Skeleton } from "@heroui/react";
/**
* Hero + Tabs skeleton. Mirrors the new PDP layout:
* breadcrumb → gallery + buy box grid → tab navigation → content sections.
*/
export function ProductDetailHeroSkeleton() {
return (
<div className="flex flex-col gap-6 md:gap-8">
{/* Breadcrumb skeleton */}
<Skeleton className="h-4 w-3/4 max-w-md rounded" />
{/* Hero grid: gallery + buy box */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-10">
{/* Gallery */}
<div className="flex flex-col gap-3">
<Skeleton className="aspect-square w-full rounded-xl md:aspect-[4/3]" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-16 w-16 shrink-0 rounded-lg" />
))}
</div>
</div>
{/* Buy box */}
<div className="flex flex-col gap-5">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-8 w-full max-w-sm rounded" />
<Skeleton className="h-4 w-32 rounded" />
<Skeleton className="h-7 w-28 rounded" />
<Skeleton className="h-6 w-20 rounded-full" />
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<Skeleton className="h-10 w-full rounded-lg md:w-36" />
<Skeleton className="h-12 w-full rounded-lg md:w-44" />
</div>
</div>
</div>
{/* Tab navigation skeleton */}
<div className="mt-8 md:mt-12">
<div className="flex gap-1 border-b border-[var(--separator)]">
<Skeleton className="h-10 w-28 rounded-t" />
<Skeleton className="h-10 w-20 rounded-t" />
<Skeleton className="h-10 w-24 rounded-t" />
</div>
<div className="py-6 md:py-8">
<Skeleton className="mb-4 h-6 w-32 rounded" />
<div className="space-y-2">
<Skeleton className="h-4 w-full max-w-2xl rounded" />
<Skeleton className="h-4 w-full max-w-2xl rounded" />
<Skeleton className="h-4 w-4/5 max-w-xl rounded" />
<Skeleton className="h-4 w-full max-w-2xl rounded" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { Skeleton } from "@heroui/react";
import { ProductListTileSkeleton } from "@/components/product/ProductListTileSkeleton";
import { PRODUCT_DETAIL_SECTION_IDS } from "@/lib/product-detail/constants";
const RELATED_SKELETON_COUNT = 4;
function ProductCardSkeleton() {
return (
<div className="flex flex-col rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-5">
<Skeleton className="aspect-square w-full rounded-lg" />
<div className="mt-4 flex gap-2">
<Skeleton className="h-4 w-16 rounded" />
<Skeleton className="h-4 w-12 rounded" />
</div>
<Skeleton className="mt-3 h-4 w-full rounded" />
<Skeleton className="mt-2 h-4 w-3/4 rounded" />
<div className="mt-auto mt-6 h-px w-full bg-[var(--separator)]" />
<Skeleton className="mt-4 h-10 w-full rounded-lg" />
</div>
);
}
/**
* Skeleton for the Related products section. Matches ShopProductGrid layout:
* mobile list of tiles, md+ grid of cards.
*/
export function ProductDetailRelatedSkeleton() {
return (
<section
id={PRODUCT_DETAIL_SECTION_IDS.related}
aria-label="Related products"
className="py-6 md:py-8"
>
<Skeleton className="mb-4 h-6 w-48 rounded md:mb-6 md:h-7" />
<>
<ul className="flex flex-col gap-3 md:hidden" aria-hidden>
{Array.from({ length: RELATED_SKELETON_COUNT }, (_, i) => (
<li key={i}>
<ProductListTileSkeleton />
</li>
))}
</ul>
<div className="hidden md:grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
{Array.from({ length: RELATED_SKELETON_COUNT }, (_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
</>
</section>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { Skeleton } from "@heroui/react";
/**
* Reviews section skeleton: heading + summary line + 3 review card skeletons.
* Uses brand-aligned styling to match the polished review cards.
*/
export function ProductDetailReviewsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-6 w-44 rounded" />
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-5 w-24 rounded" />
<Skeleton className="h-4 w-32 rounded" />
</div>
<ul className="m-0 list-none space-y-4 p-0">
{[1, 2, 3].map((i) => (
<li key={i}>
<div className="space-y-2 rounded-lg border border-[var(--separator)] bg-[var(--surface)] p-4 md:p-5">
<Skeleton className="h-4 w-3/4 rounded" />
<div className="flex gap-2">
<Skeleton className="h-4 w-20 rounded" />
<Skeleton className="h-4 w-24 rounded" />
</div>
<Skeleton className="h-3 w-full rounded" />
<Skeleton className="h-3 w-full rounded" />
<Skeleton className="h-3 w-2/3 rounded" />
</div>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,231 @@
"use client";
import { Button, Spinner, toast } from "@heroui/react";
import Image from "next/image";
import Link from "next/link";
import { useId, useState, useCallback } from "react";
import { useConvexAuth, useMutation } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import type { Id } from "../../../../../convex/_generated/dataModel";
/** Renders a single star (filled or half). */
function StarIcon({ filled, half, gradientId }: { filled?: boolean; half?: boolean; gradientId?: string }) {
if (half && gradientId) {
return (
<svg className="size-[18px] text-[var(--warm)]" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<defs>
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
<stop offset="50%" stopColor="currentColor" />
<stop offset="50%" stopColor="transparent" />
</linearGradient>
</defs>
<path fill={`url(#${gradientId})`} d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
);
}
return (
<svg className="size-[18px] text-[var(--warm)]" viewBox="0 0 24 24" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.5" aria-hidden>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
);
}
function HeartIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<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>
);
}
export type ProductCardProps = {
/** Brand name (e.g. "Freshpet"). */
brand: string;
/** Full product name (e.g. "Dognation Turkey Bacon Grain-Free Fresh Dog Treats, 3-oz bag, case of 6"). */
name: string;
/** Current price as display string (e.g. "$41.94"). */
price: string;
/** Optional compare-at / original price for sale display. */
compareAtPrice?: string;
/** Image URL. */
imageSrc: string;
/** Alt = {Product Name} {Key Attribute}; used for accessibility and SEO. */
imageAlt: string;
/** Product URL; same for image link and name link. */
url: string;
/** Rating 05 (e.g. 4.5). */
rating: number;
/** Number of reviews for display and aria-label. */
reviewCount: number;
/** Optional badge: "new" (Amber Cream) or "sale" (Coral Blush). */
badge?: "new" | "sale";
/** Product ID for wishlist. When absent, save button uses placeholder behavior. */
productId?: string;
/** Variant ID for wishlist. */
variantId?: string;
/** When true, the wishlist action button is hidden entirely. */
hideWishlistAction?: boolean;
};
/** Renders star row from rating (e.g. 4.5 → 4 full + 1 half). */
function StarRow({ rating }: { rating: number }) {
const id = useId();
const full = Math.floor(rating);
const hasHalf = rating % 1 >= 0.25 && rating % 1 < 0.75;
const stars = [];
for (let i = 0; i < full; i++) stars.push(<StarIcon key={i} filled />);
if (hasHalf) stars.push(<StarIcon key="half" half gradientId={id.replace(/:/g, "")} />);
return <div className="flex items-center gap-0.5">{stars}</div>;
}
export function ProductCard({
brand,
name,
price,
compareAtPrice,
imageSrc,
imageAlt,
url,
rating,
reviewCount,
badge,
productId,
variantId,
hideWishlistAction,
}: ProductCardProps) {
const { isAuthenticated } = useConvexAuth();
const addToWishlist = useMutation(api.wishlists.add);
const [isSaving, setIsSaving] = useState(false);
const ratingLabel = `Rated ${rating} out of 5${reviewCount ? ` (${reviewCount} reviews)` : ""}`;
const handleSave = useCallback(async () => {
if (!productId) return;
if (!isAuthenticated) {
toast.info("Sign in to save items to your wishlist");
return;
}
setIsSaving(true);
try {
const result = await addToWishlist({
productId: productId as Id<"products">,
variantId: variantId as Id<"productVariants"> | undefined,
});
if (result.alreadyExisted) {
toast.info("This item is already in your wishlist");
} else {
toast.success("Added to your wishlist");
}
} finally {
setIsSaving(false);
}
}, [productId, variantId, isAuthenticated, addToWishlist]);
return (
<article
className="group relative flex flex-col rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-5 shadow-[0_2px_8px_rgba(56,169,159,0.1)] transition-[transform,box-shadow] duration-[var(--transition-base)] hover:-translate-y-1 hover:shadow-[0_8px 24px_rgba(56,169,159,0.15)]"
>
{badge && (
<div className="absolute right-4 top-4 z-10">
<span
className={
badge === "new"
? "rounded-lg bg-[#fde8c8] px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider text-[#b36a10]"
: "rounded-lg bg-[#fce0da] px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider text-[var(--coral)]"
}
>
{badge === "new" ? "New" : "Sale"}
</span>
</div>
)}
<Link href={url} className="relative mb-6 block aspect-square overflow-hidden">
<Image
src={imageSrc}
alt={imageAlt}
fill
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, (max-width: 1024px) 33vw, 25vw"
className="object-contain mix-blend-multiply transition-transform duration-500 group-hover:scale-110"
loading="lazy"
/>
</Link>
<div className="mb-2 flex items-center gap-1" role="img" aria-label={ratingLabel}>
<StarRow rating={rating} />
<span className="text-xs font-semibold text-[var(--muted)]">({reviewCount})</span>
</div>
<h3 className="mb-6 text-sm font-medium leading-snug text-[var(--foreground)]">
<Link href={url} className="focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--surface)] rounded">
<span className="font-[family-name:var(--font-fraunces)] font-bold block text-[var(--foreground)] mb-0.5">
{brand}
</span>
{name}
</Link>
</h3>
{compareAtPrice && (
<s className="mr-2 text-[var(--muted)] text-sm" aria-label="Original price">
{compareAtPrice}
</s>
)}
<div className="mt-auto w-full h-px bg-[var(--separator)] mb-6" />
<div className="flex items-center justify-between">
<p className="text-lg font-semibold text-[var(--brand-dark)]">
<strong>{price}</strong>
</p>
{!hideWishlistAction && (
<>
<Button
variant="ghost"
isIconOnly
size="md"
aria-label={`Save ${name} to wishlist`}
aria-busy={isSaving}
className="flex shrink-0 text-[var(--brand-dark)] hover:opacity-80 md:hidden"
isPending={isSaving}
onPress={handleSave}
>
{isSaving ? (
<Spinner size="sm" color="current" />
) : (
<HeartIcon className="size-5" />
)}
</Button>
<Button
variant="ghost"
size="md"
aria-label={`Save ${name} to wishlist`}
aria-busy={isSaving}
className="hidden min-w-0 font-sans text-sm font-medium text-[var(--brand-dark)] hover:opacity-80 md:flex w-full lg:w-auto"
isPending={isSaving}
onPress={handleSave}
>
{isSaving ? (
<>
<Spinner size="sm" color="current" className="shrink-0" />
Saving
</>
) : (
<>
<HeartIcon className="size-5 shrink-0" />
Save
</>
)}
</Button>
</>
)}
</div>
</article>
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import { Button, Spinner, toast } from "@heroui/react";
import Image from "next/image";
import Link from "next/link";
import { useId, useState, useCallback } from "react";
import { useConvexAuth, useMutation } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import type { Id } from "../../../../../convex/_generated/dataModel";
import type { ProductCardProps } from "./ProductCard";
function HeartIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<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>
);
}
/** Renders a single star (filled or half) for list tile. */
function StarIcon({
filled,
half,
gradientId,
}: {
filled?: boolean;
half?: boolean;
gradientId?: string;
}) {
if (half && gradientId) {
return (
<svg
className="size-3.5 text-[var(--warm)]"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
>
<defs>
<linearGradient id={gradientId} x1="0" x2="1" y1="0" y2="0">
<stop offset="50%" stopColor="currentColor" />
<stop offset="50%" stopColor="transparent" />
</linearGradient>
</defs>
<path
fill={`url(#${gradientId})`}
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
/>
</svg>
);
}
return (
<svg
className="size-3.5 text-[var(--warm)]"
viewBox="0 0 24 24"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.5"
aria-hidden
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
);
}
function StarRow({ rating }: { rating: number }) {
const id = useId();
const full = Math.floor(rating);
const hasHalf = rating % 1 >= 0.25 && rating % 1 < 0.75;
const stars = [];
for (let i = 0; i < full; i++) stars.push(<StarIcon key={i} filled />);
if (hasHalf)
stars.push(
<StarIcon key="half" half gradientId={id.replace(/:/g, "")} />
);
return <div className="flex items-center gap-0.5">{stars}</div>;
}
/**
* Compact product tile for mobile list view. Same props as ProductCard.
* Horizontal layout: image left, brand/name/rating/price right.
*/
export function ProductListTile({
brand,
name,
price,
compareAtPrice,
imageSrc,
imageAlt,
url,
rating,
reviewCount,
badge,
productId,
variantId,
hideWishlistAction,
}: ProductCardProps) {
const { isAuthenticated } = useConvexAuth();
const addToWishlist = useMutation(api.wishlists.add);
const [isSaving, setIsSaving] = useState(false);
const ratingLabel = `Rated ${rating} out of 5${reviewCount ? ` (${reviewCount} reviews)` : ""}`;
const handleSave = useCallback(async () => {
if (!productId) return;
if (!isAuthenticated) {
toast.info("Sign in to save items to your wishlist");
return;
}
setIsSaving(true);
try {
const result = await addToWishlist({
productId: productId as Id<"products">,
variantId: variantId as Id<"productVariants"> | undefined,
});
if (result.alreadyExisted) {
toast.info("This item is already in your wishlist");
} else {
toast.success("Added to your wishlist");
}
} finally {
setIsSaving(false);
}
}, [productId, variantId, isAuthenticated, addToWishlist]);
return (
<article className="flex flex-row gap-3 rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-3 shadow-[0_2px_8px_rgba(56,169,159,0.08)] transition-[box-shadow] duration-[var(--transition-base)]">
<Link
href={url}
className="relative block h-20 w-20 shrink-0 overflow-hidden rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--surface)]"
>
<Image
src={imageSrc}
alt={imageAlt}
fill
sizes="80px"
className="object-contain mix-blend-multiply"
loading="lazy"
/>
{badge && (
<span
className={`absolute right-0.5 top-0.5 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase ${
badge === "new"
? "bg-[#fde8c8] text-[#b36a10]"
: "bg-[#fce0da] text-[var(--coral)]"
}`}
>
{badge === "new" ? "New" : "Sale"}
</span>
)}
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<div
className="flex items-center gap-1"
role="img"
aria-label={ratingLabel}
>
<StarRow rating={rating} />
{reviewCount > 0 && (
<span className="text-[10px] font-medium text-[var(--muted)]">
({reviewCount})
</span>
)}
</div>
<h3 className="mt-0.5 line-clamp-2 text-sm font-medium leading-snug text-[var(--foreground)]">
<Link
href={url}
className="focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--surface)] rounded"
>
<span className="font-[family-name:var(--font-fraunces)] font-bold text-[var(--foreground)]">
{brand}
</span>{" "}
{name}
</Link>
</h3>
<div className="mt-auto flex items-center justify-between gap-2 pt-2">
<div className="flex items-baseline gap-2 min-w-0">
{compareAtPrice && (
<s
className="text-xs text-[var(--muted)] shrink-0"
aria-label="Original price"
>
{compareAtPrice}
</s>
)}
<p className="text-base font-semibold text-[var(--brand-dark)] truncate">
{price}
</p>
</div>
{!hideWishlistAction && (
<Button
variant="ghost"
isIconOnly
size="sm"
aria-label={`Save ${name} to wishlist`}
aria-busy={isSaving}
className="shrink-0 text-[var(--brand-dark)] hover:opacity-80"
isPending={isSaving}
onPress={handleSave}
>
{isSaving ? (
<Spinner size="sm" color="current" />
) : (
<HeartIcon className="size-5" />
)}
</Button>
)}
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import { Skeleton } from "@heroui/react";
/**
* Loading skeleton for ProductListTile. Matches horizontal list tile layout.
*/
export function ProductListTileSkeleton() {
return (
<div className="flex flex-row gap-3 rounded-[var(--radius)] border border-[var(--border)] bg-[var(--surface)] p-3">
<Skeleton className="h-20 w-20 shrink-0 rounded-lg" />
<div className="min-w-0 flex-1 space-y-2">
<div className="flex gap-2">
<Skeleton className="h-3 w-16 rounded" />
<Skeleton className="h-3 w-8 rounded" />
</div>
<Skeleton className="h-3 w-full rounded" />
<Skeleton className="h-3 w-4/5 rounded" />
<Skeleton className="h-4 w-14 rounded" />
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Search } from "lucide-react";
interface SearchEmptyStateProps {
query: string;
}
export function SearchEmptyState({ query }: SearchEmptyStateProps) {
const displayQuery = query.length > 30 ? query.slice(0, 30) + "…" : query;
return (
<div className="flex flex-col items-center py-8 px-4 text-center">
<Search className="mb-3 h-8 w-8 text-[#8aa9a8]" aria-hidden="true" />
<p className="text-sm font-medium text-[#3d5554]">
No results for &ldquo;{displayQuery}&rdquo;
</p>
<p className="mt-1 text-xs text-[#8aa9a8]">
Try a different search term or browse categories
</p>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Skeleton } from "@heroui/react";
function SkeletonRow() {
return (
<div className="flex items-center gap-3 px-3 py-2">
<Skeleton className="h-12 w-12 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-3/5 rounded" />
<Skeleton className="h-3 w-2/5 rounded" />
</div>
<Skeleton className="h-4 w-16 rounded" />
</div>
);
}
export function SearchLoadingState() {
return (
<div aria-label="Loading search results" aria-busy="true">
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</div>
);
}

View File

@@ -0,0 +1,7 @@
export function SearchMinCharsHint() {
return (
<div className="py-6 px-4 text-center">
<p className="text-sm text-[#8aa9a8]">Type at least 3 characters to search</p>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { formatPrice } from "@repo/utils";
import { getProductUrl } from "@/lib/shop/productMapper";
import type { SearchResultItem as SearchResultItemType } from "@/lib/search/types";
function formatSlugLabel(slug: string): string {
return slug
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
interface SearchResultItemProps {
item: SearchResultItemType;
index: number;
isSelected: boolean;
onSelect: () => void;
onMouseEnter: () => void;
}
export function SearchResultItem({
item,
index,
isSelected,
onSelect,
onMouseEnter,
}: SearchResultItemProps) {
const hasSale =
item.compareAtPrice != null && item.compareAtPrice > item.minPrice;
const breadcrumb = `${formatSlugLabel(item.parentCategorySlug)} ${formatSlugLabel(item.childCategorySlug)}`;
return (
<Link
href={getProductUrl(item)}
id={`search-result-${index}`}
role="option"
aria-selected={isSelected}
onClick={onSelect}
onMouseEnter={onMouseEnter}
className={`flex min-h-[56px] items-center gap-3 px-3 py-2 transition-colors md:min-h-[48px] ${
isSelected ? "bg-[#e8f7f6]" : "hover:bg-[#f5fafa]"
}`}
>
{/* Thumbnail */}
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-[#f0f8f7]">
<Image
src={item.imageUrl ?? "/images/placeholder-product.png"}
alt={item.imageAlt ?? item.name}
fill
className="object-cover"
sizes="48px"
/>
</div>
{/* Name + meta */}
<div className="min-w-0 flex-1">
<p className="line-clamp-1 text-sm font-medium text-[#1a2e2d]">
{item.name}
</p>
<p className="truncate text-xs text-[#8aa9a8]">{breadcrumb}</p>
{item.brand && (
<p className="truncate text-xs text-[#8aa9a8]">{item.brand}</p>
)}
</div>
{/* Price */}
<div className="shrink-0 text-right">
{hasSale ? (
<>
<p className="text-xs text-[#8aa9a8] line-through">
{formatPrice(item.compareAtPrice!)}
</p>
<p className="text-sm font-semibold text-[#f2705a]">
{formatPrice(item.minPrice)}
</p>
</>
) : item.minPrice > 0 ? (
<p className="text-sm font-semibold text-[#236f6b]">
{formatPrice(item.minPrice)}
</p>
) : null}
</div>
</Link>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import Link from "next/link";
import { ChevronRight, WifiOff } from "lucide-react";
import type { RefObject } from "react";
import type { SearchResultItem as SearchResultItemType } from "@/lib/search/types";
import { SearchResultItem } from "./SearchResultItem";
import { SearchLoadingState } from "./SearchLoadingState";
import { SearchEmptyState } from "./SearchEmptyState";
import { SearchMinCharsHint } from "./SearchMinCharsHint";
interface SearchResultsPanelProps {
results: SearchResultItemType[];
isLoading: boolean;
isStalled: boolean;
isEmpty: boolean;
query: string;
selectedIndex: number;
onItemSelect: (item: SearchResultItemType) => void;
onItemHover: (index: number) => void;
showMinCharsHint: boolean;
panelRef: RefObject<HTMLDivElement>;
}
export function SearchResultsPanel({
results,
isLoading,
isStalled,
isEmpty,
query,
selectedIndex,
onItemSelect,
onItemHover,
showMinCharsHint,
panelRef,
}: SearchResultsPanelProps) {
return (
<div
ref={panelRef}
id="search-results-panel"
role="listbox"
aria-label="Search results"
className="motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-top-2 absolute top-full left-0 right-0 z-50 mt-1 overflow-hidden rounded-xl border border-[#d9e8e7] bg-white shadow-xl duration-150 max-h-[60vh] overflow-y-auto md:max-h-[400px]"
>
{/* 6.2.3 — Screen reader live region announces result count changes */}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{isEmpty
? `No results found for ${query}`
: results.length > 0
? `${results.length} results found`
: ""}
</div>
{showMinCharsHint ? (
<SearchMinCharsHint />
) : isStalled ? (
// 6.1.6 — Loading has been in-flight for > 5s; surface a subtle error
<div className="flex flex-col items-center py-8 px-4 text-center">
<WifiOff className="mb-3 h-7 w-7 text-[#8aa9a8]" aria-hidden="true" />
<p className="text-sm font-medium text-[#3d5554]">
Search is taking longer than expected
</p>
<p className="mt-1 text-xs text-[#8aa9a8]">
Check your connection or try again in a moment
</p>
</div>
) : isLoading ? (
<SearchLoadingState />
) : isEmpty ? (
<SearchEmptyState query={query} />
) : (
<>
<div>
{results.map((item, index) => (
<SearchResultItem
key={item._id}
item={item}
index={index}
isSelected={index === selectedIndex}
onSelect={() => onItemSelect(item)}
onMouseEnter={() => onItemHover(index)}
/>
))}
</div>
<div className="border-t border-[#d9e8e7] px-3 py-2">
<Link
href={`/shop?search=${encodeURIComponent(query)}`}
className="flex items-center justify-center gap-1 text-sm font-medium text-[#38a99f] hover:text-[#236f6b] transition-colors"
>
View all results
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,164 @@
/**
* Line-art category icons — minimal stroke style, use currentColor for brand foreground.
*/
export function DogFoodIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12 38V18a2 2 0 0 1 2-2h20a2 2 0 0 1 2 2v20" />
<path d="M14 16V14a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v2" />
<path d="M18 28h12" />
<ellipse cx="24" cy="32" rx="4" ry="2" />
</svg>
);
}
export function CatFoodIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<ellipse cx="24" cy="28" rx="10" ry="12" />
<path d="M14 28h20" />
<path d="M18 16v-4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4" />
<path d="M20 12h8" />
</svg>
);
}
export function TreatsIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M10 24c0-6 4-10 8-10s6 4 6 10-2 10-6 10-8-4-8-10z" />
<path d="M38 24c0-6-4-10-8-10s-6 4-6 10 2 10 6 10 8-4 8-10z" />
<path d="M18 24h12" />
</svg>
);
}
export function TreesScratchersIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<rect x="20" y="32" width="8" height="8" rx="1" />
<path d="M24 32V20" />
<rect x="18" y="16" width="12" height="6" rx="1" />
<path d="M24 22v-6" />
<rect x="20" y="8" width="8" height="8" rx="1" />
<path d="M22 16h4M20 22h8" />
</svg>
);
}
export function ToysIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="24" cy="24" r="10" />
<path d="M24 14v20M14 24h20" />
<path d="M18 18l12 12M30 18L18 30" />
<path d="M24 20a4 4 0 0 1 0 8" />
</svg>
);
}
export function BowlsDishesIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<ellipse cx="24" cy="28" rx="12" ry="4" />
<path d="M12 28V26a4 4 0 0 1 4-4h16a4 4 0 0 1 4 4v2" />
<path d="M20 22h8" />
<ellipse cx="24" cy="26" rx="3" ry="1" />
</svg>
);
}
export function CarriersIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M10 38V14a2 2 0 0 1 2-2h24a2 2 0 0 1 2 2v24" />
<path d="M10 20h28" />
<path d="M14 20v18M24 20v18M34 20v18" />
<path d="M18 12h12v4H18z" />
</svg>
);
}
export function LitterLitterBoxIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M8 36V20a2 2 0 0 1 2-2h28a2 2 0 0 1 2 2v16" />
<path d="M8 24h32" />
<path d="M18 24v12M30 24v12" />
<path d="M22 28h4l2 8h-8l2-8z" />
</svg>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { Button } from "@heroui/react";
import Image from "next/image";
import Link from "next/link";
import { useRef } from "react";
import { TOP_CATEGORY_SLUGS } from "@/lib/shop/constants";
import { SectionHeading } from "@/utils/common/heading/section_heading";
function ChevronLeftIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M15 18l-6-6 6-6" />
</svg>
);
}
function ChevronRightIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M9 18l6-6-6-6" />
</svg>
);
}
const BG_CLASSES = [
"bg-[var(--coral-light)]",
"bg-[var(--brand-light)]/40",
"bg-[var(--warm-light)]",
"bg-[var(--coral-light)]",
"bg-[var(--brand-light)]/40",
"bg-[var(--warm-light)]",
] as const;
function slugToTitle(slug: string): string {
return slug.charAt(0).toUpperCase() + slug.slice(1);
}
export function CategorySection() {
const scrollRef = useRef<HTMLDivElement>(null);
const scroll = (direction: "left" | "right") => {
if (!scrollRef.current) return;
const step = scrollRef.current.clientWidth * 0.6;
scrollRef.current.scrollBy({
left: direction === "left" ? -step : step,
behavior: "smooth",
});
};
return (
<section
aria-label="Top categories"
className="w-full py-10 md:py-12"
>
<div className="mx-auto max-w-7xl min-w-0 px-4 md:px-6">
<div className="flex flex-row items-center justify-between gap-4">
<SectionHeading title="Top Categories" />
<div className="flex items-center gap-2 shrink-0 lg:hidden">
<Button
isIconOnly
variant="ghost"
aria-label="Scroll categories left"
onPress={() => scroll("left")}
className="min-w-8 w-8 h-8 text-[var(--foreground)]"
>
<ChevronLeftIcon className="size-5" />
</Button>
<Button
isIconOnly
variant="ghost"
aria-label="Scroll categories right"
onPress={() => scroll("right")}
className="min-w-8 w-8 h-8 text-[var(--foreground)]"
>
<ChevronRightIcon className="size-5" />
</Button>
</div>
</div>
<div
ref={scrollRef}
className="mt-6 flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-2 md:mt-8 lg:grid lg:grid-cols-6 lg:overflow-visible lg:pb-0"
style={{ scrollSnapType: "x proximity" }}
>
{TOP_CATEGORY_SLUGS.map((slug, i) => {
const title = slugToTitle(slug);
const href = `/shop/${slug}`;
const src = `/icons/icon_${slug}.svg`;
return (
<Link
key={href}
href={href}
className="flex shrink-0 flex-col items-center gap-3 rounded-2xl transition-[transform] duration-[var(--transition-base)] hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 focus:ring-offset-[var(--brand-mist)] lg:shrink"
style={{ scrollSnapAlign: "start" }}
>
<span
className={`flex h-24 w-24 items-center justify-center rounded-xl md:h-28 md:w-28 ${BG_CLASSES[i]}`}
>
<Image
src={src}
alt=""
width={48}
height={48}
className="h-10 w-10 object-contain md:h-12 md:w-12"
/>
</span>
<span className="text-center font-sans text-sm font-medium text-[var(--foreground)] md:text-base">
{title}
</span>
</Link>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
import Image from "next/image";
import Link from "next/link";
const CTA_IMAGES = {
main: "/images/cta/cta-01.webp",
kitty: "/images/cta/cta-02.webp",
doggy: "/images/cta/cta-03.webp",
} as const;
export function CtaSection() {
return (
<section
aria-label="Promotional offers and shop by pet"
className="w-full max-w-full min-w-0 overflow-x-hidden"
>
<div className="mx-auto max-w-7xl min-w-0">
<div className="grid min-w-0 grid-cols-2 gap-4 overflow-x-hidden lg:grid-cols-3 lg:grid-rows-2 lg:gap-5">
{/* Main CTA — Up to 45% OFF */}
<section
aria-labelledby="cta-main"
className="relative col-span-2 flex min-h-[280px] flex-col justify-between overflow-hidden rounded-[var(--radius)] p-6 md:p-8 lg:row-span-2"
>
<div className="absolute inset-0 z-0">
<Image
src={CTA_IMAGES.main}
alt=""
fill
className="object-cover brightness-[0.92] contrast-[1.05] saturate-[0.88]"
sizes="(max-width: 1024px) 100vw, 66vw"
/>
</div>
<div
className="absolute inset-0 z-[1] bg-gradient-to-r from-[var(--brand-mist)]/95 via-[var(--brand-mist)]/40 to-transparent"
aria-hidden
/>
<div className="relative z-10 flex min-h-0 flex-1 flex-col justify-center pr-4 md:max-w-[58%] md:pr-8">
<p className="font-sans text-base font-normal text-[var(--foreground)] md:text-lg">
Up to
</p>
<h2
id="cta-main"
className="mt-1 font-[family-name:var(--font-fraunces)] text-4xl font-bold leading-tight tracking-tight text-[var(--foreground)] drop-shadow-sm md:text-5xl lg:text-6xl lg:leading-tight"
>
<span className="relative inline-block border-b-4 border-[var(--warm)] pb-1">
45% OFF
</span>
</h2>
<p className="mt-3 font-sans text-base text-[var(--foreground)] md:text-lg">
Thousands of pet favourites
</p>
<Link
href="/shop"
className="mt-6 inline-flex w-fit items-center gap-1 rounded-full bg-[var(--warm)] px-6 py-3 font-sans text-sm font-medium text-[var(--neutral-900)] shadow-sm transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:shadow-md focus:outline-none focus:ring-2 focus:ring-[var(--brand)] focus:ring-offset-2"
>
Shop Now
<span aria-hidden></span>
</Link>
</div>
<div className="relative z-10 mt-4 flex items-center gap-1 font-sans text-[var(--muted)]" aria-hidden>
<span></span>
<span></span>
<span></span>
<span className="opacity-60"></span>
</div>
</section>
{/* Kitty CTA */}
<section
aria-labelledby="cta-kitty"
className="relative h-[130px] overflow-hidden rounded-[var(--radius)] p-3 md:h-[160px] md:p-6 lg:h-auto"
>
<div className="absolute inset-0 z-0 bg-[var(--brand-dark)]/70" aria-hidden />
<div className="relative z-10">
<h2
id="cta-kitty"
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-3xl"
>
<span className="font-sans text-xs font-normal md:text-lg">Lookin for </span>
<span>Kitty</span>
<br />
<span className="font-sans text-xs font-normal md:text-lg"> Stuff???</span>
</h2>
<Link
href="/shop/cats"
className="mt-2 inline-flex items-center gap-1 rounded-full border-2 border-white bg-transparent px-3 py-1.5 font-sans text-xs font-medium text-white transition-[transform,opacity] duration-[var(--transition-base)] hover:scale-[1.02] hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:px-5 md:py-2.5 md:text-sm"
>
Shop here
<span aria-hidden></span>
</Link>
</div>
<div className="absolute inset-0">
<Image
src={CTA_IMAGES.doggy}
alt=""
fill
className="object-cover"
sizes="(max-width: 768px) 50vw, 33vw"
/>
</div>
</section>
{/* Doggy CTA */}
<section
aria-labelledby="cta-doggy"
className="relative h-[130px] overflow-hidden rounded-[var(--radius)] p-3 md:h-[160px] md:p-6 lg:h-auto"
>
<div className="absolute inset-0 z-0 bg-[var(--brand-dark)]/70" aria-hidden />
<div className="relative z-10">
<h2
id="cta-doggy"
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-3xl"
>
<span className="font-sans text-xs font-normal md:text-lg">Lookin for </span>
<span>Doggy</span>
<br />
<span className="font-sans text-xs font-normal md:text-lg"> Stuff???</span>
</h2>
<Link
href="/shop/dogs"
className="mt-2 inline-flex items-center gap-1 rounded-full border-2 border-white bg-transparent px-3 py-1.5 font-sans text-xs font-medium text-white transition-[transform,opacity] duration-[var(--transition-base)] hover:scale-[1.02] hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:px-5 md:py-2.5 md:text-sm"
>
Shop here
<span aria-hidden></span>
</Link>
</div>
<div className="absolute inset-0">
<Image
src={CTA_IMAGES.kitty}
alt=""
fill
className="object-cover"
sizes="(max-width: 768px) 50vw, 33vw"
/>
</div>
</section>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import Image from "next/image";
import { Button, Input } from "@heroui/react";
const NEWSLETTER_IMAGE = "/content/newsletter-dog.png";
function EnvelopeIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
);
}
export function NewsletterSection() {
return (
<section
aria-label="Newsletter signup"
className="relative max-w-7xl mx-auto w-full px-4 md:px-8 mb-12"
>
<div className="relative overflow-hidden bg-[#eaf7d9] rounded-[32px] md:rounded-[48px] p-6 sm:p-10 md:p-12 lg:p-16 w-full min-h-[460px] md:min-h-[400px] flex flex-col md:flex-row items-center md:items-stretch">
{/* Left Content */}
<div className="relative z-20 w-full md:w-[55%] lg:w-1/2 flex flex-col justify-center items-center md:items-start space-y-6 md:space-y-8 max-w-[400px] lg:max-w-md pt-2 md:pt-0 mb-[210px] sm:mb-[300px] md:mb-0 text-center md:text-left mx-auto md:mx-0">
<h2 className="font-[family-name:var(--font-fraunces)] font-medium text-2xl md:text-3xl text-neutral-900 tracking-tight flex flex-col md:block w-full">
<span className="text-balance mx-auto md:mx-0 max-w-[280px] md:max-w-none">
Inner peace, inner peas...
</span>
<span className="font-bold mt-2 md:mt-0 md:ml-1 text-balance">
Newsletter please
</span>
<span className="mt-1 md:mt-0 md:ml-1">🐶...</span>
</h2>
<form
className="w-full"
onSubmit={(e) => e.preventDefault()}
noValidate
>
{/* Mobile: stacked. Desktop (md+): combined pill */}
<div className="flex flex-col gap-2 md:flex-row md:gap-0 md:items-center md:bg-white md:rounded-full md:p-1.5 md:pl-4 md:shadow-sm md:overflow-hidden md:h-[64px]">
<div className="flex items-center bg-white rounded-full h-12 px-4 shadow-sm md:bg-transparent md:shadow-none md:rounded-none md:px-0 md:h-full flex-1 min-w-0">
<EnvelopeIcon className="size-[18px] shrink-0 text-neutral-400 mr-2" />
<Input
id="newsletter-email"
name="newsletter-email"
type="email"
placeholder="Enter your email"
aria-label="Email for newsletter"
className="flex-1 bg-transparent border-transparent shadow-none rounded-none px-0 h-full text-sm md:text-base min-w-0 focus-visible:border-transparent focus-visible:ring-0"
/>
</div>
<Button
type="submit"
className="bg-[#51a67f] hover:bg-[#43906d] text-white rounded-full px-8 h-12 md:h-full w-full md:w-auto font-medium text-sm md:text-base shadow-none transition-colors border-none shrink-0"
>
Subscribe
</Button>
</div>
</form>
</div>
{/* Right Content - Dog Image */}
<div className="absolute bottom-0 left-0 right-0 md:left-auto md:right-0 w-full md:w-[55%] h-[210px] sm:h-[340px] md:h-[110%] z-10 pointer-events-none flex justify-center md:justify-end">
<Image
src={NEWSLETTER_IMAGE}
alt="Peaceful dog"
fill
className="object-contain object-bottom md:object-right-bottom"
sizes="(max-width: 768px) 100vw, 50vw"
priority={false}
/>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useQuery } from "convex/react";
import Link from "next/link";
import { api } from "../../../../../../../../convex/_generated/api";
import { ProductCard } from "@/components/product/ProductCard";
import { SectionHeading } from "@/utils/common/heading/section_heading";
import {
enrichedProductToCardProps,
type EnrichedProduct,
} from "@/lib/shop/productMapper";
export function RecentlyAddedSection() {
const products = useQuery(api.products.listRecentlyAdded, { limit: 8 });
const isLoading = products === undefined;
const isEmpty = Array.isArray(products) && products.length === 0;
if (isEmpty) return null;
return (
<section
aria-label="Recently added products"
className="w-full py-10 md:py-12"
>
<div className="mx-auto max-w-7xl min-w-0 px-4 md:px-6">
<div className="flex flex-row flex-wrap items-center justify-between gap-4 mb-6 md:mb-8">
<SectionHeading title="Recently Added" />
<Link
href="/shop/recently-added"
className="inline-flex items-center gap-1 font-sans text-sm font-bold text-[var(--accent)] hover:underline focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 rounded"
>
View All
<span aria-hidden></span>
</Link>
</div>
<div
className="flex gap-6 overflow-x-auto scrollbar-hide scroll-smooth pb-2 lg:grid lg:grid-cols-5 lg:overflow-visible lg:pb-0"
style={{ scrollSnapType: "x proximity" }}
>
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="min-w-[280px] shrink-1 lg:min-w-0 lg:shrink animate-pulse"
style={{ scrollSnapAlign: "start" }}
>
<div className="rounded-xl bg-gray-200 aspect-square w-full" />
<div className="mt-3 space-y-2">
<div className="h-3 w-1/3 rounded bg-gray-200" />
<div className="h-4 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-1/4 rounded bg-gray-200" />
</div>
</div>
))
: (products as EnrichedProduct[]).map((product) => {
const props = enrichedProductToCardProps(product);
return (
<div
key={product._id}
className="min-w-[280px] shrink-1 lg:min-w-0 lg:shrink"
style={{ scrollSnapAlign: "start" }}
>
<ProductCard {...props} />
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useQuery } from "convex/react";
import Link from "next/link";
import { api } from "../../../../../../../../convex/_generated/api";
import { ProductCard } from "@/components/product/ProductCard";
import { SectionHeading } from "@/utils/common/heading/section_heading";
import {
enrichedProductToCardProps,
type EnrichedProduct,
} from "@/lib/shop/productMapper";
export function SpecialOffersSection() {
const products = useQuery(api.products.listByTag, { tag: "sale", limit: 8 });
const isLoading = products === undefined;
const isEmpty = Array.isArray(products) && products.length === 0;
if (isEmpty) return null;
return (
<section
id="special-offers"
aria-label="Special offers and deals"
className="w-full py-10 md:py-12"
>
<div className="mx-auto max-w-7xl min-w-0 px-4 md:px-6">
<div className="flex flex-row flex-wrap items-center justify-between gap-4 mb-6 md:mb-8">
<SectionHeading title="Special Offers" />
<Link
href="/shop/sale"
className="inline-flex items-center gap-1 font-sans text-sm font-bold text-[var(--accent)] hover:underline focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 rounded"
>
View All
<span aria-hidden></span>
</Link>
</div>
<div
className="flex gap-6 overflow-x-auto scrollbar-hide scroll-smooth pb-2 lg:grid lg:grid-cols-5 lg:overflow-visible lg:pb-0"
style={{ scrollSnapType: "x proximity" }}
>
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="min-w-[280px] shrink-1 lg:min-w-0 lg:shrink animate-pulse"
style={{ scrollSnapAlign: "start" }}
>
<div className="rounded-xl bg-gray-200 aspect-square w-full" />
<div className="mt-3 space-y-2">
<div className="h-3 w-1/3 rounded bg-gray-200" />
<div className="h-4 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-1/4 rounded bg-gray-200" />
</div>
</div>
))
: (products as EnrichedProduct[]).map((product) => {
const props = enrichedProductToCardProps(product);
return (
<div
key={product._id}
className="min-w-[280px] shrink-1 lg:min-w-0 lg:shrink"
style={{ scrollSnapAlign: "start" }}
>
<ProductCard {...props} />
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import { useQuery } from "convex/react";
import Link from "next/link";
import { api } from "../../../../../../../../convex/_generated/api";
import { ProductCard } from "@/components/product/ProductCard";
import { SectionHeading } from "@/utils/common/heading/section_heading";
import {
enrichedProductToCardProps,
type EnrichedProduct,
} from "@/lib/shop/productMapper";
export function TopPicksSection() {
const products = useQuery(api.products.listByTag, {
tag: "top-picks",
limit: 8,
});
const isLoading = products === undefined;
const isEmpty = Array.isArray(products) && products.length === 0;
if (isEmpty) return null;
return (
<section
id="customers-shopped"
aria-label="Customers also shopped"
className="w-full py-10 md:py-12"
>
<div className="mx-auto max-w-7xl min-w-0 px-4 md:px-6">
<div className="flex flex-row flex-wrap items-center justify-between gap-4 mb-6 md:mb-8">
<SectionHeading title="Top Picks" />
<Link
href="/shop/top-picks"
className="inline-flex items-center gap-1 font-sans text-sm font-bold text-[var(--accent)] hover:underline focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2 rounded"
>
View All
<span aria-hidden></span>
</Link>
</div>
<div
className="flex gap-6 overflow-x-auto scrollbar-hide scroll-smooth pb-2 lg:grid lg:grid-cols-5 lg:overflow-visible lg:pb-0"
style={{ scrollSnapType: "x proximity" }}
>
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="min-w-[280px] shrink-1 lg:min-w-0 lg:shrink animate-pulse"
style={{ scrollSnapAlign: "start" }}
>
<div className="rounded-xl bg-gray-200 aspect-square w-full" />
<div className="mt-3 space-y-2">
<div className="h-3 w-1/3 rounded bg-gray-200" />
<div className="h-4 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-1/4 rounded bg-gray-200" />
</div>
</div>
))
: (products as EnrichedProduct[]).map((product) => {
const props = enrichedProductToCardProps(product);
return (
<div
key={product._id}
className="min-w-[280px] shrink-1 lg:min-w-0 lg:shrink"
style={{ scrollSnapAlign: "start" }}
>
<ProductCard {...props} />
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { useConvexAuth } from "convex/react";
import { formatPrice } from "@repo/utils";
import { ProductCard } from "@/components/product/ProductCard";
import { SectionHeading } from "@/utils/common/heading/section_heading";
import { useWishlist } from "@/lib/wishlist";
import { WISHLIST_PATH } from "@/lib/wishlist/constants";
import type { WishlistItem } from "@/lib/wishlist/types";
import type { ProductCardProps } from "@/components/product/ProductCard";
const MAX_ITEMS = 5;
function wishlistItemToCardProps(item: WishlistItem): ProductCardProps | null {
const { product, variant } = item;
if (!product) return null;
const activeVariant = variant ?? product.variants[0];
const currentPrice = activeVariant?.price ?? 0;
const compareAtPrice = activeVariant?.compareAtPrice;
const priceDrop =
item.priceWhenAdded > 0 && currentPrice < item.priceWhenAdded;
return {
brand: product.brand ?? "",
name: product.name,
price: formatPrice(currentPrice),
compareAtPrice:
compareAtPrice && compareAtPrice > currentPrice
? formatPrice(compareAtPrice)
: undefined,
imageSrc: product.images[0]?.url ?? "/placeholder-product.png",
imageAlt: product.images[0]?.alt ?? product.name,
url: `/shop/${product.parentCategorySlug}/${product.childCategorySlug}/${product.slug}`,
rating: product.averageRating ?? 0,
reviewCount: product.reviewCount ?? 0,
badge: priceDrop ? "sale" : undefined,
productId: product._id,
variantId: activeVariant?._id,
};
}
export function WishlistSection() {
const { isAuthenticated } = useConvexAuth();
const { items, isLoading, isEmpty } = useWishlist();
if (!isAuthenticated || isLoading || isEmpty) return null;
const cards = items
.slice(0, MAX_ITEMS)
.map(wishlistItemToCardProps)
.filter((c): c is ProductCardProps => c !== null);
if (cards.length === 0) return null;
return (
<section aria-label="Your wishlist" className="w-full py-10 md:py-12">
<div className="mx-auto max-w-7xl min-w-0 px-4 md:px-6">
<div className="mb-6 flex flex-row flex-wrap items-center justify-between gap-4 md:mb-8">
<SectionHeading title="Your Wishlist" />
<Link
href={WISHLIST_PATH}
className="inline-flex items-center gap-1 rounded font-sans text-sm font-bold text-[var(--accent)] hover:underline focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:ring-offset-2"
>
View All
<span aria-hidden></span>
</Link>
</div>
<div
className="flex gap-6 overflow-x-auto scroll-smooth scrollbar-hide pb-2 lg:grid lg:grid-cols-5 lg:overflow-visible lg:pb-0"
style={{ scrollSnapType: "x proximity" }}
>
{cards.map((props) => (
<div
key={props.url}
className="min-w-[280px] shrink-1 lg:min-w-0 lg:shrink"
style={{ scrollSnapAlign: "start" }}
>
<ProductCard {...props} hideWishlistAction />
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,233 @@
"use client";
import { useQuery } from "convex/react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { api } from "../../../../../convex/_generated/api";
import type { PetCategorySlug } from "@/lib/shop/constants";
import {
filterStateFromSearchParams,
filterStateToSearchParams,
shopFilterStateToApiArgs,
} from "@/lib/shop/filterState";
import {
enrichedProductToCardProps,
type EnrichedProduct,
} from "@/lib/shop/productMapper";
import { ShopBreadcrumbBar } from "@/components/shop/ShopBreadcrumbBar";
import { ShopCategoryStrip } from "@/components/shop/ShopCategoryStrip";
import { ShopFilterModal } from "@/components/shop/ShopFilterModal";
import { ShopFilterSidebar } from "@/components/shop/ShopFilterSidebar";
import { ShopPageBanner } from "@/components/shop/ShopPageBanner";
import { ShopProductGrid } from "@/components/shop/ShopProductGrid";
import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
const SORT_OPTIONS: SortOption[] = [
{ value: "newest", label: "Newest" },
{ value: "price-asc", label: "Price: Low to High" },
{ value: "price-desc", label: "Price: High to Low" },
];
function formatCategoryTitle(slug: string): string {
if (slug === "small-pets") return "Small Pets";
return slug.charAt(0).toUpperCase() + slug.slice(1);
}
export function PetCategoryPage({ slug }: { slug: PetCategorySlug }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [sort, setSort] = useState<string>("newest");
const [filterModalOpen, setFilterModalOpen] = useState(false);
const filterState = useMemo(
() => filterStateFromSearchParams(searchParams),
[searchParams],
);
const filterArgs = useMemo(() => shopFilterStateToApiArgs(filterState), [filterState]);
const category = useQuery(api.categories.getBySlug, { slug });
const subCategories = useQuery(
api.categories.list,
category?._id != null ? { parentId: category._id } : "skip",
);
const filterOptions = useQuery(api.products.getFilterOptions, {
parentCategorySlug: slug,
});
const products = useQuery(api.products.listByParentSlug, {
parentCategorySlug: slug,
...filterArgs,
});
const setFilterStateToUrl = useCallback(
(state: typeof filterState) => {
const params = filterStateToSearchParams(state);
const q = params.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
},
[pathname, router],
);
const stripLinks = useMemo(() => {
if (!subCategories || !Array.isArray(subCategories)) return [];
return subCategories.map((c) => ({
label: c.name,
href: `/shop/${slug}/${c.slug}`,
}));
}, [subCategories, slug]);
const sortedProducts = useMemo(() => {
if (!products || !Array.isArray(products)) return [];
const list = [...products] as EnrichedProduct[];
if (sort === "newest") {
list.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
} else if (sort === "price-asc") {
list.sort((a, b) => {
const pa = a.variants?.[0]?.price ?? 0;
const pb = b.variants?.[0]?.price ?? 0;
return pa - pb;
});
} else if (sort === "price-desc") {
list.sort((a, b) => {
const pa = a.variants?.[0]?.price ?? 0;
const pb = b.variants?.[0]?.price ?? 0;
return pb - pa;
});
}
return list;
}, [products, sort]);
const handleRetry = useCallback(() => {
window.location.reload();
}, []);
const isLoading = category === undefined || (category != null && products === undefined);
const categoryNotFound = category === null;
const hasError = categoryNotFound;
const isEmpty = category != null && Array.isArray(products) && products.length === 0;
return (
<main className="mx-auto w-full max-w-7xl min-w-0 px-4 py-6 md:px-6 md:py-8">
{/* Top row: breadcrumb + toolbar */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<ShopBreadcrumbBar
items={[
{ label: "Home", href: "/" },
{ label: "Shop", href: "/shop" },
{ label: category?.name ?? slug },
]}
/>
<ShopToolbar
sortOptions={SORT_OPTIONS}
currentSort={sort}
onSortChange={setSort}
onOpenFilter={() => setFilterModalOpen(true)}
resultCount={Array.isArray(products) ? products.length : undefined}
/>
</div>
<ShopPageBanner
title={category?.name ?? formatCategoryTitle(slug)}
subtitle={category?.description ?? undefined}
/>
{hasError && (
<ShopErrorState
message="Category not found or failed to load."
onRetry={handleRetry}
/>
)}
{!hasError && (
<div className="mt-6 flex flex-col gap-6 lg:flex-row lg:items-start">
<ShopFilterSidebar
filterOptions={filterOptions ?? null}
filterState={filterState}
onFilterChange={setFilterStateToUrl}
filterOptionsLoading={filterOptions === undefined}
>
<p className="text-sm font-medium text-[var(--foreground)]">
Sub-categories
</p>
{stripLinks.length > 0 ? (
<ul className="mt-2 space-y-1">
{stripLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-[var(--muted)] hover:text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)] rounded"
>
{link.label}
</Link>
</li>
))}
</ul>
) : (
<p className="mt-1 text-sm text-[var(--muted)]">
No sub-categories
</p>
)}
</ShopFilterSidebar>
<div className="min-w-0 flex-1">
{stripLinks.length > 0 && <ShopCategoryStrip links={stripLinks} />}
<div className="mt-6">
{isLoading ? (
<ShopProductGridSkeleton />
) : categoryNotFound ? null : isEmpty ? (
<ShopEmptyState
title="No products in this category"
description="There are no products in this category right now."
/>
) : (
<ShopProductGrid
products={sortedProducts.map((p) => ({
...enrichedProductToCardProps(p as EnrichedProduct),
id: (p as { _id: string })._id,
}))}
/>
)}
</div>
</div>
</div>
)}
<ShopFilterModal
isOpen={filterModalOpen}
onClose={() => setFilterModalOpen(false)}
onApply={() => setFilterModalOpen(false)}
filterOptions={filterOptions ?? null}
filterState={filterState}
onApplyFilters={setFilterStateToUrl}
filterOptionsLoading={filterOptions === undefined}
>
<p className="text-sm font-medium text-[var(--foreground)]">
Sub-categories
</p>
{stripLinks.length > 0 ? (
<ul className="mt-2 space-y-1">
{stripLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-[var(--muted)] hover:text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)] rounded"
>
{link.label}
</Link>
</li>
))}
</ul>
) : (
<p className="mt-1 text-sm text-[var(--muted)]">
No sub-categories
</p>
)}
</ShopFilterModal>
</main>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useQuery } from "convex/react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { api } from "../../../../../convex/_generated/api";
import { ShopBreadcrumbBar } from "@/components/shop/ShopBreadcrumbBar";
import { ShopFilterModal } from "@/components/shop/ShopFilterModal";
import { ShopFilterSidebar } from "@/components/shop/ShopFilterSidebar";
import { ShopPageBanner } from "@/components/shop/ShopPageBanner";
import { ShopProductGrid } from "@/components/shop/ShopProductGrid";
import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import {
filterStateFromSearchParams,
mergeFilterStateIntoSearchParams,
shopFilterStateToApiArgs,
} from "@/lib/shop/filterState";
import {
enrichedProductToCardProps,
type EnrichedProduct,
} from "@/lib/shop/productMapper";
const SORT_OPTIONS: SortOption[] = [
{ value: "newest", label: "Newest" },
{ value: "price-asc", label: "Price: Low to High" },
{ value: "price-desc", label: "Price: High to Low" },
];
export function RecentlyAddedPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const filterState = useMemo(
() => filterStateFromSearchParams(searchParams),
[searchParams],
);
const filterArgs = useMemo(
() => shopFilterStateToApiArgs(filterState),
[filterState],
);
const [sort, setSort] = useState<string>("newest");
const [filterModalOpen, setFilterModalOpen] = useState(false);
const filterOptions = useQuery(api.products.getFilterOptions, {
recentlyAdded: true,
});
const products = useQuery(api.products.listRecentlyAdded, { ...filterArgs });
const setFilterStateToUrl = useCallback(
(state: typeof filterState) => {
const next = mergeFilterStateIntoSearchParams(
new URLSearchParams(searchParams.toString()),
state,
);
const q = next.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
},
[pathname, router, searchParams],
);
const handleApplyFiltersFromModal = useCallback(
(state: typeof filterState) => {
const next = mergeFilterStateIntoSearchParams(
new URLSearchParams(searchParams.toString()),
state,
);
const q = next.toString();
router.replace(q ? `${pathname}?${q}` : pathname);
setFilterModalOpen(false);
},
[pathname, router, searchParams],
);
const handleApplyFilter = useCallback(() => {
const next = mergeFilterStateIntoSearchParams(
new URLSearchParams(searchParams.toString()),
filterState,
);
const q = next.toString();
router.push(q ? `${pathname}?${q}` : pathname);
setFilterModalOpen(false);
}, [router, pathname, searchParams, filterState]);
const sortedProducts = useMemo(() => {
if (!products || !Array.isArray(products)) return [];
const list = [...products] as EnrichedProduct[];
if (sort === "newest") {
// Data is already ordered by _creationTime desc from the query
list.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
} else if (sort === "price-asc") {
list.sort((a, b) => (a.variants?.[0]?.price ?? 0) - (b.variants?.[0]?.price ?? 0));
} else if (sort === "price-desc") {
list.sort((a, b) => (b.variants?.[0]?.price ?? 0) - (a.variants?.[0]?.price ?? 0));
}
return list;
}, [products, sort]);
const handleRetry = useCallback(() => {
window.location.reload();
}, []);
const isLoading = products === undefined;
const hasError = products === null;
const isEmpty = Array.isArray(products) && products.length === 0;
return (
<main className="mx-auto w-full max-w-7xl min-w-0 px-4 py-6 md:px-6 md:py-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<ShopBreadcrumbBar
items={[
{ label: "Home", href: "/" },
{ label: "Shop", href: "/shop" },
{ label: "Recently Added" },
]}
/>
<ShopToolbar
sortOptions={SORT_OPTIONS}
currentSort={sort}
onSortChange={setSort}
onOpenFilter={() => setFilterModalOpen(true)}
resultCount={Array.isArray(products) ? products.length : undefined}
/>
</div>
<ShopPageBanner
title="Recently Added"
subtitle="Explore the latest products added in the last 30 days"
/>
<div className="mt-6 flex flex-col gap-6 lg:flex-row lg:items-start">
<ShopFilterSidebar
filterOptions={filterOptions ?? null}
filterState={filterState}
onFilterChange={setFilterStateToUrl}
filterOptionsLoading={filterOptions === undefined}
/>
<div className="min-w-0 flex-1">
<div className="mt-6">
{isLoading ? (
<ShopProductGridSkeleton />
) : hasError ? (
<ShopErrorState
message="Failed to load products."
onRetry={handleRetry}
/>
) : isEmpty ? (
<ShopEmptyState
title="No new products yet"
description="Nothing has been added in the last 30 days. Check back soon!"
/>
) : (
<ShopProductGrid
products={sortedProducts.map((p) => ({
...enrichedProductToCardProps(p as EnrichedProduct),
id: (p as { _id: string })._id,
}))}
/>
)}
</div>
</div>
</div>
<ShopFilterModal
isOpen={filterModalOpen}
onClose={() => setFilterModalOpen(false)}
onApply={handleApplyFilter}
filterOptions={filterOptions ?? null}
filterState={filterState}
onApplyFilters={handleApplyFiltersFromModal}
filterOptionsLoading={filterOptions === undefined}
/>
</main>
);
}

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