Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/(root)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const Home = async () => {
imageSrc={p.imageSrc}
price={p.price}
badge={p.badge}
href={`/products/${p.id}`}
/>
))}
</div>
Expand Down
288 changes: 168 additions & 120 deletions src/app/(root)/products/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,112 +1,175 @@
import Link from "next/link";
import { Suspense } from "react";
import { Card, CollapsibleSection, ProductGallery, SizePicker } from "@/components";
import { Heart, ShoppingBag, Star } from "lucide-react";
import ColorSwatches from "@/components/ColorSwatches";
import { getProduct, getProductReviews, getRecommendedProducts, type Review, type RecommendedProduct } from "@/lib/actions/product";

type Product = {
id: string;
title: string;
subtitle?: string;
price: number;
compareAt?: number;
description: string;
variants: { color: string; images: string[] }[];
};

const MOCK_PRODUCTS: Record<string, Product> = {
"1": {
id: "1",
title: "Nike Air Max 90 SE",
subtitle: "Women's Shoes",
price: 140,
description:
"The Air Max 90 stays true to its running roots with the iconic Waffle sole. Plus, stitched overlays and textured accents create the '90s look you love. Complete with romantic hues, its visible Air cushioning adds comfort to your journey.",
variants: [
{
color: "Dark Team Red",
images: ["/shoes/shoe-1.jpg", "/shoes/shoe-2.webp", "/shoes/shoe-3.webp"],
},
{
color: "Pure Platinum",
images: ["/shoes/shoe-4.webp", "/shoes/shoe-5.avif"],
},
{
color: "Platinum Tint",
images: ["/shoes/shoe-6.avif", "/shoes/shoe-7.avif"],
},
],
},
"2": {
id: "2",
title: "Nike Dunk Low Retro",
subtitle: "Men's Shoes",
price: 98.3,
description:
"Classic hoops style with modern comfort. The Dunk Low delivers iconic design and everyday wearability.",
variants: [
{ color: "Black/White", images: ["/shoes/shoe-8.avif", "/shoes/shoe-9.avif"] },
{ color: "Green/Yellow", images: ["/shoes/shoe-10.avif"] },
],
},
};

const RECS: Product[] = [
{
id: "3",
title: "Nike Air Force 1 Mid '07",
subtitle: "Men's Shoes",
price: 98.3,
description: "",
variants: [{ color: "White/Black", images: ["/shoes/shoe-11.avif"] }],
},
{
id: "4",
title: "Nike Court Vision Low Next Nature",
subtitle: "Men's Shoes",
price: 98.3,
description: "",
variants: [{ color: "Gray/Blue", images: ["/shoes/shoe-12.avif"] }],
},
{
id: "5",
title: "Nike Dunk Low Retro",
subtitle: "Men's Shoes",
price: 98.3,
description: "",
variants: [{ color: "Green/Yellow", images: ["/shoes/shoe-13.avif"] }],
},
];
type GalleryVariant = { color: string; images: string[] };

function formatPrice(price: number | null | undefined) {
if (price === null || price === undefined) return undefined;
return `$${price.toFixed(2)}`;
}

function NotFoundBlock() {
return (
<section className="mx-auto max-w-3xl rounded-xl border border-light-300 bg-light-100 p-8 text-center">
<h1 className="text-heading-3 text-dark-900">Product not found</h1>
<p className="mt-2 text-body text-dark-700">The product you’re looking for doesn’t exist or may have been removed.</p>
<div className="mt-6">
<Link
href="/products"
className="inline-block rounded-full bg-dark-900 px-6 py-3 text-body-medium text-light-100 transition hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-[--color-dark-500]"
>
Browse Products
</Link>
</div>
</section>
);
}

async function ReviewsSection({ productId }: { productId: string }) {
const reviews: Review[] = await getProductReviews(productId);
const count = reviews.length;
const avg =
count > 0 ? (reviews.reduce((s, r) => s + r.rating, 0) / count) : 0;

return (
<CollapsibleSection
title={`Reviews (${count})`}
rightMeta={
<span className="flex items-center gap-1 text-dark-900">
{[1, 2, 3, 4, 5].map((i) => (
<Star key={i} className={`h-4 w-4 ${i <= Math.round(avg) ? "fill-[--color-dark-900]" : ""}`} />
))}
</span>
}
>
{reviews.length === 0 ? (
<p>No reviews yet.</p>
) : (
<ul className="space-y-4">
{reviews.slice(0, 10).map((r) => (
<li key={r.id} className="rounded-lg border border-light-300 p-4">
<div className="mb-1 flex items-center justify-between">
<p className="text-body-medium text-dark-900">{r.author}</p>
<span className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<Star key={i} className={`h-4 w-4 ${i <= r.rating ? "fill-[--color-dark-900]" : ""}`} />
))}
</span>
</div>
{r.title && <p className="text-body-medium text-dark-900">{r.title}</p>}
{r.content && <p className="mt-1 line-clamp-[8] text-body text-dark-700">{r.content}</p>}
<p className="mt-2 text-caption text-dark-700">{new Date(r.createdAt).toLocaleDateString()}</p>
</li>
))}
</ul>
)}
</CollapsibleSection>
);
}

async function AlsoLikeSection({ productId }: { productId: string }) {
const recs: RecommendedProduct[] = await getRecommendedProducts(productId);
if (!recs.length) return null;
return (
<section className="mt-16">
<h2 className="mb-6 text-heading-3 text-dark-900">You Might Also Like</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{recs.map((p) => (
<Card
key={p.id}
title={p.title}
imageSrc={p.imageUrl}
price={p.price ?? undefined}
href={`/products/${p.id}`}
/>
))}
</div>
</section>
);
}

