medusa-ui-common 2.0.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 +93 -0
- package/src/common/components/breadcrumb/index.tsx +43 -0
- package/src/common/components/cart-totals/index.tsx +562 -0
- package/src/common/components/checkbox/index.tsx +98 -0
- package/src/common/components/delete-button/index.tsx +158 -0
- package/src/common/components/discount-code/index.tsx +220 -0
- package/src/common/components/divider/index.tsx +9 -0
- package/src/common/components/error-message/index.tsx +13 -0
- package/src/common/components/filter-checkbox-group/index.tsx +134 -0
- package/src/common/components/filter-radio-group/index.tsx +62 -0
- package/src/common/components/input/index.tsx +79 -0
- package/src/common/components/interactive-link/index.tsx +33 -0
- package/src/common/components/line-item-options/index.tsx +26 -0
- package/src/common/components/line-item-price/index.tsx +64 -0
- package/src/common/components/line-item-unit-price/index.tsx +64 -0
- package/src/common/components/localized-client-link/index.tsx +32 -0
- package/src/common/components/login-popup/index.tsx +78 -0
- package/src/common/components/modal/index.tsx +123 -0
- package/src/common/components/native-select/index.tsx +75 -0
- package/src/common/components/obfuscated-email/index.tsx +30 -0
- package/src/common/components/processing-overlay/index.tsx +83 -0
- package/src/common/components/radio/index.tsx +27 -0
- package/src/common/components/side-panel/index.tsx +65 -0
- package/src/common/components/submit-button/index.tsx +32 -0
- package/src/common/icons/arrow-left.tsx +36 -0
- package/src/common/icons/back.tsx +37 -0
- package/src/common/icons/bancontact.tsx +26 -0
- package/src/common/icons/chevron-down.tsx +30 -0
- package/src/common/icons/delivered.tsx +29 -0
- package/src/common/icons/envelope.tsx +27 -0
- package/src/common/icons/eye-off.tsx +37 -0
- package/src/common/icons/eye.tsx +37 -0
- package/src/common/icons/fast-delivery.tsx +65 -0
- package/src/common/icons/ideal.tsx +26 -0
- package/src/common/icons/lock.tsx +31 -0
- package/src/common/icons/map-pin.tsx +37 -0
- package/src/common/icons/medusa.tsx +27 -0
- package/src/common/icons/menu.tsx +45 -0
- package/src/common/icons/nextjs.tsx +27 -0
- package/src/common/icons/package.tsx +44 -0
- package/src/common/icons/paypal.tsx +30 -0
- package/src/common/icons/phone.tsx +30 -0
- package/src/common/icons/placeholder-image.tsx +44 -0
- package/src/common/icons/refresh.tsx +51 -0
- package/src/common/icons/spinner.tsx +37 -0
- package/src/common/icons/trash.tsx +51 -0
- package/src/common/icons/user.tsx +37 -0
- package/src/common/icons/x.tsx +37 -0
- package/src/constants/payments.tsx +31 -0
- package/src/context/modal-context.tsx +37 -0
- package/src/context/wishlist-context.tsx +83 -0
- package/src/index.ts +16 -0
- package/src/skeletons/components/skeleton-button/index.tsx +5 -0
- package/src/skeletons/components/skeleton-card-details/index.tsx +10 -0
- package/src/skeletons/components/skeleton-cart-item/index.tsx +35 -0
- package/src/skeletons/components/skeleton-cart-totals/index.tsx +30 -0
- package/src/skeletons/components/skeleton-code-form/index.tsx +13 -0
- package/src/skeletons/components/skeleton-line-item/index.tsx +35 -0
- package/src/skeletons/components/skeleton-order-confirmed-header/index.tsx +14 -0
- package/src/skeletons/components/skeleton-order-information/index.tsx +36 -0
- package/src/skeletons/components/skeleton-order-items/index.tsx +43 -0
- package/src/skeletons/components/skeleton-order-summary/index.tsx +15 -0
- package/src/skeletons/components/skeleton-product-preview/index.tsx +15 -0
- package/src/skeletons/templates/skeleton-cart-page/index.tsx +65 -0
- package/src/skeletons/templates/skeleton-order-confirmed/index.tsx +21 -0
- package/src/skeletons/templates/skeleton-product-grid/index.tsx +23 -0
- package/src/skeletons/templates/skeleton-related-products/index.tsx +25 -0
- package/src/types/global.ts +24 -0
- package/src/types/icon.ts +6 -0
- package/src/util/checkout-dom.ts +65 -0
- package/src/util/compare-addresses.ts +28 -0
- package/src/util/env.ts +3 -0
- package/src/util/get-percentage-diff.ts +6 -0
- package/src/util/get-product-price.ts +79 -0
- package/src/util/isEmpty.ts +11 -0
- package/src/util/money.ts +26 -0
- package/src/util/product.ts +86 -0
- package/src/util/repeat.ts +5 -0
- package/src/util/returns.ts +72 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { deleteLineItem } from "medusa-storefront-data/cart"
|
|
4
|
+
import { addToWishlist } from "medusa-wishlist-logic/server"
|
|
5
|
+
import { Spinner, Trash } from "@medusajs/icons"
|
|
6
|
+
import { clx, Button, Heading, Text } from "@medusajs/ui"
|
|
7
|
+
import Modal from "medusa-ui-common/common/components/modal"
|
|
8
|
+
import { useState } from "react"
|
|
9
|
+
import { useWishlist } from "medusa-ui-common/context/wishlist-context"
|
|
10
|
+
import { trackAddToWishlist } from "medusa-storefront-analytics"
|
|
11
|
+
|
|
12
|
+
import Image from "next/image"
|
|
13
|
+
import X from "medusa-ui-common/common/icons/x"
|
|
14
|
+
|
|
15
|
+
const DeleteButton = ({
|
|
16
|
+
id,
|
|
17
|
+
productId,
|
|
18
|
+
thumbnail,
|
|
19
|
+
children,
|
|
20
|
+
className,
|
|
21
|
+
onAfterDelete,
|
|
22
|
+
"data-testid": dataTestId,
|
|
23
|
+
}: {
|
|
24
|
+
id: string
|
|
25
|
+
productId?: string
|
|
26
|
+
thumbnail?: string | null
|
|
27
|
+
children?: React.ReactNode
|
|
28
|
+
className?: string
|
|
29
|
+
onAfterDelete?: () => void
|
|
30
|
+
"data-testid"?: string
|
|
31
|
+
}) => {
|
|
32
|
+
const [isDeleting, setIsDeleting] = useState(false)
|
|
33
|
+
const [showModal, setShowModal] = useState(false)
|
|
34
|
+
const [wishlistLoading, setWishlistLoading] = useState(false)
|
|
35
|
+
const [error, setError] = useState<string | null>(null)
|
|
36
|
+
const { refreshWishlist } = useWishlist()
|
|
37
|
+
|
|
38
|
+
const handleDelete = async () => {
|
|
39
|
+
setIsDeleting(true)
|
|
40
|
+
setShowModal(false)
|
|
41
|
+
try {
|
|
42
|
+
await deleteLineItem(id)
|
|
43
|
+
onAfterDelete?.()
|
|
44
|
+
} catch {
|
|
45
|
+
// keep isDeleting false on error
|
|
46
|
+
} finally {
|
|
47
|
+
setIsDeleting(false)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const handleMoveToWishlist = async () => {
|
|
52
|
+
if (!productId) {
|
|
53
|
+
handleDelete()
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setWishlistLoading(true)
|
|
58
|
+
setError(null)
|
|
59
|
+
|
|
60
|
+
const result = await addToWishlist(productId)
|
|
61
|
+
|
|
62
|
+
if (result.success) {
|
|
63
|
+
trackAddToWishlist({ content_ids: [productId] })
|
|
64
|
+
await refreshWishlist()
|
|
65
|
+
await handleDelete()
|
|
66
|
+
} else {
|
|
67
|
+
setError(result.error || "Please login to move items to wishlist.")
|
|
68
|
+
setWishlistLoading(false)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<div
|
|
75
|
+
className={clx(
|
|
76
|
+
"flex items-center justify-between text-small-regular",
|
|
77
|
+
className
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<button
|
|
81
|
+
className="flex gap-x-1 text-ui-fg-subtle hover:text-ui-fg-base cursor-pointer items-center"
|
|
82
|
+
onClick={() => setShowModal(true)}
|
|
83
|
+
data-testid={dataTestId}
|
|
84
|
+
>
|
|
85
|
+
{isDeleting ? <Spinner className="animate-spin" /> : <Trash />}
|
|
86
|
+
<span>{children}</span>
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<Modal isOpen={showModal} close={() => setShowModal(false)} size="medium">
|
|
91
|
+
<div className="flex flex-col bg-white overflow-hidden rounded-sm relative">
|
|
92
|
+
{/* Close Icon */}
|
|
93
|
+
<button
|
|
94
|
+
onClick={() => setShowModal(false)}
|
|
95
|
+
className="absolute top-4 right-4 text-black hover:opacity-70 transition-opacity z-10"
|
|
96
|
+
>
|
|
97
|
+
<X size={24} />
|
|
98
|
+
</button>
|
|
99
|
+
|
|
100
|
+
{/* Body Content */}
|
|
101
|
+
<div className="p-6 flex gap-6 pr-12">
|
|
102
|
+
{/* Product Thumbnail */}
|
|
103
|
+
<div className="w-24 h-24 sm:w-28 sm:h-28 flex-shrink-0 bg-gray-50 rounded-sm overflow-hidden border border-gray-100 shadow-sm">
|
|
104
|
+
{thumbnail ? (
|
|
105
|
+
<Image
|
|
106
|
+
src={thumbnail}
|
|
107
|
+
alt="Product"
|
|
108
|
+
width={120}
|
|
109
|
+
height={120}
|
|
110
|
+
className="w-full h-full object-cover"
|
|
111
|
+
/>
|
|
112
|
+
) : (
|
|
113
|
+
<div className="w-full h-full flex items-center justify-center text-gray-300 bg-gray-50 uppercase text-[10px] font-bold">No Image</div>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Title and Description */}
|
|
118
|
+
<div className="flex flex-col justify-center">
|
|
119
|
+
<h3 className="text-[17px] font-bold text-gray-800 mb-1.5 leading-tight">Move from Bag</h3>
|
|
120
|
+
<p className="text-[15px] text-gray-500 leading-snug font-medium max-w-[280px]">
|
|
121
|
+
Are you sure you want to move this item from bag?
|
|
122
|
+
</p>
|
|
123
|
+
|
|
124
|
+
{error && (
|
|
125
|
+
<p className="text-red-500 text-[11px] font-bold mt-2 uppercase tracking-wide">{error}</p>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Footer with side-by-side buttons and separator */}
|
|
131
|
+
<div className="flex border-t border-gray-100 h-[52px]">
|
|
132
|
+
<button
|
|
133
|
+
onClick={handleDelete}
|
|
134
|
+
disabled={isDeleting}
|
|
135
|
+
className="flex-1 h-full flex items-center justify-center text-[13px] font-bold text-gray-500 tracking-wider hover:bg-gray-50 transition-colors uppercase"
|
|
136
|
+
>
|
|
137
|
+
{isDeleting ? <Spinner className="animate-spin" /> : "REMOVE"}
|
|
138
|
+
</button>
|
|
139
|
+
|
|
140
|
+
{/* Vertical Separator */}
|
|
141
|
+
<div className="w-px bg-gray-100 h-full" />
|
|
142
|
+
|
|
143
|
+
<button
|
|
144
|
+
onClick={handleMoveToWishlist}
|
|
145
|
+
disabled={wishlistLoading}
|
|
146
|
+
className="flex-1 h-full flex items-center justify-center text-[13px] font-bold tracking-wider hover:bg-gray-50 transition-colors uppercase"
|
|
147
|
+
style={{ color: '#D25C78' }} // Pinkish color from reference
|
|
148
|
+
>
|
|
149
|
+
{wishlistLoading ? <Spinner className="animate-spin" /> : "MOVE TO WISHLIST"}
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</Modal>
|
|
154
|
+
</>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export default DeleteButton
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Badge, Heading, Input, Label, Text } from "@medusajs/ui"
|
|
4
|
+
import React from "react"
|
|
5
|
+
|
|
6
|
+
import { applyPromotions } from "medusa-storefront-data/cart"
|
|
7
|
+
import { convertToLocale } from "medusa-ui-common/util/money"
|
|
8
|
+
import { HttpTypes } from "@medusajs/types"
|
|
9
|
+
import Trash from "medusa-ui-common/common/icons/trash"
|
|
10
|
+
import ErrorMessage from "medusa-ui-common/common/components/error-message"
|
|
11
|
+
import { SubmitButton } from "medusa-ui-common/common/components/submit-button"
|
|
12
|
+
|
|
13
|
+
type DiscountCodeProps = {
|
|
14
|
+
cart: HttpTypes.StoreCart & {
|
|
15
|
+
promotions: HttpTypes.StorePromotion[]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
|
|
20
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
21
|
+
const [errorMessage, setErrorMessage] = React.useState("")
|
|
22
|
+
const [successMessage, setSuccessMessage] = React.useState("")
|
|
23
|
+
|
|
24
|
+
const { promotions = [] } = cart
|
|
25
|
+
const removePromotionCode = async (code: string) => {
|
|
26
|
+
const validPromotions = promotions.filter(
|
|
27
|
+
(promotion) => promotion.code !== code
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
await applyPromotions(
|
|
31
|
+
validPromotions.filter((p) => p.code !== undefined).map((p) => p.code!)
|
|
32
|
+
)
|
|
33
|
+
setSuccessMessage("")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const addPromotionCode = async (formData: FormData) => {
|
|
37
|
+
setErrorMessage("")
|
|
38
|
+
setSuccessMessage("")
|
|
39
|
+
|
|
40
|
+
const code = formData.get("code")
|
|
41
|
+
if (!code) {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const input = document.getElementById("promotion-input") as HTMLInputElement
|
|
45
|
+
const codes = promotions
|
|
46
|
+
.filter((p) => p.code !== undefined)
|
|
47
|
+
.map((p) => p.code!)
|
|
48
|
+
codes.push(code.toString())
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const updatedCart = await applyPromotions(codes)
|
|
52
|
+
const hasDiscount = (updatedCart?.discount_total || 0) > 0
|
|
53
|
+
|
|
54
|
+
if (hasDiscount) {
|
|
55
|
+
setSuccessMessage("Coupon applied successfully!")
|
|
56
|
+
} else {
|
|
57
|
+
setErrorMessage("Coupon added, but it doesn't apply to the items in your bag.")
|
|
58
|
+
}
|
|
59
|
+
} catch (e: any) {
|
|
60
|
+
setErrorMessage("Invalid coupon code. Please check the code and try again.")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (input) {
|
|
64
|
+
input.value = ""
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
className="border border-gray-200 rounded-lg p-6 flex flex-col"
|
|
71
|
+
style={{ width: '480px', maxWidth: '100%' }}
|
|
72
|
+
>
|
|
73
|
+
{/* COUPONS Heading */}
|
|
74
|
+
<Heading className="text-lg font-bold text-gray-900 mb-4 uppercase">
|
|
75
|
+
COUPONS
|
|
76
|
+
</Heading>
|
|
77
|
+
|
|
78
|
+
{/* Apply Coupons Button */}
|
|
79
|
+
<button
|
|
80
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
81
|
+
type="button"
|
|
82
|
+
className="w-full rounded-lg p-4 flex items-center justify-between transition-colors border border-purple-200"
|
|
83
|
+
style={{ backgroundColor: '#D195FF33' }}
|
|
84
|
+
data-testid="add-discount-button"
|
|
85
|
+
>
|
|
86
|
+
<div className="flex items-center gap-3">
|
|
87
|
+
{/* Percentage Icon - White circle with black % symbol */}
|
|
88
|
+
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center flex-shrink-0 shadow-sm">
|
|
89
|
+
<span className="text-gray-900 font-bold text-xl">%</span>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Text Content */}
|
|
93
|
+
<div className="flex flex-col items-start">
|
|
94
|
+
<span className="font-bold text-base text-gray-900">
|
|
95
|
+
Apply Coupons
|
|
96
|
+
</span>
|
|
97
|
+
<span className="text-sm text-gray-500">
|
|
98
|
+
View all offers
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
{/* Chevron Icon */}
|
|
104
|
+
<svg
|
|
105
|
+
className="w-5 h-5 text-gray-900"
|
|
106
|
+
fill="none"
|
|
107
|
+
stroke="currentColor"
|
|
108
|
+
viewBox="0 0 24 24"
|
|
109
|
+
>
|
|
110
|
+
<path
|
|
111
|
+
strokeLinecap="round"
|
|
112
|
+
strokeLinejoin="round"
|
|
113
|
+
strokeWidth={2}
|
|
114
|
+
d="M9 5l7 7-7 7"
|
|
115
|
+
/>
|
|
116
|
+
</svg>
|
|
117
|
+
</button>
|
|
118
|
+
|
|
119
|
+
{/* Promotion Code Input Form */}
|
|
120
|
+
{isOpen && (
|
|
121
|
+
<form action={(a) => addPromotionCode(a)} className="w-full mt-4">
|
|
122
|
+
<div className="flex w-full gap-x-2">
|
|
123
|
+
<Input
|
|
124
|
+
className="size-full"
|
|
125
|
+
id="promotion-input"
|
|
126
|
+
name="code"
|
|
127
|
+
type="text"
|
|
128
|
+
autoFocus={false}
|
|
129
|
+
placeholder="Enter coupon code"
|
|
130
|
+
data-testid="discount-input"
|
|
131
|
+
/>
|
|
132
|
+
<SubmitButton
|
|
133
|
+
variant="secondary"
|
|
134
|
+
className="!bg-[#8B5AB1] hover:!bg-[#7a4a9f] text-white border-0"
|
|
135
|
+
data-testid="discount-apply-button"
|
|
136
|
+
>
|
|
137
|
+
Apply
|
|
138
|
+
</SubmitButton>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<ErrorMessage
|
|
142
|
+
error={errorMessage}
|
|
143
|
+
data-testid="discount-error-message"
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
{successMessage && (
|
|
147
|
+
<div
|
|
148
|
+
className="mt-2 text-sm text-green-500"
|
|
149
|
+
data-testid="discount-success-message"
|
|
150
|
+
>
|
|
151
|
+
{successMessage}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</form>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{/* Applied Promotions - Clean Card Redesign */}
|
|
158
|
+
{promotions.length > 0 && (
|
|
159
|
+
<div className="w-full flex flex-col mt-6 pt-4 border-t border-gray-100">
|
|
160
|
+
<Heading className="text-sm font-bold mb-3 text-gray-900 uppercase tracking-tight">
|
|
161
|
+
Promotion(s) applied:
|
|
162
|
+
</Heading>
|
|
163
|
+
|
|
164
|
+
<div className="flex flex-col gap-y-2">
|
|
165
|
+
{promotions.map((promotion) => {
|
|
166
|
+
return (
|
|
167
|
+
<div
|
|
168
|
+
key={promotion.id}
|
|
169
|
+
className="flex items-center justify-between w-full p-3 rounded-xl border border-purple-100 bg-purple-50/50 transition-all hover:bg-purple-50 group"
|
|
170
|
+
data-testid="discount-row"
|
|
171
|
+
>
|
|
172
|
+
<div className="flex items-center gap-x-3 overflow-hidden">
|
|
173
|
+
{/* Tag Icon */}
|
|
174
|
+
<div className="flex-shrink-0 text-[#8B5AB1]">
|
|
175
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
176
|
+
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
|
|
177
|
+
<line x1="7" y1="7" x2="7.01" y2="7"></line>
|
|
178
|
+
</svg>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div className="flex flex-col overflow-hidden">
|
|
182
|
+
<span className="font-bold truncate text-sm uppercase tracking-wider" style={{ color: '#8B5AB1' }} data-testid="discount-code">
|
|
183
|
+
{promotion.code}
|
|
184
|
+
</span>
|
|
185
|
+
{promotion.application_method?.value !== undefined && (
|
|
186
|
+
<span className="text-xs font-bold" style={{ color: '#FE5FB7' }}>
|
|
187
|
+
{promotion.application_method.type === "percentage"
|
|
188
|
+
? `${promotion.application_method.value}% OFF`
|
|
189
|
+
: `${convertToLocale({
|
|
190
|
+
amount: +promotion.application_method.value,
|
|
191
|
+
currency_code: promotion.application_method.currency_code || "INR",
|
|
192
|
+
})} OFF`}
|
|
193
|
+
</span>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{!promotion.is_automatic && (
|
|
199
|
+
<button
|
|
200
|
+
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-all"
|
|
201
|
+
onClick={() => {
|
|
202
|
+
if (!promotion.code) return
|
|
203
|
+
removePromotionCode(promotion.code)
|
|
204
|
+
}}
|
|
205
|
+
data-testid="remove-discount-button"
|
|
206
|
+
>
|
|
207
|
+
<Trash size={16} />
|
|
208
|
+
</button>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
})}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export default DiscountCode
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const ErrorMessage = ({ error, 'data-testid': dataTestid }: { error?: string | null, 'data-testid'?: string }) => {
|
|
2
|
+
if (!error) {
|
|
3
|
+
return null
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return (
|
|
7
|
+
<div className="pt-2 text-rose-500 text-small-regular" data-testid={dataTestid}>
|
|
8
|
+
<span>{error}</span>
|
|
9
|
+
</div>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default ErrorMessage
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Heading } from "@medusajs/ui"
|
|
2
|
+
import CheckboxWithLabel from "medusa-ui-common/common/components/checkbox"
|
|
3
|
+
import React from "react"
|
|
4
|
+
|
|
5
|
+
type FilterCheckboxGroupProps = {
|
|
6
|
+
title: string
|
|
7
|
+
items: { value: string; label: string }[]
|
|
8
|
+
values: string[]
|
|
9
|
+
handleChange: (value: string) => void
|
|
10
|
+
isColor?: boolean
|
|
11
|
+
'data-testid'?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const FilterCheckboxGroup = ({
|
|
15
|
+
title,
|
|
16
|
+
items,
|
|
17
|
+
values,
|
|
18
|
+
handleChange,
|
|
19
|
+
isColor,
|
|
20
|
+
'data-testid': dataTestId,
|
|
21
|
+
}: FilterCheckboxGroupProps) => {
|
|
22
|
+
const [isOpen, setIsOpen] = React.useState(true)
|
|
23
|
+
const [showAll, setShowAll] = React.useState(false)
|
|
24
|
+
|
|
25
|
+
// Optimistic local state — reflects clicks instantly without waiting for server
|
|
26
|
+
const [optimisticValues, setOptimisticValues] = React.useState<string[]>(values)
|
|
27
|
+
|
|
28
|
+
// Sync back when server props update (after navigation completes)
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
setOptimisticValues(values)
|
|
31
|
+
}, [values])
|
|
32
|
+
|
|
33
|
+
const initialItemsCount = 6
|
|
34
|
+
const hasMore = items?.length > initialItemsCount
|
|
35
|
+
|
|
36
|
+
// Auto-expand if a selected item is hidden or if any item is selected
|
|
37
|
+
React.useEffect(() => {
|
|
38
|
+
if (values && values.length > 0) {
|
|
39
|
+
setIsOpen(true)
|
|
40
|
+
if (hasMore && items) {
|
|
41
|
+
const hiddenItems = items.slice(initialItemsCount)
|
|
42
|
+
const isAnyHiddenItemSelected = hiddenItems.some(item => values.includes(item.value))
|
|
43
|
+
if (isAnyHiddenItemSelected) {
|
|
44
|
+
setShowAll(true)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, [values, items, hasMore])
|
|
49
|
+
|
|
50
|
+
const handleOptimisticChange = (value: string) => {
|
|
51
|
+
// Immediately toggle locally — no flicker
|
|
52
|
+
setOptimisticValues((prev) =>
|
|
53
|
+
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
|
|
54
|
+
)
|
|
55
|
+
// Trigger actual URL navigation in background
|
|
56
|
+
handleChange(value)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const displayedItems = showAll ? items : items?.slice(0, initialItemsCount)
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex flex-col border-b border-gray-100 pb-4 last:border-0 last:pb-0">
|
|
63
|
+
<button
|
|
64
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
65
|
+
className="flex items-center justify-between w-full group transition-all py-1"
|
|
66
|
+
>
|
|
67
|
+
<Heading
|
|
68
|
+
level="h3"
|
|
69
|
+
className="text-sm font-bold text-gray-900 uppercase tracking-wider group-hover:text-[#8B5AB1] transition-colors"
|
|
70
|
+
>
|
|
71
|
+
{title}
|
|
72
|
+
</Heading>
|
|
73
|
+
<span
|
|
74
|
+
className={`transform transition-transform duration-300 text-gray-400 group-hover:text-[#8B5AB1] ${isOpen ? "" : "-rotate-180"}`}
|
|
75
|
+
>
|
|
76
|
+
<svg
|
|
77
|
+
width="18"
|
|
78
|
+
height="18"
|
|
79
|
+
viewBox="0 0 24 24"
|
|
80
|
+
fill="none"
|
|
81
|
+
stroke="currentColor"
|
|
82
|
+
strokeWidth="2.5"
|
|
83
|
+
strokeLinecap="round"
|
|
84
|
+
strokeLinejoin="round"
|
|
85
|
+
>
|
|
86
|
+
<polyline points="18 15 12 9 6 15"></polyline>
|
|
87
|
+
</svg>
|
|
88
|
+
</span>
|
|
89
|
+
</button>
|
|
90
|
+
|
|
91
|
+
<div
|
|
92
|
+
className={`flex flex-col gap-y-1 transition-all duration-300 ease-in-out ${isOpen ? "mt-3 opacity-100 px-0.5" : "max-h-0 opacity-0 invisible overflow-hidden"}`}
|
|
93
|
+
style={isOpen ? { maxHeight: showAll ? '1000px' : '300px' } : {}}
|
|
94
|
+
>
|
|
95
|
+
{displayedItems?.map((i) => (
|
|
96
|
+
<CheckboxWithLabel
|
|
97
|
+
key={i.value}
|
|
98
|
+
label={i.label}
|
|
99
|
+
color={isColor ? i.value : undefined}
|
|
100
|
+
checked={optimisticValues.includes(i.value)}
|
|
101
|
+
onChange={() => handleOptimisticChange(i.value)}
|
|
102
|
+
data-testid={dataTestId}
|
|
103
|
+
data-ga-event="filter_checkbox_click"
|
|
104
|
+
data-ga-label={`${title} - ${i.label}`}
|
|
105
|
+
/>
|
|
106
|
+
))}
|
|
107
|
+
|
|
108
|
+
{hasMore && isOpen && (
|
|
109
|
+
<button
|
|
110
|
+
onClick={() => setShowAll(!showAll)}
|
|
111
|
+
className="text-xs font-bold text-[#8B5AB1] hover:underline mt-2 flex items-center gap-1 uppercase tracking-tight"
|
|
112
|
+
>
|
|
113
|
+
{showAll ? "View Less" : `View More (${items.length - initialItemsCount})`}
|
|
114
|
+
<svg
|
|
115
|
+
width="12"
|
|
116
|
+
height="12"
|
|
117
|
+
viewBox="0 0 24 24"
|
|
118
|
+
fill="none"
|
|
119
|
+
stroke="currentColor"
|
|
120
|
+
strokeWidth="3"
|
|
121
|
+
strokeLinecap="round"
|
|
122
|
+
strokeLinejoin="round"
|
|
123
|
+
className={`transform transition-transform ${showAll ? "rotate-180" : ""}`}
|
|
124
|
+
>
|
|
125
|
+
<polyline points="6 9 12 15 18 9"></polyline>
|
|
126
|
+
</svg>
|
|
127
|
+
</button>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default FilterCheckboxGroup
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { EllipseMiniSolid } from "@medusajs/icons"
|
|
2
|
+
import { Label, RadioGroup, Text, clx } from "@medusajs/ui"
|
|
3
|
+
|
|
4
|
+
type FilterRadioGroupProps = {
|
|
5
|
+
title: string
|
|
6
|
+
items: {
|
|
7
|
+
value: string
|
|
8
|
+
label: string
|
|
9
|
+
}[]
|
|
10
|
+
value: any
|
|
11
|
+
handleChange: (...args: any[]) => void
|
|
12
|
+
"data-testid"?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FilterRadioGroup = ({
|
|
16
|
+
title,
|
|
17
|
+
items,
|
|
18
|
+
value,
|
|
19
|
+
handleChange,
|
|
20
|
+
"data-testid": dataTestId,
|
|
21
|
+
}: FilterRadioGroupProps) => {
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex gap-x-3 flex-col gap-y-3">
|
|
24
|
+
<Text className="txt-compact-small-plus text-ui-fg-muted">{title}</Text>
|
|
25
|
+
<RadioGroup data-testid={dataTestId} onValueChange={handleChange}>
|
|
26
|
+
{items?.map((i) => (
|
|
27
|
+
<div
|
|
28
|
+
key={i.value}
|
|
29
|
+
className={clx("flex gap-x-2 items-center", {
|
|
30
|
+
"ml-[-23px]": i.value === value,
|
|
31
|
+
})}
|
|
32
|
+
>
|
|
33
|
+
{i.value === value && <EllipseMiniSolid />}
|
|
34
|
+
<RadioGroup.Item
|
|
35
|
+
checked={i.value === value}
|
|
36
|
+
className="hidden peer"
|
|
37
|
+
id={i.value}
|
|
38
|
+
value={i.value}
|
|
39
|
+
/>
|
|
40
|
+
<Label
|
|
41
|
+
htmlFor={i.value}
|
|
42
|
+
className={clx(
|
|
43
|
+
"!txt-compact-small !transform-none text-ui-fg-subtle hover:cursor-pointer",
|
|
44
|
+
{
|
|
45
|
+
"text-ui-fg-base": i.value === value,
|
|
46
|
+
}
|
|
47
|
+
)}
|
|
48
|
+
data-testid="radio-label"
|
|
49
|
+
data-active={i.value === value}
|
|
50
|
+
data-ga-event="filter_sort_click"
|
|
51
|
+
data-ga-label={i.label}
|
|
52
|
+
>
|
|
53
|
+
{i.label}
|
|
54
|
+
</Label>
|
|
55
|
+
</div>
|
|
56
|
+
))}
|
|
57
|
+
</RadioGroup>
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default FilterRadioGroup
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Label } from "@medusajs/ui"
|
|
2
|
+
import React, { useEffect, useImperativeHandle, useState } from "react"
|
|
3
|
+
|
|
4
|
+
import Eye from "medusa-ui-common/common/icons/eye"
|
|
5
|
+
import EyeOff from "medusa-ui-common/common/icons/eye-off"
|
|
6
|
+
|
|
7
|
+
type InputProps = Omit<
|
|
8
|
+
Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
|
9
|
+
"placeholder"
|
|
10
|
+
> & {
|
|
11
|
+
label: string
|
|
12
|
+
errors?: Record<string, unknown>
|
|
13
|
+
touched?: Record<string, unknown>
|
|
14
|
+
name: string
|
|
15
|
+
topLabel?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
19
|
+
({ type, name, label, touched, required, topLabel, ...props }, ref) => {
|
|
20
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
21
|
+
const [showPassword, setShowPassword] = useState(false)
|
|
22
|
+
const [inputType, setInputType] = useState(type)
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (type === "password" && showPassword) {
|
|
26
|
+
setInputType("text")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (type === "password" && !showPassword) {
|
|
30
|
+
setInputType("password")
|
|
31
|
+
}
|
|
32
|
+
}, [type, showPassword])
|
|
33
|
+
|
|
34
|
+
useImperativeHandle(ref, () => inputRef.current!)
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex flex-col w-full">
|
|
38
|
+
{topLabel && (
|
|
39
|
+
<Label className="mb-2 txt-compact-medium-plus">{topLabel}</Label>
|
|
40
|
+
)}
|
|
41
|
+
<div className="flex relative z-0 w-full txt-compact-medium overflow-visible">
|
|
42
|
+
<input
|
|
43
|
+
type={inputType}
|
|
44
|
+
name={name}
|
|
45
|
+
placeholder=" "
|
|
46
|
+
required={required}
|
|
47
|
+
className="peer pt-6 pb-2 block w-full h-12 px-4 mt-0 bg-white border rounded-md appearance-none focus:outline-none focus:border-[#8B5AB1] focus:ring-0 hover:bg-gray-50 transition-colors duration-200 antialiased"
|
|
48
|
+
style={{ borderColor: '#C0C0C0' }}
|
|
49
|
+
{...props}
|
|
50
|
+
ref={inputRef}
|
|
51
|
+
/>
|
|
52
|
+
{label && (
|
|
53
|
+
<label
|
|
54
|
+
htmlFor={name}
|
|
55
|
+
onClick={() => inputRef.current?.focus()}
|
|
56
|
+
className="flex items-center justify-center absolute left-4 px-1 transition-all duration-300 top-1/2 -translate-y-1/2 origin-0 text-ui-fg-subtle pointer-events-none peer-focus:top-2 peer-focus:translate-y-0 peer-focus:text-[11px] peer-focus:text-[#8B5AB1] peer-focus:z-10 peer-[:not(:placeholder-shown)]:top-2 peer-[:not(:placeholder-shown)]:translate-y-0 peer-[:not(:placeholder-shown)]:text-[11px] peer-[:not(:placeholder-shown)]:z-10"
|
|
57
|
+
>
|
|
58
|
+
{label}
|
|
59
|
+
{required && <span className="text-rose-500">*</span>}
|
|
60
|
+
</label>
|
|
61
|
+
)}
|
|
62
|
+
{type === "password" && (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
66
|
+
className="text-ui-fg-subtle px-4 focus:outline-none transition-all duration-150 outline-none focus:text-ui-fg-base absolute right-0 top-3"
|
|
67
|
+
>
|
|
68
|
+
{showPassword ? <Eye /> : <EyeOff />}
|
|
69
|
+
</button>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
Input.displayName = "Input"
|
|
78
|
+
|
|
79
|
+
export default Input
|