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}`} /> ))} diff --git a/src/app/(root)/products/[id]/page.tsx b/src/app/(root)/products/[id]/page.tsx index 155379b..056d4ed 100644 --- a/src/app/(root)/products/[id]/page.tsx +++ b/src/app/(root)/products/[id]/page.tsx @@ -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 = { - "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 (
- + {galleryVariants.length > 0 && ( + + )}
-

{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 +179,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{ )}
- +
@@ -132,53 +195,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; +}