@pradip1995/segment-product-card 0.3.20 → 0.3.23
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 +12 -7
- package/src/beauty-product-card.tsx +27 -0
- package/src/color-swatches.ts +177 -0
- package/src/motion-product-card.tsx +33 -26
- package/src/product-card-actions.tsx +38 -21
- package/src/segment.css +63 -4
- package/src/shop-product-card.tsx +28 -38
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.23",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -17,13 +17,10 @@
|
|
|
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",
|
|
23
|
+
"@pradip1995/segment-animation-login-popup": "*",
|
|
27
24
|
"next": ">=15",
|
|
28
25
|
"react": ">=19",
|
|
29
26
|
"react-dom": ">=19"
|
|
@@ -33,9 +30,17 @@
|
|
|
33
30
|
"@pradip1995/segment-tokens": "^0.3.7"
|
|
34
31
|
},
|
|
35
32
|
"devDependencies": {
|
|
33
|
+
"@medusajs/types": "^2.0.0",
|
|
36
34
|
"@pradip1995/plugin-sdk": "^0.2.0",
|
|
37
35
|
"@types/react": "^19",
|
|
36
|
+
"@types/react-dom": "^19",
|
|
37
|
+
"next": ">=15",
|
|
38
38
|
"react": "19.0.3",
|
|
39
|
-
"typescript": "^5.7.2"
|
|
39
|
+
"typescript": "^5.7.2",
|
|
40
|
+
"@pradip1995/segment-animation-login-popup": "0.1.1"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"lint": "tsc --noEmit"
|
|
40
45
|
}
|
|
41
|
-
}
|
|
46
|
+
}
|
|
@@ -5,6 +5,10 @@ import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
|
|
|
5
5
|
import { formatPrice } from "@pradip1995/segment-primitives/format-price"
|
|
6
6
|
import type { HttpTypes } from "@medusajs/types"
|
|
7
7
|
import ProductCardActions from "./product-card-actions"
|
|
8
|
+
import {
|
|
9
|
+
getProductColorSwatches,
|
|
10
|
+
isLightSwatch,
|
|
11
|
+
} from "./color-swatches"
|
|
8
12
|
|
|
9
13
|
type Props = {
|
|
10
14
|
product: HttpTypes.StoreProduct
|
|
@@ -46,6 +50,7 @@ export default function BeautyProductCard({
|
|
|
46
50
|
: null
|
|
47
51
|
|
|
48
52
|
const subtitle = productSubtitle(product)
|
|
53
|
+
const colors = getProductColorSwatches(product)
|
|
49
54
|
|
|
50
55
|
return (
|
|
51
56
|
<article
|
|
@@ -89,6 +94,28 @@ export default function BeautyProductCard({
|
|
|
89
94
|
<p className="beauty-product-card__eyebrow">{subtitle}</p>
|
|
90
95
|
<h3 className="beauty-product-card__title">{product.title}</h3>
|
|
91
96
|
|
|
97
|
+
{colors.length > 0 ? (
|
|
98
|
+
<div className="beauty-product-card__swatches" aria-label="Available colors">
|
|
99
|
+
{colors.map((color) => {
|
|
100
|
+
const swatch = color.swatch
|
|
101
|
+
const light = isLightSwatch(swatch)
|
|
102
|
+
return (
|
|
103
|
+
<span
|
|
104
|
+
key={color.name}
|
|
105
|
+
title={color.name}
|
|
106
|
+
aria-label={color.name}
|
|
107
|
+
className={`beauty-product-card__swatch${light ? " is-light" : ""}${
|
|
108
|
+
!swatch ? " is-label" : ""
|
|
109
|
+
}`}
|
|
110
|
+
style={swatch ? { backgroundColor: swatch } : undefined}
|
|
111
|
+
>
|
|
112
|
+
{!swatch ? color.name.slice(0, 1) : null}
|
|
113
|
+
</span>
|
|
114
|
+
)
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
) : null}
|
|
118
|
+
|
|
92
119
|
<div className="beauty-product-card__footer">
|
|
93
120
|
{rating?.rating != null && rating.rating > 0 ? (
|
|
94
121
|
<p className="beauty-product-card__rating" aria-label={`${rating.rating} out of 5 stars`}>
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** Shared admin color → CSS swatch resolution (matches PDP logic). */
|
|
2
|
+
|
|
3
|
+
const ADMIN_COLOR_SWATCHES: Record<string, string> = {
|
|
4
|
+
black: "#111111",
|
|
5
|
+
white: "#ffffff",
|
|
6
|
+
red: "#c1272d",
|
|
7
|
+
blue: "#2563eb",
|
|
8
|
+
green: "#16a34a",
|
|
9
|
+
yellow: "#eab308",
|
|
10
|
+
orange: "#ea580c",
|
|
11
|
+
pink: "#ec4899",
|
|
12
|
+
purple: "#7c3aed",
|
|
13
|
+
brown: "#8b5e3c",
|
|
14
|
+
gray: "#9ca3af",
|
|
15
|
+
grey: "#9ca3af",
|
|
16
|
+
silver: "#c0c0c0",
|
|
17
|
+
gold: "#c9a86c",
|
|
18
|
+
navy: "#1e3a5f",
|
|
19
|
+
maroon: "#800000",
|
|
20
|
+
teal: "#0d9488",
|
|
21
|
+
cyan: "#06b6d4",
|
|
22
|
+
lime: "#84cc16",
|
|
23
|
+
indigo: "#4f46e5",
|
|
24
|
+
violet: "#8b5cf6",
|
|
25
|
+
magenta: "#d946ef",
|
|
26
|
+
beige: "#d4b896",
|
|
27
|
+
ivory: "#fffff0",
|
|
28
|
+
khaki: "#c3b091",
|
|
29
|
+
coral: "#ff7f50",
|
|
30
|
+
salmon: "#fa8072",
|
|
31
|
+
chocolate: "#7b3f00",
|
|
32
|
+
olive: "#556b2f",
|
|
33
|
+
tan: "#d2b48c",
|
|
34
|
+
cream: "#f5ebe0",
|
|
35
|
+
"off white": "#f5f5f0",
|
|
36
|
+
offwhite: "#f5f5f0",
|
|
37
|
+
nude: "#e8c4a8",
|
|
38
|
+
charcoal: "#36454f",
|
|
39
|
+
burgundy: "#800020",
|
|
40
|
+
rose: "#e8a4bf",
|
|
41
|
+
"navy blue": "#1e3a5f",
|
|
42
|
+
"light blue": "#93c5fd",
|
|
43
|
+
"sky blue": "#7dd3fc",
|
|
44
|
+
sky: "#7dd3fc",
|
|
45
|
+
camel: "#c19a6b",
|
|
46
|
+
rust: "#b7410e",
|
|
47
|
+
mustard: "#e1ad01",
|
|
48
|
+
wine: "#722f37",
|
|
49
|
+
peach: "#ffcba4",
|
|
50
|
+
mint: "#98d8c8",
|
|
51
|
+
lavender: "#b57edc",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isHexColor(value: string) {
|
|
55
|
+
return /^#?[0-9a-f]{3}([0-9a-f]{3})?([0-9a-f]{2})?$/i.test(value.trim())
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeColorKey(value: string) {
|
|
59
|
+
return value.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readOptionMeta(meta: Record<string, unknown> | null | undefined, key: string) {
|
|
63
|
+
const value = meta?.[key]
|
|
64
|
+
return typeof value === "string" ? value : null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Resolve swatch from admin value/metadata only — no random invented colors. */
|
|
68
|
+
export function resolveAdminSwatch(
|
|
69
|
+
...candidates: Array<string | null | undefined>
|
|
70
|
+
): string | null {
|
|
71
|
+
for (const raw of candidates) {
|
|
72
|
+
const value = (raw || "").trim()
|
|
73
|
+
if (!value) continue
|
|
74
|
+
|
|
75
|
+
if (isHexColor(value)) {
|
|
76
|
+
return value.startsWith("#") ? value : `#${value}`
|
|
77
|
+
}
|
|
78
|
+
if (/^(rgb|hsl)a?\(/i.test(value)) return value
|
|
79
|
+
|
|
80
|
+
const key = normalizeColorKey(value)
|
|
81
|
+
if (ADMIN_COLOR_SWATCHES[key]) return ADMIN_COLOR_SWATCHES[key]
|
|
82
|
+
|
|
83
|
+
const firstWord = key.split(" ")[0]
|
|
84
|
+
if (ADMIN_COLOR_SWATCHES[firstWord]) return ADMIN_COLOR_SWATCHES[firstWord]
|
|
85
|
+
}
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type ProductColorSwatch = {
|
|
90
|
+
name: string
|
|
91
|
+
swatch: string | null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type OptionValueLike = {
|
|
95
|
+
value?: string | null
|
|
96
|
+
metadata?: Record<string, unknown> | null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type OptionLike = {
|
|
100
|
+
title?: string | null
|
|
101
|
+
values?: OptionValueLike[] | null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type VariantLike = {
|
|
105
|
+
options?: Array<{ value?: string | null; option?: { title?: string | null } }> | null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type ProductLike = {
|
|
109
|
+
options?: OptionLike[] | null
|
|
110
|
+
variants?: VariantLike[] | null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getProductColorSwatches(product: ProductLike): ProductColorSwatch[] {
|
|
114
|
+
const colorOption = product.options?.find((opt) => {
|
|
115
|
+
const title = (opt.title || "").toLowerCase()
|
|
116
|
+
return title === "color" || title === "colour"
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
if (colorOption?.values?.length) {
|
|
120
|
+
const unique = Array.from(
|
|
121
|
+
new Set(
|
|
122
|
+
colorOption.values
|
|
123
|
+
.map((v) => String(v.value || "").trim())
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
return unique.map((name) => {
|
|
128
|
+
const optionValue = colorOption.values?.find((ov) => String(ov.value) === name)
|
|
129
|
+
const meta = optionValue?.metadata
|
|
130
|
+
return {
|
|
131
|
+
name,
|
|
132
|
+
swatch: resolveAdminSwatch(
|
|
133
|
+
name,
|
|
134
|
+
readOptionMeta(meta, "color"),
|
|
135
|
+
readOptionMeta(meta, "hex"),
|
|
136
|
+
readOptionMeta(meta, "swatch"),
|
|
137
|
+
readOptionMeta(meta, "value")
|
|
138
|
+
),
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fromVariants = Array.from(
|
|
144
|
+
new Set(
|
|
145
|
+
(product.variants ?? [])
|
|
146
|
+
.flatMap((variant) =>
|
|
147
|
+
(variant.options ?? [])
|
|
148
|
+
.filter((o) => {
|
|
149
|
+
const t = (o.option?.title || "").toLowerCase()
|
|
150
|
+
return t === "color" || t === "colour"
|
|
151
|
+
})
|
|
152
|
+
.map((o) => String(o.value || "").trim())
|
|
153
|
+
)
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return fromVariants.map((name) => ({
|
|
159
|
+
name,
|
|
160
|
+
swatch: resolveAdminSwatch(name),
|
|
161
|
+
}))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isLightSwatch(swatch: string | null): boolean {
|
|
165
|
+
if (!swatch) return false
|
|
166
|
+
const s = swatch.toLowerCase()
|
|
167
|
+
return (
|
|
168
|
+
s === "#fff" ||
|
|
169
|
+
s === "#ffffff" ||
|
|
170
|
+
s === "white" ||
|
|
171
|
+
s === "ivory" ||
|
|
172
|
+
s === "snow" ||
|
|
173
|
+
s === "#fffff0" ||
|
|
174
|
+
s === "#f5f5f0" ||
|
|
175
|
+
s === "#f5ebe0"
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -4,6 +4,10 @@ import Image from "next/image"
|
|
|
4
4
|
import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
|
|
5
5
|
import { formatPrice } from "@pradip1995/segment-primitives/format-price"
|
|
6
6
|
import type { HttpTypes } from "@medusajs/types"
|
|
7
|
+
import {
|
|
8
|
+
getProductColorSwatches,
|
|
9
|
+
isLightSwatch,
|
|
10
|
+
} from "./color-swatches"
|
|
7
11
|
|
|
8
12
|
type Props = {
|
|
9
13
|
product: HttpTypes.StoreProduct
|
|
@@ -18,22 +22,16 @@ export default function MotionProductCard({
|
|
|
18
22
|
product,
|
|
19
23
|
region,
|
|
20
24
|
countryCode: countryCodeProp,
|
|
21
|
-
rating,
|
|
22
|
-
showWishlist = true,
|
|
23
|
-
wishlistProductIds,
|
|
24
25
|
}: Props) {
|
|
25
26
|
const variant = product.variants?.[0]
|
|
26
27
|
const price = variant?.calculated_price?.calculated_amount
|
|
27
|
-
const originalPrice = variant?.calculated_price?.original_amount
|
|
28
28
|
const currency = region?.currency_code || variant?.calculated_price?.currency_code || "inr"
|
|
29
29
|
const countryCode =
|
|
30
30
|
countryCodeProp?.toLowerCase() ||
|
|
31
31
|
region?.countries?.[0]?.iso_2?.toLowerCase() ||
|
|
32
32
|
"in"
|
|
33
33
|
|
|
34
|
-
|
|
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) || []
|
|
34
|
+
const colors = getProductColorSwatches(product)
|
|
37
35
|
|
|
38
36
|
return (
|
|
39
37
|
<article
|
|
@@ -50,13 +48,13 @@ export default function MotionProductCard({
|
|
|
50
48
|
fill
|
|
51
49
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 50vw"
|
|
52
50
|
className={`object-cover transition-all duration-[800ms] ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
|
53
|
-
product.images && product.images.length > 1
|
|
54
|
-
?
|
|
55
|
-
:
|
|
51
|
+
product.images && product.images.length > 1
|
|
52
|
+
? "group-hover/media:opacity-0"
|
|
53
|
+
: "group-hover/media:scale-105"
|
|
56
54
|
}`}
|
|
57
55
|
loading="lazy"
|
|
58
56
|
/>
|
|
59
|
-
{product.images && product.images.length > 1
|
|
57
|
+
{product.images && product.images.length > 1 ? (
|
|
60
58
|
<Image
|
|
61
59
|
src={product.images[1].url.trim()}
|
|
62
60
|
alt={product.title || ""}
|
|
@@ -65,16 +63,17 @@ export default function MotionProductCard({
|
|
|
65
63
|
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
64
|
loading="lazy"
|
|
67
65
|
/>
|
|
68
|
-
)}
|
|
66
|
+
) : null}
|
|
69
67
|
</>
|
|
70
68
|
) : (
|
|
71
69
|
<div className="w-full h-full bg-[#F6F6F6]" />
|
|
72
70
|
)}
|
|
73
71
|
|
|
74
|
-
{/* Quick view floating circle */}
|
|
75
72
|
<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
73
|
<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
|
|
74
|
+
Quick
|
|
75
|
+
<br />
|
|
76
|
+
view
|
|
78
77
|
</div>
|
|
79
78
|
</div>
|
|
80
79
|
</div>
|
|
@@ -90,19 +89,27 @@ export default function MotionProductCard({
|
|
|
90
89
|
</div>
|
|
91
90
|
) : null}
|
|
92
91
|
|
|
93
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
92
|
+
{colors.length > 0 ? (
|
|
93
|
+
<div className="mt-2 flex items-center gap-1.5" aria-label="Available colors">
|
|
94
|
+
{colors.map((color) => {
|
|
95
|
+
const swatch = color.swatch
|
|
96
|
+
const light = isLightSwatch(swatch)
|
|
97
|
+
return (
|
|
98
|
+
<span
|
|
99
|
+
key={color.name}
|
|
100
|
+
title={color.name}
|
|
101
|
+
aria-label={color.name}
|
|
102
|
+
className={`inline-flex items-center justify-center w-[14px] h-[14px] text-[8px] font-semibold uppercase shadow-[inset_0_0_0_1px_rgba(0,0,0,0.12)] ${
|
|
103
|
+
light ? "border border-gray-300" : ""
|
|
104
|
+
} ${!swatch ? "bg-[#f3f3f3] text-[#111]" : ""}`}
|
|
105
|
+
style={swatch ? { backgroundColor: swatch } : undefined}
|
|
106
|
+
>
|
|
107
|
+
{!swatch ? color.name.slice(0, 1) : null}
|
|
108
|
+
</span>
|
|
109
|
+
)
|
|
110
|
+
})}
|
|
104
111
|
</div>
|
|
105
|
-
)}
|
|
112
|
+
) : null}
|
|
106
113
|
</div>
|
|
107
114
|
</LocalizedLink>
|
|
108
115
|
</article>
|
|
@@ -10,22 +10,24 @@ 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-animation-login-popup/required"
|
|
13
14
|
import QuickAddPanel from "./quick-add-panel"
|
|
14
15
|
|
|
15
16
|
type Props = {
|
|
16
17
|
product: HttpTypes.StoreProduct
|
|
17
18
|
countryCode: string
|
|
18
19
|
wishlistProductIds?: string[]
|
|
20
|
+
showWishlist?: boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function findVariantForOptions(
|
|
22
24
|
product: HttpTypes.StoreProduct,
|
|
23
25
|
selectedOptions: Record<string, string>
|
|
24
26
|
) {
|
|
25
|
-
return product.variants?.find((variant) => {
|
|
27
|
+
return product.variants?.find((variant: { options?: Array<{ option_id?: string | null; value?: string | null }> }) => {
|
|
26
28
|
const variantOptions =
|
|
27
29
|
variant.options?.reduce(
|
|
28
|
-
(acc, option) => {
|
|
30
|
+
(acc: Record<string, string>, option: { option_id?: string | null; value?: string | null }) => {
|
|
29
31
|
if (option.option_id) acc[option.option_id] = option.value ?? ""
|
|
30
32
|
return acc
|
|
31
33
|
},
|
|
@@ -37,7 +39,12 @@ function findVariantForOptions(
|
|
|
37
39
|
})
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
export default function ProductCardActions({
|
|
42
|
+
export default function ProductCardActions({
|
|
43
|
+
product,
|
|
44
|
+
countryCode,
|
|
45
|
+
wishlistProductIds,
|
|
46
|
+
showWishlist = true,
|
|
47
|
+
}: Props) {
|
|
41
48
|
const router = useRouter()
|
|
42
49
|
const [wishlisted, setWishlisted] = useState(false)
|
|
43
50
|
const [wishlistLoading, setWishlistLoading] = useState(false)
|
|
@@ -46,6 +53,7 @@ export default function ProductCardActions({ product, countryCode, wishlistProdu
|
|
|
46
53
|
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({})
|
|
47
54
|
const [quantity, setQuantity] = useState(1)
|
|
48
55
|
const [error, setError] = useState<string | null>(null)
|
|
56
|
+
const [loginRequired, setLoginRequired] = useState(false)
|
|
49
57
|
|
|
50
58
|
const variants = product.variants ?? []
|
|
51
59
|
const hasMultipleVariants = variants.length > 1
|
|
@@ -112,7 +120,7 @@ export default function ProductCardActions({ product, countryCode, wishlistProdu
|
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
if (result.error?.toLowerCase().includes("login")) {
|
|
115
|
-
|
|
123
|
+
setLoginRequired(true)
|
|
116
124
|
return
|
|
117
125
|
}
|
|
118
126
|
|
|
@@ -173,24 +181,26 @@ export default function ProductCardActions({ product, countryCode, wishlistProdu
|
|
|
173
181
|
|
|
174
182
|
return (
|
|
175
183
|
<>
|
|
176
|
-
<div className="product-card__actions" onClick={stopCardNav}>
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<svg
|
|
187
|
-
viewBox="0 0 24 24"
|
|
188
|
-
className="product-card__wishlist-icon"
|
|
189
|
-
aria-hidden
|
|
184
|
+
<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}>
|
|
185
|
+
{showWishlist ? (
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className={`product-card__wishlist-btn${wishlisted ? " product-card__wishlist-btn--active" : ""}${
|
|
189
|
+
wishlistLoading ? " product-card__wishlist-btn--loading" : ""
|
|
190
|
+
}`}
|
|
191
|
+
onClick={handleWishlistToggle}
|
|
192
|
+
disabled={wishlistLoading}
|
|
193
|
+
aria-label={wishlisted ? "Remove from wishlist" : "Add to wishlist"}
|
|
190
194
|
>
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
195
|
+
<svg
|
|
196
|
+
viewBox="0 0 24 24"
|
|
197
|
+
className="product-card__wishlist-icon"
|
|
198
|
+
aria-hidden
|
|
199
|
+
>
|
|
200
|
+
<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" />
|
|
201
|
+
</svg>
|
|
202
|
+
</button>
|
|
203
|
+
) : null}
|
|
194
204
|
|
|
195
205
|
<button
|
|
196
206
|
type="button"
|
|
@@ -231,6 +241,13 @@ export default function ProductCardActions({ product, countryCode, wishlistProdu
|
|
|
231
241
|
</div>
|
|
232
242
|
) : null}
|
|
233
243
|
|
|
244
|
+
<LoginRequiredModal
|
|
245
|
+
isOpen={loginRequired}
|
|
246
|
+
onClose={() => setLoginRequired(false)}
|
|
247
|
+
countryCode={countryCode}
|
|
248
|
+
message="Please log in to save items to your wishlist."
|
|
249
|
+
/>
|
|
250
|
+
|
|
234
251
|
{showQuickAdd ? (
|
|
235
252
|
<QuickAddPanel
|
|
236
253
|
product={product}
|
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,
|
|
@@ -153,6 +154,35 @@
|
|
|
153
154
|
min-height: 2.5rem;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
.beauty-product-card__swatches {
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-wrap: wrap;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: 0.35rem;
|
|
162
|
+
margin: 0.45rem 0 0.15rem;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.beauty-product-card__swatch {
|
|
166
|
+
display: inline-flex;
|
|
167
|
+
align-items: center;
|
|
168
|
+
justify-content: center;
|
|
169
|
+
width: 0.875rem;
|
|
170
|
+
height: 0.875rem;
|
|
171
|
+
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
|
|
172
|
+
font-size: 0.5rem;
|
|
173
|
+
font-weight: 700;
|
|
174
|
+
text-transform: uppercase;
|
|
175
|
+
color: #111;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.beauty-product-card__swatch.is-light {
|
|
179
|
+
border: 1px solid #d4d4d4;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.beauty-product-card__swatch.is-label {
|
|
183
|
+
background: #f3f3f3;
|
|
184
|
+
}
|
|
185
|
+
|
|
156
186
|
.beauty-product-card__footer {
|
|
157
187
|
display: flex;
|
|
158
188
|
align-items: flex-end;
|
|
@@ -275,8 +305,9 @@
|
|
|
275
305
|
}
|
|
276
306
|
|
|
277
307
|
.jewelry-product-card .product-card__actions {
|
|
278
|
-
|
|
279
|
-
|
|
308
|
+
bottom: 0.875rem;
|
|
309
|
+
top: auto;
|
|
310
|
+
right: 0.875rem;
|
|
280
311
|
}
|
|
281
312
|
|
|
282
313
|
.jewelry-product-card .product-card__wishlist-btn {
|
|
@@ -406,3 +437,31 @@
|
|
|
406
437
|
pointer-events: auto;
|
|
407
438
|
}
|
|
408
439
|
}
|
|
440
|
+
|
|
441
|
+
/* Shop product card (Motion style) */
|
|
442
|
+
|
|
443
|
+
.shop-product-card .product-card__actions {
|
|
444
|
+
bottom: 0.875rem;
|
|
445
|
+
top: auto;
|
|
446
|
+
right: 0.875rem;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.shop-product-card .product-card__wishlist-btn {
|
|
450
|
+
width: 2.125rem;
|
|
451
|
+
height: 2.125rem;
|
|
452
|
+
border-radius: 999px;
|
|
453
|
+
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
454
|
+
background: rgba(255, 255, 255, 0.95);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.shop-product-card .product-card__wishlist-btn--active {
|
|
458
|
+
background: var(--color-brand-accent-muted, #f4eff8);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.shop-product-card .product-card__cart-btn {
|
|
462
|
+
display: none; /* Hide the cart icon since shop card uses the Quick View circle */
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.shop-product-card .product-card__quick-add-wrap {
|
|
466
|
+
display: none; /* Hide the quick add bar at the bottom, triggered by the blue circle instead */
|
|
467
|
+
}
|
|
@@ -7,6 +7,10 @@ import { formatPrice } from "@pradip1995/segment-primitives/format-price"
|
|
|
7
7
|
import type { HttpTypes } from "@medusajs/types"
|
|
8
8
|
import ProductCardActions from "./product-card-actions"
|
|
9
9
|
import { resolveProductCardImages } from "./resolve-product-images"
|
|
10
|
+
import {
|
|
11
|
+
getProductColorSwatches,
|
|
12
|
+
isLightSwatch,
|
|
13
|
+
} from "./color-swatches"
|
|
10
14
|
|
|
11
15
|
type Props = {
|
|
12
16
|
product: HttpTypes.StoreProduct
|
|
@@ -17,13 +21,11 @@ type Props = {
|
|
|
17
21
|
wishlistProductIds?: string[]
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
type OptionWithValue = { value?: string | null }
|
|
21
|
-
type OptionItem = { title?: string | null; values?: OptionWithValue[] | null }
|
|
22
|
-
|
|
23
24
|
export default function ShopProductCard({
|
|
24
25
|
product,
|
|
25
26
|
region,
|
|
26
27
|
countryCode: countryCodeProp,
|
|
28
|
+
showWishlist = false,
|
|
27
29
|
wishlistProductIds,
|
|
28
30
|
}: Props) {
|
|
29
31
|
const variant = product.variants?.[0]
|
|
@@ -38,26 +40,9 @@ export default function ShopProductCard({
|
|
|
38
40
|
const [mainImgSrc, setMainImgSrc] = useState(primary)
|
|
39
41
|
const [hoverImgSrc, setHoverImgSrc] = useState(hover)
|
|
40
42
|
|
|
41
|
-
|
|
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 | 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 | undefined): val is string => Boolean(val))
|
|
55
|
-
)
|
|
56
|
-
)
|
|
57
|
-
const colorValues: string[] = rawColorValues.length > 0 ? rawColorValues : variantColors
|
|
58
|
-
|
|
43
|
+
const colors = getProductColorSwatches(product)
|
|
59
44
|
const [selectedColor, setSelectedColor] = useState<string | null>(
|
|
60
|
-
|
|
45
|
+
colors.length > 0 ? colors[0].name : null
|
|
61
46
|
)
|
|
62
47
|
|
|
63
48
|
const originalPrice = variant?.calculated_price?.original_amount
|
|
@@ -65,9 +50,11 @@ export default function ShopProductCard({
|
|
|
65
50
|
originalPrice && price && originalPrice > price
|
|
66
51
|
? Math.round(((originalPrice - price) / originalPrice) * 100)
|
|
67
52
|
: 0
|
|
68
|
-
|
|
53
|
+
|
|
69
54
|
const isBestSelling = product.tags?.some(
|
|
70
|
-
(tag
|
|
55
|
+
(tag: { value?: string }) =>
|
|
56
|
+
tag.value?.toLowerCase() === "best selling" ||
|
|
57
|
+
tag.value?.toLowerCase() === "bestseller"
|
|
71
58
|
)
|
|
72
59
|
|
|
73
60
|
return (
|
|
@@ -150,6 +137,7 @@ export default function ShopProductCard({
|
|
|
150
137
|
product={product}
|
|
151
138
|
countryCode={countryCode}
|
|
152
139
|
wishlistProductIds={wishlistProductIds}
|
|
140
|
+
showWishlist={showWishlist}
|
|
153
141
|
/>
|
|
154
142
|
) : null}
|
|
155
143
|
</div>
|
|
@@ -166,32 +154,34 @@ export default function ShopProductCard({
|
|
|
166
154
|
) : null}
|
|
167
155
|
|
|
168
156
|
{/* Dynamic Color Swatches below price */}
|
|
169
|
-
{
|
|
157
|
+
{colors.length > 0 && (
|
|
170
158
|
<div className="mt-2.5 flex items-center gap-1.5">
|
|
171
|
-
{
|
|
172
|
-
const isSelected = selectedColor === color
|
|
173
|
-
const
|
|
174
|
-
const
|
|
159
|
+
{colors.map((color) => {
|
|
160
|
+
const isSelected = selectedColor === color.name
|
|
161
|
+
const swatch = color.swatch
|
|
162
|
+
const light = isLightSwatch(swatch)
|
|
175
163
|
return (
|
|
176
164
|
<button
|
|
177
|
-
key={
|
|
165
|
+
key={color.name}
|
|
178
166
|
type="button"
|
|
179
167
|
onClick={(e) => {
|
|
180
168
|
e.preventDefault()
|
|
181
169
|
e.stopPropagation()
|
|
182
|
-
setSelectedColor(color)
|
|
170
|
+
setSelectedColor(color.name)
|
|
183
171
|
}}
|
|
184
|
-
className={`w-4 h-4 transition-all relative rounded-none ${
|
|
185
|
-
|
|
172
|
+
className={`inline-flex items-center justify-center w-4 h-4 transition-all relative rounded-none text-[8px] font-semibold uppercase ${
|
|
173
|
+
light ? "border border-gray-300" : ""
|
|
186
174
|
} ${
|
|
187
175
|
isSelected
|
|
188
176
|
? "ring-1 ring-black ring-offset-1 scale-110 z-10"
|
|
189
177
|
: "hover:scale-105 opacity-90 hover:opacity-100"
|
|
190
|
-
}`}
|
|
191
|
-
style={{ backgroundColor:
|
|
192
|
-
title={color}
|
|
193
|
-
aria-label={`Select ${color} color`}
|
|
194
|
-
|
|
178
|
+
} ${!swatch ? "bg-[#f3f3f3] text-[#111]" : ""}`}
|
|
179
|
+
style={swatch ? { backgroundColor: swatch } : undefined}
|
|
180
|
+
title={color.name}
|
|
181
|
+
aria-label={`Select ${color.name} color`}
|
|
182
|
+
>
|
|
183
|
+
{!swatch ? color.name.slice(0, 1) : null}
|
|
184
|
+
</button>
|
|
195
185
|
)
|
|
196
186
|
})}
|
|
197
187
|
</div>
|