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:
18
apps/storefront/src/app/account/layout.tsx
Normal file
18
apps/storefront/src/app/account/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "My Account",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AccountLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="w-full max-w-full px-4 py-8 md:px-6 lg:mx-auto lg:max-w-5xl lg:px-8">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
19
apps/storefront/src/app/account/orders/[orderId]/page.tsx
Normal file
19
apps/storefront/src/app/account/orders/[orderId]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { OrderDetailPageView } from "@/components/orders/OrderDetailPageView";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ orderId: string }>;
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Order Details",
|
||||
};
|
||||
|
||||
export default async function OrderDetailPage({ params }: Props) {
|
||||
const { orderId } = await params;
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<OrderDetailPageView orderId={orderId} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
18
apps/storefront/src/app/account/orders/loading.tsx
Normal file
18
apps/storefront/src/app/account/orders/loading.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@heroui/react";
|
||||
import { OrdersSkeleton } from "@/components/orders/state/OrdersSkeleton";
|
||||
|
||||
export default function OrdersLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page heading skeleton */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-40 rounded-md" />
|
||||
<Skeleton className="h-4 w-56 rounded-md" />
|
||||
</div>
|
||||
|
||||
<OrdersSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/storefront/src/app/account/orders/page.tsx
Normal file
17
apps/storefront/src/app/account/orders/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SectionHeading } from "@/utils/common/heading/section_heading";
|
||||
import { OrdersPageView } from "@/components/orders/OrdersPageView";
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<SectionHeading title="My Orders" as="h1" />
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
View and manage your order history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OrdersPageView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/storefront/src/app/account/page.tsx
Normal file
139
apps/storefront/src/app/account/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import Link from "next/link";
|
||||
import { api } from "../../../../../convex/_generated/api";
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "Order History",
|
||||
description: "View your past orders and track current ones.",
|
||||
href: "/account/orders",
|
||||
cta: "View orders",
|
||||
},
|
||||
{
|
||||
title: "Wishlist",
|
||||
description: "View and manage your saved items.",
|
||||
href: "/wishlist",
|
||||
cta: "Coming soon",
|
||||
},
|
||||
{
|
||||
title: "Addresses",
|
||||
description: "Manage your shipping and billing addresses.",
|
||||
href: "/account/addresses",
|
||||
cta: "Coming soon",
|
||||
},
|
||||
{
|
||||
title: "Profile Settings",
|
||||
description: "Update your name, email, and preferences.",
|
||||
href: "/account/settings",
|
||||
cta: "Coming soon",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function AccountPage() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useConvexAuth();
|
||||
const user = useQuery(
|
||||
api.users.current,
|
||||
isAuthenticated ? {} : "skip",
|
||||
);
|
||||
|
||||
if (authLoading || (isAuthenticated && user === undefined)) {
|
||||
return <AccountSkeleton />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated || user === null) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-gray-500">You need to sign in to view your account.</p>
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="mt-4 inline-block rounded-md bg-[#236f6b] px-6 py-2 text-sm font-medium text-white hover:bg-[#1b5955] transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="font-serif text-2xl font-semibold text-gray-900 md:text-3xl">
|
||||
My Account
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Welcome back, {user.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wide text-gray-500">
|
||||
Account Details
|
||||
</h2>
|
||||
<dl className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Name</dt>
|
||||
<dd className="mt-1 text-sm font-medium text-gray-900">{user.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Email</dt>
|
||||
<dd className="mt-1 text-sm font-medium text-gray-900">{user.email}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Role</dt>
|
||||
<dd className="mt-1 text-sm font-medium text-gray-900 capitalize">{user.role}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{sections.map((section) => (
|
||||
<Link
|
||||
key={section.href}
|
||||
href={section.href}
|
||||
className="group rounded-lg border border-gray-200 bg-white p-6 transition-shadow hover:shadow-md"
|
||||
>
|
||||
<h3 className="font-medium text-gray-900 group-hover:text-[#236f6b]">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">{section.description}</p>
|
||||
<span className="mt-3 inline-block text-sm font-medium text-[#236f6b]">
|
||||
{section.cta} →
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-8">
|
||||
<div>
|
||||
<div className="h-8 w-40 rounded bg-gray-200" />
|
||||
<div className="mt-2 h-4 w-56 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="h-4 w-32 rounded bg-gray-200" />
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="h-3 w-16 rounded bg-gray-200" />
|
||||
<div className="mt-2 h-4 w-36 rounded bg-gray-200" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="h-5 w-28 rounded bg-gray-200" />
|
||||
<div className="mt-2 h-4 w-full rounded bg-gray-200" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
apps/storefront/src/app/cart/CartPageView.tsx
Normal file
55
apps/storefront/src/app/cart/CartPageView.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { CartPageLayout } from "@/components/cart/containers/CartPageLayout";
|
||||
import { CartContent } from "@/components/cart/content/CartContent";
|
||||
import { CartContentSkeleton } from "@/components/cart/state/CartContentSkeleton";
|
||||
import { CartErrorState } from "@/components/cart/state/CartErrorState";
|
||||
import { useCart } from "@/lib/cart/useCart";
|
||||
import { useCartMutations } from "@/lib/cart/useCartMutations";
|
||||
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
|
||||
|
||||
/**
|
||||
* Cart page: wires Convex data and mutations to shared CartContent.
|
||||
* Shows skeleton while loading, error state on failure, otherwise CartContent with real data.
|
||||
*/
|
||||
export function CartPageView() {
|
||||
const sessionId = useCartSessionId();
|
||||
const { items, subtotal, isLoading, error } = useCart(sessionId);
|
||||
const { updateItem, removeItem } = useCartMutations(sessionId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CartPageLayout itemCount={0}>
|
||||
<CartContentSkeleton />
|
||||
</CartPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<CartPageLayout itemCount={0}>
|
||||
<CartErrorState
|
||||
message={error.message ?? "Something went wrong loading your cart."}
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
</CartPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0);
|
||||
|
||||
return (
|
||||
<CartPageLayout itemCount={itemCount}>
|
||||
<CartContent
|
||||
items={items}
|
||||
subtotal={subtotal}
|
||||
onUpdateQuantity={updateItem}
|
||||
onRemove={removeItem}
|
||||
isEmpty={items.length === 0}
|
||||
onViewFullCart={undefined}
|
||||
hideHeading
|
||||
layout="page"
|
||||
/>
|
||||
</CartPageLayout>
|
||||
);
|
||||
}
|
||||
18
apps/storefront/src/app/cart/layout.tsx
Normal file
18
apps/storefront/src/app/cart/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/**
|
||||
* Cart layout: noindex/nofollow and title (Phase 6 rules compliance).
|
||||
* See docs/development/ui-implementation-rules/07-cart-checkout.md.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: "Cart",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function CartLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
13
apps/storefront/src/app/cart/loading.tsx
Normal file
13
apps/storefront/src/app/cart/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CartPageLayout } from "@/components/cart/containers/CartPageLayout";
|
||||
import { CartContentSkeleton } from "@/components/cart/state/CartContentSkeleton";
|
||||
|
||||
/**
|
||||
* Cart page loading state: heading + card-based skeleton.
|
||||
*/
|
||||
export default function CartLoading() {
|
||||
return (
|
||||
<CartPageLayout>
|
||||
<CartContentSkeleton />
|
||||
</CartPageLayout>
|
||||
);
|
||||
}
|
||||
10
apps/storefront/src/app/cart/page.tsx
Normal file
10
apps/storefront/src/app/cart/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { CartPageView } from "./CartPageView";
|
||||
|
||||
/**
|
||||
* Cart page: full-page cart with shared content.
|
||||
* Meta (noindex, title) in layout.tsx. Loading state in loading.tsx.
|
||||
* Visible h1 and item count come from CartPageLayout + CartContent.
|
||||
*/
|
||||
export default function CartPage() {
|
||||
return <CartPageView />;
|
||||
}
|
||||
106
apps/storefront/src/app/checkout/CheckoutPageView.tsx
Normal file
106
apps/storefront/src/app/checkout/CheckoutPageView.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
|
||||
import { useCartValidation, useShippingAddresses } from "@/lib/checkout";
|
||||
import type { CheckoutStep } from "@/lib/checkout/constants";
|
||||
import type { CheckoutSelectedShippingRate } from "@/lib/checkout/types";
|
||||
import { CheckoutShell } from "@/components/checkout/CheckoutShell";
|
||||
import { CartValidationStep } from "@/components/checkout/steps/CartValidationStep";
|
||||
import { ShippingAddressStep } from "@/components/checkout/steps/ShippingAddressStep";
|
||||
import { OrderReviewStep } from "@/components/checkout/steps/OrderReviewStep";
|
||||
import { PaymentStep } from "@/components/checkout/steps/PaymentStep";
|
||||
import { CheckoutSkeleton } from "@/components/checkout/state/CheckoutSkeleton";
|
||||
import { CheckoutErrorState } from "@/components/checkout/state/CheckoutErrorState";
|
||||
|
||||
export function CheckoutPageView() {
|
||||
const sessionId = useCartSessionId();
|
||||
const { result, isLoading } = useCartValidation(sessionId);
|
||||
const { addresses, isLoading: isLoadingAddresses } = useShippingAddresses();
|
||||
const router = useRouter();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<CheckoutStep>("validation");
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(null);
|
||||
const [shipmentObjectId, setShipmentObjectId] = useState<string | null>(null);
|
||||
const [selectedShippingRate, setSelectedShippingRate] = useState<CheckoutSelectedShippingRate | null>(null);
|
||||
|
||||
const isEmpty = !isLoading && result !== null && result.items.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty) router.replace("/cart");
|
||||
}, [isEmpty, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CheckoutShell currentStep="validation">
|
||||
<CheckoutSkeleton />
|
||||
</CheckoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
<CheckoutShell currentStep="validation">
|
||||
<CheckoutErrorState
|
||||
message="Something went wrong loading your checkout."
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
</CheckoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<CheckoutShell currentStep={currentStep}>
|
||||
{currentStep === "validation" && (
|
||||
<CartValidationStep
|
||||
result={result}
|
||||
onProceed={() => setCurrentStep("shipping-address")}
|
||||
onBackToCart={() => router.push("/cart")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "shipping-address" && (
|
||||
<ShippingAddressStep
|
||||
addresses={addresses}
|
||||
isLoadingAddresses={isLoadingAddresses}
|
||||
onProceed={(addressId) => {
|
||||
setSelectedAddressId(addressId);
|
||||
setCurrentStep("review");
|
||||
}}
|
||||
onBack={() => setCurrentStep("validation")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "review" && selectedAddressId && result && (
|
||||
<OrderReviewStep
|
||||
addressId={selectedAddressId}
|
||||
addresses={addresses}
|
||||
cartResult={result}
|
||||
sessionId={sessionId}
|
||||
onProceed={(shipmentId, shippingRate) => {
|
||||
setShipmentObjectId(shipmentId);
|
||||
setSelectedShippingRate(shippingRate);
|
||||
setCurrentStep("payment");
|
||||
}}
|
||||
onBack={() => setCurrentStep("shipping-address")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "payment" &&
|
||||
selectedAddressId &&
|
||||
shipmentObjectId &&
|
||||
selectedShippingRate && (
|
||||
<PaymentStep
|
||||
shipmentObjectId={shipmentObjectId}
|
||||
selectedShippingRate={selectedShippingRate}
|
||||
addressId={selectedAddressId}
|
||||
sessionId={sessionId}
|
||||
onBack={() => setCurrentStep("review")}
|
||||
/>
|
||||
)}
|
||||
</CheckoutShell>
|
||||
);
|
||||
}
|
||||
18
apps/storefront/src/app/checkout/layout.tsx
Normal file
18
apps/storefront/src/app/checkout/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Checkout",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function CheckoutLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="w-full max-w-full px-4 py-6 md:px-6 lg:mx-auto lg:max-w-5xl lg:px-8">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
5
apps/storefront/src/app/checkout/loading.tsx
Normal file
5
apps/storefront/src/app/checkout/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { CheckoutSkeleton } from "@/components/checkout/state/CheckoutSkeleton";
|
||||
|
||||
export default function CheckoutLoading() {
|
||||
return <CheckoutSkeleton />;
|
||||
}
|
||||
7
apps/storefront/src/app/checkout/page.tsx
Normal file
7
apps/storefront/src/app/checkout/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CheckoutPageView } from "./CheckoutPageView";
|
||||
|
||||
export default function CheckoutPage() {
|
||||
return <CheckoutPageView />;
|
||||
}
|
||||
253
apps/storefront/src/app/checkout/success/page.tsx
Normal file
253
apps/storefront/src/app/checkout/success/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useAction } from "convex/react";
|
||||
import { Button, Card, Link, Spinner } from "@heroui/react";
|
||||
import { api } from "../../../../../../convex/_generated/api";
|
||||
|
||||
type PageState =
|
||||
| { phase: "loading" }
|
||||
| { phase: "complete"; email: string | null }
|
||||
| { phase: "incomplete" }
|
||||
| { phase: "error"; message: string };
|
||||
|
||||
// ─── Main Content (inside Suspense) ──────────────────────────────────────────
|
||||
|
||||
function SuccessContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get("session_id");
|
||||
const getStatus = useAction(api.stripeActions.getCheckoutSessionStatus);
|
||||
const [state, setState] = useState<PageState>({ phase: "loading" });
|
||||
const fetchRef = useRef(0);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
setState({
|
||||
phase: "error",
|
||||
message:
|
||||
"No payment session found. If you completed a payment, please check your email for confirmation.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const id = ++fetchRef.current;
|
||||
setState({ phase: "loading" });
|
||||
|
||||
try {
|
||||
const result = await getStatus({ sessionId });
|
||||
if (id !== fetchRef.current) return;
|
||||
|
||||
if (result.status === "complete") {
|
||||
setState({ phase: "complete", email: result.customerEmail });
|
||||
} else {
|
||||
setState({ phase: "incomplete" });
|
||||
}
|
||||
} catch {
|
||||
if (id !== fetchRef.current) return;
|
||||
setState({
|
||||
phase: "error",
|
||||
message:
|
||||
"Unable to retrieve payment status. Please check your email for confirmation or contact support.",
|
||||
});
|
||||
}
|
||||
}, [sessionId, getStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
if (state.phase === "loading") {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Card className="w-full max-w-md rounded-xl p-6 md:p-8">
|
||||
<Card.Content className="flex flex-col items-center gap-5 p-0 text-center">
|
||||
{state.phase === "complete" && (
|
||||
<CompleteView email={state.email} />
|
||||
)}
|
||||
{state.phase === "incomplete" && <IncompleteView />}
|
||||
{state.phase === "error" && <ErrorView message={state.message} />}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── State Views ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CompleteView({ email }: { email: string | null }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-50">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="size-8 text-[#236f6b]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-semibold">
|
||||
Payment successful!
|
||||
</h1>
|
||||
{email && (
|
||||
<p className="text-sm text-default-500">
|
||||
A confirmation has been sent to{" "}
|
||||
<span className="font-medium text-foreground">{email}</span>.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-default-500">
|
||||
Your order has been placed and you'll receive a confirmation
|
||||
email shortly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 pt-2">
|
||||
<Button
|
||||
as={Link}
|
||||
href="/account/orders"
|
||||
color="primary"
|
||||
className="w-full bg-[#236f6b] font-medium text-white"
|
||||
size="lg"
|
||||
>
|
||||
View your orders
|
||||
</Button>
|
||||
<Button as={Link} href="/shop" variant="ghost" className="w-full">
|
||||
Continue shopping
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IncompleteView() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-amber-50">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="size-8 text-amber-600"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-semibold">
|
||||
Payment was not completed
|
||||
</h1>
|
||||
<p className="text-sm text-default-500">
|
||||
Your payment was not completed. No charges have been made. You can
|
||||
return to checkout to try again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 pt-2">
|
||||
<Button
|
||||
as={Link}
|
||||
href="/checkout"
|
||||
color="primary"
|
||||
className="w-full bg-[#236f6b] font-medium text-white"
|
||||
size="lg"
|
||||
>
|
||||
Return to checkout
|
||||
</Button>
|
||||
<Button as={Link} href="/shop" variant="ghost" className="w-full">
|
||||
Continue shopping
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorView({ message }: { message: string }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-red-50">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="size-8 text-danger"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-semibold">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="text-sm text-default-500">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 pt-2">
|
||||
<Button
|
||||
as={Link}
|
||||
href="/checkout"
|
||||
color="primary"
|
||||
className="w-full bg-[#236f6b] font-medium text-white"
|
||||
size="lg"
|
||||
>
|
||||
Return to checkout
|
||||
</Button>
|
||||
<Button as={Link} href="/" variant="ghost" className="w-full">
|
||||
Go to homepage
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Loading State ───────────────────────────────────────────────────────────
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[60vh] flex-col items-center justify-center gap-4"
|
||||
role="status"
|
||||
>
|
||||
<Spinner size="lg" />
|
||||
<p className="text-sm text-default-500">Verifying your payment…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page Export ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CheckoutSuccessPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<SuccessContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
196
apps/storefront/src/app/globals.css
Normal file
196
apps/storefront/src/app/globals.css
Normal file
@@ -0,0 +1,196 @@
|
||||
@import "tailwindcss";
|
||||
@import "@heroui/styles";
|
||||
|
||||
/*
|
||||
* The Pet Loft Brand Theme — mapped to HeroUI CSS variables
|
||||
*
|
||||
* Primary Teal: #38a99f (Brand) · #236f6b (Deep) · #8dd5d1 (Soft) · #e8f7f6 (Mist)
|
||||
* Accent Warm: #f4a13a (Amber) · #fde8c8 (Cream)
|
||||
* Accent Coral: #f2705a (Coral) · #fce0da (Blush)
|
||||
* Neutrals: #1a2e2d (Forest) · #3d5554 (Moss) · #8aa9a8 (Sage) · #f0f8f7 (Ice)
|
||||
*/
|
||||
|
||||
:root,
|
||||
.light,
|
||||
.default,
|
||||
[data-theme="light"],
|
||||
[data-theme="default"] {
|
||||
/* The Pet Loft brand palette — beyond HeroUI variables */
|
||||
--brand: #38a99f;
|
||||
--brand-dark: #236f6b;
|
||||
--brand-light: #8dd5d1;
|
||||
--brand-mist: #e8f7f6;
|
||||
--warm: #f4a13a;
|
||||
--warm-light: #fde8c8;
|
||||
--coral: #f2705a;
|
||||
--coral-light: #fce0da;
|
||||
--neutral-900: #1a2e2d;
|
||||
--neutral-700: #3d5554;
|
||||
--neutral-400: #8aa9a8;
|
||||
--neutral-100: #f0f8f7;
|
||||
|
||||
/* Brand Teal #38a99f — primary actions, links, focus */
|
||||
--accent: oklch(66.96% 0.1009 186.67);
|
||||
--accent-foreground: oklch(100.00% 0 0);
|
||||
|
||||
/* Ice White #f0f8f7 — page background */
|
||||
--background: oklch(97.28% 0.0086 188.66);
|
||||
|
||||
/* Sage Mist #8aa9a8 — borders */
|
||||
--border: oklch(71.13% 0.0341 194.09);
|
||||
|
||||
--danger: oklch(65.32% 0.2335 17.37);
|
||||
--danger-foreground: oklch(99.11% 0 0);
|
||||
|
||||
/* Teal Mist #e8f7f6 — default/neutral chip & tag fills */
|
||||
--default: oklch(96.47% 0.0159 192.37);
|
||||
--default-foreground: oklch(28.38% 0.0260 191.82);
|
||||
|
||||
/* White #ffffff — input backgrounds */
|
||||
--field-background: oklch(100.00% 0.0001 263.28);
|
||||
--field-foreground: oklch(28.38% 0.0260 191.82);
|
||||
/* Sage Mist #8aa9a8 — placeholders */
|
||||
--field-placeholder: oklch(71.13% 0.0341 194.09);
|
||||
|
||||
/* Brand Teal — focus rings */
|
||||
--focus: oklch(66.96% 0.1009 186.67);
|
||||
|
||||
/* Forest Black #1a2e2d — body text */
|
||||
--foreground: oklch(28.38% 0.0260 191.82);
|
||||
|
||||
/* Moss Grey #3d5554 — muted/secondary text */
|
||||
--muted: oklch(42.97% 0.0292 192.97);
|
||||
|
||||
/* White — overlays */
|
||||
--overlay: oklch(100.00% 0.0001 263.28);
|
||||
--overlay-foreground: oklch(28.38% 0.0260 191.82);
|
||||
|
||||
--scrollbar: oklch(71.13% 0.0341 194.09);
|
||||
|
||||
/* White — segmented controls */
|
||||
--segment: oklch(100.00% 0.0001 263.28);
|
||||
--segment-foreground: oklch(28.38% 0.0260 191.82);
|
||||
|
||||
--separator: oklch(96.47% 0.0159 192.37);
|
||||
|
||||
--success: oklch(73.29% 0.1941 142.44);
|
||||
--success-foreground: oklch(21.03% 0.0059 142.44);
|
||||
|
||||
/* White — card surfaces */
|
||||
--surface: oklch(100.00% 0.0001 263.28);
|
||||
--surface-foreground: oklch(28.38% 0.0260 191.82);
|
||||
|
||||
/* Teal Mist #e8f7f6 — secondary surfaces */
|
||||
--surface-secondary: oklch(96.47% 0.0159 192.37);
|
||||
--surface-secondary-foreground: oklch(28.38% 0.0260 191.82);
|
||||
|
||||
/* Ice White #f0f8f7 — tertiary surfaces */
|
||||
--surface-tertiary: oklch(97.28% 0.0086 188.66);
|
||||
--surface-tertiary-foreground: oklch(28.38% 0.0260 191.82);
|
||||
|
||||
--warning: oklch(78.19% 0.1590 63.96);
|
||||
--warning-foreground: oklch(21.03% 0.0059 63.96);
|
||||
|
||||
--radius: 0.75rem;
|
||||
--field-radius: 0.75rem;
|
||||
|
||||
/* Fonts: DM Sans (body) + Fraunces (headings) */
|
||||
--font-sans: var(--font-dm-sans);
|
||||
--font-serif: var(--font-fraunces);
|
||||
|
||||
/* Design system: 8px grid (spatial system) */
|
||||
--spacing-1: 0.25rem; /* 4px — tight */
|
||||
--spacing-2: 0.5rem; /* 8px — base unit */
|
||||
--spacing-3: 0.75rem; /* 12px — compact */
|
||||
--spacing-4: 1rem; /* 16px — standard */
|
||||
--spacing-6: 1.5rem; /* 24px — comfortable */
|
||||
--spacing-8: 2rem; /* 32px — generous */
|
||||
--spacing-12: 3rem; /* 48px — section spacing */
|
||||
--spacing-16: 4rem; /* 64px — large sections */
|
||||
|
||||
/* Design system: transition speeds */
|
||||
--transition-fast: 150ms; /* color changes, small UI updates */
|
||||
--transition-base: 250ms; /* standard interactions */
|
||||
--transition-slow: 350ms; /* complex animations */
|
||||
--transition-bounce: 400ms; /* playful hover states */
|
||||
--transition-spring: 600ms; /* entrances/exits */
|
||||
|
||||
/* Design system: breakpoints (use in media queries; values in px) */
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
--breakpoint-2xl: 1536px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Base layer — PetPaws branding + Design System (DESIGN_SYSTEM_DOCS.md, petpaws-branding).
|
||||
* Body: DM Sans (--font-sans), Forest Black text (--foreground), Ice White page bg (--background).
|
||||
* Borders: Sage Mist (--border). Headings use Fraunces via .font-serif / font-[family-name:var(--font-serif)].
|
||||
* Smooth scroll; 8px grid spacing via --spacing-*; transitions via --transition-*.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
scroll-behavior: smooth;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 1rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Design system: carousel / marquee (use duration with var(--transition-spring) or var(--transition-base)) */
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Continuous right-to-left marquee for utility bar */
|
||||
.animate-marquee {
|
||||
animation: scroll 25s linear infinite;
|
||||
}
|
||||
|
||||
/* Playful micro-animation (design system: bouncy hover feel) */
|
||||
@keyframes sparkle {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-10px) scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar for carousel / horizontal scroll areas */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
58
apps/storefront/src/app/layout.tsx
Normal file
58
apps/storefront/src/app/layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DM_Sans, Fraunces } from "next/font/google";
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { ConvexClientProvider } from "@repo/convex";
|
||||
import { CartUIProvider } from "../components/cart/CartUIProvider";
|
||||
import { Header } from "../components/layout/header/Header";
|
||||
import { SessionCartMerge } from "../lib/session/SessionCartMerge";
|
||||
import { StoreUserSync } from "../lib/session/StoreUserSync";
|
||||
import { Footer } from "../components/layout/footer/Footer";
|
||||
import { ToastProvider } from "../components/layout/ToastProvider";
|
||||
import "./globals.css";
|
||||
|
||||
const dmSans = DM_Sans({
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500"],
|
||||
variable: "--font-dm-sans",
|
||||
});
|
||||
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "600", "700"],
|
||||
variable: "--font-fraunces",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | The Pet Loft",
|
||||
default: "The Pet Loft — Pet Supplies & More",
|
||||
},
|
||||
description: "Your one-stop shop for premium pet supplies",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${dmSans.variable} ${fraunces.variable} font-sans flex min-h-screen max-w-full flex-col overflow-x-hidden`}
|
||||
>
|
||||
<ClerkProvider>
|
||||
<ConvexClientProvider>
|
||||
<SessionCartMerge />
|
||||
<StoreUserSync />
|
||||
<CartUIProvider>
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
<ToastProvider />
|
||||
</CartUIProvider>
|
||||
</ConvexClientProvider>
|
||||
</ClerkProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
46
apps/storefront/src/app/not-found.tsx
Normal file
46
apps/storefront/src/app/not-found.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SectionHeading } from "../utils/common/heading/section_heading";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center bg-[var(--background)] px-4 py-16 md:px-6 md:py-24">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-8 md:flex-row md:gap-12">
|
||||
<div className="flex w-full flex-col items-center text-center md:w-1/2 md:items-start md:text-left">
|
||||
<SectionHeading title="Page Not Found" as="h1" className="text-3xl md:text-4xl" />
|
||||
|
||||
<p className="mt-4 max-w-md text-base leading-relaxed text-[var(--muted)] md:text-lg">
|
||||
Sorry, we couldn't find the page you're looking for. It
|
||||
may have been moved, renamed, or no longer exists.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-flex items-center rounded-full bg-[#f4a13a] px-8 py-3 text-sm font-medium text-[#1a2e2d] transition-opacity hover:opacity-90 md:w-auto"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="order-first flex w-full max-w-xs flex-col items-center gap-2 md:order-last md:w-1/2 md:max-w-none">
|
||||
<Image
|
||||
src="/content/illustrations/404_not_found.svg"
|
||||
alt="Page not found illustration"
|
||||
width={500}
|
||||
height={500}
|
||||
priority
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
<a
|
||||
href="https://storyset.com/online"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-[var(--muted)] hover:underline"
|
||||
>
|
||||
Online illustrations by Storyset
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
26
apps/storefront/src/app/page.tsx
Normal file
26
apps/storefront/src/app/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { CtaSection } from "../components/sections/hompepage/cta/CtaSection";
|
||||
import { CategorySection } from "../components/sections/hompepage/category/CategorySection";
|
||||
import { NewsletterSection } from "../components/sections/hompepage/newsletter/NewsletterSection";
|
||||
import { RecentlyAddedSection } from "../components/sections/hompepage/products-sections/recently-added/RecentlyAddedSection";
|
||||
import { SpecialOffersSection } from "../components/sections/hompepage/products-sections/special-offers/SpecialOffersSection";
|
||||
import { TopPicksSection } from "../components/sections/hompepage/products-sections/top-picks/TopPicsSection";
|
||||
import { WishlistSection } from "../components/sections/hompepage/wishlist/WishlistSection";
|
||||
import { Toast } from "@heroui/react";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="min-h-screen min-w-0 max-w-full overflow-x-hidden bg-background px-4 py-8 text-foreground md:px-6">
|
||||
<Toast.Provider placement="top" className="bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-sm" />
|
||||
|
||||
<CtaSection />
|
||||
<CategorySection />
|
||||
<WishlistSection />
|
||||
<RecentlyAddedSection />
|
||||
<SpecialOffersSection />
|
||||
<TopPicksSection />
|
||||
<NewsletterSection />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Page-level loading fallback for /shop/[category]/[subCategory]/[slug].
|
||||
* Mirrors the new PDP layout: hero grid + tab navigation + content skeleton.
|
||||
* Uses Tailwind only so this stays a Server Component (HeroUI Skeleton is client-only).
|
||||
*/
|
||||
export default function ProductDetailLoading() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-7xl px-4 py-6 md:py-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-6 h-4 w-2/3 max-w-sm animate-pulse rounded bg-[var(--muted)] md:mb-8" />
|
||||
|
||||
{/* Hero grid */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-10">
|
||||
{/* Gallery */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="aspect-square w-full animate-pulse rounded-xl bg-[var(--muted)] md:aspect-[4/3]" />
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 w-16 shrink-0 animate-pulse rounded-lg bg-[var(--muted)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buy box */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="h-6 w-20 animate-pulse rounded-full bg-[var(--muted)]" />
|
||||
<div className="h-8 w-full max-w-sm animate-pulse rounded bg-[var(--muted)]" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-[var(--muted)]" />
|
||||
<div className="h-7 w-28 animate-pulse rounded bg-[var(--muted)]" />
|
||||
<div className="h-6 w-20 animate-pulse rounded-full bg-[var(--muted)]" />
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<div className="h-10 w-full animate-pulse rounded-lg bg-[var(--muted)] md:w-36" />
|
||||
<div className="h-12 w-full animate-pulse rounded-lg bg-[var(--muted)] md:w-44" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation + first section skeleton */}
|
||||
<div className="mt-8 md:mt-12">
|
||||
<div className="flex gap-4 border-b border-[var(--muted)]">
|
||||
<div className="h-10 w-28 animate-pulse rounded-t bg-[var(--muted)]" />
|
||||
<div className="h-10 w-20 animate-pulse rounded-t bg-[var(--muted)]" />
|
||||
<div className="h-10 w-24 animate-pulse rounded-t bg-[var(--muted)]" />
|
||||
</div>
|
||||
<div className="space-y-2 py-6 md:py-8">
|
||||
<div className="mb-4 h-6 w-32 animate-pulse rounded bg-[var(--muted)]" />
|
||||
<div className="h-4 w-full max-w-2xl animate-pulse rounded bg-[var(--muted)]" />
|
||||
<div className="h-4 w-full max-w-2xl animate-pulse rounded bg-[var(--muted)]" />
|
||||
<div className="h-4 w-3/4 max-w-xl animate-pulse rounded bg-[var(--muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Product-specific 404 when slug is invalid or product not found / not active
|
||||
* at /shop/[category]/[subCategory]/[slug].
|
||||
*/
|
||||
export default function ProductNotFound() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-16 text-center">
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[var(--foreground)] md:text-3xl">
|
||||
Product not found
|
||||
</h1>
|
||||
<p className="mt-3 text-[var(--muted)]">
|
||||
This product doesn't exist or is no longer available. Try browsing
|
||||
the shop.
|
||||
</p>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="mt-6 inline-block rounded-lg bg-[var(--brand-dark)] px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--brand-hover)] w-full md:w-auto"
|
||||
>
|
||||
Back to Shop
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { api } from "../../../../../../../../convex/_generated/api";
|
||||
import { ProductDetailContent } from "@/components/product-detail/ProductDetailContent";
|
||||
import type { ProductDetailParams } from "@/lib/product-detail/constants";
|
||||
import { getProductDetailPath } from "@/lib/product-detail/constants";
|
||||
import { isPetCategorySlug } from "@/lib/shop/constants";
|
||||
import { ProductDetailStructuredData } from "@/components/product-detail/ProductDetailStructuredData";
|
||||
|
||||
// PPR: when using Next.js canary, uncomment so PDP uses Partial Prerendering:
|
||||
// export const experimental_ppr = true;
|
||||
|
||||
type Props = {
|
||||
params: Promise<ProductDetailParams>;
|
||||
};
|
||||
|
||||
function isValidSlug(slug: string): boolean {
|
||||
return typeof slug === "string" && slug.length > 0 && !slug.includes("/");
|
||||
}
|
||||
|
||||
/** Strip HTML tags for plain-text meta description. */
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Meta description: 150–160 chars per SEO rule. */
|
||||
function metaDescription(
|
||||
seoDescription: string | undefined | null,
|
||||
rawDescription: string | undefined | null,
|
||||
): string | undefined {
|
||||
if (seoDescription && seoDescription.trim()) {
|
||||
return seoDescription.slice(0, 160);
|
||||
}
|
||||
if (rawDescription && rawDescription.trim()) {
|
||||
return stripHtml(rawDescription).slice(0, 160);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { category, subCategory, slug } = await params;
|
||||
if (!isPetCategorySlug(category) || !isValidSlug(slug)) {
|
||||
return { title: "Not Found" };
|
||||
}
|
||||
const resolved = await fetchQuery(api.categories.getByPath, {
|
||||
categorySlug: category,
|
||||
subCategorySlug: subCategory,
|
||||
});
|
||||
if (!resolved) {
|
||||
return { title: "Not Found" };
|
||||
}
|
||||
const product = await fetchQuery(api.products.getBySlug, { slug });
|
||||
if (!product) {
|
||||
return { title: "Not Found" };
|
||||
}
|
||||
const title = product.seoTitle ?? product.name;
|
||||
const description = metaDescription(
|
||||
product.seoDescription,
|
||||
product.description,
|
||||
);
|
||||
const canonicalPath = getProductDetailPath(category, subCategory, slug);
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_APP_URL ?? process.env.NEXT_PUBLIC_STOREFRONT_URL ?? "";
|
||||
const canonical = baseUrl ? `${baseUrl.replace(/\/$/, "")}${canonicalPath}` : undefined;
|
||||
const firstImage =
|
||||
product.images && product.images.length > 0 ? product.images[0] : null;
|
||||
const openGraphImages =
|
||||
firstImage?.url != null
|
||||
? [{ url: firstImage.url, alt: firstImage.alt ?? product.name }]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: canonical ? { canonical } : undefined,
|
||||
openGraph: {
|
||||
title,
|
||||
description: description ?? undefined,
|
||||
url: canonical,
|
||||
images: openGraphImages,
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
description: description ?? undefined,
|
||||
images: firstImage?.url != null ? [firstImage.url] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductDetailPage({ params }: Props) {
|
||||
const { category, subCategory, slug } = await params;
|
||||
|
||||
if (!isPetCategorySlug(category)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const resolved = await fetchQuery(api.categories.getByPath, {
|
||||
categorySlug: category,
|
||||
subCategorySlug: subCategory,
|
||||
});
|
||||
if (!resolved) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!isValidSlug(slug)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const product = await fetchQuery(api.products.getBySlug, { slug });
|
||||
if (!product) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProductDetailStructuredData
|
||||
category={category}
|
||||
subCategory={subCategory}
|
||||
slug={slug}
|
||||
productName={product.name}
|
||||
subCategoryName={resolved.name}
|
||||
/>
|
||||
<ProductDetailContent
|
||||
category={category}
|
||||
subCategory={subCategory}
|
||||
slug={slug}
|
||||
categoryId={product.categoryId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { api } from "../../../../../../../convex/_generated/api";
|
||||
import type { Id } from "../../../../../../../convex/_generated/dataModel";
|
||||
import { isPetCategorySlug } from "@/lib/shop/constants";
|
||||
import { SubCategoryPageContent } from "@/components/shop/SubCategoryPageContent";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ category: string; subCategory: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { category, subCategory } = await params;
|
||||
if (!isPetCategorySlug(category)) {
|
||||
return { title: "Not Found" };
|
||||
}
|
||||
const resolved = await fetchQuery(api.categories.getByPath, {
|
||||
categorySlug: category,
|
||||
subCategorySlug: subCategory,
|
||||
});
|
||||
if (!resolved) {
|
||||
return { title: "Not Found" };
|
||||
}
|
||||
const title = resolved.seoTitle ?? resolved.name;
|
||||
const description =
|
||||
resolved.seoDescription ?? resolved.description ?? `Shop ${resolved.name}`;
|
||||
return { title, description };
|
||||
}
|
||||
|
||||
export default async function ShopSubCategoryPage({ params }: Props) {
|
||||
const { category, subCategory } = await params;
|
||||
|
||||
if (!isPetCategorySlug(category)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const resolved = await fetchQuery(api.categories.getByPath, {
|
||||
categorySlug: category,
|
||||
subCategorySlug: subCategory,
|
||||
});
|
||||
|
||||
if (!resolved) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const subCategoryData = {
|
||||
_id: resolved._id as Id<"categories">,
|
||||
name: resolved.name,
|
||||
description: resolved.description,
|
||||
slug: resolved.slug,
|
||||
parentId: resolved.parentId as Id<"categories">,
|
||||
};
|
||||
|
||||
return (
|
||||
<SubCategoryPageContent
|
||||
categorySlug={category}
|
||||
subCategorySlug={subCategory}
|
||||
subCategory={subCategoryData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
apps/storefront/src/app/shop/birds/page.tsx
Normal file
11
apps/storefront/src/app/shop/birds/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PetCategoryPage } from "@/components/shop/PetCategoryPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Birds",
|
||||
description: "Shop products for birds",
|
||||
};
|
||||
|
||||
export default function ShopBirdsPage() {
|
||||
return <PetCategoryPage slug="birds" />;
|
||||
}
|
||||
11
apps/storefront/src/app/shop/bowl/page.tsx
Normal file
11
apps/storefront/src/app/shop/bowl/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Bowl",
|
||||
description: "Shop bowls and feeders for dogs, cats, birds, and more",
|
||||
};
|
||||
|
||||
export default function ShopBowlPage() {
|
||||
return <TopCategoryPage slug="bowl" />;
|
||||
}
|
||||
11
apps/storefront/src/app/shop/carrier/page.tsx
Normal file
11
apps/storefront/src/app/shop/carrier/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Carrier",
|
||||
description: "Shop carriers and travel gear for dogs, cats, birds, and more",
|
||||
};
|
||||
|
||||
export default function ShopCarrierPage() {
|
||||
return <TopCategoryPage slug="carrier" />;
|
||||
}
|
||||
11
apps/storefront/src/app/shop/cats/page.tsx
Normal file
11
apps/storefront/src/app/shop/cats/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PetCategoryPage } from "@/components/shop/PetCategoryPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Cats",
|
||||
description: "Shop products for cats",
|
||||
};
|
||||
|
||||
export default function ShopCatsPage() {
|
||||
return <PetCategoryPage slug="cats" />;
|
||||
}
|
||||
11
apps/storefront/src/app/shop/dogs/page.tsx
Normal file
11
apps/storefront/src/app/shop/dogs/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PetCategoryPage } from "@/components/shop/PetCategoryPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dogs",
|
||||
description: "Shop products for dogs",
|
||||
};
|
||||
|
||||
export default function ShopDogsPage() {
|
||||
return <PetCategoryPage slug="dogs" />;
|
||||
}
|
||||
11
apps/storefront/src/app/shop/food/page.tsx
Normal file
11
apps/storefront/src/app/shop/food/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Food",
|
||||
description: "Shop pet food for dogs, cats, birds, and more",
|
||||
};
|
||||
|
||||
export default function ShopFoodPage() {
|
||||
return <TopCategoryPage slug="food" />;
|
||||
}
|
||||
11
apps/storefront/src/app/shop/litter/page.tsx
Normal file
11
apps/storefront/src/app/shop/litter/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Litter",
|
||||
description: "Shop litter and litter supplies for cats and small pets",
|
||||
};
|
||||
|
||||
export default function ShopLitterPage() {
|
||||
return <TopCategoryPage slug="litter" />;
|
||||
}
|
||||
25
apps/storefront/src/app/shop/not-found.tsx
Normal file
25
apps/storefront/src/app/shop/not-found.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Shop-specific 404 when an invalid category or sub-category path is requested
|
||||
* (e.g. unknown [category] or [subCategory] in /shop/[category]/[subCategory]).
|
||||
*/
|
||||
export default function ShopNotFound() {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-16 text-center">
|
||||
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[var(--foreground)] md:text-3xl">
|
||||
Page not found
|
||||
</h1>
|
||||
<p className="mt-3 text-[var(--muted)]">
|
||||
This shop category or page doesn't exist. Try browsing from the
|
||||
shop home.
|
||||
</p>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="mt-6 inline-block rounded-lg bg-[var(--brand-dark)] px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--brand-hover)]"
|
||||
>
|
||||
Back to Shop
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
apps/storefront/src/app/shop/page.tsx
Normal file
11
apps/storefront/src/app/shop/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ShopIndexContent } from "@/components/shop/ShopIndexContent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Shop",
|
||||
description: "Browse all products",
|
||||
};
|
||||
|
||||
export default function ShopPage() {
|
||||
return <ShopIndexContent />;
|
||||
}
|
||||
12
apps/storefront/src/app/shop/recently-added/page.tsx
Normal file
12
apps/storefront/src/app/shop/recently-added/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { RecentlyAddedPage } from "@/components/shop/RecentlyAddedPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Recently Added | The Pet Loft",
|
||||
description:
|
||||
"Discover the newest products added to our store in the last 30 days.",
|
||||
};
|
||||
|
||||
export default function ShopRecentlyAddedPage() {
|
||||
return <RecentlyAddedPage />;
|
||||
}
|
||||
19
apps/storefront/src/app/shop/sale/page.tsx
Normal file
19
apps/storefront/src/app/shop/sale/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TagShopPage } from "@/components/shop/TagShopPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Special Offers | The Pet Loft",
|
||||
description:
|
||||
"Browse current deals and discounts on pet food, toys, treats, and accessories.",
|
||||
};
|
||||
|
||||
export default function ShopSalePage() {
|
||||
return (
|
||||
<TagShopPage
|
||||
tag="sale"
|
||||
title="Special Offers"
|
||||
subtitle="Great deals on your pet's favorites"
|
||||
breadcrumbLabel="Special Offers"
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
apps/storefront/src/app/shop/small-pets/page.tsx
Normal file
11
apps/storefront/src/app/shop/small-pets/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PetCategoryPage } from "@/components/shop/PetCategoryPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Small Pets",
|
||||
description: "Shop products for small pets",
|
||||
};
|
||||
|
||||
export default function ShopSmallPetsPage() {
|
||||
return <PetCategoryPage slug="small-pets" />;
|
||||
}
|
||||
19
apps/storefront/src/app/shop/top-picks/page.tsx
Normal file
19
apps/storefront/src/app/shop/top-picks/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TagShopPage } from "@/components/shop/TagShopPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Top Picks | The Pet Loft",
|
||||
description:
|
||||
"Our curated selection of the best products for your furry, feathered, or scaled friend.",
|
||||
};
|
||||
|
||||
export default function ShopTopPicksPage() {
|
||||
return (
|
||||
<TagShopPage
|
||||
tag="top-picks"
|
||||
title="Top Picks"
|
||||
subtitle="Curated favorites loved by pet parents"
|
||||
breadcrumbLabel="Top Picks"
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
apps/storefront/src/app/shop/toys/page.tsx
Normal file
11
apps/storefront/src/app/shop/toys/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Toys",
|
||||
description: "Shop pet toys for dogs, cats, birds, and more",
|
||||
};
|
||||
|
||||
export default function ShopToysPage() {
|
||||
return <TopCategoryPage slug="toys" />;
|
||||
}
|
||||
11
apps/storefront/src/app/shop/treats/page.tsx
Normal file
11
apps/storefront/src/app/shop/treats/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { TopCategoryPage } from "@/components/shop/TopCategoryPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Treats",
|
||||
description: "Shop pet treats for dogs, cats, birds, and more",
|
||||
};
|
||||
|
||||
export default function ShopTreatsPage() {
|
||||
return <TopCategoryPage slug="treats" />;
|
||||
}
|
||||
9
apps/storefront/src/app/sign-in/[[...sign-in]]/page.tsx
Normal file
9
apps/storefront/src/app/sign-in/[[...sign-in]]/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SignIn } from "@clerk/nextjs";
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<main className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
||||
<SignIn />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
9
apps/storefront/src/app/sign-up/[[...sign-up]]/page.tsx
Normal file
9
apps/storefront/src/app/sign-up/[[...sign-up]]/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SignUp } from "@clerk/nextjs";
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<main className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
||||
<SignUp />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
18
apps/storefront/src/app/wishlist/layout.tsx
Normal file
18
apps/storefront/src/app/wishlist/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "My Wishlist",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function WishlistLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="w-full max-w-full px-4 py-8 md:px-6 lg:mx-auto lg:max-w-6xl lg:px-8">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
5
apps/storefront/src/app/wishlist/loading.tsx
Normal file
5
apps/storefront/src/app/wishlist/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { WishlistSkeleton } from "@/components/wishlist/state/WishlistSkeleton";
|
||||
|
||||
export default function WishlistLoading() {
|
||||
return <WishlistSkeleton />;
|
||||
}
|
||||
13
apps/storefront/src/app/wishlist/page.tsx
Normal file
13
apps/storefront/src/app/wishlist/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { WishlistPageView } from "@/components/wishlist/WishlistPageView";
|
||||
import { SectionHeading } from "@/utils/common/heading/section_heading";
|
||||
|
||||
export default function WishlistPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeading title="My Wishlist" as="h1" />
|
||||
<WishlistPageView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user