From 16c1ab50b8fdfa1c52b3304b6db5cbc396be3f19 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 07:49:40 +0000 Subject: [PATCH 1/5] feat(products): server-rendered Product Details page with gallery, variants, size picker, and collapsibles; link product cards to dynamic route; add lucide-react Co-Authored-By: Adrian | JS Mastery --- package-lock.json | 10 ++ package.json | 3 +- src/app/(root)/products/[id]/page.tsx | 183 ++++++++++++++++++++++++++ src/app/(root)/products/page.tsx | 1 + src/components/CollapsibleSection.tsx | 46 +++++++ src/components/ProductGallery.tsx | 149 +++++++++++++++++++++ src/components/SizePicker.tsx | 42 ++++++ src/components/index.ts | 3 + 8 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 src/app/(root)/products/[id]/page.tsx create mode 100644 src/components/CollapsibleSection.tsx create mode 100644 src/components/ProductGallery.tsx create mode 100644 src/components/SizePicker.tsx diff --git a/package-lock.json b/package-lock.json index 9f129c5..d087e33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "better-auth": "^1.3.7", "dotenv": "^17.2.1", "drizzle-orm": "^0.44.4", + "lucide-react": "^0.475.0", "next": "15.4.6", "query-string": "^9.1.1", "react": "19.1.0", @@ -5876,6 +5877,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lucide-react": { + "version": "0.475.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", + "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/package.json b/package.json index 09c4c7f..e242c70 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(root)/products/[id]/page.tsx b/src/app/(root)/products/[id]/page.tsx new file mode 100644 index 0000000..bcf1fe5 --- /dev/null +++ b/src/app/(root)/products/[id]/page.tsx @@ -0,0 +1,183 @@ +import Image from "next/image"; +import Link from "next/link"; +import { Card, CollapsibleSection, ProductGallery, SizePicker } from "@/components"; +import { Heart, ShoppingBag, Star } from "lucide-react"; + +type Product = { + id: string; + title: string; + subtitle?: string; + price: number; + compareAt?: number; + description: string; + variants: { color: string; images: string[] }[]; +}; + +const MOCK_PRODUCTS: Record = { + "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 ( +
+ + +
+ + +
+
+

{product.title}

+ {product.subtitle &&

{product.subtitle}

} +
+ +
+

${product.price.toFixed(2)}

+ {product.compareAt && ( + <> + ${product.compareAt.toFixed(2)} + {discount !== null && ( + + {discount}% off + + )} + + )} +
+ + + +
+ + +
+ + +

{product.description}

+
    +
  • Padded collar
  • +
  • Foam midsole
  • +
  • Shown: Multiple colors
  • +
  • Style: HM9451-600
  • +
+
+ + +

Free standard shipping and free 30-day returns for Nike Members.

+
+ + + + + + + + + } + > +

No reviews yet.

+
+
+
+ +
+

You Might Also Like

