Skip to content

Commit f527928

Browse files
committed
feat(web): scalable product grid — compact, fully-clickable shop tiles
The public cart was a single/double column of oversized cards that didn't scale past a handful of products (and looked heavy on mobile). Redesign: - ProductCard is now a compact, whole-card-clickable tile with a retailer chip on the image, 2-line clamped title, price, and a 'Shop ↗' affordance. - Public page grid is 2-up on mobile, 3-up on desktop (was 1/2-up). - Editor live preview mirrors the real 2-up mobile layout.
1 parent ef27832 commit f527928

3 files changed

Lines changed: 46 additions & 47 deletions

File tree

web/app/(public)/c/[slug]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ export default async function PublicCartPage({ params }: { params: { slug: strin
5656
{cart.products.length === 0 ? (
5757
<p className="text-center text-muted py-16">This cart is still being curated. Check back soon.</p>
5858
) : (
59-
<div className="grid gap-6 md:grid-cols-2">
59+
<div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-3">
6060
{cart.products.map((p, i) => (
6161
<RevealOnScroll key={p.id} index={i}>
62-
<ProductCard product={p} eagerImage={i < 2} />
62+
<ProductCard product={p} eagerImage={i < 4} />
6363
</RevealOnScroll>
6464
))}
6565
</div>

web/app/dashboard/carts/[id]/editor.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,14 +336,16 @@ function PreviewCartPage({ cart }: { cart: Cart }) {
336336
<p className="font-serif text-xl leading-tight">{cart.title}</p>
337337
</div>
338338
</div>
339-
<div className="p-3 space-y-3">
339+
<div className="p-3">
340340
{cart.products.length === 0 ? (
341341
<p className="text-xs text-muted text-center py-6">No products yet.</p>
342342
) : (
343-
cart.products.slice(0, 3).map((p) => <ProductCard key={p.id} product={p} />)
343+
<div className="grid grid-cols-2 gap-2">
344+
{cart.products.slice(0, 4).map((p) => <ProductCard key={p.id} product={p} />)}
345+
</div>
344346
)}
345-
{cart.products.length > 3 && (
346-
<p className="text-center text-xs text-muted">+ {cart.products.length - 3} more on the live page</p>
347+
{cart.products.length > 4 && (
348+
<p className="text-center text-xs text-muted mt-3">+ {cart.products.length - 4} more on the live page</p>
347349
)}
348350
</div>
349351
</div>

web/components/product-card.tsx

Lines changed: 38 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import Image from "next/image";
44
import { useState } from "react";
5-
import { ExternalLink } from "lucide-react";
5+
import { ArrowUpRight } from "lucide-react";
66
import type { Product } from "@/lib/types";
77
import { RetailerIcon, retailerLabel } from "./retailer-icon";
88

@@ -11,54 +11,51 @@ interface ProductCardProps {
1111
eagerImage?: boolean;
1212
}
1313

14+
// Compact, fully-clickable product tile built for a grid — scales cleanly
15+
// from a few products to dozens. The whole card links through the redirect
16+
// service (/go/{slug}) so clicks are logged and affiliate-tagged.
1417
export function ProductCard({ product, eagerImage = false }: ProductCardProps) {
1518
const [imgFailed, setImgFailed] = useState(false);
1619
const imgSrc = imgFailed ? "/placeholder-product.svg" : product.imageUrl;
17-
18-
// Shop via the redirect service so the click is logged and the affiliate
19-
// tag is applied. /go/{slug} is rewritten to shoplit-redirect (see
20-
// next.config.mjs). Fall back to the raw URL if a slug is somehow missing.
2120
const shopHref = product.goSlug ? `/go/${product.goSlug}` : product.originalUrl;
2221

2322
return (
24-
<article className="group rounded-xl overflow-hidden border border-rule bg-cream transition-shadow hover:shadow-md">
25-
<a
26-
href={shopHref}
27-
target="_blank"
28-
rel="noopener noreferrer sponsored"
29-
aria-label={`Shop ${product.title} on ${retailerLabel(product.retailer)}`}
30-
>
31-
<div className="relative aspect-square bg-paper">
32-
<Image
33-
src={imgSrc}
34-
alt={product.title}
35-
fill
36-
sizes="(max-width: 768px) 100vw, 50vw"
37-
priority={eagerImage}
38-
loading={eagerImage ? "eager" : "lazy"}
39-
className="object-cover transition-transform duration-300 group-hover:scale-[1.02]"
40-
unoptimized
41-
onError={() => setImgFailed(true)}
42-
/>
43-
</div>
44-
</a>
45-
<div className="p-5 sm:p-6">
46-
<h3 className="font-serif text-xl leading-tight mb-1">{product.title}</h3>
23+
<a
24+
href={shopHref}
25+
target="_blank"
26+
rel="noopener noreferrer sponsored"
27+
aria-label={`Shop ${product.title} on ${retailerLabel(product.retailer)}`}
28+
className="group flex flex-col rounded-xl overflow-hidden border border-rule bg-cream transition-all hover:shadow-md hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
29+
>
30+
<div className="relative aspect-square bg-paper">
31+
<Image
32+
src={imgSrc}
33+
alt={product.title}
34+
fill
35+
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 300px"
36+
priority={eagerImage}
37+
loading={eagerImage ? "eager" : "lazy"}
38+
className="object-cover transition-transform duration-300 group-hover:scale-[1.03]"
39+
unoptimized
40+
onError={() => setImgFailed(true)}
41+
/>
42+
<span className="absolute top-2 left-2 inline-flex items-center gap-1 rounded-full bg-cream/90 backdrop-blur px-2 py-0.5 text-[10px] font-medium text-ink">
43+
<RetailerIcon retailer={product.retailer} size={11} />
44+
{retailerLabel(product.retailer)}
45+
</span>
46+
</div>
47+
<div className="flex flex-1 flex-col p-3 sm:p-4">
48+
<h3 className="font-serif text-sm sm:text-base leading-snug line-clamp-2 mb-1">{product.title}</h3>
4749
{product.note && (
48-
<p className="italic text-sm text-muted mb-2 leading-relaxed">&ldquo;{product.note}&rdquo;</p>
50+
<p className="italic text-xs text-muted line-clamp-1 mb-1.5">&ldquo;{product.note}&rdquo;</p>
4951
)}
50-
{product.priceText && <p className="text-sm text-muted mb-4">{product.priceText}</p>}
51-
<a
52-
href={shopHref}
53-
target="_blank"
54-
rel="noopener noreferrer sponsored"
55-
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-accent text-cream font-medium py-3 px-4 transition-opacity hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-cream"
56-
>
57-
<RetailerIcon retailer={product.retailer} size={16} />
58-
Shop on {retailerLabel(product.retailer)}
59-
<ExternalLink size={14} aria-hidden />
60-
</a>
52+
<div className="mt-auto flex items-center justify-between pt-2">
53+
<span className="text-sm font-medium text-ink">{product.priceText || " "}</span>
54+
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-accent transition-all group-hover:gap-1.5">
55+
Shop <ArrowUpRight size={14} aria-hidden />
56+
</span>
57+
</div>
6158
</div>
62-
</article>
59+
</a>
6360
);
6461
}

0 commit comments

Comments
 (0)