Skip to content

Commit 1e6c693

Browse files
committed
feat(shop): add product dialog popup on products listing page
Replace navigation to /products/[id] with an inline ProductDialog modal when clicking a product tile on the /products page. The dialog shows the full image gallery, description, and add-to-cart button without leaving the page. https://claude.ai/code/session_013LJXFRo7A4PdPgcbA9Dk7n
1 parent d7bfac7 commit 1e6c693

3 files changed

Lines changed: 186 additions & 5 deletions

File tree

apps/shop/metal-mart-frontend/src/app/globals.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,30 @@ a, button {
109109
opacity: 0.9;
110110
}
111111

112+
/* Dialog backdrop fade */
113+
@keyframes fadeIn {
114+
from { opacity: 0; }
115+
to { opacity: 1; }
116+
}
117+
.animate-fade-in {
118+
animation: fadeIn 0.2s ease-out forwards;
119+
}
120+
121+
/* Dialog panel slide up */
122+
@keyframes slideUp {
123+
from {
124+
opacity: 0;
125+
transform: translateY(24px) scale(0.97);
126+
}
127+
to {
128+
opacity: 1;
129+
transform: translateY(0) scale(1);
130+
}
131+
}
132+
.animate-slide-up {
133+
animation: slideUp 0.25s ease-out forwards;
134+
}
135+
112136
/* Decorative wave above footer */
113137
.footer-wave {
114138
display: block;

apps/shop/metal-mart-frontend/src/app/products/page.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
"use client";
22

33
import { useEffect, useState } from "react";
4-
import Link from "next/link";
54
import Header from "@/components/Header";
65
import LoadingSpinner from "@/components/LoadingSpinner";
76
import NewBadge from "@/components/NewBadge";
87
import ProductImage from "@/components/ProductImage";
8+
import ProductDialog from "@/components/ProductDialog";
99
import { getPrimaryImageUrl, type Product } from "@/lib/product";
1010

1111
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
@@ -14,6 +14,7 @@ export default function ProductsPage() {
1414
const [products, setProducts] = useState<Product[]>([]);
1515
const [loading, setLoading] = useState(true);
1616
const [error, setError] = useState<string | null>(null);
17+
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
1718

1819
useEffect(() => {
1920
fetch(`${basePath}/api/products`)
@@ -56,10 +57,11 @@ export default function ProductsPage() {
5657
</h1>
5758
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
5859
{products.map((p, i) => (
59-
<Link
60+
<button
6061
key={p.id}
61-
href={`/products/${p.id}`}
62-
className="group relative flex flex-col overflow-hidden rounded-xl border border-slate-300 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-[#6a4ff5]/30 hover:shadow-xl hover:shadow-[#6a4ff5]/10 animate-card-reveal"
62+
type="button"
63+
onClick={() => setSelectedProduct(p)}
64+
className="group relative flex flex-col overflow-hidden rounded-xl border border-slate-300 bg-white shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-[#6a4ff5]/30 hover:shadow-xl hover:shadow-[#6a4ff5]/10 animate-card-reveal text-left"
6365
style={{ animationDelay: `${i * 0.06}s` }}
6466
>
6567
{p.is_new && <NewBadge size="default" />}
@@ -85,11 +87,18 @@ export default function ProductsPage() {
8587
<p className="mt-2 text-lg font-semibold text-[#6a4ff5]">${(p.price_cents / 100).toFixed(2)}</p>
8688
<p className="mt-auto pt-3 text-xs text-slate-500">In stock: {p.stock}</p>
8789
</div>
88-
</Link>
90+
</button>
8991
))}
9092
</div>
9193
</div>
9294
</main>
95+
96+
{selectedProduct && (
97+
<ProductDialog
98+
product={selectedProduct}
99+
onClose={() => setSelectedProduct(null)}
100+
/>
101+
)}
93102
</div>
94103
);
95104
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"use client";
2+
3+
import { useEffect, useState, useCallback } from "react";
4+
import Link from "next/link";
5+
import NewBadge from "@/components/NewBadge";
6+
import ProductImage from "@/components/ProductImage";
7+
import { getImageUrls, type Product } from "@/lib/product";
8+
9+
interface ProductDialogProps {
10+
product: Product;
11+
onClose: () => void;
12+
}
13+
14+
export default function ProductDialog({ product, onClose }: ProductDialogProps) {
15+
const [selectedIndex, setSelectedIndex] = useState(0);
16+
17+
useEffect(() => {
18+
setSelectedIndex(0);
19+
}, [product.id]);
20+
21+
const handleKeyDown = useCallback(
22+
(e: KeyboardEvent) => {
23+
if (e.key === "Escape") onClose();
24+
},
25+
[onClose],
26+
);
27+
28+
useEffect(() => {
29+
document.addEventListener("keydown", handleKeyDown);
30+
document.body.style.overflow = "hidden";
31+
return () => {
32+
document.removeEventListener("keydown", handleKeyDown);
33+
document.body.style.overflow = "";
34+
};
35+
}, [handleKeyDown]);
36+
37+
const imageUrls = getImageUrls(product);
38+
const labels = imageUrls.length === 2 ? ["Front", "Back"] : undefined;
39+
40+
return (
41+
<div
42+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
43+
role="dialog"
44+
aria-modal="true"
45+
aria-label={product.name}
46+
>
47+
{/* Backdrop */}
48+
<div
49+
className="absolute inset-0 bg-black/50 animate-fade-in"
50+
onClick={onClose}
51+
/>
52+
53+
{/* Dialog panel */}
54+
<div className="relative z-10 w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-2xl border border-slate-200 bg-white shadow-2xl animate-slide-up">
55+
{/* Close button */}
56+
<button
57+
type="button"
58+
onClick={onClose}
59+
className="absolute right-4 top-4 z-20 flex h-9 w-9 items-center justify-center rounded-full bg-white/80 text-slate-500 shadow backdrop-blur transition-colors hover:bg-white hover:text-slate-900"
60+
aria-label="Close"
61+
>
62+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
63+
<line x1="18" y1="6" x2="6" y2="18" />
64+
<line x1="6" y1="6" x2="18" y2="18" />
65+
</svg>
66+
</button>
67+
68+
<div className="grid gap-6 p-6 sm:grid-cols-2 sm:gap-8 sm:p-8">
69+
{/* Image gallery */}
70+
<div className="space-y-3">
71+
<div className="relative aspect-square overflow-hidden rounded-xl border border-slate-200 bg-slate-50 shadow-lg">
72+
{product.is_new && <NewBadge size="lg" />}
73+
{imageUrls.length > 0 ? (
74+
<ProductImage
75+
src={imageUrls[selectedIndex]}
76+
alt={labels ? `${product.name}${labels[selectedIndex]}` : product.name}
77+
className="h-full w-full object-cover"
78+
fill
79+
sizes="(max-width: 640px) 90vw, 40vw"
80+
/>
81+
) : (
82+
<div className="flex h-full w-full items-center justify-center text-slate-400">
83+
No image
84+
</div>
85+
)}
86+
</div>
87+
{imageUrls.length > 1 && (
88+
<div className="flex gap-2">
89+
{imageUrls.map((url, i) => (
90+
<button
91+
key={i}
92+
type="button"
93+
onClick={() => setSelectedIndex(i)}
94+
className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border-2 transition-colors ${
95+
selectedIndex === i
96+
? "border-[#6a4ff5] ring-2 ring-[#6a4ff5]/30"
97+
: "border-slate-200 hover:border-slate-300"
98+
}`}
99+
aria-label={labels ? labels[i] : `Image ${i + 1}`}
100+
>
101+
<ProductImage
102+
src={url}
103+
alt=""
104+
width={56}
105+
height={56}
106+
className="h-full w-full object-cover"
107+
/>
108+
</button>
109+
))}
110+
</div>
111+
)}
112+
</div>
113+
114+
{/* Product details */}
115+
<div className="flex flex-col">
116+
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
117+
{product.name}
118+
</h2>
119+
<p className="mt-2 text-xl font-semibold text-[#6a4ff5]">
120+
${(product.price_cents / 100).toFixed(2)}
121+
</p>
122+
{product.description && (
123+
<p className="mt-4 text-slate-600 leading-relaxed">
124+
{product.description}
125+
</p>
126+
)}
127+
<p className="mt-3 text-sm text-slate-500">In stock: {product.stock}</p>
128+
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
129+
<Link
130+
href={`/cart?add=${product.id}`}
131+
className="btn-primary inline-flex items-center justify-center rounded-xl px-7 py-3 font-semibold focus:outline-none focus:ring-2 focus:ring-[#6a4ff5]/40 focus:ring-offset-2"
132+
>
133+
Add to cart
134+
</Link>
135+
<button
136+
type="button"
137+
onClick={onClose}
138+
className="btn-secondary inline-flex items-center justify-center rounded-xl px-5 py-3 font-medium focus:outline-none focus:ring-2 focus:ring-amber-400/40 focus:ring-offset-2"
139+
>
140+
Close
141+
</button>
142+
</div>
143+
</div>
144+
</div>
145+
</div>
146+
</div>
147+
);
148+
}

0 commit comments

Comments
 (0)