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
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"react-dom": "19.1.0",
"uuid": "^11.1.0",
"zustand": "^5.0.7",
"query-string": "^9.1.1"
"query-string": "^9.1.1",
"lucide-react": "^0.475.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
Expand Down
184 changes: 184 additions & 0 deletions src/app/(root)/products/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import Link from "next/link";
import { Card, CollapsibleSection, ProductGallery, SizePicker } from "@/components";
import { Heart, ShoppingBag, Star } from "lucide-react";
import ColorSwatches from "@/components/ColorSwatches";

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"] }],
},
];

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 discount =
product.compareAt && product.compareAt > product.price
? Math.round(((product.compareAt - product.price) / product.compareAt) * 100)
: null;

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>
</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" />

<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>}
</header>

<div className="flex items-center gap-3">
<p className="text-lead text-dark-900">${product.price.toFixed(2)}</p>
{product.compareAt && (
<>
<span className="text-body text-dark-700 line-through">${product.compareAt.toFixed(2)}</span>
{discount !== null && (
<span className="rounded-full border border-light-300 px-2 py-1 text-caption text-[--color-green]">
{discount}% off
</span>
)}
</>
)}
</div>

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

<div className="flex flex-col gap-3">
<button className="flex items-center justify-center gap-2 rounded-full bg-dark-900 px-6 py-4 text-body-medium text-light-100 transition hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-[--color-dark-500]">
<ShoppingBag className="h-5 w-5" />
Add to Bag
</button>
<button className="flex items-center justify-center gap-2 rounded-full border border-light-300 px-6 py-4 text-body-medium text-dark-900 transition hover:border-dark-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-[--color-dark-500]">
<Heart className="h-5 w-5" />
Favorite
</button>
</div>

<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>
}
>
<p>No reviews yet.</p>
</CollapsibleSection>
</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>
</main>
);
}
1 change: 1 addition & 0 deletions src/app/(root)/products/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export default async function ProductsPage({
subtitle={p.subtitle ?? undefined}
imageSrc={p.imageUrl ?? "/shoes/shoe-1.jpg"}
price={price}
href={`/products/${p.id}`}
/>
);
})}
Expand Down
46 changes: 46 additions & 0 deletions src/components/CollapsibleSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";

import { ChevronDown } from "lucide-react";
import { useState } from "react";

export interface CollapsibleSectionProps {
title: string;
children?: React.ReactNode;
defaultOpen?: boolean;
rightMeta?: React.ReactNode;
className?: string;
}