+
+ {RECS.map((p) => { + const firstImg = p.variants.flatMap((v) => v.images)[0] ?? "/shoes/shoe-1.jpg"; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/app/(root)/products/page.tsx b/src/app/(root)/products/page.tsx index d248525..850c8a6 100644 --- a/src/app/(root)/products/page.tsx +++ b/src/app/(root)/products/page.tsx @@ -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}`} /> ); })} diff --git a/src/components/CollapsibleSection.tsx b/src/components/CollapsibleSection.tsx new file mode 100644 index 0000000..345ccc4 --- /dev/null +++ b/src/components/CollapsibleSection.tsx @@ -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 ( +
+ + {open && ( +
+ {children ?
{children}
: null} +
+ )} +
+ ); +} diff --git a/src/components/ProductGallery.tsx b/src/components/ProductGallery.tsx new file mode 100644 index 0000000..e15de6c --- /dev/null +++ b/src/components/ProductGallery.tsx @@ -0,0 +1,149 @@ +"use client"; + +import Image from "next/image"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Check, ChevronLeft, ChevronRight, ImageOff } from "lucide-react"; + +type Variant = { + color: string; + images: string[]; +}; + +export interface ProductGalleryProps { + variants: Variant[]; + initialVariantIndex?: number; + className?: string; +} + +function isValidSrc(src: string | undefined | null) { + return typeof src === "string" && src.trim().length > 0; +} + +export default function ProductGallery({ + variants, + initialVariantIndex = 0, + className = "", +}: ProductGalleryProps) { + const validVariants = useMemo( + () => variants.filter((v) => Array.isArray(v.images) && v.images.some(isValidSrc)), + [variants] + ); + + const [variantIndex, setVariantIndex] = useState(() => + Math.min(initialVariantIndex, Math.max(validVariants.length - 1, 0)) + ); + + const images = validVariants[variantIndex]?.images?.filter(isValidSrc) ?? []; + const [activeIndex, setActiveIndex] = useState(0); + const mainRef = useRef(null); + + useEffect(() => { + setActiveIndex(0); + }, [variantIndex]); + + const go = useCallback( + (dir: -1 | 1) => { + if (images.length === 0) return; + setActiveIndex((i) => (i + dir + images.length) % images.length); + }, + [images.length] + ); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!mainRef.current) return; + if (!document.activeElement) return; + if (!mainRef.current.contains(document.activeElement)) return; + if (e.key === "ArrowLeft") go(-1); + if (e.key === "ArrowRight") go(1); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [go]); + + return ( +
+
+ {images.map((src, i) => ( + + ))} +
+ +
+ {images.length > 0 ? ( + <> + Product image +
+ + + + + + +
+
+ + +
+ + ) : ( +
+
+ + No images available +
+
+ )} +
+ +
+ {validVariants.map((v, i) => { + const thumb = v.images.find(isValidSrc); + if (!thumb) return null; + const selected = i === variantIndex; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/SizePicker.tsx b/src/components/SizePicker.tsx new file mode 100644 index 0000000..96cc76d --- /dev/null +++ b/src/components/SizePicker.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState } from "react"; + +const SIZES = ["5", "5.5", "6", "6.5", "7", "7.5", "8", "8.5", "9", "9.5", "10", "10.5", "11", "11.5", "12"]; + +export interface SizePickerProps { + className?: string; +} + +export default function SizePicker({ className = "" }: SizePickerProps) { + const [selected, setSelected] = useState(null); + + return ( +
+
+

Select Size

+ +
+ +
+ {SIZES.map((s) => { + const isActive = selected === s; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 61c6b5d..00d26f5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,3 +3,6 @@ export { default as Card } from "./Card"; export { default as Footer } from "./Footer"; export { default as AuthForm } from "./AuthForm"; export { default as SocialProviders } from "./SocialProviders"; +export { default as ProductGallery } from "./ProductGallery"; +export { default as SizePicker } from "./SizePicker"; +export { default as CollapsibleSection } from "./CollapsibleSection"; From db6694697648a4aa4336def08ef28e4d3a594b43 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 07:52:22 +0000 Subject: [PATCH 2/5] chore(pdp): remove unused Image import Co-Authored-By: Adrian | JS Mastery --- src/app/(root)/products/[id]/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/(root)/products/[id]/page.tsx b/src/app/(root)/products/[id]/page.tsx index bcf1fe5..9c13d4b 100644 --- a/src/app/(root)/products/[id]/page.tsx +++ b/src/app/(root)/products/[id]/page.tsx @@ -1,4 +1,3 @@ -import Image from "next/image"; import Link from "next/link"; import { Card, CollapsibleSection, ProductGallery, SizePicker } from "@/components"; import { Heart, ShoppingBag, Star } from "lucide-react"; From 423e7b41cb786291cdf555d1836a050a647ebd78 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 08:03:43 +0000 Subject: [PATCH 3/5] feat(pdp): align gallery to design and place color swatches above size picker; extract ColorSwatches with shared variant store Co-Authored-By: Adrian | JS Mastery --- src/components/ColorSwatches.tsx | 51 +++++++++++++++++++++++++++++++ src/components/ProductGallery.tsx | 34 +++++---------------- src/store/variant.ts | 21 +++++++++++++ 3 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 src/components/ColorSwatches.tsx create mode 100644 src/store/variant.ts diff --git a/src/components/ColorSwatches.tsx b/src/components/ColorSwatches.tsx new file mode 100644 index 0000000..4367551 --- /dev/null +++ b/src/components/ColorSwatches.tsx @@ -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 ( +
+ {variants.map((v, i) => { + const src = firstValidImage(v.images); + if (!src) return null; + const isActive = selected === i; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/ProductGallery.tsx b/src/components/ProductGallery.tsx index e15de6c..8443052 100644 --- a/src/components/ProductGallery.tsx +++ b/src/components/ProductGallery.tsx @@ -2,7 +2,8 @@ import Image from "next/image"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Check, ChevronLeft, ChevronRight, ImageOff } from "lucide-react"; +import { ChevronLeft, ChevronRight, ImageOff } from "lucide-react"; +import { useVariantStore } from "@/store/variant"; type Variant = { color: string; @@ -10,6 +11,7 @@ type Variant = { }; export interface ProductGalleryProps { + productId: string; variants: Variant[]; initialVariantIndex?: number; className?: string; @@ -20,6 +22,7 @@ function isValidSrc(src: string | undefined | null) { } export default function ProductGallery({ + productId, variants, initialVariantIndex = 0, className = "", @@ -29,9 +32,10 @@ export default function ProductGallery({ [variants] ); - const [variantIndex, setVariantIndex] = useState(() => - Math.min(initialVariantIndex, Math.max(validVariants.length - 1, 0)) - ); + const variantIndex = + useVariantStore( + (s) => s.selectedByProduct[productId] ?? Math.min(initialVariantIndex, Math.max(validVariants.length - 1, 0)) + ); const images = validVariants[variantIndex]?.images?.filter(isValidSrc) ?? []; const [activeIndex, setActiveIndex] = useState(0); @@ -122,28 +126,6 @@ export default function ProductGallery({ )} -
- {validVariants.map((v, i) => { - const thumb = v.images.find(isValidSrc); - if (!thumb) return null; - const selected = i === variantIndex; - return ( - - ); - })} -
); } diff --git a/src/store/variant.ts b/src/store/variant.ts new file mode 100644 index 0000000..398dbd6 --- /dev/null +++ b/src/store/variant.ts @@ -0,0 +1,21 @@ +"use client"; + +import { create } from "zustand"; + +type State = { + selectedByProduct: Record; + setSelected: (productId: string, index: number) => void; + getSelected: (productId: string, fallback?: number) => number; +}; + +export const useVariantStore = create((set, get) => ({ + selectedByProduct: {}, + setSelected: (productId, index) => + set((s) => ({ + selectedByProduct: { ...s.selectedByProduct, [productId]: index }, + })), + getSelected: (productId, fallback = 0) => { + const map = get().selectedByProduct; + return map[productId] ?? fallback; + }, +})); From 0ba0546940c8b0eb3a33e060773ea047d676f93a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 08:05:10 +0000 Subject: [PATCH 4/5] fix(pdp): import ColorSwatches directly in server page (remove next/dynamic ssr:false) Co-Authored-By: Adrian | JS Mastery --- src/app/(root)/products/[id]/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/(root)/products/[id]/page.tsx b/src/app/(root)/products/[id]/page.tsx index 9c13d4b..155379b 100644 --- a/src/app/(root)/products/[id]/page.tsx +++ b/src/app/(root)/products/[id]/page.tsx @@ -1,6 +1,7 @@ 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; @@ -93,7 +94,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
- +
@@ -115,6 +116,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{ )}
+
From a8daabf1279ab63553a908590c80aa059ad51381 Mon Sep 17 00:00:00 2001 From: Adrian Hajdin - JS Mastery Date: Wed, 20 Aug 2025 10:18:32 +0200 Subject: [PATCH 5/5] small fixes to the layout --- src/components/ProductGallery.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/components/ProductGallery.tsx b/src/components/ProductGallery.tsx index 8443052..30f5278 100644 --- a/src/components/ProductGallery.tsx +++ b/src/components/ProductGallery.tsx @@ -80,7 +80,7 @@ export default function ProductGallery({ ))}
-
+
{images.length > 0 ? ( <> -
- - - - - - -
+