export default async function ProductDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = MOCK_PRODUCTS[id] ?? Object.values(MOCK_PRODUCTS)[0];
const data = await getProduct(id);

if (!data) {
return (
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<nav className="py-4 text-caption text-dark-700">
<Link href="/" className="hover:underline">Home</Link> / <Link href="/products" className="hover:underline">Products</Link> /{" "}
<span className="text-dark-900">Not found</span>
</nav>
<NotFoundBlock />
</main>
);
}

const { product, variants, images } = data;

const galleryVariants: GalleryVariant[] = variants.map((v) => {
const imgs = images
.filter((img) => img.variantId === v.id)
.map((img) => img.url);

const fallback = images
.filter((img) => img.variantId === null)
.sort((a, b) => {
if (a.isPrimary && !b.isPrimary) return -1;
if (!a.isPrimary && b.isPrimary) return 1;
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
})
.map((img) => img.url);

return {
color: v.color?.name || "Default",
images: imgs.length ? imgs : fallback,
};
}).filter((gv) => gv.images.length > 0);

const defaultVariant =
variants.find((v) => v.id === product.defaultVariantId) || variants[0];

const basePrice = defaultVariant ? Number(defaultVariant.price) : null;
const salePrice = defaultVariant?.salePrice ? Number(defaultVariant.salePrice) : null;

const displayPrice = salePrice !== null && !Number.isNaN(salePrice) ? salePrice : basePrice;
const compareAt = salePrice !== null && !Number.isNaN(salePrice) ? basePrice : null;

const discount =
product.compareAt && product.compareAt > product.price
? Math.round(((product.compareAt - product.price) / product.compareAt) * 100)
compareAt && displayPrice && compareAt > displayPrice
? Math.round(((compareAt - displayPrice) / compareAt) * 100)
: null;

const subtitle =
product.gender?.label ? `${product.gender.label} Shoes` : undefined;

return (
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<nav className="py-4 text-caption text-dark-700">
<Link href="/" className="hover:underline">Home</Link> / <Link href="/products" className="hover:underline">Products</Link> /{" "}
<span className="text-dark-900">{product.title}</span>
<span className="text-dark-900">{product.name}</span>
</nav>

<section className="grid grid-cols-1 gap-10 lg:grid-cols-[1fr_480px]">
<ProductGallery productId={product.id} variants={product.variants} className="lg:sticky lg:top-6" />
{galleryVariants.length > 0 && (
<ProductGallery productId={product.id} variants={galleryVariants} className="lg:sticky lg:top-6" />
)}

<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<h1 className="text-heading-2 text-dark-900">{product.title}</h1>
{product.subtitle && <p className="text-body text-dark-700">{product.subtitle}</p>}
<h1 className="text-heading-2 text-dark-900">{product.name}</h1>
{subtitle && <p className="text-body text-dark-700">{subtitle}</p>}
</header>

<div className="flex items-center gap-3">
<p className="text-lead text-dark-900">${product.price.toFixed(2)}</p>
{product.compareAt && (
<p className="text-lead text-dark-900">{formatPrice(displayPrice)}</p>
{compareAt && (
<>
<span className="text-body text-dark-700 line-through">${product.compareAt.toFixed(2)}</span>
<span className="text-body text-dark-700 line-through">{formatPrice(compareAt)}</span>
{discount !== null && (
<span className="rounded-full border border-light-300 px-2 py-1 text-caption text-[--color-green]">
{discount}% off
Expand All @@ -116,7 +179,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
)}
</div>

<ColorSwatches productId={product.id} variants={product.variants} />
<ColorSwatches productId={product.id} variants={galleryVariants} />
<SizePicker />

<div className="flex flex-col gap-3">
Expand All @@ -132,53 +195,38 @@ export default async function ProductDetailPage({ params }: { params: Promise<{

<CollapsibleSection title="Product Details" defaultOpen>
<p>{product.description}</p>
<ul className="mt-4 list-disc space-y-1 pl-5">
<li>Padded collar</li>
<li>Foam midsole</li>
<li>Shown: Multiple colors</li>
<li>Style: HM9451-600</li>
</ul>
</CollapsibleSection>

<CollapsibleSection title="Shipping & Returns">
<p>Free standard shipping and free 30-day returns for Nike Members.</p>
</CollapsibleSection>

<CollapsibleSection
title="Reviews (10)"
rightMeta={
<span className="flex items-center gap-1 text-dark-900">
<Star className="h-4 w-4 fill-[--color-dark-900]" />
<Star className="h-4 w-4 fill-[--color-dark-900]" />
<Star className="h-4 w-4 fill-[--color-dark-900]" />
<Star className="h-4 w-4 fill-[--color-dark-900]" />
<Star className="h-4 w-4" />
</span>
<Suspense
fallback={
<CollapsibleSection title="Reviews">
<p className="text-body text-dark-700">Loading reviews…</p>
</CollapsibleSection>
}
>
<p>No reviews yet.</p>
</CollapsibleSection>
<ReviewsSection productId={product.id} />
</Suspense>
</div>
</section>

<section className="mt-16">
<h2 className="mb-6 text-heading-3 text-dark-900">You Might Also Like</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{RECS.map((p) => {
const firstImg = p.variants.flatMap((v) => v.images)[0] ?? "/shoes/shoe-1.jpg";
return (
<Card
key={p.id}
title={p.title}
subtitle={p.subtitle}
imageSrc={firstImg}
price={p.price}
href={`/products/${p.id}`}
/>
);
})}
</div>
</section>
<Suspense
fallback={
<section className="mt-16">
<h2 className="mb-6 text-heading-3 text-dark-900">You Might Also Like</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-64 animate-pulse rounded-xl bg-light-200" />
))}
</div>
</section>
}
>
<AlsoLikeSection productId={product.id} />
</Suspense>
</main>
);
}
Loading