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:
19
apps/storefront/src/components/cart/CartUIContext.tsx
Normal file
19
apps/storefront/src/components/cart/CartUIContext.tsx
Normal 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;
|
||||
}
|
||||
39
apps/storefront/src/components/cart/CartUIProvider.tsx
Normal file
39
apps/storefront/src/components/cart/CartUIProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
73
apps/storefront/src/components/cart/content/CartActions.tsx
Normal file
73
apps/storefront/src/components/cart/content/CartActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/storefront/src/components/cart/content/CartContent.tsx
Normal file
105
apps/storefront/src/components/cart/content/CartContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
145
apps/storefront/src/components/cart/content/CartItemCard.tsx
Normal file
145
apps/storefront/src/components/cart/content/CartItemCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
35
apps/storefront/src/components/cart/state/CartErrorState.tsx
Normal file
35
apps/storefront/src/components/cart/state/CartErrorState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
apps/storefront/src/components/checkout/CheckoutShell.tsx
Normal file
107
apps/storefront/src/components/checkout/CheckoutShell.tsx
Normal 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">—</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>
|
||||
);
|
||||
}
|
||||
306
apps/storefront/src/components/checkout/content/AddressForm.tsx
Normal file
306
apps/storefront/src/components/checkout/content/AddressForm.tsx
Normal 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 & save address
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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)} × {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)} →{" "}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
275
apps/storefront/src/components/checkout/steps/PaymentStep.tsx
Normal file
275
apps/storefront/src/components/checkout/steps/PaymentStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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't verify your address right now. You can save it and
|
||||
continue — we'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>
|
||||
);
|
||||
}
|
||||
32
apps/storefront/src/components/layout/BrandLogo.tsx
Normal file
32
apps/storefront/src/components/layout/BrandLogo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/storefront/src/components/layout/ToastProvider.tsx
Normal file
7
apps/storefront/src/components/layout/ToastProvider.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Toast } from "@heroui/react";
|
||||
|
||||
export function ToastProvider() {
|
||||
return <Toast.Provider placement="bottom end" />;
|
||||
}
|
||||
252
apps/storefront/src/components/layout/footer/Footer.tsx
Normal file
252
apps/storefront/src/components/layout/footer/Footer.tsx
Normal 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'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">
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
20
apps/storefront/src/components/layout/header/Header.tsx
Normal file
20
apps/storefront/src/components/layout/header/Header.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
212
apps/storefront/src/components/layout/header/HeaderSearchBar.tsx
Normal file
212
apps/storefront/src/components/layout/header/HeaderSearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
apps/storefront/src/components/layout/header/code.html
Normal file
242
apps/storefront/src/components/layout/header/code.html
Normal 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&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,700;1,400&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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 & 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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
BIN
apps/storefront/src/components/layout/header/screen.png
Normal file
BIN
apps/storefront/src/components/layout/header/screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
152
apps/storefront/src/components/orders/OrderDetailPageView.tsx
Normal file
152
apps/storefront/src/components/orders/OrderDetailPageView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/storefront/src/components/orders/OrdersPageView.tsx
Normal file
51
apps/storefront/src/components/orders/OrdersPageView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
61
apps/storefront/src/components/orders/detail/OrderHeader.tsx
Normal file
61
apps/storefront/src/components/orders/detail/OrderHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
84
apps/storefront/src/components/orders/list/OrderCard.tsx
Normal file
84
apps/storefront/src/components/orders/list/OrderCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/storefront/src/components/orders/list/OrderCardList.tsx
Normal file
48
apps/storefront/src/components/orders/list/OrderCardList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
231
apps/storefront/src/components/product/ProductCard.tsx
Normal file
231
apps/storefront/src/components/product/ProductCard.tsx
Normal 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 0–5 (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>
|
||||
);
|
||||
}
|
||||
219
apps/storefront/src/components/product/ProductListTile.tsx
Normal file
219
apps/storefront/src/components/product/ProductListTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
21
apps/storefront/src/components/search/SearchEmptyState.tsx
Normal file
21
apps/storefront/src/components/search/SearchEmptyState.tsx
Normal 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 “{displayQuery}”
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#8aa9a8]">
|
||||
Try a different search term or browse categories
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/storefront/src/components/search/SearchLoadingState.tsx
Normal file
24
apps/storefront/src/components/search/SearchLoadingState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
89
apps/storefront/src/components/search/SearchResultItem.tsx
Normal file
89
apps/storefront/src/components/search/SearchResultItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
apps/storefront/src/components/search/SearchResultsPanel.tsx
Normal file
99
apps/storefront/src/components/search/SearchResultsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
233
apps/storefront/src/components/shop/PetCategoryPage.tsx
Normal file
233
apps/storefront/src/components/shop/PetCategoryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
apps/storefront/src/components/shop/RecentlyAddedPage.tsx
Normal file
180
apps/storefront/src/components/shop/RecentlyAddedPage.tsx
Normal 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
Reference in New Issue
Block a user