@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pradip1995/segment-product-card",
3
- "version": "0.3.19",
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
- router.push(`/${countryCode}/account`)
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 => !!url) ?? []
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) => resolveMediaUrl((variant as { thumbnail?: string | null }).thumbnail))
12
- .filter((url): url is string => !!url)
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 images = [...gallery, ...variantImages]
15
- const thumbnail = resolveMediaUrl(product.thumbnail) || ""
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 = [...new Set(images)].find((url) => url && url !== primary) || ""
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
- 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,
@@ -275,8 +276,9 @@
275
276
  }
276
277
 
277
278
  .jewelry-product-card .product-card__actions {
278
- top: 0.5rem;
279
- right: 0.5rem;
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 (variant === "beauty" || variant === "default" || variant === "jewelry") return variant
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
+ }