@86d-app/products 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +65 -0
- package/COMPONENTS.md +231 -0
- package/README.md +201 -0
- package/package.json +46 -0
- package/src/__tests__/controllers.test.ts +2227 -0
- package/src/__tests__/state.test.ts +138 -0
- package/src/admin/components/categories-admin.mdx +3 -0
- package/src/admin/components/categories-admin.tsx +449 -0
- package/src/admin/components/category-form.mdx +9 -0
- package/src/admin/components/category-form.tsx +490 -0
- package/src/admin/components/category-list.mdx +75 -0
- package/src/admin/components/category-list.tsx +168 -0
- package/src/admin/components/collections-admin.mdx +3 -0
- package/src/admin/components/collections-admin.tsx +771 -0
- package/src/admin/components/index.tsx +8 -0
- package/src/admin/components/product-detail.mdx +12 -0
- package/src/admin/components/product-detail.tsx +790 -0
- package/src/admin/components/product-edit.tsx +60 -0
- package/src/admin/components/product-form.tsx +793 -0
- package/src/admin/components/product-list.mdx +3 -0
- package/src/admin/components/product-list.tsx +1125 -0
- package/src/admin/components/product-new.tsx +38 -0
- package/src/admin/endpoints/add-collection-product.ts +17 -0
- package/src/admin/endpoints/bulk-action.ts +43 -0
- package/src/admin/endpoints/create-category.ts +52 -0
- package/src/admin/endpoints/create-collection.ts +35 -0
- package/src/admin/endpoints/create-product.ts +50 -0
- package/src/admin/endpoints/create-variant.ts +45 -0
- package/src/admin/endpoints/delete-category.ts +27 -0
- package/src/admin/endpoints/delete-collection.ts +12 -0
- package/src/admin/endpoints/delete-product.ts +27 -0
- package/src/admin/endpoints/delete-variant.ts +27 -0
- package/src/admin/endpoints/get-product.ts +23 -0
- package/src/admin/endpoints/import-products.ts +47 -0
- package/src/admin/endpoints/index.ts +43 -0
- package/src/admin/endpoints/list-categories.ts +21 -0
- package/src/admin/endpoints/list-collections.ts +20 -0
- package/src/admin/endpoints/list-products.ts +25 -0
- package/src/admin/endpoints/remove-collection-product.ts +15 -0
- package/src/admin/endpoints/update-category.ts +82 -0
- package/src/admin/endpoints/update-collection.ts +22 -0
- package/src/admin/endpoints/update-product.ts +67 -0
- package/src/admin/endpoints/update-variant.ts +41 -0
- package/src/controllers.ts +1410 -0
- package/src/index.ts +120 -0
- package/src/markdown.ts +150 -0
- package/src/mdx.d.ts +5 -0
- package/src/schema.ts +352 -0
- package/src/state.ts +84 -0
- package/src/store/components/_hooks.ts +78 -0
- package/src/store/components/_types.ts +73 -0
- package/src/store/components/_utils.ts +14 -0
- package/src/store/components/back-in-stock-notify.tsx +97 -0
- package/src/store/components/collection-card.mdx +42 -0
- package/src/store/components/collection-card.tsx +12 -0
- package/src/store/components/collection-detail.mdx +12 -0
- package/src/store/components/collection-detail.tsx +149 -0
- package/src/store/components/collection-grid.mdx +9 -0
- package/src/store/components/collection-grid.tsx +80 -0
- package/src/store/components/featured-products.mdx +9 -0
- package/src/store/components/featured-products.tsx +75 -0
- package/src/store/components/filter-chip.mdx +25 -0
- package/src/store/components/filter-chip.tsx +12 -0
- package/src/store/components/index.tsx +39 -0
- package/src/store/components/product-card.mdx +69 -0
- package/src/store/components/product-card.tsx +71 -0
- package/src/store/components/product-detail.mdx +30 -0
- package/src/store/components/product-detail.tsx +488 -0
- package/src/store/components/product-listing.mdx +7 -0
- package/src/store/components/product-listing.tsx +423 -0
- package/src/store/components/product-reviews-section.mdx +21 -0
- package/src/store/components/product-reviews-section.tsx +372 -0
- package/src/store/components/recently-viewed.tsx +100 -0
- package/src/store/components/related-products.mdx +6 -0
- package/src/store/components/related-products.tsx +62 -0
- package/src/store/components/star-display.mdx +18 -0
- package/src/store/components/star-display.tsx +27 -0
- package/src/store/components/star-picker.mdx +21 -0
- package/src/store/components/star-picker.tsx +21 -0
- package/src/store/components/stock-badge.mdx +12 -0
- package/src/store/components/stock-badge.tsx +19 -0
- package/src/store/endpoints/get-category.ts +61 -0
- package/src/store/endpoints/get-collection.ts +46 -0
- package/src/store/endpoints/get-featured.ts +18 -0
- package/src/store/endpoints/get-product.ts +52 -0
- package/src/store/endpoints/get-related.ts +20 -0
- package/src/store/endpoints/index.ts +23 -0
- package/src/store/endpoints/list-categories.ts +13 -0
- package/src/store/endpoints/list-collections.ts +22 -0
- package/src/store/endpoints/list-products.ts +28 -0
- package/src/store/endpoints/search-products.ts +18 -0
- package/src/store/endpoints/store-search.ts +111 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useStoreContext } from "@86d-app/core/client";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
useCartMutation,
|
|
7
|
+
useProductsApi,
|
|
8
|
+
useReviewsApi,
|
|
9
|
+
useTrack,
|
|
10
|
+
} from "./_hooks";
|
|
11
|
+
import type {
|
|
12
|
+
ProductVariant,
|
|
13
|
+
ProductWithVariants,
|
|
14
|
+
ReviewsResponse,
|
|
15
|
+
} from "./_types";
|
|
16
|
+
import { formatPrice } from "./_utils";
|
|
17
|
+
import { BackInStockNotify } from "./back-in-stock-notify";
|
|
18
|
+
import ProductDetailTemplate from "./product-detail.mdx";
|
|
19
|
+
import { ProductReviewsSection } from "./product-reviews-section";
|
|
20
|
+
import { RecentlyViewedProducts } from "./recently-viewed";
|
|
21
|
+
import { RelatedProducts } from "./related-products";
|
|
22
|
+
import { StarDisplay } from "./star-display";
|
|
23
|
+
import { StockBadge } from "./stock-badge";
|
|
24
|
+
|
|
25
|
+
export interface ProductDetailProps {
|
|
26
|
+
slug?: string;
|
|
27
|
+
params?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ProductDetail(props: ProductDetailProps) {
|
|
31
|
+
const slug = props.slug ?? props.params?.slug;
|
|
32
|
+
const api = useProductsApi();
|
|
33
|
+
const cartApi = useCartMutation();
|
|
34
|
+
const reviewsApi = useReviewsApi();
|
|
35
|
+
const track = useTrack();
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: store context shape varies per app
|
|
37
|
+
const store = useStoreContext<{ cart: any }>();
|
|
38
|
+
|
|
39
|
+
const { data, isLoading } = api.getProduct.useQuery(
|
|
40
|
+
{ params: { id: slug ?? "" } },
|
|
41
|
+
{ enabled: !!slug },
|
|
42
|
+
) as {
|
|
43
|
+
data: { product: ProductWithVariants } | undefined;
|
|
44
|
+
isLoading: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const product = data?.product ?? null;
|
|
48
|
+
|
|
49
|
+
const { data: reviewsSummaryData } = reviewsApi.listProductReviews.useQuery(
|
|
50
|
+
product
|
|
51
|
+
? {
|
|
52
|
+
params: { productId: product.id },
|
|
53
|
+
take: "1",
|
|
54
|
+
}
|
|
55
|
+
: undefined,
|
|
56
|
+
) as { data: ReviewsResponse | undefined };
|
|
57
|
+
const reviewSummary = reviewsSummaryData?.summary;
|
|
58
|
+
|
|
59
|
+
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
|
|
60
|
+
null,
|
|
61
|
+
);
|
|
62
|
+
const [selectedImage, setSelectedImage] = useState(0);
|
|
63
|
+
const [qty, setQty] = useState(1);
|
|
64
|
+
const [added, setAdded] = useState(false);
|
|
65
|
+
|
|
66
|
+
const trackedRef = useRef<string | null>(null);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (product && trackedRef.current !== product.id) {
|
|
69
|
+
trackedRef.current = product.id;
|
|
70
|
+
track({
|
|
71
|
+
type: "productView",
|
|
72
|
+
productId: product.id,
|
|
73
|
+
data: {
|
|
74
|
+
name: product.name,
|
|
75
|
+
slug: product.slug,
|
|
76
|
+
price: product.price,
|
|
77
|
+
image: product.images[0],
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}, [product, track]);
|
|
82
|
+
|
|
83
|
+
const firstVariant = product?.variants?.[0] ?? null;
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (product && firstVariant) {
|
|
86
|
+
setSelectedVariant(firstVariant);
|
|
87
|
+
}
|
|
88
|
+
}, [product?.id]);
|
|
89
|
+
|
|
90
|
+
const addToCartMutation = cartApi.addToCart.useMutation({
|
|
91
|
+
onSuccess: () => {
|
|
92
|
+
void cartApi.getCart.invalidate();
|
|
93
|
+
store.cart.openDrawer();
|
|
94
|
+
setAdded(true);
|
|
95
|
+
setTimeout(() => setAdded(false), 2000);
|
|
96
|
+
if (product) {
|
|
97
|
+
track({
|
|
98
|
+
type: "addToCart",
|
|
99
|
+
productId: product.id,
|
|
100
|
+
value: selectedVariant?.price ?? product.price,
|
|
101
|
+
data: {
|
|
102
|
+
name: product.name,
|
|
103
|
+
quantity: qty,
|
|
104
|
+
variantId: selectedVariant?.id,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!slug) {
|
|
112
|
+
return (
|
|
113
|
+
<div className="rounded-md border border-border bg-muted/30 p-4 text-muted-foreground">
|
|
114
|
+
<p className="font-medium">Product not found</p>
|
|
115
|
+
<p className="mt-1 text-sm">No product was specified.</p>
|
|
116
|
+
<a href="/products" className="mt-3 inline-block text-sm underline">
|
|
117
|
+
Back to products
|
|
118
|
+
</a>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (isLoading) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="py-6">
|
|
126
|
+
<div className="grid gap-8 lg:grid-cols-2 lg:gap-12">
|
|
127
|
+
<div className="aspect-square animate-pulse rounded-lg bg-muted" />
|
|
128
|
+
<div className="space-y-4 py-2">
|
|
129
|
+
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
|
|
130
|
+
<div className="h-7 w-2/3 animate-pulse rounded bg-muted" />
|
|
131
|
+
<div className="h-6 w-24 animate-pulse rounded bg-muted" />
|
|
132
|
+
<div className="h-20 animate-pulse rounded bg-muted" />
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!product) {
|
|
140
|
+
return (
|
|
141
|
+
<div className="flex flex-col items-center justify-center py-24 text-center">
|
|
142
|
+
<p className="font-medium text-foreground text-sm">Product not found</p>
|
|
143
|
+
<a
|
|
144
|
+
href="/products"
|
|
145
|
+
className="mt-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
146
|
+
>
|
|
147
|
+
Back to products
|
|
148
|
+
</a>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const optionKeys: string[] = [];
|
|
154
|
+
const optionValues: Record<string, string[]> = {};
|
|
155
|
+
for (const v of product.variants) {
|
|
156
|
+
for (const [key, value] of Object.entries(v.options)) {
|
|
157
|
+
if (!optionValues[key]) {
|
|
158
|
+
optionKeys.push(key);
|
|
159
|
+
optionValues[key] = [];
|
|
160
|
+
}
|
|
161
|
+
if (!optionValues[key].includes(value)) {
|
|
162
|
+
optionValues[key].push(value);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const selectedOptions: Record<string, string> = {};
|
|
168
|
+
if (selectedVariant) {
|
|
169
|
+
for (const [key, value] of Object.entries(selectedVariant.options)) {
|
|
170
|
+
selectedOptions[key] = value;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const handleOptionChange = (key: string, value: string) => {
|
|
175
|
+
const newOptions = { ...selectedOptions, [key]: value };
|
|
176
|
+
const match = product.variants.find((v) =>
|
|
177
|
+
Object.entries(newOptions).every(([k, val]) => v.options[k] === val),
|
|
178
|
+
);
|
|
179
|
+
if (match) {
|
|
180
|
+
setSelectedVariant(match);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const displayPrice = selectedVariant?.price ?? product.price;
|
|
185
|
+
const comparePrice =
|
|
186
|
+
selectedVariant?.compareAtPrice ?? product.compareAtPrice;
|
|
187
|
+
const hasDiscount = comparePrice != null && comparePrice > displayPrice;
|
|
188
|
+
const inStock = (selectedVariant?.inventory ?? product.inventory) > 0;
|
|
189
|
+
|
|
190
|
+
const handleAddToCart = () => {
|
|
191
|
+
addToCartMutation.mutate({
|
|
192
|
+
productId: product.id,
|
|
193
|
+
variantId: selectedVariant?.id ?? undefined,
|
|
194
|
+
quantity: qty,
|
|
195
|
+
price: displayPrice,
|
|
196
|
+
productName: product.name,
|
|
197
|
+
productSlug: product.slug,
|
|
198
|
+
productImage: product.images[0],
|
|
199
|
+
variantName: selectedVariant?.name,
|
|
200
|
+
variantOptions: selectedVariant?.options,
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// --- Pre-computed JSX blocks for template ---
|
|
205
|
+
|
|
206
|
+
const breadcrumbs = (
|
|
207
|
+
<nav className="mb-6 flex items-center gap-1.5 text-muted-foreground text-xs">
|
|
208
|
+
<a href="/" className="transition-colors hover:text-foreground">
|
|
209
|
+
Home
|
|
210
|
+
</a>
|
|
211
|
+
<span className="text-border">/</span>
|
|
212
|
+
<a href="/products" className="transition-colors hover:text-foreground">
|
|
213
|
+
Products
|
|
214
|
+
</a>
|
|
215
|
+
<span className="text-border">/</span>
|
|
216
|
+
<span className="truncate text-foreground">{product.name}</span>
|
|
217
|
+
</nav>
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const imageGallery = (
|
|
221
|
+
<div className="space-y-2.5">
|
|
222
|
+
<div className="aspect-square overflow-hidden rounded-lg bg-muted">
|
|
223
|
+
{product.images[selectedImage] ? (
|
|
224
|
+
<img
|
|
225
|
+
src={product.images[selectedImage]}
|
|
226
|
+
alt={product.name}
|
|
227
|
+
className="h-full w-full object-cover object-center"
|
|
228
|
+
/>
|
|
229
|
+
) : (
|
|
230
|
+
<div className="flex h-full w-full items-center justify-center text-muted-foreground/30">
|
|
231
|
+
<svg
|
|
232
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
233
|
+
width="40"
|
|
234
|
+
height="40"
|
|
235
|
+
viewBox="0 0 24 24"
|
|
236
|
+
fill="none"
|
|
237
|
+
stroke="currentColor"
|
|
238
|
+
strokeWidth="1.5"
|
|
239
|
+
strokeLinecap="round"
|
|
240
|
+
strokeLinejoin="round"
|
|
241
|
+
aria-hidden="true"
|
|
242
|
+
>
|
|
243
|
+
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
244
|
+
<circle cx="9" cy="9" r="2" />
|
|
245
|
+
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
246
|
+
</svg>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
{product.images.length > 1 && (
|
|
251
|
+
<div className="flex gap-1.5">
|
|
252
|
+
{product.images.map((img, i) => (
|
|
253
|
+
<button
|
|
254
|
+
key={i}
|
|
255
|
+
type="button"
|
|
256
|
+
onClick={() => setSelectedImage(i)}
|
|
257
|
+
className={`h-14 w-14 overflow-hidden rounded-md transition-all ${
|
|
258
|
+
i === selectedImage
|
|
259
|
+
? "ring-1.5 ring-foreground ring-offset-1 ring-offset-background"
|
|
260
|
+
: "opacity-50 hover:opacity-80"
|
|
261
|
+
}`}
|
|
262
|
+
>
|
|
263
|
+
<img
|
|
264
|
+
src={img}
|
|
265
|
+
alt={`${product.name} view ${i + 1}`}
|
|
266
|
+
className="h-full w-full object-cover"
|
|
267
|
+
/>
|
|
268
|
+
</button>
|
|
269
|
+
))}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const categoryLink = product.category ? (
|
|
276
|
+
<a
|
|
277
|
+
href={`/products?category=${product.category.id}`}
|
|
278
|
+
className="w-fit text-muted-foreground text-xs transition-colors hover:text-foreground"
|
|
279
|
+
>
|
|
280
|
+
{product.category.name}
|
|
281
|
+
</a>
|
|
282
|
+
) : null;
|
|
283
|
+
|
|
284
|
+
const reviewSummaryLink =
|
|
285
|
+
reviewSummary && reviewSummary.count > 0 ? (
|
|
286
|
+
<a
|
|
287
|
+
href="#reviews"
|
|
288
|
+
className="flex items-center gap-1.5 transition-opacity hover:opacity-80"
|
|
289
|
+
>
|
|
290
|
+
<StarDisplay rating={reviewSummary.average} size="sm" />
|
|
291
|
+
<span className="text-muted-foreground text-sm">
|
|
292
|
+
{reviewSummary.average.toFixed(1)} ({reviewSummary.count})
|
|
293
|
+
</span>
|
|
294
|
+
</a>
|
|
295
|
+
) : null;
|
|
296
|
+
|
|
297
|
+
const priceBlock = (
|
|
298
|
+
<div className="flex items-center gap-2.5">
|
|
299
|
+
<span className="font-display text-foreground text-xl tabular-nums sm:text-2xl">
|
|
300
|
+
{formatPrice(displayPrice)}
|
|
301
|
+
</span>
|
|
302
|
+
{hasDiscount && (
|
|
303
|
+
<>
|
|
304
|
+
<span className="text-muted-foreground text-sm tabular-nums line-through">
|
|
305
|
+
{formatPrice(comparePrice as number)}
|
|
306
|
+
</span>
|
|
307
|
+
<span className="rounded-full bg-foreground px-2 py-0.5 font-medium text-2xs text-background tabular-nums">
|
|
308
|
+
−{Math.round((1 - displayPrice / (comparePrice as number)) * 100)}%
|
|
309
|
+
</span>
|
|
310
|
+
</>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const stockBadge = (
|
|
316
|
+
<StockBadge inventory={selectedVariant?.inventory ?? product.inventory} />
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const shortDescription = product.shortDescription ? (
|
|
320
|
+
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
321
|
+
{product.shortDescription}
|
|
322
|
+
</p>
|
|
323
|
+
) : null;
|
|
324
|
+
|
|
325
|
+
let variantSelector: React.ReactNode = null;
|
|
326
|
+
if (product.variants.length > 0 && optionKeys.length > 0) {
|
|
327
|
+
variantSelector = (
|
|
328
|
+
<div className="space-y-3.5">
|
|
329
|
+
{optionKeys.map((key) => (
|
|
330
|
+
<div key={key} className="space-y-1.5">
|
|
331
|
+
<p className="text-foreground text-xs">
|
|
332
|
+
{key}
|
|
333
|
+
{selectedOptions[key] && (
|
|
334
|
+
<span className="ml-1.5 text-muted-foreground">
|
|
335
|
+
{selectedOptions[key]}
|
|
336
|
+
</span>
|
|
337
|
+
)}
|
|
338
|
+
</p>
|
|
339
|
+
<div className="flex flex-wrap gap-1.5">
|
|
340
|
+
{(optionValues[key] ?? []).map((value) => {
|
|
341
|
+
const isSelected = selectedOptions[key] === value;
|
|
342
|
+
const wouldMatch = product.variants.some(
|
|
343
|
+
(v) =>
|
|
344
|
+
v.options[key] === value &&
|
|
345
|
+
Object.entries(selectedOptions).every(
|
|
346
|
+
([k, val]) => k === key || v.options[k] === val,
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
return (
|
|
350
|
+
<button
|
|
351
|
+
key={value}
|
|
352
|
+
type="button"
|
|
353
|
+
onClick={() => handleOptionChange(key, value)}
|
|
354
|
+
disabled={!wouldMatch}
|
|
355
|
+
className={`rounded-md border px-3 py-1.5 text-sm transition-all ${
|
|
356
|
+
isSelected
|
|
357
|
+
? "border-foreground bg-foreground text-background"
|
|
358
|
+
: wouldMatch
|
|
359
|
+
? "border-border text-foreground hover:border-foreground/40"
|
|
360
|
+
: "border-border/40 text-muted-foreground/30 line-through"
|
|
361
|
+
}`}
|
|
362
|
+
>
|
|
363
|
+
{value}
|
|
364
|
+
</button>
|
|
365
|
+
);
|
|
366
|
+
})}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
))}
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
} else if (product.variants.length > 0 && optionKeys.length === 0) {
|
|
373
|
+
variantSelector = (
|
|
374
|
+
<div className="space-y-1.5">
|
|
375
|
+
<p className="text-foreground text-xs">
|
|
376
|
+
{selectedVariant ? selectedVariant.name : "Select option"}
|
|
377
|
+
</p>
|
|
378
|
+
<div className="flex flex-wrap gap-1.5">
|
|
379
|
+
{product.variants.map((v) => (
|
|
380
|
+
<button
|
|
381
|
+
key={v.id}
|
|
382
|
+
type="button"
|
|
383
|
+
onClick={() => setSelectedVariant(v)}
|
|
384
|
+
className={`rounded-md border px-3 py-1.5 text-sm transition-all ${
|
|
385
|
+
selectedVariant?.id === v.id
|
|
386
|
+
? "border-foreground bg-foreground text-background"
|
|
387
|
+
: "border-border text-foreground hover:border-foreground/40"
|
|
388
|
+
}`}
|
|
389
|
+
>
|
|
390
|
+
{v.name}
|
|
391
|
+
</button>
|
|
392
|
+
))}
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const addToCartBlock = (
|
|
399
|
+
<div className="mt-1 flex items-center gap-2.5">
|
|
400
|
+
<div className="flex items-center rounded-md border border-border">
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
onClick={() => setQty((q) => Math.max(1, q - 1))}
|
|
404
|
+
className="flex h-10 w-10 items-center justify-center text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
405
|
+
>
|
|
406
|
+
−
|
|
407
|
+
</button>
|
|
408
|
+
<span className="min-w-8 text-center text-foreground text-sm tabular-nums">
|
|
409
|
+
{qty}
|
|
410
|
+
</span>
|
|
411
|
+
<button
|
|
412
|
+
type="button"
|
|
413
|
+
onClick={() => setQty((q) => q + 1)}
|
|
414
|
+
className="flex h-10 w-10 items-center justify-center text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
415
|
+
>
|
|
416
|
+
+
|
|
417
|
+
</button>
|
|
418
|
+
</div>
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
onClick={handleAddToCart}
|
|
422
|
+
disabled={addToCartMutation.isPending || !inStock}
|
|
423
|
+
className="flex-1 rounded-md bg-foreground py-2.5 font-medium text-background text-sm transition-opacity hover:opacity-85 active:opacity-75 disabled:opacity-40"
|
|
424
|
+
>
|
|
425
|
+
{!inStock
|
|
426
|
+
? "Sold out"
|
|
427
|
+
: added
|
|
428
|
+
? "Added!"
|
|
429
|
+
: addToCartMutation.isPending
|
|
430
|
+
? "Adding…"
|
|
431
|
+
: "Add to cart"}
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const outOfStockNotice = !inStock ? (
|
|
437
|
+
<BackInStockNotify
|
|
438
|
+
productId={product.id}
|
|
439
|
+
variantId={selectedVariant?.id}
|
|
440
|
+
productName={product.name}
|
|
441
|
+
/>
|
|
442
|
+
) : null;
|
|
443
|
+
|
|
444
|
+
const descriptionBlock = product.description ? (
|
|
445
|
+
<div className="mt-2 border-border/50 border-t pt-5">
|
|
446
|
+
<p className="mb-2 font-medium text-foreground text-xs">Description</p>
|
|
447
|
+
<p className="whitespace-pre-wrap text-muted-foreground text-sm leading-relaxed">
|
|
448
|
+
{product.description}
|
|
449
|
+
</p>
|
|
450
|
+
</div>
|
|
451
|
+
) : null;
|
|
452
|
+
|
|
453
|
+
const tagsBlock =
|
|
454
|
+
product.tags.length > 0 ? (
|
|
455
|
+
<div className="flex flex-wrap gap-1">
|
|
456
|
+
{product.tags.map((t) => (
|
|
457
|
+
<a
|
|
458
|
+
key={t}
|
|
459
|
+
href={`/products?tag=${encodeURIComponent(t)}`}
|
|
460
|
+
className="rounded-md bg-muted px-2 py-0.5 text-muted-foreground text-xs transition-colors hover:bg-muted-foreground/20 hover:text-foreground"
|
|
461
|
+
>
|
|
462
|
+
{t}
|
|
463
|
+
</a>
|
|
464
|
+
))}
|
|
465
|
+
</div>
|
|
466
|
+
) : null;
|
|
467
|
+
|
|
468
|
+
return (
|
|
469
|
+
<ProductDetailTemplate
|
|
470
|
+
breadcrumbs={breadcrumbs}
|
|
471
|
+
imageGallery={imageGallery}
|
|
472
|
+
categoryLink={categoryLink}
|
|
473
|
+
name={product.name}
|
|
474
|
+
reviewSummaryLink={reviewSummaryLink}
|
|
475
|
+
priceBlock={priceBlock}
|
|
476
|
+
stockBadge={stockBadge}
|
|
477
|
+
shortDescription={shortDescription}
|
|
478
|
+
variantSelector={variantSelector}
|
|
479
|
+
addToCartBlock={addToCartBlock}
|
|
480
|
+
outOfStockNotice={outOfStockNotice}
|
|
481
|
+
descriptionBlock={descriptionBlock}
|
|
482
|
+
tagsBlock={tagsBlock}
|
|
483
|
+
reviewsSection={<ProductReviewsSection productId={product.id} />}
|
|
484
|
+
relatedProducts={<RelatedProducts productId={product.id} />}
|
|
485
|
+
recentlyViewed={<RecentlyViewedProducts excludeProductId={product.id} />}
|
|
486
|
+
/>
|
|
487
|
+
);
|
|
488
|
+
}
|