export default function CollapsibleSection({
title,
children,
defaultOpen = false,
rightMeta,
className = "",
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen);

return (
<section className={`border-b border-light-300 ${className}`}>
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between gap-4 py-5 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[--color-dark-500]"
aria-expanded={open}
>
<span className="text-body-medium text-dark-900">{title}</span>
<span className="flex items-center gap-2">
{rightMeta}
<ChevronDown
className={`h-5 w-5 text-dark-900 transition-transform ${open ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</span>
</button>
{open && (
<div className="pb-6">
{children ? <div className="text-body text-dark-700">{children}</div> : null}
</div>
)}
</section>
);
}
51 changes: 51 additions & 0 deletions src/components/ColorSwatches.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"use client";

import Image from "next/image";
import { Check } from "lucide-react";
import { useVariantStore } from "@/store/variant";

type Variant = { color: string; images: string[] };

export interface ColorSwatchesProps {
productId: string;
variants: Variant[];
className?: string;
}

function firstValidImage(images: string[]) {
return images.find((s) => typeof s === "string" && s.trim().length > 0);
}

export default function ColorSwatches({ productId, variants, className = "" }: ColorSwatchesProps) {
const setSelected = useVariantStore((s) => s.setSelected);
const selected = useVariantStore((s) => s.getSelected(productId, 0));

return (
<div className={`flex flex-wrap gap-3 ${className}`} role="listbox" aria-label="Choose color">
{variants.map((v, i) => {
const src = firstValidImage(v.images);
if (!src) return null;
const isActive = selected === i;
return (
<button
key={`${v.color}-${i}`}
onClick={() => setSelected(productId, i)}
aria-label={`Color ${v.color}`}
aria-selected={isActive}
role="option"
className={`relative h-[72px] w-[120px] overflow-hidden rounded-lg ring-1 ring-light-300 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[--color-dark-500] ${
isActive ? "ring-[--color-dark-500]" : "hover:ring-dark-500"
}`}
>
<Image src={src} alt={v.color} fill sizes="120px" className="object-cover" />
{isActive && (
Comment on lines +19 to +41
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Variant index desync with ProductGallery (filtering mismatch) — align on the same filtered index set

ProductGallery filters out variants without valid images and uses indices into that filtered list. ColorSwatches currently uses indices from the unfiltered variants array (skipping null-rendered items), which can desynchronize the selected index across components. Result: clicking a swatch may show a different variant in the gallery.

Apply this diff to:

  • Use the same filtered set (only variants with a valid image).
  • Set/select indices based on the filtered list.
  • Add type="button" to avoid accidental form submission.
 "use client";

-import Image from "next/image";
+import Image from "next/image";
+import { useMemo } from "react";
 import { Check } from "lucide-react";
 import { useVariantStore } from "@/store/variant";

 type Variant = { color: string; images: string[] };

 export interface ColorSwatchesProps {
   productId: string;
   variants: Variant[];
   className?: string;
 }

 function firstValidImage(images: string[]) {
   return images.find((s) => typeof s === "string" && s.trim().length > 0);
 }

 export default function ColorSwatches({ productId, variants, className = "" }: ColorSwatchesProps) {
   const setSelected = useVariantStore((s) => s.setSelected);
-  const selected = useVariantStore((s) => s.getSelected(productId, 0));
+  const selected = useVariantStore((s) => s.getSelected(productId, 0));
+
+  // Use the same filtering rule as the gallery: only variants with a valid image.
+  const valid = useMemo(
+    () =>
+      variants
+        .map((v) => ({ v, src: firstValidImage(v.images) }))
+        .filter((e): e is { v: Variant; src: string } => Boolean(e.src)),
+    [variants]
+  );

   return (
-    <div className={`flex flex-wrap gap-3 ${className}`} role="listbox" aria-label="Choose color">
-      {variants.map((v, i) => {
-        const src = firstValidImage(v.images);
-        if (!src) return null;
-        const isActive = selected === i;
+    <div className={`flex flex-wrap gap-3 ${className}`} role="listbox" aria-label="Choose color">
+      {valid.map(({ v, src }, idx) => {
+        const isActive = selected === idx;
         return (
           <button
-            key={`${v.color}-${i}`}
-            onClick={() => setSelected(productId, i)}
+            key={`${v.color}-${idx}`}
+            type="button"
+            onClick={() => setSelected(productId, idx)}
             aria-label={`Color ${v.color}`}
             aria-selected={isActive}
             role="option"
             className={`relative h-[72px] w-[120px] overflow-hidden rounded-lg ring-1 ring-light-300 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[--color-dark-500] ${
               isActive ? "ring-[--color-dark-500]" : "hover:ring-dark-500"
             }`}
           >
-            <Image src={src} alt={v.color} fill sizes="120px" className="object-cover" />
+            <Image src={src} alt={v.color} fill sizes="120px" className="object-cover" />
             {isActive && (
               <span className="absolute right-1 top-1 rounded-full bg-light-100 p-1">
                 <Check className="h-4 w-4 text-dark-900" />
               </span>
             )}
           </button>
         );
       })}
     </div>
   );
 }

Also applies to: 25-49, 3-6

🤖 Prompt for AI Agents
In src/components/ColorSwatches.tsx around lines 19-41, the component maps over
the original variants array and skips variants without valid images, causing
index desync with ProductGallery; instead compute a filteredVariants =
variants.filter(v => firstValidImage(v.images)) (and a corresponding src per
item), map over filteredVariants so indices align, call setSelected(productId,
i) and use the same index i everywhere, and ensure the button elements include
type="button" to prevent accidental form submission.

<span className="absolute right-1 top-1 rounded-full bg-light-100 p-1">
<Check className="h-4 w-4 text-dark-900" />
</span>
)}
</button>
);
})}
</div>
);
}
Loading