@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 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
@@ -0,0 +1,2 @@
1
+ export { default } from "./segment"
2
+ export { default as manifest } from "./manifest"
@@ -0,0 +1,10 @@
1
+ import type { SegmentManifest } from "@pradip1995/plugin-sdk"
2
+
3
+ const manifest: SegmentManifest = {
4
+ id: "product-card",
5
+ type: "segment",
6
+ version: "0.1.0",
7
+ compatibleFramework: ["^1.0.0"],
8
+ }
9
+
10
+ export default manifest
@@ -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
+ }
@@ -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
+ }