@pradip1995/segment-product-card 0.2.0
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 +38 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +10 -0
- package/src/product-card-actions.tsx +225 -0
- package/src/product-scroll.tsx +69 -0
- package/src/quick-add-panel.tsx +103 -0
- package/src/segment.tsx +86 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pradip1995/segment-product-card",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.ts",
|
|
14
|
+
"./manifest": "./src/manifest.ts",
|
|
15
|
+
"./product-scroll": "./src/product-scroll.tsx"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@pradip1995/commerce-core": "^4.0.0",
|
|
19
|
+
"@pradip1995/plugin-sdk": "^0.2.0",
|
|
20
|
+
"react": ">=19",
|
|
21
|
+
"react-dom": ">=19",
|
|
22
|
+
"next": ">=15"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@pradip1995/segment-primitives": "0.2.0",
|
|
26
|
+
"@pradip1995/segment-tokens": "0.2.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@pradip1995/plugin-sdk": "^0.2.0",
|
|
30
|
+
"@types/react": "^19",
|
|
31
|
+
"react": "19.0.3",
|
|
32
|
+
"typescript": "^5.7.2"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"lint": "tsc --noEmit"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/index.ts
ADDED
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react"
|
|
4
|
+
import { useRouter } from "next/navigation"
|
|
5
|
+
import { addToCart } from "@pradip1995/commerce-core/client/actions/cart"
|
|
6
|
+
import {
|
|
7
|
+
addToWishlist,
|
|
8
|
+
getWishlistProductIds,
|
|
9
|
+
removeFromWishlist,
|
|
10
|
+
} from "@pradip1995/commerce-core/client/actions/wishlist"
|
|
11
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
12
|
+
import QuickAddPanel from "./quick-add-panel"
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
product: HttpTypes.StoreProduct
|
|
16
|
+
countryCode: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function findVariantForOptions(
|
|
20
|
+
product: HttpTypes.StoreProduct,
|
|
21
|
+
selectedOptions: Record<string, string>
|
|
22
|
+
) {
|
|
23
|
+
return product.variants?.find((variant) => {
|
|
24
|
+
const variantOptions =
|
|
25
|
+
variant.options?.reduce(
|
|
26
|
+
(acc, option) => {
|
|
27
|
+
if (option.option_id) acc[option.option_id] = option.value ?? ""
|
|
28
|
+
return acc
|
|
29
|
+
},
|
|
30
|
+
{} as Record<string, string>
|
|
31
|
+
) ?? {}
|
|
32
|
+
return Object.entries(selectedOptions).every(
|
|
33
|
+
([optionId, value]) => variantOptions[optionId] === value
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function ProductCardActions({ product, countryCode }: Props) {
|
|
39
|
+
const router = useRouter()
|
|
40
|
+
const [wishlisted, setWishlisted] = useState(false)
|
|
41
|
+
const [wishlistLoading, setWishlistLoading] = useState(false)
|
|
42
|
+
const [isAdding, setIsAdding] = useState(false)
|
|
43
|
+
const [showQuickAdd, setShowQuickAdd] = useState(false)
|
|
44
|
+
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({})
|
|
45
|
+
const [quantity, setQuantity] = useState(1)
|
|
46
|
+
const [error, setError] = useState<string | null>(null)
|
|
47
|
+
|
|
48
|
+
const variants = product.variants ?? []
|
|
49
|
+
const hasMultipleVariants = variants.length > 1
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!product.id) return
|
|
53
|
+
getWishlistProductIds()
|
|
54
|
+
.then((ids) => setWishlisted(ids.includes(product.id!)))
|
|
55
|
+
.catch(() => {})
|
|
56
|
+
}, [product.id])
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!showQuickAdd) return
|
|
60
|
+
const onOutside = (event: MouseEvent) => {
|
|
61
|
+
const target = event.target as HTMLElement
|
|
62
|
+
if (!target.closest(`[data-product-card-id="${product.id}"]`)) {
|
|
63
|
+
setShowQuickAdd(false)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
document.addEventListener("mousedown", onOutside)
|
|
67
|
+
return () => document.removeEventListener("mousedown", onOutside)
|
|
68
|
+
}, [showQuickAdd, product.id])
|
|
69
|
+
|
|
70
|
+
const selectedVariant = useMemo(() => {
|
|
71
|
+
if (!hasMultipleVariants) return variants[0]
|
|
72
|
+
if (Object.keys(selectedOptions).length === 0) return undefined
|
|
73
|
+
return findVariantForOptions(product, selectedOptions)
|
|
74
|
+
}, [hasMultipleVariants, product, selectedOptions, variants])
|
|
75
|
+
|
|
76
|
+
const stopCardNav = (event: React.MouseEvent) => {
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
event.stopPropagation()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const handleWishlistToggle = async (event: React.MouseEvent) => {
|
|
82
|
+
stopCardNav(event)
|
|
83
|
+
if (!product.id || wishlistLoading) return
|
|
84
|
+
|
|
85
|
+
setWishlistLoading(true)
|
|
86
|
+
try {
|
|
87
|
+
const result = wishlisted
|
|
88
|
+
? await removeFromWishlist(product.id)
|
|
89
|
+
: await addToWishlist(product.id)
|
|
90
|
+
|
|
91
|
+
if (result.success) {
|
|
92
|
+
setWishlisted(!wishlisted)
|
|
93
|
+
router.refresh()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (result.error?.toLowerCase().includes("login")) {
|
|
98
|
+
router.push(`/${countryCode}/account`)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setError(result.error || "Could not update wishlist")
|
|
103
|
+
} finally {
|
|
104
|
+
setWishlistLoading(false)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const addVariantToCart = async (variantId: string, qty: number) => {
|
|
109
|
+
setIsAdding(true)
|
|
110
|
+
setError(null)
|
|
111
|
+
try {
|
|
112
|
+
await addToCart({ variantId, quantity: qty, countryCode })
|
|
113
|
+
window.dispatchEvent(new CustomEvent("cart:item-added", { detail: { message: "Added to cart" } }))
|
|
114
|
+
setShowQuickAdd(false)
|
|
115
|
+
router.refresh()
|
|
116
|
+
} catch (err) {
|
|
117
|
+
setError(err instanceof Error ? err.message : "Could not add to cart")
|
|
118
|
+
} finally {
|
|
119
|
+
setIsAdding(false)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const handleQuickAdd = async (event: React.MouseEvent) => {
|
|
124
|
+
stopCardNav(event)
|
|
125
|
+
setError(null)
|
|
126
|
+
|
|
127
|
+
if (variants.length === 0) return
|
|
128
|
+
|
|
129
|
+
if (!hasMultipleVariants) {
|
|
130
|
+
const variant = variants[0]
|
|
131
|
+
if (!variant?.id) return
|
|
132
|
+
await addVariantToCart(variant.id, 1)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
setShowQuickAdd(true)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const handleQuickAddSubmit = async (event: React.MouseEvent) => {
|
|
140
|
+
stopCardNav(event)
|
|
141
|
+
if (!selectedVariant?.id) {
|
|
142
|
+
setError("Please select all options")
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
await addVariantToCart(selectedVariant.id, quantity)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<>
|
|
150
|
+
<div className="product-card__actions" onClick={stopCardNav}>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
className={`product-card__wishlist-btn${wishlisted ? " product-card__wishlist-btn--active" : ""}${
|
|
154
|
+
wishlistLoading ? " product-card__wishlist-btn--loading" : ""
|
|
155
|
+
}`}
|
|
156
|
+
onClick={handleWishlistToggle}
|
|
157
|
+
disabled={wishlistLoading}
|
|
158
|
+
aria-label={wishlisted ? "Remove from wishlist" : "Add to wishlist"}
|
|
159
|
+
>
|
|
160
|
+
<svg
|
|
161
|
+
viewBox="0 0 24 24"
|
|
162
|
+
className="product-card__wishlist-icon"
|
|
163
|
+
aria-hidden
|
|
164
|
+
>
|
|
165
|
+
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
|
166
|
+
</svg>
|
|
167
|
+
</button>
|
|
168
|
+
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
className="product-card__cart-btn"
|
|
172
|
+
onClick={handleQuickAdd}
|
|
173
|
+
disabled={isAdding}
|
|
174
|
+
aria-label={isAdding ? "Adding to cart" : "Add to cart"}
|
|
175
|
+
>
|
|
176
|
+
<svg
|
|
177
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
178
|
+
viewBox="0 0 24 24"
|
|
179
|
+
fill="none"
|
|
180
|
+
strokeWidth="2"
|
|
181
|
+
strokeLinecap="round"
|
|
182
|
+
strokeLinejoin="round"
|
|
183
|
+
className="product-card__cart-icon"
|
|
184
|
+
aria-hidden
|
|
185
|
+
>
|
|
186
|
+
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
|
|
187
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
188
|
+
<path d="M16 10a4 4 0 0 1-8 0" />
|
|
189
|
+
</svg>
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{!showQuickAdd ? (
|
|
194
|
+
<div className="product-card__quick-add-wrap" onClick={stopCardNav}>
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={handleQuickAdd}
|
|
198
|
+
disabled={isAdding}
|
|
199
|
+
className="product-card__quick-add"
|
|
200
|
+
>
|
|
201
|
+
<span className="product-card__quick-add-label">
|
|
202
|
+
{isAdding ? "Adding…" : "Quick add"}
|
|
203
|
+
</span>
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
) : null}
|
|
207
|
+
|
|
208
|
+
{showQuickAdd ? (
|
|
209
|
+
<QuickAddPanel
|
|
210
|
+
product={product}
|
|
211
|
+
selectedOptions={selectedOptions}
|
|
212
|
+
setOptionValue={(optionId, value) =>
|
|
213
|
+
setSelectedOptions((prev) => ({ ...prev, [optionId]: value }))
|
|
214
|
+
}
|
|
215
|
+
quantity={quantity}
|
|
216
|
+
setQuantity={setQuantity}
|
|
217
|
+
isAdding={isAdding}
|
|
218
|
+
error={error}
|
|
219
|
+
onClose={() => setShowQuickAdd(false)}
|
|
220
|
+
onSubmit={handleQuickAddSubmit}
|
|
221
|
+
/>
|
|
222
|
+
) : null}
|
|
223
|
+
</>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useRef } from "react"
|
|
4
|
+
import ProductCard from "./segment"
|
|
5
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
6
|
+
|
|
7
|
+
type ProductScrollProps = {
|
|
8
|
+
products: HttpTypes.StoreProduct[]
|
|
9
|
+
region?: HttpTypes.StoreRegion | null
|
|
10
|
+
ratings?: Array<{ product_id?: string; rating?: number }>
|
|
11
|
+
countryCode?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function ProductScroll({ products, region, ratings, countryCode }: ProductScrollProps) {
|
|
15
|
+
const viewportRef = useRef<HTMLDivElement>(null)
|
|
16
|
+
|
|
17
|
+
const scroll = (direction: "prev" | "next") => {
|
|
18
|
+
const viewport = viewportRef.current
|
|
19
|
+
if (!viewport) return
|
|
20
|
+
const amount = Math.max(viewport.clientWidth * 0.85, 280)
|
|
21
|
+
viewport.scrollBy({
|
|
22
|
+
left: direction === "next" ? amount : -amount,
|
|
23
|
+
behavior: "smooth",
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (products.length === 0) return null
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="product-scroll bestsellers-carousel">
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
className="product-scroll__nav bestsellers-carousel__nav product-scroll__nav--prev bestsellers-carousel__nav--prev"
|
|
34
|
+
onClick={() => scroll("prev")}
|
|
35
|
+
aria-label="Previous products"
|
|
36
|
+
>
|
|
37
|
+
‹
|
|
38
|
+
</button>
|
|
39
|
+
<div
|
|
40
|
+
ref={viewportRef}
|
|
41
|
+
className="product-scroll__viewport bestsellers-carousel__viewport"
|
|
42
|
+
>
|
|
43
|
+
<div className="product-scroll__track bestsellers-carousel__track">
|
|
44
|
+
{products.map((product) => (
|
|
45
|
+
<div
|
|
46
|
+
key={product.id}
|
|
47
|
+
className="product-scroll__slide bestsellers-carousel__slide"
|
|
48
|
+
>
|
|
49
|
+
<ProductCard
|
|
50
|
+
product={product}
|
|
51
|
+
region={region ?? undefined}
|
|
52
|
+
countryCode={countryCode}
|
|
53
|
+
rating={ratings?.find((r) => r.product_id === product.id)}
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
className="product-scroll__nav bestsellers-carousel__nav product-scroll__nav--next bestsellers-carousel__nav--next"
|
|
62
|
+
onClick={() => scroll("next")}
|
|
63
|
+
aria-label="Next products"
|
|
64
|
+
>
|
|
65
|
+
›
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
product: HttpTypes.StoreProduct
|
|
7
|
+
selectedOptions: Record<string, string>
|
|
8
|
+
setOptionValue: (optionId: string, value: string) => void
|
|
9
|
+
quantity: number
|
|
10
|
+
setQuantity: (quantity: number) => void
|
|
11
|
+
isAdding: boolean
|
|
12
|
+
error: string | null
|
|
13
|
+
onClose: () => void
|
|
14
|
+
onSubmit: (event: React.MouseEvent) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function QuickAddPanel({
|
|
18
|
+
product,
|
|
19
|
+
selectedOptions,
|
|
20
|
+
setOptionValue,
|
|
21
|
+
quantity,
|
|
22
|
+
setQuantity,
|
|
23
|
+
isAdding,
|
|
24
|
+
error,
|
|
25
|
+
onClose,
|
|
26
|
+
onSubmit,
|
|
27
|
+
}: Props) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
className="product-card__quick-add-panel"
|
|
31
|
+
onClick={(event) => {
|
|
32
|
+
event.preventDefault()
|
|
33
|
+
event.stopPropagation()
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<div className="quick-add">
|
|
37
|
+
<div className="quick-add__head">
|
|
38
|
+
<h4 className="quick-add__title">Select options</h4>
|
|
39
|
+
<button type="button" onClick={onClose} className="quick-add__close" aria-label="Close">
|
|
40
|
+
×
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div className="quick-add__body">
|
|
45
|
+
{(product.options ?? []).map((option) => (
|
|
46
|
+
<div key={option.id} className="quick-add__option-group">
|
|
47
|
+
<p className="quick-add__option-label">{option.title}</p>
|
|
48
|
+
<div className="quick-add__option-values">
|
|
49
|
+
{(option.values ?? []).map((value) => {
|
|
50
|
+
const isActive = selectedOptions[option.id!] === value.value
|
|
51
|
+
return (
|
|
52
|
+
<button
|
|
53
|
+
key={value.id ?? value.value}
|
|
54
|
+
type="button"
|
|
55
|
+
className={`quick-add__option-btn${isActive ? " quick-add__option-btn--active" : ""}`}
|
|
56
|
+
onClick={() => setOptionValue(option.id!, value.value ?? "")}
|
|
57
|
+
>
|
|
58
|
+
{value.value}
|
|
59
|
+
</button>
|
|
60
|
+
)
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
))}
|
|
65
|
+
|
|
66
|
+
<div className="quick-add__qty-wrap">
|
|
67
|
+
<div className="quick-add__qty">
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
71
|
+
disabled={quantity <= 1}
|
|
72
|
+
className="quick-add__qty-btn"
|
|
73
|
+
aria-label="Decrease quantity"
|
|
74
|
+
>
|
|
75
|
+
−
|
|
76
|
+
</button>
|
|
77
|
+
<span className="quick-add__qty-value">{quantity}</span>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={() => setQuantity(quantity + 1)}
|
|
81
|
+
className="quick-add__qty-btn"
|
|
82
|
+
aria-label="Increase quantity"
|
|
83
|
+
>
|
|
84
|
+
+
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{error ? <p className="quick-add__error">{error}</p> : null}
|
|
90
|
+
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
className="quick-add__cta"
|
|
94
|
+
onClick={onSubmit}
|
|
95
|
+
disabled={isAdding}
|
|
96
|
+
>
|
|
97
|
+
{isAdding ? "Adding…" : "Add to bag"}
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
package/src/segment.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
|
|
4
|
+
import { formatPrice } from "@pradip1995/segment-primitives/format-price"
|
|
5
|
+
import type { HttpTypes } from "@medusajs/types"
|
|
6
|
+
import ProductCardActions from "./product-card-actions"
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
product: HttpTypes.StoreProduct
|
|
10
|
+
region?: HttpTypes.StoreRegion
|
|
11
|
+
countryCode?: string
|
|
12
|
+
rating?: { rating?: number }
|
|
13
|
+
showWishlist?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function ProductCard({
|
|
17
|
+
product,
|
|
18
|
+
region,
|
|
19
|
+
countryCode: countryCodeProp,
|
|
20
|
+
rating,
|
|
21
|
+
showWishlist = true,
|
|
22
|
+
}: Props) {
|
|
23
|
+
const variant = product.variants?.[0]
|
|
24
|
+
const price = variant?.calculated_price?.calculated_amount
|
|
25
|
+
const originalPrice = variant?.calculated_price?.original_amount
|
|
26
|
+
const currency = region?.currency_code || variant?.calculated_price?.currency_code || "inr"
|
|
27
|
+
const countryCode =
|
|
28
|
+
countryCodeProp?.toLowerCase() ??
|
|
29
|
+
region?.countries?.[0]?.iso_2?.toLowerCase() ??
|
|
30
|
+
process.env.NEXT_PUBLIC_DEFAULT_REGION?.toLowerCase() ??
|
|
31
|
+
"in"
|
|
32
|
+
|
|
33
|
+
const discountPct =
|
|
34
|
+
originalPrice && price && originalPrice > price
|
|
35
|
+
? Math.round(((originalPrice - price) / originalPrice) * 100)
|
|
36
|
+
: null
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<article
|
|
40
|
+
data-product-card-id={product.id}
|
|
41
|
+
className="product-card group flex flex-col w-full h-full relative"
|
|
42
|
+
>
|
|
43
|
+
<LocalizedLink href={`/products/${product.handle}`} className="product-card__link flex flex-col h-full">
|
|
44
|
+
<div className="product-card__media relative aspect-[3/4] bg-surface-muted overflow-hidden rounded-lg">
|
|
45
|
+
{product.thumbnail ? (
|
|
46
|
+
<img
|
|
47
|
+
src={product.thumbnail}
|
|
48
|
+
alt={product.title || ""}
|
|
49
|
+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
50
|
+
/>
|
|
51
|
+
) : (
|
|
52
|
+
<div className="w-full h-full bg-surface-muted" />
|
|
53
|
+
)}
|
|
54
|
+
|
|
55
|
+
{showWishlist && product.id ? (
|
|
56
|
+
<ProductCardActions product={product} countryCode={countryCode} />
|
|
57
|
+
) : null}
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className="product-card__info mt-3 flex flex-col gap-1 min-w-0">
|
|
61
|
+
<h3 className="product-card__title text-sm font-medium text-heading group-hover:text-brand-accent transition-colors line-clamp-2">
|
|
62
|
+
{product.title}
|
|
63
|
+
</h3>
|
|
64
|
+
|
|
65
|
+
{rating?.rating != null && rating.rating > 0 ? (
|
|
66
|
+
<p className="product-card__rating text-xs text-muted">★ {rating.rating.toFixed(1)}</p>
|
|
67
|
+
) : null}
|
|
68
|
+
|
|
69
|
+
{price != null ? (
|
|
70
|
+
<div className="product-card__prices">
|
|
71
|
+
<span className="product-card__price">{formatPrice(price, currency)}</span>
|
|
72
|
+
{originalPrice && price && originalPrice > price ? (
|
|
73
|
+
<>
|
|
74
|
+
<span className="product-card__compare">{formatPrice(originalPrice, currency)}</span>
|
|
75
|
+
{discountPct != null ? (
|
|
76
|
+
<span className="product-card__discount">{discountPct}% OFF</span>
|
|
77
|
+
) : null}
|
|
78
|
+
</>
|
|
79
|
+
) : null}
|
|
80
|
+
</div>
|
|
81
|
+
) : null}
|
|
82
|
+
</div>
|
|
83
|
+
</LocalizedLink>
|
|
84
|
+
</article>
|
|
85
|
+
)
|
|
86
|
+
}
|