@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pradip1995/segment-product-card",
3
- "version": "0.3.20",
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
- // 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) || []
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
- ? 'group-hover/media:opacity-0'
55
- : 'group-hover/media:scale-105'
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<br/>view
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
- {/* 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
- ))}
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({ product, countryCode, wishlistProductIds }: Props) {
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
- router.push(`/${countryCode}/account`)
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
- <button
178
- type="button"
179
- className={`product-card__wishlist-btn${wishlisted ? " product-card__wishlist-btn--active" : ""}${
180
- wishlistLoading ? " product-card__wishlist-btn--loading" : ""
181
- }`}
182
- onClick={handleWishlistToggle}
183
- disabled={wishlistLoading}
184
- aria-label={wishlisted ? "Remove from wishlist" : "Add to wishlist"}
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
- <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" />
192
- </svg>
193
- </button>
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
- top: 0.5rem;
57
- right: 0.5rem;
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
- top: 0.5rem;
279
- right: 0.5rem;
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
- // 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 | 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
- colorValues.length > 0 ? (colorValues[0] as string) : null
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) => tag.value?.toLowerCase() === "best selling" || tag.value?.toLowerCase() === "bestseller"
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
- {colorValues.length > 0 && (
157
+ {colors.length > 0 && (
170
158
  <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"
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={idx}
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
- isWhite ? "border border-gray-300" : ""
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: lowerColor }}
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>