From f3e3364279fc2538a231e37dc0f8f71454f32cab 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:42:12 +0000 Subject: [PATCH 1/3] PDP: integrate real backend data; add getProductReviews and getRecommendedProducts; server-render PDP with Suspense Co-Authored-By: Adrian | JS Mastery --- src/app/(root)/products/[id]/page.tsx | 286 +++++++++++++++----------- src/lib/actions/product.ts | 121 +++++++++++ 2 files changed, 287 insertions(+), 120 deletions(-) diff --git a/src/app/(root)/products/[id]/page.tsx b/src/app/(root)/products/[id]/page.tsx index 155379b..702ecb6 100644 --- a/src/app/(root)/products/[id]/page.tsx +++ b/src/app/(root)/products/[id]/page.tsx @@ -1,112 +1,173 @@ 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 = { - "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 ( +
+

Product not found

+

The product you’re looking for doesn’t exist or may have been removed.

+
+ + Browse Products + +
+
+ ); +} + +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 ( + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + } + > + {reviews.length === 0 ? ( +

No reviews yet.

+ ) : ( +
    + {reviews.slice(0, 10).map((r) => ( +
  • +
    +

    {r.author}

    + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + +
    + {r.title &&

    {r.title}

    } + {r.content &&

    {r.content}

    } +

    {new Date(r.createdAt).toLocaleDateString()}

    +
  • + ))} +
+ )} +
+ ); +} + +async function AlsoLikeSection({ productId }: { productId: string }) { + const recs: RecommendedProduct[] = await getRecommendedProducts(productId); + if (!recs.length) return null; + return ( +
+

You Might Also Like

+
+ {recs.map((p) => ( + + ))} +
+
+ ); +} 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 ( +
+ + +
+ ); + } + + 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 (
- +
-

{product.title}

- {product.subtitle &&

{product.subtitle}

} +

{product.name}

+ {subtitle &&

{subtitle}

}
-

${product.price.toFixed(2)}

- {product.compareAt && ( +

{formatPrice(displayPrice)}

+ {compareAt && ( <> - ${product.compareAt.toFixed(2)} + {formatPrice(compareAt)} {discount !== null && ( {discount}% off @@ -116,7 +177,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{ )}
- +
@@ -132,53 +193,38 @@ export default async function ProductDetailPage({ params }: { params: Promise<{

{product.description}

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

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

- - - - - - - + +

Loading reviews…

+
} > -

No reviews yet.

- + +
-
-

You Might Also Like

-
- {RECS.map((p) => { - const firstImg = p.variants.flatMap((v) => v.images)[0] ?? "/shoes/shoe-1.jpg"; - return ( - - ); - })} -
-
+ +

You Might Also Like

+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ + } + > + +
); } diff --git a/src/lib/actions/product.ts b/src/lib/actions/product.ts index c29a1d9..ac6e6f5 100644 --- a/src/lib/actions/product.ts +++ b/src/lib/actions/product.ts @@ -1,3 +1,5 @@ + + "use server"; import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"; @@ -11,6 +13,8 @@ import { products, sizes, colors, + users, + reviews, type SelectProduct, type SelectVariant, type SelectProductImage, @@ -20,6 +24,7 @@ import { type SelectColor, type SelectSize, } from "@/lib/db/schema"; + import { NormalizedProductFilters } from "@/lib/utils/query"; type ProductListItem = { @@ -377,3 +382,119 @@ export async function getProduct(productId: string): Promise images: Array.from(imagesMap.values()), }; } +export type Review = { + id: string; + author: string; + rating: number; + title?: string; + content: string; + createdAt: string; +}; + +export type RecommendedProduct = { + id: string; + title: string; + price: number | null; + imageUrl: string; +}; + +export async function getProductReviews(productId: string): Promise { + const rows = await db + .select({ + id: reviews.id, + rating: reviews.rating, + comment: reviews.comment, + createdAt: reviews.createdAt, + authorName: users.name, + authorEmail: users.email, + }) + .from(reviews) + .innerJoin(users, eq(users.id, reviews.userId)) + .where(eq(reviews.productId, productId)) + .orderBy(desc(reviews.createdAt)) + .limit(10); + + return rows.map((r) => ({ + id: r.id, + author: r.authorName?.trim() || r.authorEmail || "Anonymous", + rating: r.rating, + title: undefined, + content: r.comment || "", + createdAt: r.createdAt.toISOString(), + })); +} + +export async function getRecommendedProducts(productId: string): Promise { + const base = await db + .select({ + id: products.id, + categoryId: products.categoryId, + brandId: products.brandId, + genderId: products.genderId, + }) + .from(products) + .where(eq(products.id, productId)) + .limit(1); + + if (!base.length) return []; + const b = base[0]; + + const v = db + .select({ + productId: productVariants.productId, + price: sql`${productVariants.price}::numeric`.as("price"), + }) + .from(productVariants) + .as("v"); + + const pi = db + .select({ + productId: productImages.productId, + url: productImages.url, + rn: sql`row_number() over (partition by ${productImages.productId} order by ${productImages.isPrimary} desc, ${productImages.sortOrder} asc)`.as( + "rn", + ), + }) + .from(productImages) + .as("pi"); + + const priority = sql` + (case when ${products.categoryId} is not null and ${products.categoryId} = ${b.categoryId} then 1 else 0 end) * 3 + + (case when ${products.brandId} is not null and ${products.brandId} = ${b.brandId} then 1 else 0 end) * 2 + + (case when ${products.genderId} is not null and ${products.genderId} = ${b.genderId} then 1 else 0 end) * 1 + `; + + const rows = await db + .select({ + id: products.id, + title: products.name, + minPrice: sql`min(${v.price})`, + imageUrl: sql`max(case when ${pi.rn} = 1 then ${pi.url} else null end)`, + createdAt: products.createdAt, + }) + .from(products) + .leftJoin(v, eq(v.productId, products.id)) + .leftJoin(pi, eq(pi.productId, products.id)) + .where(and(eq(products.isPublished, true), sql`${products.id} <> ${productId}`)) + .groupBy(products.id, products.name, products.createdAt) + .orderBy( + desc(priority), + desc(products.createdAt), + asc(products.id) + ) + .limit(8); + + const out: RecommendedProduct[] = []; + for (const r of rows) { + const img = r.imageUrl?.trim(); + if (!img) continue; + out.push({ + id: r.id, + title: r.title, + price: r.minPrice === null ? null : Number(r.minPrice), + imageUrl: img, + }); + if (out.length >= 6) break; + } + return out; +} From 03ec5ae293796297bd85270ba17b8035bbcffeaf 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:44:53 +0000 Subject: [PATCH 2/3] Home: link product cards to PDP at /products/[id] Co-Authored-By: Adrian | JS Mastery --- src/app/(root)/page.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/(root)/page.tsx b/src/app/(root)/page.tsx index ba151f1..4e1f0f5 100644 --- a/src/app/(root)/page.tsx +++ b/src/app/(root)/page.tsx @@ -61,6 +61,7 @@ const Home = async () => { imageSrc={p.imageSrc} price={p.price} badge={p.badge} + href={`/products/${p.id}`} /> ))} From b2fce31fedee5c17b8da89b243151a005cac8b99 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:45:32 +0000 Subject: [PATCH 3/3] PDP: render ProductGallery only when images are available 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 702ecb6..056d4ed 100644 --- a/src/app/(root)/products/[id]/page.tsx +++ b/src/app/(root)/products/[id]/page.tsx @@ -155,7 +155,9 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
- + {galleryVariants.length > 0 && ( + + )}