hey-pharmacist-ecommerce 1.0.5 → 1.0.7
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/README.md +157 -17
- package/dist/index.d.mts +3636 -316
- package/dist/index.d.ts +3636 -316
- package/dist/index.js +6802 -3866
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6756 -3818
- package/dist/index.mjs.map +1 -1
- package/package.json +18 -15
- package/src/components/AddressFormModal.tsx +171 -0
- package/src/components/CartItem.tsx +17 -12
- package/src/components/FilterChips.tsx +195 -0
- package/src/components/Header.tsx +121 -71
- package/src/components/OrderCard.tsx +18 -25
- package/src/components/ProductCard.tsx +209 -72
- package/src/components/ui/Button.tsx +13 -5
- package/src/components/ui/Card.tsx +46 -0
- package/src/hooks/useAddresses.ts +83 -0
- package/src/hooks/useOrders.ts +37 -19
- package/src/hooks/useProducts.ts +55 -63
- package/src/hooks/useWishlistProducts.ts +75 -0
- package/src/index.ts +3 -19
- package/src/lib/Apis/api.ts +1 -0
- package/src/lib/Apis/apis/cart-api.ts +3 -3
- package/src/lib/Apis/apis/inventory-api.ts +0 -108
- package/src/lib/Apis/apis/stores-api.ts +70 -0
- package/src/lib/Apis/apis/wishlist-api.ts +447 -0
- package/src/lib/Apis/models/cart-item-populated.ts +0 -1
- package/src/lib/Apis/models/create-single-variant-product-dto.ts +3 -10
- package/src/lib/Apis/models/create-variant-dto.ts +26 -33
- package/src/lib/Apis/models/extended-product-dto.ts +20 -24
- package/src/lib/Apis/models/index.ts +2 -1
- package/src/lib/Apis/models/order-time-line-dto.ts +49 -0
- package/src/lib/Apis/models/order.ts +3 -8
- package/src/lib/Apis/models/populated-order.ts +3 -8
- package/src/lib/Apis/models/product-variant.ts +29 -0
- package/src/lib/Apis/models/update-product-variant-dto.ts +16 -23
- package/src/lib/Apis/models/wishlist.ts +51 -0
- package/src/lib/Apis/wrapper.ts +18 -7
- package/src/lib/api-adapter/index.ts +0 -12
- package/src/lib/types/index.ts +16 -61
- package/src/lib/utils/colors.ts +7 -4
- package/src/lib/utils/format.ts +1 -1
- package/src/lib/validations/address.ts +14 -0
- package/src/providers/AuthProvider.tsx +61 -31
- package/src/providers/CartProvider.tsx +18 -28
- package/src/providers/EcommerceProvider.tsx +7 -0
- package/src/providers/FavoritesProvider.tsx +86 -0
- package/src/providers/ThemeProvider.tsx +16 -1
- package/src/providers/WishlistProvider.tsx +174 -0
- package/src/screens/AddressesScreen.tsx +484 -0
- package/src/screens/CartScreen.tsx +120 -84
- package/src/screens/CategoriesScreen.tsx +120 -0
- package/src/screens/CheckoutScreen.tsx +919 -241
- package/src/screens/CurrentOrdersScreen.tsx +125 -61
- package/src/screens/HomeScreen.tsx +209 -0
- package/src/screens/LoginScreen.tsx +133 -88
- package/src/screens/NewAddressScreen.tsx +187 -0
- package/src/screens/OrdersScreen.tsx +162 -50
- package/src/screens/ProductDetailScreen.tsx +641 -190
- package/src/screens/ProfileScreen.tsx +192 -116
- package/src/screens/RegisterScreen.tsx +193 -144
- package/src/screens/SearchResultsScreen.tsx +165 -0
- package/src/screens/ShopScreen.tsx +1110 -146
- package/src/screens/WishlistScreen.tsx +428 -0
- package/src/lib/Apis/models/inventory-paginated-response.ts +0 -75
- package/src/lib/api/auth.ts +0 -81
- package/src/lib/api/cart.ts +0 -42
- package/src/lib/api/orders.ts +0 -53
- package/src/lib/api/products.ts +0 -51
- package/src/lib/api-adapter/auth-adapter.ts +0 -196
- package/src/lib/api-adapter/cart-adapter.ts +0 -193
- package/src/lib/api-adapter/mappers.ts +0 -152
- package/src/lib/api-adapter/orders-adapter.ts +0 -195
- package/src/lib/api-adapter/products-adapter.ts +0 -194
|
@@ -1,255 +1,706 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState } from 'react';
|
|
3
|
+
import React, { useEffect, useState, useMemo } from 'react';
|
|
4
4
|
import { motion } from 'framer-motion';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ArrowLeft,
|
|
7
|
+
Award,
|
|
8
|
+
Check,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
Heart,
|
|
11
|
+
Minus,
|
|
12
|
+
Package,
|
|
13
|
+
Plus,
|
|
14
|
+
Shield,
|
|
15
|
+
ShieldCheck,
|
|
16
|
+
ShoppingCart,
|
|
17
|
+
Sparkles,
|
|
18
|
+
Star,
|
|
19
|
+
Truck,
|
|
20
|
+
} from 'lucide-react';
|
|
21
|
+
import Link from 'next/link';
|
|
22
|
+
import Image from 'next/image';
|
|
6
23
|
import { Button } from '@/components/ui/Button';
|
|
7
24
|
import { Badge } from '@/components/ui/Badge';
|
|
8
25
|
import { ProductCard } from '@/components/ProductCard';
|
|
9
26
|
import { useProduct } from '@/hooks/useProducts';
|
|
10
27
|
import { useCart } from '@/providers/CartProvider';
|
|
11
|
-
import { formatPrice } from '@/lib/utils/format';
|
|
12
|
-
import { productsApi } from '@/lib/api/products';
|
|
13
|
-
import Image from 'next/image';
|
|
28
|
+
import { formatDate, formatPrice } from '@/lib/utils/format';
|
|
14
29
|
import { toast } from 'sonner';
|
|
30
|
+
import { useRouter } from 'next/navigation';
|
|
31
|
+
import { ProductVariantsApi } from '@/lib/Apis/apis/product-variants-api';
|
|
32
|
+
import { AXIOS_CONFIG } from '@/lib/Apis/wrapper';
|
|
33
|
+
import { useWishlist } from '@/providers/WishlistProvider';
|
|
34
|
+
import { ProductsApi, ProductVariant, ProductVariantInventoryStatusEnum } from '@/lib/Apis';
|
|
35
|
+
|
|
36
|
+
const safeFormatDate = (date?: Date | string, format: 'long' | 'short' = 'long'): string => {
|
|
37
|
+
if (!date) return 'N/A';
|
|
38
|
+
try {
|
|
39
|
+
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
|
40
|
+
return formatDate(dateObj, format);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error('Error formatting date:', error);
|
|
43
|
+
return 'N/A';
|
|
44
|
+
}
|
|
45
|
+
};
|
|
15
46
|
|
|
16
47
|
interface ProductDetailScreenProps {
|
|
17
48
|
productId: string;
|
|
18
49
|
}
|
|
19
50
|
|
|
20
51
|
export function ProductDetailScreen({ productId }: ProductDetailScreenProps) {
|
|
21
|
-
|
|
52
|
+
// Hooks and state at the top level
|
|
53
|
+
const router = useRouter();
|
|
54
|
+
const { product: productData, isLoading } = useProduct(productId);
|
|
22
55
|
const { addToCart } = useCart();
|
|
23
|
-
|
|
56
|
+
|
|
57
|
+
// State declarations
|
|
58
|
+
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
|
24
59
|
const [quantity, setQuantity] = useState(1);
|
|
25
60
|
const [isAddingToCart, setIsAddingToCart] = useState(false);
|
|
26
61
|
const [isFavorited, setIsFavorited] = useState(false);
|
|
27
62
|
const [relatedProducts, setRelatedProducts] = useState<any[]>([]);
|
|
63
|
+
const [initialProductData, setInitialProductData] = useState<any>(null);
|
|
64
|
+
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
|
28
65
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
setActiveImageIndex(0);
|
|
69
|
+
}, [selectedVariant]);
|
|
70
|
+
|
|
71
|
+
const product = useMemo(() => {
|
|
72
|
+
if (initialProductData && !productData) {
|
|
73
|
+
return initialProductData;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!productData) return null;
|
|
77
|
+
|
|
78
|
+
if (productData.productVariants?.length && selectedVariant) {
|
|
79
|
+
return {
|
|
80
|
+
...productData,
|
|
81
|
+
price: selectedVariant.finalPrice,
|
|
82
|
+
inStock: selectedVariant.isActive,
|
|
83
|
+
sku: selectedVariant.sku || productData.sku,
|
|
84
|
+
variantId: selectedVariant.id,
|
|
85
|
+
variantName: selectedVariant.name
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return productData;
|
|
90
|
+
}, [productData, selectedVariant, initialProductData]);
|
|
91
|
+
|
|
92
|
+
const getVariantImages = () => {
|
|
93
|
+
if (selectedVariant?.productMedia?.length) {
|
|
94
|
+
return selectedVariant.productMedia.map((media: any) => ({
|
|
95
|
+
src: media.file,
|
|
96
|
+
width: 800,
|
|
97
|
+
height: 1000,
|
|
98
|
+
alt: product?.name || 'Product image',
|
|
99
|
+
...media,
|
|
100
|
+
url: media.file,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (product?.images?.length) {
|
|
105
|
+
return product.images.map((image: string) => ({
|
|
106
|
+
src: image,
|
|
107
|
+
width: 800,
|
|
108
|
+
height: 1000,
|
|
109
|
+
alt: product?.name || 'Product image',
|
|
110
|
+
url: image,
|
|
111
|
+
file: image,
|
|
112
|
+
type: 'image' as const
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return [];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const currentVariant = selectedVariant || product?.productVariants?.[0] || product;
|
|
120
|
+
const variantImages = getVariantImages();
|
|
121
|
+
|
|
122
|
+
// Use only the selected variant's prices for discount calculation
|
|
123
|
+
const variantPrice = selectedVariant?.finalPrice || currentVariant?.finalPrice || product?.price || 0;
|
|
124
|
+
const variantComparePrice = selectedVariant?.retailPrice ||
|
|
125
|
+
(currentVariant && 'retailPrice' in currentVariant ? currentVariant.retailPrice : null) ||
|
|
126
|
+
product?.compareAtPrice;
|
|
127
|
+
|
|
128
|
+
// Only show discount if we have both prices and the compare price is higher
|
|
129
|
+
const discount = variantComparePrice && variantPrice && variantComparePrice > variantPrice
|
|
130
|
+
? Math.round(((variantComparePrice - variantPrice) / variantComparePrice) * 100)
|
|
131
|
+
: 0;
|
|
132
|
+
const lastUpdatedLabel = safeFormatDate(
|
|
133
|
+
(currentVariant && 'updatedAt' in currentVariant && currentVariant.updatedAt)
|
|
134
|
+
? currentVariant.updatedAt
|
|
135
|
+
: product?.updatedAt
|
|
136
|
+
);
|
|
137
|
+
const variantSku = currentVariant?.sku || product?.sku || 'N/A';
|
|
138
|
+
// const variantInStock = currentVariant && 'inStock' in currentVariant
|
|
139
|
+
// ? currentVariant.inStock
|
|
140
|
+
// : product?.inStock;
|
|
141
|
+
|
|
142
|
+
const variantInStock = currentVariant?.inventoryCount > 0;
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (typeof window === 'undefined') return;
|
|
147
|
+
|
|
148
|
+
const searchParams = new URLSearchParams(window.location.search);
|
|
149
|
+
const productParam = searchParams.get('product');
|
|
150
|
+
|
|
151
|
+
if (productParam) {
|
|
152
|
+
try {
|
|
153
|
+
const decodedProduct = JSON.parse(decodeURIComponent(productParam));
|
|
154
|
+
setInitialProductData(decodedProduct);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error('Failed to parse product data from URL', error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (product?.productVariants?.length > 0) {
|
|
165
|
+
const newVariant = product?.productVariants?.[0];
|
|
166
|
+
setSelectedVariant(newVariant);
|
|
36
167
|
}
|
|
37
|
-
}, [product]);
|
|
168
|
+
}, [product?.productVariants]);
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!product?.id) return;
|
|
172
|
+
|
|
173
|
+
let isMounted = true;
|
|
174
|
+
const fetchRelated = async () => {
|
|
175
|
+
try {
|
|
176
|
+
const response = await new ProductsApi(AXIOS_CONFIG).getRelatedProducts(product.id, {
|
|
177
|
+
params: {
|
|
178
|
+
limit: 4
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
setRelatedProducts(response.data || []);
|
|
182
|
+
} catch (error: any) {
|
|
183
|
+
console.error('Failed to fetch related products', error);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
fetchRelated();
|
|
187
|
+
return () => {
|
|
188
|
+
isMounted = false;
|
|
189
|
+
};
|
|
190
|
+
}, [product?.id]);
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
const handleVariantSelect = async (variant: ProductVariant) => {
|
|
194
|
+
setSelectedVariant(variant);
|
|
195
|
+
};
|
|
38
196
|
|
|
39
197
|
const handleAddToCart = async () => {
|
|
40
|
-
if (!product) return;
|
|
198
|
+
if (!product || !selectedVariant) return;
|
|
199
|
+
|
|
41
200
|
setIsAddingToCart(true);
|
|
42
201
|
try {
|
|
43
|
-
await addToCart(
|
|
202
|
+
await addToCart(
|
|
203
|
+
product.id,
|
|
204
|
+
quantity,
|
|
205
|
+
selectedVariant.id
|
|
206
|
+
);
|
|
207
|
+
toast.success('Added to cart', {
|
|
208
|
+
description: `${quantity} × ${product.name}${selectedVariant.name ? ` (${selectedVariant.name})` : ''}`,
|
|
209
|
+
});
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error('Failed to add to cart', error);
|
|
212
|
+
toast.error('Could not add to cart. Please try again.');
|
|
44
213
|
} finally {
|
|
45
214
|
setIsAddingToCart(false);
|
|
46
215
|
}
|
|
47
216
|
};
|
|
48
217
|
|
|
218
|
+
const { addToWishlist, removeFromWishlist, isInWishlist } = useWishlist();
|
|
219
|
+
|
|
220
|
+
// Initialize isFavorited based on whether the product is in the wishlist
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (product) {
|
|
223
|
+
setIsFavorited(isInWishlist(product.id));
|
|
224
|
+
}
|
|
225
|
+
}, [product, isInWishlist]);
|
|
226
|
+
|
|
227
|
+
const handleToggleFavorite = async () => {
|
|
228
|
+
if (!product) return;
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
if (isFavorited) {
|
|
232
|
+
await removeFromWishlist(product.id);
|
|
233
|
+
toast.info('Removed from saved items');
|
|
234
|
+
} else {
|
|
235
|
+
await addToWishlist(product);
|
|
236
|
+
toast.success('Saved to your favorites');
|
|
237
|
+
}
|
|
238
|
+
// Update the local state after successful API call
|
|
239
|
+
setIsFavorited(!isFavorited);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
console.error('Error updating wishlist:', error);
|
|
242
|
+
toast.error('Failed to update wishlist');
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
49
246
|
if (isLoading) {
|
|
50
|
-
return
|
|
247
|
+
return (
|
|
248
|
+
<div className="min-h-screen bg-slate-50">
|
|
249
|
+
<div className="container mx-auto px-4 py-16">
|
|
250
|
+
<div className="grid gap-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
251
|
+
<div className="space-y-6">
|
|
252
|
+
<div className="h-[520px] animate-pulse rounded-3xl bg-slate-200" />
|
|
253
|
+
<div className="grid grid-cols-3 gap-4">
|
|
254
|
+
{Array.from({ length: 3 }).map((_, index) => (
|
|
255
|
+
<div key={index} className="h-32 animate-pulse rounded-2xl bg-slate-200" />
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
<div className="space-y-4 rounded-3xl bg-white p-6 shadow-sm">
|
|
260
|
+
<div className="h-8 w-32 animate-pulse rounded-full bg-slate-200" />
|
|
261
|
+
<div className="h-10 w-48 animate-pulse rounded-full bg-slate-200" />
|
|
262
|
+
<div className="h-6 w-full animate-pulse rounded-full bg-slate-200" />
|
|
263
|
+
<div className="h-12 w-full animate-pulse rounded-2xl bg-slate-200" />
|
|
264
|
+
<div className="h-12 w-full animate-pulse rounded-2xl bg-slate-200" />
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
51
270
|
}
|
|
52
271
|
|
|
53
272
|
if (!product) {
|
|
54
|
-
return
|
|
273
|
+
return (
|
|
274
|
+
<div className="min-h-screen bg-slate-50">
|
|
275
|
+
<div className="container mx-auto px-4 py-16">
|
|
276
|
+
<div className="rounded-3xl bg-white p-10 text-center shadow-sm">
|
|
277
|
+
<Sparkles className="mx-auto h-10 w-10 text-primary-500" />
|
|
278
|
+
<h1 className="mt-6 text-2xl font-semibold text-gray-900">Product not found</h1>
|
|
279
|
+
<p className="mt-2 text-gray-600">
|
|
280
|
+
It may have been removed or is temporarily unavailable. Discover other pharmacy
|
|
281
|
+
essentials in our catalogue.
|
|
282
|
+
</p>
|
|
283
|
+
<div className="mt-6">
|
|
284
|
+
<Link href="/shop" className="inline-block">
|
|
285
|
+
<Button>Browse products</Button>
|
|
286
|
+
</Link>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
55
292
|
}
|
|
56
293
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
294
|
+
const benefitPills =
|
|
295
|
+
product.tags && product.tags.length > 0
|
|
296
|
+
? product.tags.slice(0, 6)
|
|
297
|
+
: ['Pharmacist approved', 'Gentle on daily routines', 'Backed by real customers'];
|
|
298
|
+
|
|
299
|
+
const highlightCards = [
|
|
300
|
+
{
|
|
301
|
+
icon: ShieldCheck,
|
|
302
|
+
title: 'Pharmacy grade assurance',
|
|
303
|
+
description: 'Sourced from trusted suppliers and reviewed by licensed professionals.',
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
icon: Truck,
|
|
307
|
+
title: 'Fast, cold-chain ready shipping',
|
|
308
|
+
description: 'Carefully packed and dispatched within 24 hours on business days.',
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
icon: Award,
|
|
312
|
+
title: 'Loved by patients',
|
|
313
|
+
description: 'Average rating 4.8/5 with over 120 verified customer experiences.',
|
|
314
|
+
},
|
|
315
|
+
];
|
|
60
316
|
|
|
61
|
-
return (
|
|
62
|
-
<div className="min-h-screen bg-gray-50 py-12">
|
|
63
|
-
<div className="container mx-auto px-4">
|
|
64
|
-
{/* Breadcrumbs */}
|
|
65
|
-
<div className="flex items-center gap-2 text-sm text-gray-600 mb-8">
|
|
66
|
-
<a href="/" className="hover:text-primary-600">Home</a>
|
|
67
|
-
<span>/</span>
|
|
68
|
-
<a href="/shop" className="hover:text-primary-600">Shop</a>
|
|
69
|
-
<span>/</span>
|
|
70
|
-
<span className="text-gray-900">{product.name}</span>
|
|
71
|
-
</div>
|
|
72
317
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<div className="min-h-screen bg-slate-50">
|
|
321
|
+
<section className="relative overflow-hidden bg-gradient-to-br from-[rgb(var(--header-from))] via-[rgb(var(--header-via))] to-[rgb(var(--header-to))] text-white mb-8">
|
|
322
|
+
<div
|
|
323
|
+
className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.35),_transparent_60%)]"
|
|
324
|
+
aria-hidden="true"
|
|
325
|
+
/>
|
|
326
|
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_right,_rgba(255,255,255,0.25),_transparent_55%)] opacity-70" />
|
|
327
|
+
<div className="relative container mx-auto px-4 py-16">
|
|
328
|
+
<div className="flex flex-col gap-6">
|
|
329
|
+
<div className="flex items-center justify-between">
|
|
330
|
+
<Button
|
|
331
|
+
variant="ghost"
|
|
332
|
+
className="text-white hover:bg-white/10"
|
|
333
|
+
onClick={() => router.push('/shop')}
|
|
334
|
+
>
|
|
335
|
+
<ArrowLeft className="h-5 w-5" />
|
|
336
|
+
Continue shopping
|
|
337
|
+
</Button>
|
|
338
|
+
<div className="hidden items-center gap-3 text-sm text-white/80 md:flex">
|
|
339
|
+
<Link href="/" className="transition hover:text-white">
|
|
340
|
+
Home
|
|
341
|
+
</Link>
|
|
342
|
+
<ChevronRight className="h-4 w-4" />
|
|
343
|
+
<Link href="/shop" className="transition hover:text-white">
|
|
344
|
+
Shop
|
|
345
|
+
</Link>
|
|
346
|
+
<ChevronRight className="h-4 w-4" />
|
|
347
|
+
<span className="truncate font-medium text-white">{product.name}</span>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
77
350
|
<motion.div
|
|
78
|
-
initial={{ opacity: 0, y:
|
|
351
|
+
initial={{ opacity: 0, y: 24 }}
|
|
79
352
|
animate={{ opacity: 1, y: 0 }}
|
|
80
|
-
className="
|
|
353
|
+
className="max-w-3xl space-y-4"
|
|
81
354
|
>
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
355
|
+
{product.category && (
|
|
356
|
+
<Badge
|
|
357
|
+
variant="secondary"
|
|
358
|
+
className="bg-white/15 text-white backdrop-blur-sm"
|
|
359
|
+
>
|
|
360
|
+
{product.category}
|
|
361
|
+
</Badge>
|
|
362
|
+
)}
|
|
363
|
+
<h1 className="text-4xl font-bold leading-tight md:text-5xl">{product.name}</h1>
|
|
364
|
+
<div className="flex flex-wrap items-center gap-3 text-sm text-white/80">
|
|
365
|
+
<span className="inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 font-medium text-white">
|
|
366
|
+
<Sparkles className="h-4 w-4" />
|
|
367
|
+
Ready to ship today
|
|
368
|
+
</span>
|
|
369
|
+
<span className="inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 font-medium text-white">
|
|
370
|
+
<Shield className="h-4 w-4" />
|
|
371
|
+
30-day happiness guarantee
|
|
372
|
+
</span>
|
|
94
373
|
</div>
|
|
95
374
|
</motion.div>
|
|
96
|
-
|
|
97
|
-
{/* Thumbnail Images */}
|
|
98
|
-
{product.images.length > 1 && (
|
|
99
|
-
<div className="grid grid-cols-4 gap-4">
|
|
100
|
-
{product.images.map((image, index) => (
|
|
101
|
-
<button
|
|
102
|
-
key={index}
|
|
103
|
-
onClick={() => setSelectedImage(index)}
|
|
104
|
-
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all ${
|
|
105
|
-
selectedImage === index
|
|
106
|
-
? 'border-primary-600 ring-2 ring-primary-600/20'
|
|
107
|
-
: 'border-gray-200 hover:border-gray-300'
|
|
108
|
-
}`}
|
|
109
|
-
>
|
|
110
|
-
<Image src={image} alt="" fill className="object-cover" />
|
|
111
|
-
</button>
|
|
112
|
-
))}
|
|
113
|
-
</div>
|
|
114
|
-
)}
|
|
115
375
|
</div>
|
|
376
|
+
</div>
|
|
377
|
+
</section>
|
|
116
378
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
key={i}
|
|
138
|
-
className="w-5 h-5 fill-yellow-400 text-yellow-400"
|
|
379
|
+
<div className="relative -mt-16 pb-20">
|
|
380
|
+
<div className="container mx-auto px-4">
|
|
381
|
+
<div className="grid gap-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
382
|
+
<div className="space-y-10">
|
|
383
|
+
<section className="rounded-3xl border border-white bg-white/70 p-6 shadow-xl shadow-primary-100/40 backdrop-blur">
|
|
384
|
+
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_220px]">
|
|
385
|
+
<motion.div
|
|
386
|
+
key={variantImages[activeImageIndex]}
|
|
387
|
+
initial={{ opacity: 0.4, scale: 0.98 }}
|
|
388
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
389
|
+
transition={{ duration: 0.35 }}
|
|
390
|
+
className="relative overflow-hidden rounded-3xl bg-slate-100 h-[420px] md:h-[560px]"
|
|
391
|
+
>
|
|
392
|
+
<Image
|
|
393
|
+
src={variantImages[activeImageIndex]}
|
|
394
|
+
alt={currentVariant.name || product.name}
|
|
395
|
+
fill
|
|
396
|
+
priority
|
|
397
|
+
sizes="(max-width: 1024px) 100vw, 800px"
|
|
398
|
+
className="object-contain"
|
|
139
399
|
/>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
400
|
+
{discount > 0 && (
|
|
401
|
+
<Badge
|
|
402
|
+
variant="danger"
|
|
403
|
+
className="absolute left-6 top-6 shadow-lg shadow-red-500/20"
|
|
404
|
+
>
|
|
405
|
+
Save {discount}%
|
|
406
|
+
</Badge>
|
|
407
|
+
)}
|
|
408
|
+
{!variantInStock && (
|
|
409
|
+
<Badge
|
|
410
|
+
variant="secondary"
|
|
411
|
+
className="absolute right-6 top-6 bg-white/90 text-slate-700 shadow-lg border-slate-200"
|
|
412
|
+
>
|
|
413
|
+
Out of Stock
|
|
414
|
+
</Badge>
|
|
415
|
+
)}
|
|
416
|
+
</motion.div>
|
|
144
417
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
418
|
+
<div className="flex flex-col gap-4">
|
|
419
|
+
{product?.productVariants?.length > 0 && (
|
|
420
|
+
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
|
421
|
+
<p className="mb-2 text-sm font-semibold uppercase tracking-[0.25em] text-slate-400">
|
|
422
|
+
Variant
|
|
423
|
+
</p>
|
|
424
|
+
<div className="flex flex-wrap gap-2">
|
|
425
|
+
{product?.productVariants?.map((variant: ProductVariant, index: number) => (
|
|
426
|
+
<button
|
|
427
|
+
key={variant.id}
|
|
428
|
+
type="button"
|
|
429
|
+
onClick={() => handleVariantSelect(variant)}
|
|
430
|
+
className={`rounded-full px-4 py-2 text-sm font-medium transition ${selectedVariant?.id === variant.id
|
|
431
|
+
? 'bg-primary-600 text-white'
|
|
432
|
+
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
|
433
|
+
}`}
|
|
434
|
+
>
|
|
435
|
+
{variant.name}
|
|
436
|
+
</button>
|
|
437
|
+
))}
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
156
441
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
442
|
+
<div className="grid grid-cols-3 gap-3">
|
|
443
|
+
{variantImages.map((image: { src: string; alt?: string }, index: number) => (
|
|
444
|
+
<button
|
|
445
|
+
key={image.src + index}
|
|
446
|
+
type="button"
|
|
447
|
+
onClick={() => setActiveImageIndex(index)}
|
|
448
|
+
className={`relative aspect-square overflow-hidden rounded-2xl border transition ${activeImageIndex === index
|
|
449
|
+
? 'border-primary-500 shadow-lg shadow-primary-200/60'
|
|
450
|
+
: 'border-transparent hover:border-primary-200'
|
|
451
|
+
}`}
|
|
452
|
+
>
|
|
453
|
+
<Image
|
|
454
|
+
src={image.src}
|
|
455
|
+
alt={image.alt || `Product image ${index + 1}`}
|
|
456
|
+
className="h-full w-full object-cover object-center"
|
|
457
|
+
width={200}
|
|
458
|
+
height={200}
|
|
459
|
+
unoptimized={true}
|
|
460
|
+
/>
|
|
461
|
+
</button>
|
|
462
|
+
))}
|
|
463
|
+
</div>
|
|
164
464
|
</div>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)}
|
|
168
|
-
</div>
|
|
169
|
-
|
|
170
|
-
{/* Description */}
|
|
171
|
-
<div className="mb-8">
|
|
172
|
-
<p className="text-gray-700 leading-relaxed">{product.description}</p>
|
|
173
|
-
</div>
|
|
465
|
+
</div>
|
|
466
|
+
</section>
|
|
174
467
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
<button
|
|
183
|
-
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
184
|
-
className="p-3 hover:bg-gray-100 transition-colors"
|
|
468
|
+
<section className="grid gap-6 lg:grid-cols-3">
|
|
469
|
+
{highlightCards.map((card) => {
|
|
470
|
+
const Icon = card.icon;
|
|
471
|
+
return (
|
|
472
|
+
<div
|
|
473
|
+
key={card.title}
|
|
474
|
+
className="rounded-3xl border border-slate-100 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg"
|
|
185
475
|
>
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
476
|
+
<div className="flex items-center gap-3">
|
|
477
|
+
<span className="rounded-2xl bg-primary-50 p-3 text-primary-600">
|
|
478
|
+
<Icon className="h-5 w-5" />
|
|
479
|
+
</span>
|
|
480
|
+
<h3 className="text-base font-semibold text-slate-900">{card.title}</h3>
|
|
481
|
+
</div>
|
|
482
|
+
<p className="mt-3 text-sm leading-relaxed text-slate-600">{card.description}</p>
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
})}
|
|
486
|
+
</section>
|
|
487
|
+
|
|
488
|
+
<section className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
|
489
|
+
<div className="rounded-3xl border border-slate-100 bg-white p-8 shadow-sm">
|
|
490
|
+
<div className="flex flex-wrap items-center gap-4 pb-6">
|
|
491
|
+
<div className="flex items-center gap-1 text-amber-500">
|
|
492
|
+
{Array.from({ length: 5 }).map((_, index) => (
|
|
493
|
+
<Star key={index} className="h-4 w-4 fill-current" />
|
|
494
|
+
))}
|
|
495
|
+
</div>
|
|
496
|
+
<span className="text-sm font-medium text-slate-500">
|
|
497
|
+
Rated 4.8 • Patients love the results
|
|
190
498
|
</span>
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
499
|
+
</div>
|
|
500
|
+
<div className="space-y-8">
|
|
501
|
+
{product.description && (
|
|
502
|
+
<div className="space-y-3">
|
|
503
|
+
<h3 className="text-lg font-semibold text-slate-900">Description</h3>
|
|
504
|
+
<p className="text-base leading-relaxed text-slate-600" dangerouslySetInnerHTML={{ __html: product.description }} />
|
|
505
|
+
</div>
|
|
506
|
+
)}
|
|
197
507
|
</div>
|
|
198
508
|
</div>
|
|
199
|
-
|
|
509
|
+
<div className="h-full rounded-3xl border border-slate-100 bg-gradient-to-br from-primary-50 via-white to-secondary-50 p-8 shadow-sm">
|
|
510
|
+
<h3 className="text-sm font-semibold uppercase tracking-[0.3em] text-primary-500">
|
|
511
|
+
Care tips
|
|
512
|
+
</h3>
|
|
513
|
+
<div className="mt-4 space-y-4 text-sm text-slate-600">
|
|
514
|
+
<p className="leading-relaxed">
|
|
515
|
+
Store in a cool, dry place away from direct sunlight. Check packaging for
|
|
516
|
+
allergen statements.
|
|
517
|
+
</p>
|
|
518
|
+
<p className="leading-relaxed">
|
|
519
|
+
Consult with your local pharmacist if you are combining with other treatments
|
|
520
|
+
or have chronic conditions.
|
|
521
|
+
</p>
|
|
522
|
+
<p className="rounded-2xl bg-white/60 p-4 leading-relaxed text-primary-700">
|
|
523
|
+
Questions? Our care team is on standby — reach us via chat for tailored
|
|
524
|
+
support before you checkout.
|
|
525
|
+
</p>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
</section>
|
|
200
529
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
530
|
+
<section className="rounded-3xl border border-slate-100 bg-white p-8 shadow-sm">
|
|
531
|
+
<h2 className="text-xl font-semibold text-slate-900">Product insights</h2>
|
|
532
|
+
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
|
533
|
+
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-5">
|
|
534
|
+
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-500">
|
|
535
|
+
Availability
|
|
536
|
+
</p>
|
|
537
|
+
<div className="mt-3 flex items-center gap-2 text-sm text-slate-600">
|
|
538
|
+
<Check className="h-4 w-4 text-primary-500" />
|
|
539
|
+
{product.inStock ? 'Available for dispatch today' : 'Currently restocking'}
|
|
540
|
+
</div>
|
|
541
|
+
{product.stock !== undefined && (
|
|
542
|
+
<p className="mt-2 text-xs text-slate-500">
|
|
543
|
+
{product.stock > 0
|
|
544
|
+
? `${product.stock} units remaining in inventory`
|
|
545
|
+
: 'Join the waitlist to be notified first'}
|
|
546
|
+
</p>
|
|
547
|
+
)}
|
|
548
|
+
</div>
|
|
549
|
+
<div className="rounded-2xl border border-slate-200 bg-slate-50/60 p-5">
|
|
550
|
+
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-500">
|
|
551
|
+
Product details
|
|
552
|
+
</p>
|
|
553
|
+
<div className="mt-3 space-y-2 text-sm text-slate-600">
|
|
554
|
+
<p>
|
|
555
|
+
<span className="font-medium text-slate-700">Variant:</span> {currentVariant.name}
|
|
556
|
+
</p>
|
|
557
|
+
<p>
|
|
558
|
+
<span className="font-medium text-slate-700">SKU:</span> {variantSku}
|
|
559
|
+
</p>
|
|
560
|
+
<p>
|
|
561
|
+
<span className="font-medium text-slate-700">Status:</span>{' '}
|
|
562
|
+
<span className={variantInStock ? 'text-green-600' : 'text-amber-600'}>
|
|
563
|
+
{variantInStock ? 'In Stock' : 'Out of Stock'}
|
|
564
|
+
</span>
|
|
565
|
+
</p>
|
|
566
|
+
<p>
|
|
567
|
+
<span className="font-medium text-slate-700">Last updated:</span>{' '}
|
|
568
|
+
{lastUpdatedLabel}
|
|
569
|
+
</p>
|
|
570
|
+
<p>
|
|
571
|
+
<span className="font-medium text-slate-700">Ships from:</span> Local
|
|
572
|
+
pharmacy distribution center
|
|
573
|
+
</p>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
</section>
|
|
578
|
+
</div>
|
|
224
579
|
|
|
225
|
-
|
|
226
|
-
<div className="
|
|
227
|
-
<div className="flex items-
|
|
228
|
-
<
|
|
229
|
-
|
|
580
|
+
<aside className="space-y-6 lg:sticky lg:top-24">
|
|
581
|
+
<div className="rounded-3xl border border-slate-100 bg-white p-8 shadow-lg shadow-primary-100/40">
|
|
582
|
+
<div className="flex items-baseline gap-3">
|
|
583
|
+
<p className="text-3xl font-bold text-slate-900">
|
|
584
|
+
{selectedVariant ? formatPrice(selectedVariant.finalPrice) : formatPrice(product.price)}
|
|
585
|
+
</p>
|
|
586
|
+
{variantComparePrice && variantComparePrice > variantPrice && (
|
|
587
|
+
<p className="text-base text-slate-400 line-through">
|
|
588
|
+
{formatPrice(variantComparePrice)}
|
|
589
|
+
</p>
|
|
590
|
+
)}
|
|
591
|
+
{discount > 0 && (
|
|
592
|
+
<Badge variant="danger" size="sm">
|
|
593
|
+
-{discount}%
|
|
594
|
+
</Badge>
|
|
595
|
+
)}
|
|
230
596
|
</div>
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
<
|
|
597
|
+
|
|
598
|
+
<div className="mt-6 space-y-4">
|
|
599
|
+
<div className="flex items-center gap-3 rounded-2xl bg-primary-50/80 px-4 py-3 text-sm font-medium text-primary-700">
|
|
600
|
+
<ShieldCheck className="h-4 w-4" />
|
|
601
|
+
Pharmacist verified product
|
|
602
|
+
</div>
|
|
603
|
+
|
|
604
|
+
<div className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3">
|
|
605
|
+
<span className="text-sm font-medium text-slate-600">Qty</span>
|
|
606
|
+
<div className="flex items-center rounded-full border border-slate-200 bg-slate-50">
|
|
607
|
+
<button
|
|
608
|
+
type="button"
|
|
609
|
+
onClick={() => setQuantity((current) => Math.max(1, current - 1))}
|
|
610
|
+
className="rounded-l-full p-2 hover:bg-primary-100/60"
|
|
611
|
+
aria-label="Decrease quantity"
|
|
612
|
+
>
|
|
613
|
+
<Minus className="h-4 w-4" />
|
|
614
|
+
</button>
|
|
615
|
+
<span className="w-12 text-center text-sm font-semibold text-slate-700">
|
|
616
|
+
{quantity}
|
|
617
|
+
</span>
|
|
618
|
+
<button
|
|
619
|
+
type="button"
|
|
620
|
+
onClick={() => setQuantity((current) => current + 1)}
|
|
621
|
+
className="rounded-r-full p-2 hover:bg-primary-100/60"
|
|
622
|
+
aria-label="Increase quantity"
|
|
623
|
+
>
|
|
624
|
+
<Plus className="h-4 w-4" />
|
|
625
|
+
</button>
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
234
628
|
</div>
|
|
629
|
+
|
|
630
|
+
{selectedVariant && (
|
|
631
|
+
<div className="mt-4 text-sm">
|
|
632
|
+
{selectedVariant.inventoryStatus === ProductVariantInventoryStatusEnum.OUTOFSTOCK ||
|
|
633
|
+
selectedVariant.inventoryStatus === ProductVariantInventoryStatusEnum.LOWSTOCK ? (
|
|
634
|
+
<div className="text-red-600 font-medium">Out of Stock</div>
|
|
635
|
+
) : <div className="text-green-600 font-medium">In Stock</div>
|
|
636
|
+
}
|
|
637
|
+
</div>
|
|
638
|
+
)}
|
|
639
|
+
<div className="mt-6 space-x-3">
|
|
640
|
+
<Button
|
|
641
|
+
size="lg"
|
|
642
|
+
className="w-full"
|
|
643
|
+
onClick={handleAddToCart}
|
|
644
|
+
isLoading={isAddingToCart}
|
|
645
|
+
disabled={!selectedVariant || selectedVariant.inventoryStatus === ProductVariantInventoryStatusEnum.OUTOFSTOCK}
|
|
646
|
+
>
|
|
647
|
+
<ShoppingCart className="h-5 w-5" />
|
|
648
|
+
{!selectedVariant
|
|
649
|
+
? 'Select a variant'
|
|
650
|
+
: selectedVariant.inventoryStatus === ProductVariantInventoryStatusEnum.OUTOFSTOCK
|
|
651
|
+
? 'Out of Stock'
|
|
652
|
+
: `Add to Cart`}
|
|
653
|
+
</Button>
|
|
654
|
+
<Button
|
|
655
|
+
size="lg"
|
|
656
|
+
variant="outline"
|
|
657
|
+
className="w-full"
|
|
658
|
+
onClick={handleToggleFavorite}
|
|
659
|
+
>
|
|
660
|
+
<Heart
|
|
661
|
+
className={`h-5 w-5 ${isFavorited ? 'fill-red-500 text-red-500' : 'text-slate-500'
|
|
662
|
+
}`}
|
|
663
|
+
/>
|
|
664
|
+
{isFavorited ? 'Saved' : 'Save for later'}
|
|
665
|
+
</Button>
|
|
666
|
+
</div>
|
|
667
|
+
|
|
235
668
|
</div>
|
|
236
|
-
</motion.div>
|
|
237
|
-
</div>
|
|
238
|
-
</div>
|
|
239
669
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
</div>
|
|
670
|
+
<div className="rounded-3xl border border-primary-100 bg-primary-50/70 p-6 text-sm text-primary-700 shadow-sm">
|
|
671
|
+
<p className="font-semibold uppercase tracking-[0.25em]">Need advice?</p>
|
|
672
|
+
<p className="mt-2 leading-relaxed">
|
|
673
|
+
Chat with a pharmacist in real time before completing your purchase. We will help
|
|
674
|
+
you choose supporting supplements and answer dosing questions.
|
|
675
|
+
</p>
|
|
676
|
+
</div>
|
|
677
|
+
</aside>
|
|
249
678
|
</div>
|
|
250
|
-
|
|
679
|
+
|
|
680
|
+
{relatedProducts.length > 0 && (
|
|
681
|
+
<section className="mt-20">
|
|
682
|
+
<div className="flex items-center justify-between">
|
|
683
|
+
<div>
|
|
684
|
+
<h2 className="text-2xl font-semibold text-slate-900">You may also like</h2>
|
|
685
|
+
<p className="mt-1 text-sm text-slate-500">
|
|
686
|
+
Hand-picked recommendations that pair nicely with this product.
|
|
687
|
+
</p>
|
|
688
|
+
</div>
|
|
689
|
+
<Link href="/shop" className="hidden md:inline-flex">
|
|
690
|
+
<Button variant="ghost" className="text-primary-600">
|
|
691
|
+
View all products
|
|
692
|
+
</Button>
|
|
693
|
+
</Link>
|
|
694
|
+
</div>
|
|
695
|
+
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
696
|
+
{relatedProducts?.map((relatedProduct) => (
|
|
697
|
+
<ProductCard key={relatedProduct.id} product={relatedProduct} />
|
|
698
|
+
))}
|
|
699
|
+
</div>
|
|
700
|
+
</section>
|
|
701
|
+
)}
|
|
702
|
+
</div>
|
|
251
703
|
</div>
|
|
252
704
|
</div>
|
|
253
705
|
);
|
|
254
|
-
}
|
|
255
|
-
|
|
706
|
+
}
|