@pradip1995/theme-impulse 1.1.4
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/README.md +31 -0
- package/assets/hero-desktop.svg +10 -0
- package/assets/hero-mobile.svg +9 -0
- package/assets/logo.svg +3 -0
- package/package.json +60 -0
- package/src/blocks/home/Features/index.tsx +37 -0
- package/src/blocks/home/Hero/index.tsx +102 -0
- package/src/blocks/home/LovedByMoms/index.tsx +59 -0
- package/src/blocks/home/NewArrivals/index.tsx +57 -0
- package/src/blocks/home/ShopByAge/index.tsx +82 -0
- package/src/blocks/home/ShopByCategory/index.tsx +92 -0
- package/src/blocks/home/Testimonials/index.tsx +130 -0
- package/src/blocks/home/WhyChooseUs/index.tsx +46 -0
- package/src/layouts/MainLayoutShell.tsx +14 -0
- package/src/primitives/Button.tsx +31 -0
- package/src/primitives/Card.tsx +32 -0
- package/src/primitives/index.ts +2 -0
- package/src/slots/account/ForgotPassword/index.tsx +1 -0
- package/src/slots/account/Login/index.tsx +1 -0
- package/src/slots/account/LoginTemplate/index.tsx +44 -0
- package/src/slots/account/Register/index.tsx +1 -0
- package/src/slots/cart/CartItem/index.tsx +11 -0
- package/src/slots/cart/CartSummary/index.tsx +13 -0
- package/src/slots/checkout/CheckoutForm/index.tsx +1 -0
- package/src/slots/checkout/CheckoutSummary/index.tsx +1 -0
- package/src/slots/layout/Footer/index.tsx +103 -0
- package/src/slots/layout/Nav/index.tsx +97 -0
- package/src/slots/layout/PromoBar/index.tsx +19 -0
- package/src/slots/layout/PromoBar/promo-bar-content.tsx +174 -0
- package/src/slots/order/OrderDetails/index.tsx +12 -0
- package/src/slots/product/ProductActions/ProductCTASection.tsx +191 -0
- package/src/slots/product/ProductActions/ProductDetailsSection.tsx +137 -0
- package/src/slots/product/ProductActions/ProductFeaturePanel.tsx +245 -0
- package/src/slots/product/ProductActions/ProductHighlightsSection.tsx +99 -0
- package/src/slots/product/ProductActions/ProductOptionsSection.tsx +234 -0
- package/src/slots/product/ProductActions/ProductPriceSection.tsx +53 -0
- package/src/slots/product/ProductActions/ProductTrustSection.tsx +84 -0
- package/src/slots/product/ProductActions/index.tsx +161 -0
- package/src/slots/product/ProductCard/index.tsx +132 -0
- package/src/slots/product/ProductInfo/index.tsx +40 -0
- package/src/templates/StorePage/index.tsx +154 -0
- package/src/tokens/colors.js +16 -0
- package/src/tokens/colors.ts +21 -0
- package/src/tokens/fonts.ts +13 -0
- package/src/tokens/index.ts +3 -0
- package/src/tokens/spacing.ts +9 -0
- package/src/tokens/theme.css +89 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Image from "next/image"
|
|
4
|
+
import Color from "color"
|
|
5
|
+
import { HttpTypes } from "@medusajs/types"
|
|
6
|
+
import OptionSelect from "@modules/products/components/product-actions/option-select"
|
|
7
|
+
import type { ProductOptions } from "@core/domain/product/variant-selection"
|
|
8
|
+
|
|
9
|
+
type ProductOptionsSectionProps = {
|
|
10
|
+
product: HttpTypes.StoreProduct
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
isAdding: boolean
|
|
13
|
+
options: ProductOptions
|
|
14
|
+
setOptionValue: (optionId: string, value: string) => void
|
|
15
|
+
setColorValue: (optionId: string, value: string) => void
|
|
16
|
+
validationErrors: Record<string, string>
|
|
17
|
+
colorOption?: HttpTypes.StoreProductOption
|
|
18
|
+
sizeOption?: HttpTypes.StoreProductOption
|
|
19
|
+
otherOptions: HttpTypes.StoreProductOption[]
|
|
20
|
+
colorVariants: HttpTypes.StoreProductVariant[]
|
|
21
|
+
selectedColorValue?: string
|
|
22
|
+
quantity: number
|
|
23
|
+
setQuantity: (value: number) => void
|
|
24
|
+
inventoryLimit: number | null
|
|
25
|
+
onOpenSizeChart: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ProductOptionsSection({
|
|
29
|
+
product,
|
|
30
|
+
disabled,
|
|
31
|
+
isAdding,
|
|
32
|
+
options,
|
|
33
|
+
setOptionValue,
|
|
34
|
+
setColorValue,
|
|
35
|
+
validationErrors,
|
|
36
|
+
colorOption,
|
|
37
|
+
sizeOption,
|
|
38
|
+
otherOptions,
|
|
39
|
+
colorVariants,
|
|
40
|
+
selectedColorValue,
|
|
41
|
+
quantity,
|
|
42
|
+
setQuantity,
|
|
43
|
+
inventoryLimit,
|
|
44
|
+
onOpenSizeChart,
|
|
45
|
+
}: ProductOptionsSectionProps) {
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
{colorOption && (
|
|
49
|
+
<div className="flex flex-col gap-3">
|
|
50
|
+
<div className="flex items-center gap-2">
|
|
51
|
+
<h3 className="text-[11px] font-semibold text-heading uppercase tracking-[var(--letter-spacing-nav)]">
|
|
52
|
+
{colorVariants.length > 0
|
|
53
|
+
? "More Colors"
|
|
54
|
+
: `Select ${colorOption.title}`}
|
|
55
|
+
</h3>
|
|
56
|
+
{validationErrors[colorOption.id] && (
|
|
57
|
+
<span className="text-[10px] text-red-600 font-black uppercase tracking-wider animate-pulse border-l-2 border-red-600 pl-2">
|
|
58
|
+
Please Select {String(colorOption.title || "")}
|
|
59
|
+
</span>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
{colorVariants.length > 0 ? (
|
|
63
|
+
<div className="flex gap-2 sm:gap-3 overflow-x-auto pb-2">
|
|
64
|
+
{colorVariants.map((variant) => {
|
|
65
|
+
const variantImage =
|
|
66
|
+
(variant.metadata?.image as string) ||
|
|
67
|
+
variant.thumbnail ||
|
|
68
|
+
variant.images?.[0]?.url ||
|
|
69
|
+
product.thumbnail
|
|
70
|
+
const variantColorValue = variant.options?.find(
|
|
71
|
+
(opt: HttpTypes.StoreProductOptionValue) =>
|
|
72
|
+
opt.option_id === colorOption.id
|
|
73
|
+
)?.value
|
|
74
|
+
const isSelected = variantColorValue === selectedColorValue
|
|
75
|
+
let stripColor = (variant.metadata?.color_hex as string) || null
|
|
76
|
+
if (!stripColor && variantColorValue) {
|
|
77
|
+
try {
|
|
78
|
+
stripColor = Color(
|
|
79
|
+
variantColorValue.toLowerCase().replace(/\s+/g, "")
|
|
80
|
+
).hex()
|
|
81
|
+
} catch {
|
|
82
|
+
stripColor = null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
key={variant.id}
|
|
88
|
+
type="button"
|
|
89
|
+
className={`w-12 h-14 sm:w-16 sm:h-[4.5rem] overflow-hidden border flex-shrink-0 flex flex-col ${
|
|
90
|
+
isSelected
|
|
91
|
+
? "border-brand-accent ring-1 ring-brand-accent"
|
|
92
|
+
: "border-[var(--color-header-border)] hover:border-brand-accent-light"
|
|
93
|
+
}`}
|
|
94
|
+
onClick={() => {
|
|
95
|
+
if (colorOption && variantColorValue) {
|
|
96
|
+
setColorValue(colorOption.id, variantColorValue)
|
|
97
|
+
}
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
<div className="flex-1 w-full relative overflow-hidden">
|
|
101
|
+
{variantImage && (
|
|
102
|
+
<Image
|
|
103
|
+
src={variantImage}
|
|
104
|
+
alt={`Color variant: ${variantColorValue || "Unknown"}`}
|
|
105
|
+
fill
|
|
106
|
+
sizes="64px"
|
|
107
|
+
className="object-cover"
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
{stripColor && (
|
|
112
|
+
<div
|
|
113
|
+
className="h-2 w-full border-t border-gray-100"
|
|
114
|
+
style={{ backgroundColor: stripColor }}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
</button>
|
|
118
|
+
)
|
|
119
|
+
})}
|
|
120
|
+
</div>
|
|
121
|
+
) : (
|
|
122
|
+
<OptionSelect
|
|
123
|
+
option={colorOption}
|
|
124
|
+
current={options[colorOption.id]}
|
|
125
|
+
updateOption={setOptionValue}
|
|
126
|
+
title={colorOption.title ?? ""}
|
|
127
|
+
data-testid="product-options"
|
|
128
|
+
disabled={!!disabled || isAdding}
|
|
129
|
+
error={!!validationErrors[colorOption.id]}
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{sizeOption && (
|
|
136
|
+
<div className="flex flex-col gap-3">
|
|
137
|
+
<div className="flex items-center justify-between">
|
|
138
|
+
<div className="flex items-center gap-2">
|
|
139
|
+
<h3 className="text-[11px] font-semibold text-heading uppercase tracking-[var(--letter-spacing-nav)]">
|
|
140
|
+
Select Size
|
|
141
|
+
</h3>
|
|
142
|
+
{validationErrors[sizeOption.id] && (
|
|
143
|
+
<span className="text-[10px] text-red-600 font-black uppercase tracking-wider animate-pulse border-l-2 border-red-600 pl-2">
|
|
144
|
+
Please Select {String(sizeOption.title || "")}
|
|
145
|
+
</span>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={onOpenSizeChart}
|
|
151
|
+
className="flex items-center gap-1.5 text-xs sm:text-sm font-medium hover:opacity-80 text-brand-accent"
|
|
152
|
+
>
|
|
153
|
+
<Image src="/Size Chart.svg" alt="Size Chart" width={16} height={16} />
|
|
154
|
+
Size Chart
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
<OptionSelect
|
|
158
|
+
option={sizeOption}
|
|
159
|
+
current={options[sizeOption.id]}
|
|
160
|
+
updateOption={setOptionValue}
|
|
161
|
+
title={sizeOption.title ?? ""}
|
|
162
|
+
data-testid="product-options"
|
|
163
|
+
disabled={!!disabled || isAdding}
|
|
164
|
+
error={!!validationErrors[sizeOption.id]}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{otherOptions
|
|
170
|
+
.sort((a, b) => (a.title || "").localeCompare(b.title || ""))
|
|
171
|
+
.map((option) => (
|
|
172
|
+
<div key={option.id} className="flex flex-col gap-3">
|
|
173
|
+
<div className="flex items-center gap-2">
|
|
174
|
+
<h3 className="text-[11px] font-semibold text-heading uppercase tracking-[var(--letter-spacing-nav)]">
|
|
175
|
+
Select {String(option.title || "")}
|
|
176
|
+
</h3>
|
|
177
|
+
{validationErrors[option.id] && (
|
|
178
|
+
<span className="text-[10px] text-red-600 font-black uppercase tracking-wider animate-pulse border-l-2 border-red-600 pl-2">
|
|
179
|
+
Please Select {String(option.title || "")}
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
<OptionSelect
|
|
184
|
+
option={option}
|
|
185
|
+
current={options[option.id]}
|
|
186
|
+
updateOption={setOptionValue}
|
|
187
|
+
title={option.title ?? ""}
|
|
188
|
+
data-testid="product-options"
|
|
189
|
+
disabled={!!disabled || isAdding}
|
|
190
|
+
error={!!validationErrors[option.id]}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
))}
|
|
194
|
+
|
|
195
|
+
<div className="flex flex-col gap-3">
|
|
196
|
+
<div className="flex items-center justify-between">
|
|
197
|
+
<h3 className="text-xs sm:text-sm font-semibold text-gray-900 uppercase">
|
|
198
|
+
Quantity
|
|
199
|
+
</h3>
|
|
200
|
+
{inventoryLimit !== null && inventoryLimit > 0 && inventoryLimit <= 5 && (
|
|
201
|
+
<span className="text-[10px] sm:text-xs font-bold uppercase tracking-wider text-orange-600">
|
|
202
|
+
Hurry! Only {inventoryLimit} left
|
|
203
|
+
</span>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
<div className="flex items-center border border-[var(--color-header-border)] overflow-hidden bg-surface w-[120px]">
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
210
|
+
disabled={quantity <= 1}
|
|
211
|
+
className="w-10 h-10 flex items-center justify-center hover:bg-gray-50 disabled:opacity-50"
|
|
212
|
+
>
|
|
213
|
+
−
|
|
214
|
+
</button>
|
|
215
|
+
<span className="flex-1 text-center font-semibold text-gray-900 py-2">
|
|
216
|
+
{quantity}
|
|
217
|
+
</span>
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={() => {
|
|
221
|
+
if (inventoryLimit === null || quantity < inventoryLimit) {
|
|
222
|
+
setQuantity(quantity + 1)
|
|
223
|
+
}
|
|
224
|
+
}}
|
|
225
|
+
disabled={inventoryLimit !== null && quantity >= inventoryLimit}
|
|
226
|
+
className="w-10 h-10 flex items-center justify-center hover:bg-gray-50 disabled:opacity-50"
|
|
227
|
+
>
|
|
228
|
+
+
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatDisplayPrice,
|
|
3
|
+
formatINRPrice,
|
|
4
|
+
type VariantPrice,
|
|
5
|
+
} from "@core/domain/product/pricing"
|
|
6
|
+
|
|
7
|
+
type ProductPriceSectionProps = {
|
|
8
|
+
displayPrice: VariantPrice
|
|
9
|
+
quantityInCart: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ProductPriceSection({
|
|
13
|
+
displayPrice,
|
|
14
|
+
quantityInCart,
|
|
15
|
+
}: ProductPriceSectionProps) {
|
|
16
|
+
const originalPrice = displayPrice?.original_price_number
|
|
17
|
+
const currentPrice = displayPrice?.calculated_price_number
|
|
18
|
+
const discountPercentage =
|
|
19
|
+
originalPrice && currentPrice && originalPrice > currentPrice
|
|
20
|
+
? Math.round(((originalPrice - currentPrice) / originalPrice) * 100)
|
|
21
|
+
: null
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex flex-col gap-1">
|
|
25
|
+
<div className="flex items-baseline justify-between flex-wrap gap-2">
|
|
26
|
+
<div className="flex items-baseline gap-3 flex-wrap">
|
|
27
|
+
<span className="text-xl sm:text-2xl font-semibold text-heading">
|
|
28
|
+
{formatDisplayPrice(displayPrice, currentPrice)}
|
|
29
|
+
</span>
|
|
30
|
+
{originalPrice && currentPrice && originalPrice > currentPrice && (
|
|
31
|
+
<>
|
|
32
|
+
<span className="text-base text-[var(--color-text-muted)] line-through">
|
|
33
|
+
{formatINRPrice(originalPrice)}
|
|
34
|
+
</span>
|
|
35
|
+
{discountPercentage && (
|
|
36
|
+
<span className="text-sm font-medium text-brand-sale">
|
|
37
|
+
{discountPercentage}% off
|
|
38
|
+
</span>
|
|
39
|
+
)}
|
|
40
|
+
</>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{quantityInCart > 0 && (
|
|
45
|
+
<span className="text-[11px] font-semibold uppercase tracking-[var(--letter-spacing-nav)] text-brand-success">
|
|
46
|
+
{quantityInCart} in bag
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
<p className="text-xs text-[var(--color-text-muted)]">Tax included.</p>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Image from "next/image"
|
|
4
|
+
import PincodeChecker from "@modules/cart/components/pincode-checker"
|
|
5
|
+
|
|
6
|
+
type ProductTrustSectionProps = {
|
|
7
|
+
selectedVariantId?: string
|
|
8
|
+
onOpenFeature: (feature: string) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ProductTrustSection({ selectedVariantId, onOpenFeature }: ProductTrustSectionProps) {
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<div className="flex items-center justify-between py-2 px-4 bg-gray-50/60 rounded-xl border border-gray-100/80 mt-0.5 sm:-mt-1.5 mb-1 sm:mb-0">
|
|
15
|
+
<div className="flex items-center gap-3 sm:gap-4">
|
|
16
|
+
<Image src="/Item.svg" alt="Mastercard" width={40} height={28} className="h-6 sm:h-7 w-auto grayscale-[0.2]" />
|
|
17
|
+
<Image src="/Item (1).svg" alt="Amex" width={40} height={28} className="h-6 sm:h-7 w-auto grayscale-[0.2]" />
|
|
18
|
+
<Image src="/Item (2).svg" alt="Diners" width={40} height={28} className="h-7 sm:h-7 w-auto grayscale-[0.2]" />
|
|
19
|
+
<Image src="/visa.svg" alt="Visa" width={40} height={28} className="h-5 sm:h-6 w-auto grayscale-[0.2]" />
|
|
20
|
+
</div>
|
|
21
|
+
<div className="flex items-center gap-1.5 sm:gap-2 ml-2 sm:ml-0">
|
|
22
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#156229" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
23
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
|
24
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
|
25
|
+
</svg>
|
|
26
|
+
<span className="text-[9px] sm:text-[11px] font-bold text-gray-700 uppercase tracking-wider whitespace-nowrap">Secure Payments</span>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
{/* Pincode Checker Section */}
|
|
31
|
+
<div className="mt-0.5 sm:-mt-1.5 mb-0">
|
|
32
|
+
<PincodeChecker variantId={selectedVariantId} />
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
{/* Service Features Section */}
|
|
36
|
+
<div className="flex flex-row overflow-x-auto no-scrollbar py-4 border-t border-gray-200 gap-4 min-[500px]:gap-0 min-[500px]:justify-between min-[500px]:overflow-visible">
|
|
37
|
+
{/* 7 Days Easy Return */}
|
|
38
|
+
<div className="flex flex-col items-center gap-2 flex-shrink-0 min-w-[120px] min-[500px]:flex-1 min-[500px]:min-w-0 group">
|
|
39
|
+
<div className="w-8 h-8 flex items-center justify-center">
|
|
40
|
+
<Image src="/Return.svg" alt="Return" width={28} height={26} />
|
|
41
|
+
</div>
|
|
42
|
+
<div className="flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-opacity" onClick={() => onOpenFeature('return')}>
|
|
43
|
+
<span className="text-xs text-gray-800 font-medium text-center whitespace-nowrap leading-none">7 Days Easy Return</span>
|
|
44
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" className="text-gray-600 group-hover:text-purple-600 transition-colors relative top-[-0.5px]">
|
|
45
|
+
<polyline points="9 18 15 12 9 6"></polyline>
|
|
46
|
+
</svg>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
{/* Separator */}
|
|
51
|
+
<div className="hidden min-[500px]:block w-px h-12 bg-gray-200 flex-shrink-0"></div>
|
|
52
|
+
|
|
53
|
+
{/* Fast Delivery */}
|
|
54
|
+
<div className="flex flex-col items-center gap-2 flex-shrink-0 min-w-[120px] min-[500px]:flex-1 min-[500px]:min-w-0 group">
|
|
55
|
+
<div className="w-8 h-8 flex items-center justify-center">
|
|
56
|
+
<Image src="/delivery.svg" alt="Delivery" width={30} height={18} />
|
|
57
|
+
</div>
|
|
58
|
+
<div className="flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-opacity" onClick={() => onOpenFeature('delivery')}>
|
|
59
|
+
<span className="text-xs text-gray-800 font-medium text-center whitespace-nowrap leading-none">Fast Delivery</span>
|
|
60
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" className="text-gray-600 group-hover:text-purple-600 transition-colors relative top-[-0.5px]">
|
|
61
|
+
<polyline points="9 18 15 12 9 6"></polyline>
|
|
62
|
+
</svg>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Separator */}
|
|
67
|
+
<div className="hidden min-[500px]:block w-px h-12 bg-gray-200 flex-shrink-0"></div>
|
|
68
|
+
|
|
69
|
+
{/* Cash On Delivery Available */}
|
|
70
|
+
<div className="flex flex-col items-center gap-2 flex-shrink-0 min-w-[120px] min-[500px]:flex-1 min-[500px]:min-w-0 group">
|
|
71
|
+
<div className="w-8 h-8 flex items-center justify-center">
|
|
72
|
+
<Image src="/Cod.svg" alt="COD" width={30} height={18} />
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-opacity" onClick={() => onOpenFeature('cod')}>
|
|
75
|
+
<span className="text-xs text-gray-800 font-medium text-center whitespace-nowrap leading-none">Cash On Delivery Available</span>
|
|
76
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" className="text-gray-600 group-hover:text-purple-600 transition-colors relative top-[-0.5px]">
|
|
77
|
+
<polyline points="9 18 15 12 9 6"></polyline>
|
|
78
|
+
</svg>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
4
|
+
import { useParams } from "next/navigation"
|
|
5
|
+
import { HttpTypes } from "@medusajs/types"
|
|
6
|
+
import { useProductVariant } from "@core/hooks/use-product-variant"
|
|
7
|
+
import { useProductActions } from "@core/hooks/use-product-actions"
|
|
8
|
+
import { getDisplayPrice } from "@core/domain/product/pricing"
|
|
9
|
+
import LoginPopup from "@modules/common/components/login-popup"
|
|
10
|
+
import { ProductPriceSection } from "./ProductPriceSection"
|
|
11
|
+
import { ProductOptionsSection } from "./ProductOptionsSection"
|
|
12
|
+
import { ProductCTASection } from "./ProductCTASection"
|
|
13
|
+
import { ProductTrustSection } from "./ProductTrustSection"
|
|
14
|
+
import { ProductHighlightsSection } from "./ProductHighlightsSection"
|
|
15
|
+
import { ProductFeaturePanel } from "./ProductFeaturePanel"
|
|
16
|
+
import { ProductDetailsSection } from "./ProductDetailsSection"
|
|
17
|
+
|
|
18
|
+
type ProductActionsProps = {
|
|
19
|
+
product: HttpTypes.StoreProduct
|
|
20
|
+
region?: HttpTypes.StoreRegion
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
cart?: HttpTypes.StoreCart | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function ProductActions({
|
|
26
|
+
product,
|
|
27
|
+
region,
|
|
28
|
+
disabled,
|
|
29
|
+
cart,
|
|
30
|
+
}: ProductActionsProps) {
|
|
31
|
+
const countryCode = useParams().countryCode as string
|
|
32
|
+
const actionsRef = useRef<HTMLDivElement>(null)
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
options,
|
|
36
|
+
setOptionValue,
|
|
37
|
+
setColorValue,
|
|
38
|
+
selectedVariant,
|
|
39
|
+
isValidVariant,
|
|
40
|
+
validationErrors,
|
|
41
|
+
validateOptions,
|
|
42
|
+
colorOption,
|
|
43
|
+
sizeOption,
|
|
44
|
+
otherOptions,
|
|
45
|
+
colorVariants,
|
|
46
|
+
selectedColorValue,
|
|
47
|
+
} = useProductVariant({ product })
|
|
48
|
+
|
|
49
|
+
const displayPrice = useMemo(
|
|
50
|
+
() => getDisplayPrice(product, selectedVariant),
|
|
51
|
+
[product, selectedVariant]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
quantity,
|
|
56
|
+
setQuantity,
|
|
57
|
+
isAdding,
|
|
58
|
+
isBuyingNow,
|
|
59
|
+
variantInCart,
|
|
60
|
+
quantityInCart,
|
|
61
|
+
inStock,
|
|
62
|
+
inventoryLimit,
|
|
63
|
+
handleAddToCart,
|
|
64
|
+
handleBuyNow,
|
|
65
|
+
handleIncreaseQuantity,
|
|
66
|
+
handleDecreaseQuantity,
|
|
67
|
+
} = useProductActions({
|
|
68
|
+
product,
|
|
69
|
+
region,
|
|
70
|
+
cart,
|
|
71
|
+
selectedVariant,
|
|
72
|
+
options,
|
|
73
|
+
isValidVariant,
|
|
74
|
+
displayPrice,
|
|
75
|
+
validateOptions,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const [activeFeature, setActiveFeature] = useState<string | null>(null)
|
|
79
|
+
const [showLoginPopup, setShowLoginPopup] = useState(false)
|
|
80
|
+
const [showNotifyMessage, setShowNotifyMessage] = useState(false)
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
setShowNotifyMessage(false)
|
|
84
|
+
}, [options])
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
<div
|
|
89
|
+
className="flex flex-col gap-4 sm:gap-5 w-full"
|
|
90
|
+
ref={actionsRef}
|
|
91
|
+
>
|
|
92
|
+
{displayPrice && (
|
|
93
|
+
<ProductPriceSection
|
|
94
|
+
displayPrice={displayPrice}
|
|
95
|
+
quantityInCart={quantityInCart}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
<ProductOptionsSection
|
|
100
|
+
product={product}
|
|
101
|
+
disabled={disabled}
|
|
102
|
+
isAdding={isAdding}
|
|
103
|
+
options={options}
|
|
104
|
+
setOptionValue={setOptionValue}
|
|
105
|
+
setColorValue={setColorValue}
|
|
106
|
+
validationErrors={validationErrors}
|
|
107
|
+
colorOption={colorOption}
|
|
108
|
+
sizeOption={sizeOption}
|
|
109
|
+
otherOptions={otherOptions}
|
|
110
|
+
colorVariants={colorVariants}
|
|
111
|
+
selectedColorValue={selectedColorValue}
|
|
112
|
+
quantity={quantity}
|
|
113
|
+
setQuantity={setQuantity}
|
|
114
|
+
inventoryLimit={inventoryLimit}
|
|
115
|
+
onOpenSizeChart={() => setActiveFeature("size_chart")}
|
|
116
|
+
/>
|
|
117
|
+
|
|
118
|
+
<ProductCTASection
|
|
119
|
+
product={product}
|
|
120
|
+
selectedVariant={selectedVariant}
|
|
121
|
+
options={options}
|
|
122
|
+
isValidVariant={isValidVariant}
|
|
123
|
+
disabled={disabled}
|
|
124
|
+
inStock={inStock}
|
|
125
|
+
inventoryLimit={inventoryLimit}
|
|
126
|
+
quantity={quantity}
|
|
127
|
+
quantityInCart={quantityInCart}
|
|
128
|
+
variantInCart={variantInCart}
|
|
129
|
+
isAdding={isAdding}
|
|
130
|
+
isBuyingNow={isBuyingNow}
|
|
131
|
+
showNotifyMessage={showNotifyMessage}
|
|
132
|
+
setShowNotifyMessage={setShowNotifyMessage}
|
|
133
|
+
handleAddToCart={handleAddToCart}
|
|
134
|
+
handleBuyNow={handleBuyNow}
|
|
135
|
+
handleIncreaseQuantity={handleIncreaseQuantity}
|
|
136
|
+
handleDecreaseQuantity={handleDecreaseQuantity}
|
|
137
|
+
/>
|
|
138
|
+
|
|
139
|
+
<ProductTrustSection
|
|
140
|
+
selectedVariantId={selectedVariant?.id}
|
|
141
|
+
onOpenFeature={setActiveFeature}
|
|
142
|
+
/>
|
|
143
|
+
|
|
144
|
+
<ProductHighlightsSection product={product} />
|
|
145
|
+
|
|
146
|
+
<ProductFeaturePanel
|
|
147
|
+
activeFeature={activeFeature}
|
|
148
|
+
onClose={() => setActiveFeature(null)}
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
<ProductDetailsSection product={product} />
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<LoginPopup
|
|
155
|
+
isOpen={showLoginPopup}
|
|
156
|
+
onClose={() => setShowLoginPopup(false)}
|
|
157
|
+
countryCode={countryCode}
|
|
158
|
+
/>
|
|
159
|
+
</>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react"
|
|
4
|
+
import Image from "next/image"
|
|
5
|
+
import LocalizedClientLink from "@modules/common/components/localized-client-link"
|
|
6
|
+
import { getProductPrice } from "@core/util/get-product-price"
|
|
7
|
+
import { convertToLocale } from "@core/util/money"
|
|
8
|
+
import { useAddToCart } from "@core/hooks/use-add-to-cart"
|
|
9
|
+
import PlaceholderImage from "@modules/common/icons/placeholder-image"
|
|
10
|
+
import { clx } from "@medusajs/ui"
|
|
11
|
+
import type { ProductCardProps } from "@core/types/product-card"
|
|
12
|
+
|
|
13
|
+
export default function ProductCard({
|
|
14
|
+
product,
|
|
15
|
+
region,
|
|
16
|
+
className,
|
|
17
|
+
}: ProductCardProps) {
|
|
18
|
+
const { isAdding, handleAddToCart } = useAddToCart({ region })
|
|
19
|
+
const [imageHover, setImageHover] = useState(false)
|
|
20
|
+
|
|
21
|
+
const { cheapestPrice } = getProductPrice({ product })
|
|
22
|
+
const thumbnail = product.thumbnail
|
|
23
|
+
const secondImage = product.images?.[1]?.url
|
|
24
|
+
|
|
25
|
+
const discount = cheapestPrice?.percentage_diff
|
|
26
|
+
const hasSale =
|
|
27
|
+
discount && Number.parseInt(String(discount).replace(/\D/g, ""), 10) > 0
|
|
28
|
+
|
|
29
|
+
const priceLabel = useMemo(() => {
|
|
30
|
+
if (!cheapestPrice) return null
|
|
31
|
+
return convertToLocale({
|
|
32
|
+
amount: cheapestPrice.calculated_price_number,
|
|
33
|
+
currency_code: cheapestPrice.currency_code,
|
|
34
|
+
})
|
|
35
|
+
}, [cheapestPrice])
|
|
36
|
+
|
|
37
|
+
const compareLabel = useMemo(() => {
|
|
38
|
+
if (!cheapestPrice?.original_price_number) return null
|
|
39
|
+
if (cheapestPrice.calculated_price_number >= cheapestPrice.original_price_number)
|
|
40
|
+
return null
|
|
41
|
+
return convertToLocale({
|
|
42
|
+
amount: cheapestPrice.original_price_number,
|
|
43
|
+
currency_code: cheapestPrice.currency_code,
|
|
44
|
+
})
|
|
45
|
+
}, [cheapestPrice])
|
|
46
|
+
|
|
47
|
+
const onQuickAdd = (e: React.MouseEvent) => {
|
|
48
|
+
e.preventDefault()
|
|
49
|
+
e.stopPropagation()
|
|
50
|
+
handleAddToCart(product)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<article className={clx("group flex flex-col w-full", className)}>
|
|
55
|
+
<LocalizedClientLink
|
|
56
|
+
href={`/products/${product.handle}`}
|
|
57
|
+
className="block"
|
|
58
|
+
data-testid="product-card"
|
|
59
|
+
>
|
|
60
|
+
<div
|
|
61
|
+
className="relative aspect-[3/4] overflow-hidden bg-surface-muted mb-4"
|
|
62
|
+
onMouseEnter={() => setImageHover(true)}
|
|
63
|
+
onMouseLeave={() => setImageHover(false)}
|
|
64
|
+
>
|
|
65
|
+
{thumbnail ? (
|
|
66
|
+
<>
|
|
67
|
+
<Image
|
|
68
|
+
src={thumbnail}
|
|
69
|
+
alt={product.title || "Product"}
|
|
70
|
+
fill
|
|
71
|
+
sizes="(max-width:640px) 50vw, (max-width:1024px) 33vw, 25vw"
|
|
72
|
+
className={clx(
|
|
73
|
+
"object-cover transition-opacity duration-500",
|
|
74
|
+
imageHover && secondImage ? "opacity-0" : "opacity-100"
|
|
75
|
+
)}
|
|
76
|
+
/>
|
|
77
|
+
{secondImage && (
|
|
78
|
+
<Image
|
|
79
|
+
src={secondImage}
|
|
80
|
+
alt=""
|
|
81
|
+
fill
|
|
82
|
+
sizes="(max-width:640px) 50vw, (max-width:1024px) 33vw, 25vw"
|
|
83
|
+
className={clx(
|
|
84
|
+
"object-cover transition-opacity duration-500",
|
|
85
|
+
imageHover ? "opacity-100" : "opacity-0"
|
|
86
|
+
)}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
</>
|
|
90
|
+
) : (
|
|
91
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
92
|
+
<PlaceholderImage size={48} />
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{hasSale && (
|
|
97
|
+
<span className="absolute top-3 left-3 bg-brand-sale text-white text-[10px] font-semibold uppercase tracking-wider px-2.5 py-1">
|
|
98
|
+
Sale
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
|
|
102
|
+
<div className="absolute inset-x-0 bottom-0 p-3 translate-y-full group-hover:translate-y-0 transition-transform duration-300">
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={onQuickAdd}
|
|
106
|
+
disabled={isAdding === product.id}
|
|
107
|
+
className="w-full bg-brand-accent text-[var(--color-text-inverse)] py-3 text-[11px] font-semibold uppercase tracking-[var(--letter-spacing-nav)] hover:bg-brand-accent-hover transition-colors disabled:opacity-60"
|
|
108
|
+
>
|
|
109
|
+
{isAdding === product.id ? "Adding…" : "Quick add"}
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="space-y-1.5">
|
|
115
|
+
<h3 className="text-sm font-medium text-heading leading-snug line-clamp-2 group-hover:underline underline-offset-2">
|
|
116
|
+
{product.title}
|
|
117
|
+
</h3>
|
|
118
|
+
<div className="flex items-baseline gap-2">
|
|
119
|
+
{priceLabel && (
|
|
120
|
+
<span className="text-sm font-semibold text-heading">{priceLabel}</span>
|
|
121
|
+
)}
|
|
122
|
+
{compareLabel && (
|
|
123
|
+
<span className="text-sm text-[var(--color-text-muted)] line-through">
|
|
124
|
+
{compareLabel}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</LocalizedClientLink>
|
|
130
|
+
</article>
|
|
131
|
+
)
|
|
132
|
+
}
|