@pradip1995/segment-product-card 0.3.19 → 0.3.21
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/package.json +6 -6
- package/src/motion-product-card.tsx +110 -0
- package/src/product-card-actions.tsx +11 -2
- package/src/resolve-product-images.ts +15 -9
- package/src/segment.css +34 -4
- package/src/segment.tsx +13 -2
- package/src/shop-product-card.tsx +203 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pradip1995/segment-product-card",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -17,10 +17,6 @@
|
|
|
17
17
|
"./product-scroll": "./src/product-scroll.tsx",
|
|
18
18
|
"./product-rail-variants": "./src/product-rail-variants.tsx"
|
|
19
19
|
},
|
|
20
|
-
"scripts": {
|
|
21
|
-
"typecheck": "tsc --noEmit",
|
|
22
|
-
"lint": "tsc --noEmit"
|
|
23
|
-
},
|
|
24
20
|
"peerDependencies": {
|
|
25
21
|
"@pradip1995/commerce-core": "^4.0.0",
|
|
26
22
|
"@pradip1995/plugin-sdk": "^0.2.0",
|
|
@@ -37,5 +33,9 @@
|
|
|
37
33
|
"@types/react": "^19",
|
|
38
34
|
"react": "19.0.3",
|
|
39
35
|
"typescript": "^5.7.2"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"lint": "tsc --noEmit"
|
|
40
40
|
}
|
|
41
|
-
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Image from "next/image"
|
|
4
|
+
import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
|
|
5
|
+
import { formatPrice } from "@pradip1995/segment-primitives/format-price"
|
|
6
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
product: HttpTypes.StoreProduct
|
|
10
|
+
region?: HttpTypes.StoreRegion
|
|
11
|
+
countryCode?: string
|
|
12
|
+
rating?: { rating?: number }
|
|
13
|
+
showWishlist?: boolean
|
|
14
|
+
wishlistProductIds?: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function MotionProductCard({
|
|
18
|
+
product,
|
|
19
|
+
region,
|
|
20
|
+
countryCode: countryCodeProp,
|
|
21
|
+
rating,
|
|
22
|
+
showWishlist = true,
|
|
23
|
+
wishlistProductIds,
|
|
24
|
+
}: Props) {
|
|
25
|
+
const variant = product.variants?.[0]
|
|
26
|
+
const price = variant?.calculated_price?.calculated_amount
|
|
27
|
+
const originalPrice = variant?.calculated_price?.original_amount
|
|
28
|
+
const currency = region?.currency_code || variant?.calculated_price?.currency_code || "inr"
|
|
29
|
+
const countryCode =
|
|
30
|
+
countryCodeProp?.toLowerCase() ||
|
|
31
|
+
region?.countries?.[0]?.iso_2?.toLowerCase() ||
|
|
32
|
+
"in"
|
|
33
|
+
|
|
34
|
+
// Get color options for swatches
|
|
35
|
+
const colorOption = product.options?.find((opt: any) => opt.title?.toLowerCase() === 'color' || opt.title?.toLowerCase() === 'colour')
|
|
36
|
+
const colorValues = colorOption?.values?.map((v: any) => v.value) || []
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<article
|
|
40
|
+
data-product-card-id={product.id}
|
|
41
|
+
className="product-card group/card flex flex-col w-full h-full relative font-sans"
|
|
42
|
+
>
|
|
43
|
+
<LocalizedLink href={`/products/${product.handle}`} className="flex flex-col h-full relative">
|
|
44
|
+
<div className="product-card__media relative aspect-[3/4] bg-[#F6F6F6] overflow-hidden group/media">
|
|
45
|
+
{product.thumbnail?.trim() ? (
|
|
46
|
+
<>
|
|
47
|
+
<Image
|
|
48
|
+
src={product.thumbnail.trim()}
|
|
49
|
+
alt={product.title || ""}
|
|
50
|
+
fill
|
|
51
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 50vw"
|
|
52
|
+
className={`object-cover transition-all duration-[800ms] ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
|
53
|
+
product.images && product.images.length > 1
|
|
54
|
+
? 'group-hover/media:opacity-0'
|
|
55
|
+
: 'group-hover/media:scale-105'
|
|
56
|
+
}`}
|
|
57
|
+
loading="lazy"
|
|
58
|
+
/>
|
|
59
|
+
{product.images && product.images.length > 1 && (
|
|
60
|
+
<Image
|
|
61
|
+
src={product.images[1].url.trim()}
|
|
62
|
+
alt={product.title || ""}
|
|
63
|
+
fill
|
|
64
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 50vw"
|
|
65
|
+
className="absolute inset-0 object-cover opacity-0 scale-100 group-hover/media:opacity-100 group-hover/media:scale-105 transition-all duration-[800ms] ease-[cubic-bezier(0.16,1,0.3,1)]"
|
|
66
|
+
loading="lazy"
|
|
67
|
+
/>
|
|
68
|
+
)}
|
|
69
|
+
</>
|
|
70
|
+
) : (
|
|
71
|
+
<div className="w-full h-full bg-[#F6F6F6]" />
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{/* Quick view floating circle */}
|
|
75
|
+
<div className="absolute top-3 right-3 z-10 opacity-0 group-hover/media:opacity-100 transition-all duration-300 pointer-events-none translate-y-1 group-hover/media:translate-y-0">
|
|
76
|
+
<div className="w-14 h-14 rounded-full bg-white text-gray-900 flex items-center justify-center text-[10px] uppercase font-bold tracking-widest text-center leading-[1.1] shadow-[0_4px_15px_rgba(0,0,0,0.1)] pointer-events-auto cursor-pointer hover:bg-gray-50 hover:scale-105 transition-transform">
|
|
77
|
+
Quick<br/>view
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="mt-4 flex flex-col min-w-0">
|
|
83
|
+
<h3 className="text-sm font-medium text-gray-900 group-hover/card:underline transition-all">
|
|
84
|
+
{product.title}
|
|
85
|
+
</h3>
|
|
86
|
+
|
|
87
|
+
{price != null ? (
|
|
88
|
+
<div className="mt-1 text-sm text-gray-600">
|
|
89
|
+
<span>{formatPrice(price, currency)}</span>
|
|
90
|
+
</div>
|
|
91
|
+
) : null}
|
|
92
|
+
|
|
93
|
+
{/* Color Swatches */}
|
|
94
|
+
{colorValues.length > 0 && (
|
|
95
|
+
<div className="mt-2 flex items-center space-x-1.5">
|
|
96
|
+
{colorValues.map((color: string, idx: number) => (
|
|
97
|
+
<div
|
|
98
|
+
key={idx}
|
|
99
|
+
className="w-[14px] h-[14px] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]"
|
|
100
|
+
style={{ backgroundColor: color.toLowerCase() }}
|
|
101
|
+
title={color}
|
|
102
|
+
/>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
</LocalizedLink>
|
|
108
|
+
</article>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "@pradip1995/commerce-core/client/actions/wishlist"
|
|
11
11
|
import type { HttpTypes } from "@medusajs/types"
|
|
12
12
|
import { isNextRedirect } from "@pradip1995/segment-primitives/is-next-redirect"
|
|
13
|
+
import LoginRequiredModal from "@pradip1995/segment-login-popup/required"
|
|
13
14
|
import QuickAddPanel from "./quick-add-panel"
|
|
14
15
|
|
|
15
16
|
type Props = {
|
|
@@ -46,6 +47,7 @@ export default function ProductCardActions({ product, countryCode, wishlistProdu
|
|
|
46
47
|
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({})
|
|
47
48
|
const [quantity, setQuantity] = useState(1)
|
|
48
49
|
const [error, setError] = useState<string | null>(null)
|
|
50
|
+
const [loginRequired, setLoginRequired] = useState(false)
|
|
49
51
|
|
|
50
52
|
const variants = product.variants ?? []
|
|
51
53
|
const hasMultipleVariants = variants.length > 1
|
|
@@ -112,7 +114,7 @@ export default function ProductCardActions({ product, countryCode, wishlistProdu
|
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
if (result.error?.toLowerCase().includes("login")) {
|
|
115
|
-
|
|
117
|
+
setLoginRequired(true)
|
|
116
118
|
return
|
|
117
119
|
}
|
|
118
120
|
|
|
@@ -173,7 +175,7 @@ export default function ProductCardActions({ product, countryCode, wishlistProdu
|
|
|
173
175
|
|
|
174
176
|
return (
|
|
175
177
|
<>
|
|
176
|
-
<div className="product-card__actions" onClick={stopCardNav}>
|
|
178
|
+
<div className="product-card__actions absolute bottom-3.5 right-3.5 z-30 opacity-0 group-hover/media:opacity-100 transition-opacity duration-300 pointer-events-none group-hover/media:pointer-events-auto" onClick={stopCardNav}>
|
|
177
179
|
<button
|
|
178
180
|
type="button"
|
|
179
181
|
className={`product-card__wishlist-btn${wishlisted ? " product-card__wishlist-btn--active" : ""}${
|
|
@@ -231,6 +233,13 @@ export default function ProductCardActions({ product, countryCode, wishlistProdu
|
|
|
231
233
|
</div>
|
|
232
234
|
) : null}
|
|
233
235
|
|
|
236
|
+
<LoginRequiredModal
|
|
237
|
+
isOpen={loginRequired}
|
|
238
|
+
onClose={() => setLoginRequired(false)}
|
|
239
|
+
countryCode={countryCode}
|
|
240
|
+
message="Please log in to save items to your wishlist."
|
|
241
|
+
/>
|
|
242
|
+
|
|
234
243
|
{showQuickAdd ? (
|
|
235
244
|
<QuickAddPanel
|
|
236
245
|
product={product}
|
|
@@ -3,18 +3,24 @@ import { resolveMediaUrl } from "@pradip1995/segment-primitives/resolve-media-ur
|
|
|
3
3
|
|
|
4
4
|
export function resolveProductCardImages(product: HttpTypes.StoreProduct) {
|
|
5
5
|
const gallery =
|
|
6
|
-
product.images
|
|
7
|
-
?.map((image) => resolveMediaUrl(image.url))
|
|
8
|
-
.filter((url): url is string =>
|
|
6
|
+
(product.images as { url?: string | null }[] | undefined)
|
|
7
|
+
?.map((image: { url?: string | null }) => resolveMediaUrl(image.url) || image.url?.trim())
|
|
8
|
+
.filter((url: string | undefined): url is string => Boolean(url)) ?? []
|
|
9
9
|
|
|
10
|
-
const variantImages = (product.variants ?? [])
|
|
11
|
-
.map((variant
|
|
12
|
-
|
|
10
|
+
const variantImages = ((product.variants as { thumbnail?: string | null }[] | undefined) ?? [])
|
|
11
|
+
.map((variant: { thumbnail?: string | null }) => {
|
|
12
|
+
const raw = variant.thumbnail
|
|
13
|
+
return resolveMediaUrl(raw) || raw?.trim()
|
|
14
|
+
})
|
|
15
|
+
.filter((url: string | undefined): url is string => Boolean(url))
|
|
13
16
|
|
|
14
|
-
const
|
|
15
|
-
const thumbnail = resolveMediaUrl(
|
|
17
|
+
const rawThumb = product.thumbnail?.trim() || ""
|
|
18
|
+
const thumbnail = resolveMediaUrl(rawThumb) || rawThumb
|
|
19
|
+
|
|
20
|
+
const images = Array.from(new Set([thumbnail, ...gallery, ...variantImages].filter((url: string): url is string => Boolean(url))))
|
|
21
|
+
|
|
16
22
|
const primary = thumbnail || images[0] || ""
|
|
17
|
-
const hover =
|
|
23
|
+
const hover = images.find((url: string) => Boolean(url) && url !== primary) || ""
|
|
18
24
|
|
|
19
25
|
return { primary, hover }
|
|
20
26
|
}
|
package/src/segment.css
CHANGED
|
@@ -53,8 +53,9 @@
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
.beauty-product-card .product-card__actions {
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
bottom: 0.875rem;
|
|
57
|
+
top: auto;
|
|
58
|
+
right: 0.875rem;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
.beauty-product-card .product-card__wishlist-btn,
|
|
@@ -275,8 +276,9 @@
|
|
|
275
276
|
}
|
|
276
277
|
|
|
277
278
|
.jewelry-product-card .product-card__actions {
|
|
278
|
-
|
|
279
|
-
|
|
279
|
+
bottom: 0.875rem;
|
|
280
|
+
top: auto;
|
|
281
|
+
right: 0.875rem;
|
|
280
282
|
}
|
|
281
283
|
|
|
282
284
|
.jewelry-product-card .product-card__wishlist-btn {
|
|
@@ -406,3 +408,31 @@
|
|
|
406
408
|
pointer-events: auto;
|
|
407
409
|
}
|
|
408
410
|
}
|
|
411
|
+
|
|
412
|
+
/* Shop product card (Motion style) */
|
|
413
|
+
|
|
414
|
+
.shop-product-card .product-card__actions {
|
|
415
|
+
bottom: 0.875rem;
|
|
416
|
+
top: auto;
|
|
417
|
+
right: 0.875rem;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.shop-product-card .product-card__wishlist-btn {
|
|
421
|
+
width: 2.125rem;
|
|
422
|
+
height: 2.125rem;
|
|
423
|
+
border-radius: 999px;
|
|
424
|
+
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
425
|
+
background: rgba(255, 255, 255, 0.95);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.shop-product-card .product-card__wishlist-btn--active {
|
|
429
|
+
background: var(--color-brand-accent-muted, #f4eff8);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.shop-product-card .product-card__cart-btn {
|
|
433
|
+
display: none; /* Hide the cart icon since shop card uses the Quick View circle */
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.shop-product-card .product-card__quick-add-wrap {
|
|
437
|
+
display: none; /* Hide the quick add bar at the bottom, triggered by the blue circle instead */
|
|
438
|
+
}
|
package/src/segment.tsx
CHANGED
|
@@ -5,8 +5,10 @@ import type { HttpTypes } from "@medusajs/types"
|
|
|
5
5
|
import BeautyProductCard from "./beauty-product-card"
|
|
6
6
|
import DefaultProductCard from "./default-product-card"
|
|
7
7
|
import JewelryProductCard from "./jewelry-product-card"
|
|
8
|
+
import MotionProductCard from "./motion-product-card"
|
|
9
|
+
import ShopProductCard from "./shop-product-card"
|
|
8
10
|
|
|
9
|
-
export type ProductCardVariant = "default" | "beauty" | "jewelry"
|
|
11
|
+
export type ProductCardVariant = "default" | "beauty" | "jewelry" | "motion" | "shop"
|
|
10
12
|
|
|
11
13
|
type Props = {
|
|
12
14
|
product: HttpTypes.StoreProduct
|
|
@@ -19,12 +21,21 @@ type Props = {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function resolveProductCardVariant(variant?: ProductCardVariant): ProductCardVariant {
|
|
22
|
-
if (
|
|
24
|
+
if (
|
|
25
|
+
variant === "beauty" ||
|
|
26
|
+
variant === "default" ||
|
|
27
|
+
variant === "jewelry" ||
|
|
28
|
+
variant === "motion" ||
|
|
29
|
+
variant === "shop"
|
|
30
|
+
)
|
|
31
|
+
return variant
|
|
23
32
|
return "default"
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export default function ProductCard({ variant, ...props }: Props) {
|
|
27
36
|
const layout = resolveProductCardVariant(variant)
|
|
37
|
+
if (layout === "shop") return <ShopProductCard {...props} />
|
|
38
|
+
if (layout === "motion") return <MotionProductCard {...props} />
|
|
28
39
|
if (layout === "beauty") return <BeautyProductCard {...props} />
|
|
29
40
|
if (layout === "jewelry") return <JewelryProductCard {...props} />
|
|
30
41
|
return <DefaultProductCard {...props} />
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import Image from "next/image"
|
|
5
|
+
import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
|
|
6
|
+
import { formatPrice } from "@pradip1995/segment-primitives/format-price"
|
|
7
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
8
|
+
import ProductCardActions from "./product-card-actions"
|
|
9
|
+
import { resolveProductCardImages } from "./resolve-product-images"
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
product: HttpTypes.StoreProduct
|
|
13
|
+
region?: HttpTypes.StoreRegion
|
|
14
|
+
countryCode?: string
|
|
15
|
+
rating?: { rating?: number }
|
|
16
|
+
showWishlist?: boolean
|
|
17
|
+
wishlistProductIds?: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type OptionWithValue = { value?: string | null }
|
|
21
|
+
type OptionItem = { title?: string | null; values?: OptionWithValue[] | null }
|
|
22
|
+
|
|
23
|
+
export default function ShopProductCard({
|
|
24
|
+
product,
|
|
25
|
+
region,
|
|
26
|
+
countryCode: countryCodeProp,
|
|
27
|
+
wishlistProductIds,
|
|
28
|
+
}: Props) {
|
|
29
|
+
const variant = product.variants?.[0]
|
|
30
|
+
const price = variant?.calculated_price?.calculated_amount
|
|
31
|
+
const currency = region?.currency_code || variant?.calculated_price?.currency_code || "inr"
|
|
32
|
+
const countryCode =
|
|
33
|
+
countryCodeProp?.toLowerCase() ||
|
|
34
|
+
region?.countries?.[0]?.iso_2?.toLowerCase() ||
|
|
35
|
+
"in"
|
|
36
|
+
|
|
37
|
+
const { primary, hover } = resolveProductCardImages(product)
|
|
38
|
+
const [mainImgSrc, setMainImgSrc] = useState(primary)
|
|
39
|
+
const [hoverImgSrc, setHoverImgSrc] = useState(hover)
|
|
40
|
+
|
|
41
|
+
// Extract color options dynamically from product options
|
|
42
|
+
const colorOption = (product.options as OptionItem[] | undefined)?.find(
|
|
43
|
+
(opt) => opt.title?.toLowerCase() === "color" || opt.title?.toLowerCase() === "colour"
|
|
44
|
+
)
|
|
45
|
+
const rawColorValues: string[] = colorOption?.values
|
|
46
|
+
?.map((v: OptionWithValue) => v.value)
|
|
47
|
+
.filter((val: string | null | undefined): val is string => Boolean(val)) || []
|
|
48
|
+
|
|
49
|
+
// Fallback to variant options if no top-level color options exist
|
|
50
|
+
const variantColors: string[] = Array.from(
|
|
51
|
+
new Set(
|
|
52
|
+
(product.variants ?? [])
|
|
53
|
+
.flatMap((v: unknown) => (v as { options?: OptionWithValue[] }).options?.map((o: OptionWithValue) => o.value))
|
|
54
|
+
.filter((val: string | null | undefined): val is string => Boolean(val))
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
const colorValues: string[] = rawColorValues.length > 0 ? rawColorValues : variantColors
|
|
58
|
+
|
|
59
|
+
const [selectedColor, setSelectedColor] = useState<string | null>(
|
|
60
|
+
colorValues.length > 0 ? (colorValues[0] as string) : null
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const originalPrice = variant?.calculated_price?.original_amount
|
|
64
|
+
const discountPct =
|
|
65
|
+
originalPrice && price && originalPrice > price
|
|
66
|
+
? Math.round(((originalPrice - price) / originalPrice) * 100)
|
|
67
|
+
: 0
|
|
68
|
+
|
|
69
|
+
const isBestSelling = product.tags?.some(
|
|
70
|
+
(tag: any) => tag.value?.toLowerCase() === "best selling" || tag.value?.toLowerCase() === "bestseller"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<article
|
|
75
|
+
data-product-card-id={product.id}
|
|
76
|
+
className="product-card group/card flex flex-col w-full h-full relative font-sans"
|
|
77
|
+
>
|
|
78
|
+
<LocalizedLink href={`/products/${product.handle}`} className="flex flex-col h-full relative group/link">
|
|
79
|
+
<div className="product-card__media relative aspect-[3/4] bg-[#F4F4F4] overflow-hidden group/media rounded-none">
|
|
80
|
+
{/* TAGS */}
|
|
81
|
+
<div className="absolute top-0 right-0 z-30 flex flex-col items-end">
|
|
82
|
+
{discountPct > 0 && (
|
|
83
|
+
<span className="bg-[#E23B00] text-white text-[10px] font-bold tracking-widest px-2 py-1 uppercase leading-none">
|
|
84
|
+
Save {discountPct}%
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
{isBestSelling && !discountPct && (
|
|
88
|
+
<span className="bg-[#3B63EB] text-white text-[10px] font-bold tracking-widest px-2 py-1 uppercase leading-none">
|
|
89
|
+
Best Selling
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{mainImgSrc ? (
|
|
95
|
+
<>
|
|
96
|
+
<Image
|
|
97
|
+
src={mainImgSrc}
|
|
98
|
+
alt={product.title || ""}
|
|
99
|
+
fill
|
|
100
|
+
unoptimized
|
|
101
|
+
onError={() => setMainImgSrc("")}
|
|
102
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
103
|
+
className={`object-cover transition-all duration-[700ms] ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
|
104
|
+
hoverImgSrc
|
|
105
|
+
? "group-hover/media:opacity-0 group-hover/media:scale-105"
|
|
106
|
+
: "group-hover/media:scale-105"
|
|
107
|
+
}`}
|
|
108
|
+
loading="lazy"
|
|
109
|
+
/>
|
|
110
|
+
{hoverImgSrc ? (
|
|
111
|
+
<Image
|
|
112
|
+
src={hoverImgSrc}
|
|
113
|
+
alt={product.title || ""}
|
|
114
|
+
fill
|
|
115
|
+
unoptimized
|
|
116
|
+
onError={() => setHoverImgSrc("")}
|
|
117
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
118
|
+
className="absolute inset-0 object-cover opacity-0 scale-100 group-hover/media:opacity-100 group-hover/media:scale-105 transition-all duration-[700ms] ease-[cubic-bezier(0.16,1,0.3,1)]"
|
|
119
|
+
loading="lazy"
|
|
120
|
+
/>
|
|
121
|
+
) : null}
|
|
122
|
+
</>
|
|
123
|
+
) : (
|
|
124
|
+
<div className="w-full h-full bg-[#F4F4F4] flex items-center justify-center text-xs text-gray-400 font-medium">
|
|
125
|
+
No Image
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Floating Royal Blue "Quick View" Circle Button (on image hover) */}
|
|
130
|
+
<div className="absolute top-3.5 right-3.5 z-20 opacity-0 group-hover/media:opacity-100 transition-all duration-300 pointer-events-none translate-y-1 group-hover/media:translate-y-0">
|
|
131
|
+
<div
|
|
132
|
+
className="w-[54px] h-[54px] rounded-full bg-[#2563EB] text-[#FFFFFF] flex items-center justify-center text-[11px] font-medium tracking-normal text-center leading-[1.15] shadow-lg pointer-events-auto cursor-pointer hover:bg-[#1d4ed8] hover:scale-105 transition-transform"
|
|
133
|
+
onClick={(e) => {
|
|
134
|
+
e.preventDefault()
|
|
135
|
+
e.stopPropagation()
|
|
136
|
+
const cardEl = (e.currentTarget as HTMLElement).closest("[data-product-card-id]")
|
|
137
|
+
const quickAddBtn = cardEl?.querySelector(".product-card__quick-add") as HTMLButtonElement | null
|
|
138
|
+
if (quickAddBtn) {
|
|
139
|
+
quickAddBtn.click()
|
|
140
|
+
}
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
Quick<br />view
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* ProductCardActions handles Cart and Wishlist actions */}
|
|
148
|
+
{product.id ? (
|
|
149
|
+
<ProductCardActions
|
|
150
|
+
product={product}
|
|
151
|
+
countryCode={countryCode}
|
|
152
|
+
wishlistProductIds={wishlistProductIds}
|
|
153
|
+
/>
|
|
154
|
+
) : null}
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<div className="mt-3 flex flex-col min-w-0">
|
|
158
|
+
<h3 className="text-[15px] font-normal text-[#111111] group-hover/link:underline transition-all line-clamp-1 leading-snug">
|
|
159
|
+
{product.title}
|
|
160
|
+
</h3>
|
|
161
|
+
|
|
162
|
+
{price != null ? (
|
|
163
|
+
<div className="mt-1 text-sm text-[#555555]">
|
|
164
|
+
<span>{formatPrice(price, currency)}</span>
|
|
165
|
+
</div>
|
|
166
|
+
) : null}
|
|
167
|
+
|
|
168
|
+
{/* Dynamic Color Swatches below price */}
|
|
169
|
+
{colorValues.length > 0 && (
|
|
170
|
+
<div className="mt-2.5 flex items-center gap-1.5">
|
|
171
|
+
{colorValues.map((color: string, idx: number) => {
|
|
172
|
+
const isSelected = selectedColor === color
|
|
173
|
+
const lowerColor = color.toLowerCase()
|
|
174
|
+
const isWhite = lowerColor === "#ffffff" || lowerColor === "white"
|
|
175
|
+
return (
|
|
176
|
+
<button
|
|
177
|
+
key={idx}
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={(e) => {
|
|
180
|
+
e.preventDefault()
|
|
181
|
+
e.stopPropagation()
|
|
182
|
+
setSelectedColor(color)
|
|
183
|
+
}}
|
|
184
|
+
className={`w-4 h-4 transition-all relative rounded-none ${
|
|
185
|
+
isWhite ? "border border-gray-300" : ""
|
|
186
|
+
} ${
|
|
187
|
+
isSelected
|
|
188
|
+
? "ring-1 ring-black ring-offset-1 scale-110 z-10"
|
|
189
|
+
: "hover:scale-105 opacity-90 hover:opacity-100"
|
|
190
|
+
}`}
|
|
191
|
+
style={{ backgroundColor: lowerColor }}
|
|
192
|
+
title={color}
|
|
193
|
+
aria-label={`Select ${color} color`}
|
|
194
|
+
/>
|
|
195
|
+
)
|
|
196
|
+
})}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</LocalizedLink>
|
|
201
|
+
</article>
|
|
202
|
+
)
|
|
203
|
+
}
|