medusa-ui-common 2.0.0 → 2.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "medusa-ui-common",
3
- "version": "2.0.0",
3
+ "version": "2.3.0",
4
4
  "description": "Shared storefront UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,10 +22,11 @@
22
22
  "lucide-react": "*",
23
23
  "medusa-contact-logic-plugin": "^2.0.0",
24
24
  "medusa-reviews-logic": "^2.0.0",
25
- "medusa-storefront-analytics": "^1.3.0",
25
+ "medusa-storefront-analytics": "^1.0.0",
26
26
  "medusa-storefront-data": "^2.0.0",
27
27
  "medusa-storefront-hooks": "^1.0.0",
28
28
  "medusa-wishlist-logic": "^2.0.0",
29
+ "medusa-storefront-theme-base": "^2.0.0",
29
30
  "next": ">=14.0.0",
30
31
  "react": "^18.0.0 || ^19.0.0",
31
32
  "react-dom": "^18.0.0 || ^19.0.0"
@@ -47,6 +48,21 @@
47
48
  "import": "./src/common/*",
48
49
  "default": "./src/common/*"
49
50
  },
51
+ "./providers": {
52
+ "types": "./src/storefront-providers.tsx",
53
+ "import": "./src/storefront-providers.tsx",
54
+ "default": "./src/storefront-providers.tsx"
55
+ },
56
+ "./context/wishlist-context": {
57
+ "types": "./src/context/wishlist-context.tsx",
58
+ "import": "./src/context/wishlist-context.tsx",
59
+ "default": "./src/context/wishlist-context.tsx"
60
+ },
61
+ "./context/modal-context": {
62
+ "types": "./src/context/modal-context.tsx",
63
+ "import": "./src/context/modal-context.tsx",
64
+ "default": "./src/context/modal-context.tsx"
65
+ },
50
66
  "./context/*": {
51
67
  "types": "./src/context/*",
52
68
  "import": "./src/context/*",
@@ -81,6 +97,11 @@
81
97
  }
82
98
  },
83
99
  "devDependencies": {
100
+ "next": "^15.0.0",
101
+ "medusa-storefront-analytics": "workspace:*",
102
+ "medusa-storefront-core": "workspace:*",
103
+ "medusa-storefront-data": "workspace:*",
104
+ "medusa-storefront-theme-base": "workspace:*",
84
105
  "@headlessui/react": "^2.2.0",
85
106
  "@medusajs/icons": "^2.0.0",
86
107
  "@medusajs/ui": "^4.0.0",
@@ -9,13 +9,13 @@ export default function Breadcrumb({ product }: BreadcrumbProps) {
9
9
  const primaryCategory = product.categories?.[0]
10
10
 
11
11
  return (
12
- <nav className="text-xs sm:text-sm text-gray-800 mb-3 sm:mb-4 overflow-x-auto font-medium">
12
+ <nav className="text-xs sm:text-sm text-[var(--sf-color-text)] mb-3 sm:mb-4 overflow-x-auto font-medium">
13
13
  <div className="flex items-center whitespace-nowrap">
14
- <LocalizedClientLink href="/" className="hover:text-gray-900 transition-colors">
14
+ <LocalizedClientLink href="/" className="hover:text-[var(--sf-color-text)] transition-colors">
15
15
  Home
16
16
  </LocalizedClientLink>
17
17
  <span className="mx-2">/</span>
18
- <LocalizedClientLink href="/store" className="hover:text-gray-900 transition-colors">
18
+ <LocalizedClientLink href="/store" className="hover:text-[var(--sf-color-text)] transition-colors">
19
19
  Shop
20
20
  </LocalizedClientLink>
21
21
  {primaryCategory && (
@@ -23,14 +23,14 @@ export default function Breadcrumb({ product }: BreadcrumbProps) {
23
23
  <span className="mx-2">/</span>
24
24
  <LocalizedClientLink
25
25
  href={`/store?category=${primaryCategory.id}`}
26
- className="hover:text-gray-900 transition-colors"
26
+ className="hover:text-[var(--sf-color-text)] transition-colors"
27
27
  >
28
28
  {primaryCategory.name}
29
29
  </LocalizedClientLink>
30
30
  </>
31
31
  )}
32
32
  <span className="mx-2">/</span>
33
- <span className="text-gray-900 truncate max-w-[150px] sm:max-w-[300px] md:max-w-[400px] inline-block align-bottom" title={product.title ?? ""}>
33
+ <span className="text-[var(--sf-color-text)] truncate max-w-[150px] sm:max-w-[300px] md:max-w-[400px] inline-block align-bottom" title={product.title ?? ""}>
34
34
  {product.title}
35
35
  </span>
36
36
  </div>
@@ -9,6 +9,8 @@ import LocalizedClientLink from "medusa-ui-common/common/components/localized-cl
9
9
  import { useRouter, usePathname, useSearchParams } from "next/navigation"
10
10
  import ProcessingOverlay from "medusa-ui-common/common/components/processing-overlay"
11
11
  import { useSiteCompany } from "medusa-storefront-core"
12
+ import { useButtonClassName } from "../../../context/button-theme-context"
13
+ import { ThemedButton } from "../themed-button"
12
14
 
13
15
  type CartTotalsProps = {
14
16
  totals: {
@@ -27,6 +29,8 @@ type CartTotalsProps = {
27
29
 
28
30
  const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutStep, customer }) => {
29
31
  const company = useSiteCompany()
32
+ const primaryBtn = useButtonClassName("primary", { size: "lg", className: "w-full" })
33
+ const secondaryBtn = useButtonClassName("secondary", { size: "lg", className: "w-full" })
30
34
  const router = useRouter()
31
35
  const pathname = usePathname()
32
36
  const searchParams = useSearchParams()
@@ -270,13 +274,13 @@ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutSte
270
274
  }
271
275
 
272
276
  return (
273
- <div className="bg-[#FFFAFE] rounded-lg p-3 min-[550px]:p-4 sm:p-5 md:p-6 min-[1023px]:p-4 min-[1150px]:p-5 min-[1360px]:p-6 border shadow-sm w-full" style={{ borderColor: '#C0C0C0' }}>
277
+ <div className="bg-[var(--sf-color-background)] rounded-lg p-3 min-[550px]:p-4 sm:p-5 md:p-6 min-[1023px]:p-4 min-[1150px]:p-5 min-[1360px]:p-6 border shadow-sm w-full" style={{ borderColor: '#C0C0C0' }}>
274
278
  <ProcessingOverlay isOpen={showOverlay} variant={processingVariant} />
275
- <h3 className="text-sm min-[550px]:text-base sm:text-lg min-[1023px]:text-base min-[1150px]:text-lg min-[1360px]:text-lg font-bold text-gray-900 mb-2.5 min-[550px]:mb-3 sm:mb-4 min-[1023px]:mb-3 min-[1150px]:mb-4 min-[1360px]:mb-4">
279
+ <h3 className="text-sm min-[550px]:text-base sm:text-lg min-[1023px]:text-base min-[1150px]:text-lg min-[1360px]:text-lg font-bold text-[var(--sf-color-text)] mb-2.5 min-[550px]:mb-3 sm:mb-4 min-[1023px]:mb-3 min-[1150px]:mb-4 min-[1360px]:mb-4">
276
280
  PRICE SUMMARY ({itemCount} {itemCount === 1 ? "ITEM" : "ITEMS"})
277
281
  </h3>
278
282
 
279
- <div className="flex flex-col gap-y-2 min-[550px]:gap-y-2.5 sm:gap-y-3 min-[1023px]:gap-y-2 min-[1150px]:gap-y-3 min-[1360px]:gap-y-3 text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base text-gray-900">
283
+ <div className="flex flex-col gap-y-2 min-[550px]:gap-y-2.5 sm:gap-y-3 min-[1023px]:gap-y-2 min-[1150px]:gap-y-3 min-[1360px]:gap-y-3 text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base text-[var(--sf-color-text)]">
280
284
  <div className="flex items-center justify-between">
281
285
  <span>Total MRP</span>
282
286
  <span data-testid="cart-mrp" data-value={totalMRP}>
@@ -288,7 +292,7 @@ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutSte
288
292
  <div className="flex items-center justify-between">
289
293
  <span>Discount</span>
290
294
  <span
291
- className="text-gray-900"
295
+ className="text-[var(--sf-color-text)]"
292
296
  data-testid="cart-discount"
293
297
  data-value={calculatedDiscount}
294
298
  >
@@ -311,7 +315,7 @@ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutSte
311
315
  <span>Shipping</span>
312
316
  <span data-testid="cart-shipping" data-value={shippingAmount} className="flex items-center gap-x-2">
313
317
  {isAutoSettingShipping && (
314
- <span className="w-4 h-4 border-2 border-[#8B5AB1] border-t-transparent rounded-full animate-spin"></span>
318
+ <span className="w-4 h-4 border-2 border-[var(--sf-btn-primary)] border-t-transparent rounded-full animate-spin"></span>
315
319
  )}
316
320
  {convertToLocale({ amount: shippingAmount, currency_code })}
317
321
  </span>
@@ -320,7 +324,7 @@ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutSte
320
324
 
321
325
  <div className="h-px w-full border-b border-gray-200 my-3 min-[550px]:my-4" />
322
326
 
323
- <div className="flex items-center justify-between text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base font-bold text-gray-900 mb-2.5 min-[550px]:mb-3 sm:mb-4 min-[1023px]:mb-3 min-[1150px]:mb-4 min-[1360px]:mb-4">
327
+ <div className="flex items-center justify-between text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base font-bold text-[var(--sf-color-text)] mb-2.5 min-[550px]:mb-3 sm:mb-4 min-[1023px]:mb-3 min-[1150px]:mb-4 min-[1360px]:mb-4">
324
328
  <span>Total</span>
325
329
  <span
326
330
  className="text-sm min-[550px]:text-base sm:text-lg min-[1023px]:text-base min-[1150px]:text-lg min-[1360px]:text-lg"
@@ -344,22 +348,22 @@ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutSte
344
348
 
345
349
  {/* No manual continue button needed as we are in one-step mode */}
346
350
  {isOnCartPage && (
347
- <button
351
+ <ThemedButton
352
+ variant="primary"
353
+ size="lg"
354
+ fullWidth
348
355
  onClick={handleProceedToCheckout}
349
356
  disabled={isRedirecting}
357
+ isLoading={isRedirecting}
350
358
  data-ga-event="proceed_to_checkout_click"
351
359
  data-meta-event="InitiateCheckout"
352
360
  data-meta-action="proceed_to_checkout"
353
361
  data-ga-label={convertToLocale({ amount: finalTotal, currency_code })}
354
- className="w-full h-10 min-[550px]:h-11 sm:h-12 min-[1023px]:h-11 min-[1150px]:h-12 min-[1360px]:h-12 text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base text-white font-medium transition-colors duration-200 hover:opacity-90 mt-3 min-[550px]:mt-4 sm:mt-6 min-[1023px]:mt-5 min-[1150px]:mt-6 min-[1360px]:mt-6 disabled:opacity-50"
355
- style={{
356
- backgroundColor: '#8B5AB1',
357
- borderRadius: '30px'
358
- }}
362
+ className="mt-3 min-[550px]:mt-4 sm:mt-6 min-[1023px]:mt-5 min-[1150px]:mt-6 min-[1360px]:mt-6"
359
363
  data-testid="checkout-button"
360
364
  >
361
365
  {isRedirecting ? "Loading..." : "Proceed to Checkout"}
362
- </button>
366
+ </ThemedButton>
363
367
  )}
364
368
  {!isOnCartPage && (
365
369
  <div className="w-full">
@@ -376,6 +380,7 @@ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutSte
376
380
  return (
377
381
  <>
378
382
  <button
383
+ type="button"
379
384
  onClick={async () => {
380
385
  trackPaymentMethodClick("cod")
381
386
  setProcessingVariant("order")
@@ -404,18 +409,14 @@ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutSte
404
409
  data-meta-event="AddPaymentInfo"
405
410
  data-meta-action="select_payment_method"
406
411
  data-meta-payment-type="cod"
407
- className={clx(
408
- "w-full py-2.5 min-[550px]:py-3 text-xs min-[550px]:text-sm sm:text-base text-gray-900 bg-white border-2 font-bold rounded-lg transition-colors border-[#8B5AB1]",
409
- {
410
- "hover:bg-purple-50": !isPaymentDisabled,
411
- "opacity-50 cursor-not-allowed": isPaymentDisabled
412
- }
413
- )}
414
- style={{ borderRadius: '30px' }}
412
+ className={clx(secondaryBtn, {
413
+ "opacity-50 cursor-not-allowed": isPaymentDisabled,
414
+ })}
415
415
  >
416
416
  Cash on Delivery
417
417
  </button>
418
418
  <button
419
+ type="button"
419
420
  onClick={async () => {
420
421
  trackPaymentMethodClick("razorpay")
421
422
  setProcessingVariant("payment")
@@ -469,7 +470,7 @@ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutSte
469
470
  contact: paymentPhone || undefined,
470
471
  },
471
472
  theme: {
472
- color: "#8B5AB1"
473
+ color: "var(--sf-btn-primary)"
473
474
  },
474
475
  modal: {
475
476
  ondismiss: () => {
@@ -498,14 +499,9 @@ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutSte
498
499
  data-meta-event="AddPaymentInfo"
499
500
  data-meta-action="select_payment_method"
500
501
  data-meta-payment-type="razorpay"
501
- className={clx(
502
- "w-full py-2.5 min-[550px]:py-3 text-xs min-[550px]:text-sm sm:text-base text-white font-bold rounded-lg transition-colors bg-[#8B5AB1]",
503
- {
504
- "hover:opacity-90": !isPaymentDisabled,
505
- "opacity-50 cursor-not-allowed": isPaymentDisabled
506
- }
507
- )}
508
- style={{ borderRadius: '30px' }}
502
+ className={clx(primaryBtn, {
503
+ "opacity-50 cursor-not-allowed": isPaymentDisabled,
504
+ })}
509
505
  >
510
506
  Pay with Razorpay
511
507
  </button>
@@ -522,36 +518,35 @@ const CartTotals: React.FC<CartTotalsProps> = ({ totals, items = [], checkoutSte
522
518
  )}
523
519
  </div>
524
520
  ) : activeStep === "address" ? (
525
- <button
521
+ <ThemedButton
522
+ variant="primary"
523
+ size="lg"
524
+ fullWidth
526
525
  onClick={() => {
527
526
  window.dispatchEvent(new CustomEvent('trigger-checkout-step'))
528
527
  }}
529
- className="w-full py-2.5 min-[550px]:py-3 sm:py-4 min-[1023px]:py-3 min-[1150px]:py-4 min-[1360px]:py-4 text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base text-white font-medium mt-3 min-[550px]:mt-4 sm:mt-6 min-[1023px]:mt-5 min-[1150px]:mt-6 min-[1360px]:mt-6 rounded-lg transition-colors duration-200 hover:opacity-90"
530
- style={{
531
- backgroundColor: '#8B5AB1',
532
- borderRadius: '30px'
533
- }}
528
+ className="mt-3 min-[550px]:mt-4 sm:mt-6 min-[1023px]:mt-5 min-[1150px]:mt-6 min-[1360px]:mt-6"
534
529
  data-testid="checkout-button-address"
535
530
  >
536
531
  Continue to delivery
537
- </button>
532
+ </ThemedButton>
538
533
  ) : (
539
- <button
534
+ <ThemedButton
535
+ variant="primary"
536
+ size="lg"
537
+ fullWidth
540
538
  onClick={handleProceedToCheckout}
541
539
  disabled={isRedirecting}
540
+ isLoading={isRedirecting}
542
541
  data-ga-event="proceed_to_checkout_click"
543
542
  data-meta-event="InitiateCheckout"
544
543
  data-meta-action="proceed_to_checkout"
545
544
  data-ga-label={convertToLocale({ amount: finalTotal, currency_code })}
546
- className="w-full h-10 min-[550px]:h-11 sm:h-12 min-[1023px]:h-11 min-[1150px]:h-12 min-[1360px]:h-12 text-xs min-[550px]:text-sm sm:text-base min-[1023px]:text-sm min-[1150px]:text-base min-[1360px]:text-base text-white font-medium transition-colors duration-200 hover:opacity-90 mt-3 min-[550px]:mt-4 sm:mt-6 min-[1023px]:mt-5 min-[1150px]:mt-6 min-[1360px]:mt-6 disabled:opacity-50"
547
- style={{
548
- backgroundColor: '#8B5AB1',
549
- borderRadius: '30px'
550
- }}
545
+ className="mt-3 min-[550px]:mt-4 sm:mt-6 min-[1023px]:mt-5 min-[1150px]:mt-6 min-[1360px]:mt-6"
551
546
  data-testid="checkout-button"
552
547
  >
553
548
  {isRedirecting ? "Loading..." : "Proceed to Checkout"}
554
- </button>
549
+ </ThemedButton>
555
550
  )}
556
551
  </div>
557
552
  )}
@@ -56,8 +56,8 @@ const CheckboxWithLabel: React.FC<CheckboxProps> = ({
56
56
  data-ga-label={dataGaLabel}
57
57
  className="w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all focus:outline-none focus:ring-2 focus:ring-offset-2"
58
58
  style={checked ? {
59
- backgroundColor: '#8B5AB1',
60
- borderColor: '#8B5AB1'
59
+ backgroundColor: 'var(--sf-btn-primary)',
60
+ borderColor: 'var(--sf-btn-primary)'
61
61
  } : {
62
62
  backgroundColor: 'white',
63
63
  borderColor: '#C0C0C0'
@@ -100,7 +100,7 @@ const DeleteButton = ({
100
100
  {/* Body Content */}
101
101
  <div className="p-6 flex gap-6 pr-12">
102
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">
103
+ <div className="w-24 h-24 sm:w-28 sm:h-28 flex-shrink-0 bg-[color-mix(in_srgb,var(--sf-color-text-muted)_8%,var(--sf-color-surface))] rounded-sm overflow-hidden border border-gray-100 shadow-sm">
104
104
  {thumbnail ? (
105
105
  <Image
106
106
  src={thumbnail}
@@ -110,14 +110,14 @@ const DeleteButton = ({
110
110
  className="w-full h-full object-cover"
111
111
  />
112
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>
113
+ <div className="w-full h-full flex items-center justify-center text-[var(--sf-color-text-muted)] bg-[color-mix(in_srgb,var(--sf-color-text-muted)_8%,var(--sf-color-surface))] uppercase text-[10px] font-bold">No Image</div>
114
114
  )}
115
115
  </div>
116
116
 
117
117
  {/* Title and Description */}
118
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]">
119
+ <h3 className="text-[17px] font-bold text-[var(--sf-color-text)] mb-1.5 leading-tight">Move from Bag</h3>
120
+ <p className="text-[15px] text-[var(--sf-color-text-muted)] leading-snug font-medium max-w-[280px]">
121
121
  Are you sure you want to move this item from bag?
122
122
  </p>
123
123
 
@@ -132,7 +132,7 @@ const DeleteButton = ({
132
132
  <button
133
133
  onClick={handleDelete}
134
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"
135
+ className="flex-1 h-full flex items-center justify-center text-[13px] font-bold text-[var(--sf-color-text-muted)] tracking-wider hover:bg-[color-mix(in_srgb,var(--sf-color-text-muted)_8%,transparent)] transition-colors uppercase"
136
136
  >
137
137
  {isDeleting ? <Spinner className="animate-spin" /> : "REMOVE"}
138
138
  </button>
@@ -143,7 +143,7 @@ const DeleteButton = ({
143
143
  <button
144
144
  onClick={handleMoveToWishlist}
145
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"
146
+ className="flex-1 h-full flex items-center justify-center text-[13px] font-bold tracking-wider hover:bg-[color-mix(in_srgb,var(--sf-color-text-muted)_8%,transparent)] transition-colors uppercase"
147
147
  style={{ color: '#D25C78' }} // Pinkish color from reference
148
148
  >
149
149
  {wishlistLoading ? <Spinner className="animate-spin" /> : "MOVE TO WISHLIST"}
@@ -71,7 +71,7 @@ const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
71
71
  style={{ width: '480px', maxWidth: '100%' }}
72
72
  >
73
73
  {/* COUPONS Heading */}
74
- <Heading className="text-lg font-bold text-gray-900 mb-4 uppercase">
74
+ <Heading className="text-lg font-bold text-[var(--sf-color-text)] mb-4 uppercase">
75
75
  COUPONS
76
76
  </Heading>
77
77
 
@@ -86,15 +86,15 @@ const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
86
86
  <div className="flex items-center gap-3">
87
87
  {/* Percentage Icon - White circle with black % symbol */}
88
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>
89
+ <span className="text-[var(--sf-color-text)] font-bold text-xl">%</span>
90
90
  </div>
91
91
 
92
92
  {/* Text Content */}
93
93
  <div className="flex flex-col items-start">
94
- <span className="font-bold text-base text-gray-900">
94
+ <span className="font-bold text-base text-[var(--sf-color-text)]">
95
95
  Apply Coupons
96
96
  </span>
97
- <span className="text-sm text-gray-500">
97
+ <span className="text-sm text-[var(--sf-color-text-muted)]">
98
98
  View all offers
99
99
  </span>
100
100
  </div>
@@ -102,7 +102,7 @@ const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
102
102
 
103
103
  {/* Chevron Icon */}
104
104
  <svg
105
- className="w-5 h-5 text-gray-900"
105
+ className="w-5 h-5 text-[var(--sf-color-text)]"
106
106
  fill="none"
107
107
  stroke="currentColor"
108
108
  viewBox="0 0 24 24"
@@ -131,7 +131,7 @@ const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
131
131
  />
132
132
  <SubmitButton
133
133
  variant="secondary"
134
- className="!bg-[#8B5AB1] hover:!bg-[#7a4a9f] text-white border-0"
134
+ className="!bg-[var(--sf-btn-primary)] hover:!bg-[var(--sf-btn-primary-hover)] text-white border-0"
135
135
  data-testid="discount-apply-button"
136
136
  >
137
137
  Apply
@@ -157,7 +157,7 @@ const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
157
157
  {/* Applied Promotions - Clean Card Redesign */}
158
158
  {promotions.length > 0 && (
159
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">
160
+ <Heading className="text-sm font-bold mb-3 text-[var(--sf-color-text)] uppercase tracking-tight">
161
161
  Promotion(s) applied:
162
162
  </Heading>
163
163
 
@@ -166,12 +166,12 @@ const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
166
166
  return (
167
167
  <div
168
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"
169
+ className="flex items-center justify-between w-full p-3 rounded-xl border border-purple-100 bg-[var(--sf-color-primary)]/10 transition-all hover:bg-[var(--sf-color-primary)]/10 group"
170
170
  data-testid="discount-row"
171
171
  >
172
172
  <div className="flex items-center gap-x-3 overflow-hidden">
173
173
  {/* Tag Icon */}
174
- <div className="flex-shrink-0 text-[#8B5AB1]">
174
+ <div className="flex-shrink-0 text-[var(--sf-btn-primary)]">
175
175
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
176
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
177
  <line x1="7" y1="7" x2="7.01" y2="7"></line>
@@ -179,7 +179,7 @@ const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
179
179
  </div>
180
180
 
181
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">
182
+ <span className="font-bold truncate text-sm uppercase tracking-wider" style={{ color: 'var(--sf-btn-primary)' }} data-testid="discount-code">
183
183
  {promotion.code}
184
184
  </span>
185
185
  {promotion.application_method?.value !== undefined && (
@@ -197,7 +197,7 @@ const DiscountCode: React.FC<DiscountCodeProps> = ({ cart }) => {
197
197
 
198
198
  {!promotion.is_automatic && (
199
199
  <button
200
- className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-all"
200
+ className="p-2 text-[var(--sf-color-text-muted)] hover:text-red-500 hover:bg-red-50 rounded-full transition-all"
201
201
  onClick={() => {
202
202
  if (!promotion.code) return
203
203
  removePromotionCode(promotion.code)
@@ -66,12 +66,12 @@ const FilterCheckboxGroup = ({
66
66
  >
67
67
  <Heading
68
68
  level="h3"
69
- className="text-sm font-bold text-gray-900 uppercase tracking-wider group-hover:text-[#8B5AB1] transition-colors"
69
+ className="text-sm font-bold text-[var(--sf-color-text)] uppercase tracking-wider group-hover:text-[var(--sf-btn-primary)] transition-colors"
70
70
  >
71
71
  {title}
72
72
  </Heading>
73
73
  <span
74
- className={`transform transition-transform duration-300 text-gray-400 group-hover:text-[#8B5AB1] ${isOpen ? "" : "-rotate-180"}`}
74
+ className={`transform transition-transform duration-300 text-[var(--sf-color-text-muted)] group-hover:text-[var(--sf-btn-primary)] ${isOpen ? "" : "-rotate-180"}`}
75
75
  >
76
76
  <svg
77
77
  width="18"
@@ -108,7 +108,7 @@ const FilterCheckboxGroup = ({
108
108
  {hasMore && isOpen && (
109
109
  <button
110
110
  onClick={() => setShowAll(!showAll)}
111
- className="text-xs font-bold text-[#8B5AB1] hover:underline mt-2 flex items-center gap-1 uppercase tracking-tight"
111
+ className="text-xs font-bold text-[var(--sf-btn-primary)] hover:underline mt-2 flex items-center gap-1 uppercase tracking-tight"
112
112
  >
113
113
  {showAll ? "View Less" : `View More (${items.length - initialItemsCount})`}
114
114
  <svg
@@ -1,8 +1,13 @@
1
+ "use client"
2
+
1
3
  import { Label } from "@medusajs/ui"
4
+ import { clx } from "@medusajs/ui"
2
5
  import React, { useEffect, useImperativeHandle, useState } from "react"
3
6
 
4
7
  import Eye from "medusa-ui-common/common/icons/eye"
5
8
  import EyeOff from "medusa-ui-common/common/icons/eye-off"
9
+ import { useFormThemeClasses } from "../../../context/storefront-theme-classes-context"
10
+ import type { FormThemeClassNames } from "medusa-storefront-theme-base/form-theme"
6
11
 
7
12
  type InputProps = Omit<
8
13
  Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
@@ -13,13 +18,17 @@ type InputProps = Omit<
13
18
  touched?: Record<string, unknown>
14
19
  name: string
15
20
  topLabel?: string
21
+ /** Override site form theme per field */
22
+ classNames?: Partial<FormThemeClassNames>
16
23
  }
17
24
 
18
25
  const Input = React.forwardRef<HTMLInputElement, InputProps>(
19
- ({ type, name, label, touched, required, topLabel, ...props }, ref) => {
26
+ ({ type, name, label, touched, required, topLabel, classNames, className, ...props }, ref) => {
20
27
  const inputRef = React.useRef<HTMLInputElement>(null)
21
28
  const [showPassword, setShowPassword] = useState(false)
22
29
  const [inputType, setInputType] = useState(type)
30
+ const formTheme = useFormThemeClasses(classNames)
31
+ const cn = formTheme
23
32
 
24
33
  useEffect(() => {
25
34
  if (type === "password" && showPassword) {
@@ -36,7 +45,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
36
45
  return (
37
46
  <div className="flex flex-col w-full">
38
47
  {topLabel && (
39
- <Label className="mb-2 txt-compact-medium-plus">{topLabel}</Label>
48
+ <Label className={cn.topLabel}>{topLabel}</Label>
40
49
  )}
41
50
  <div className="flex relative z-0 w-full txt-compact-medium overflow-visible">
42
51
  <input
@@ -44,8 +53,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
44
53
  name={name}
45
54
  placeholder=" "
46
55
  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' }}
56
+ className={clx(cn.field, className)}
49
57
  {...props}
50
58
  ref={inputRef}
51
59
  />
@@ -53,7 +61,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
53
61
  <label
54
62
  htmlFor={name}
55
63
  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"
64
+ className={clx(
65
+ "flex items-center justify-center absolute left-4 px-1 transition-all duration-300 top-1/2 -translate-y-1/2 origin-0 pointer-events-none",
66
+ cn.label,
67
+ cn.labelFloating,
68
+ )}
57
69
  >
58
70
  {label}
59
71
  {required && <span className="text-rose-500">*</span>}
@@ -63,7 +75,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
63
75
  <button
64
76
  type="button"
65
77
  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"
78
+ className="text-[var(--sf-input-label)] px-4 focus:outline-none transition-all duration-150 outline-none hover:text-[var(--sf-input-text)] absolute right-0 top-3"
67
79
  >
68
80
  {showPassword ? <Eye /> : <EyeOff />}
69
81
  </button>
@@ -34,38 +34,38 @@ export default function LoginPopup({ isOpen, onClose, countryCode = "us", messag
34
34
  return (
35
35
  <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/10 backdrop-blur-md p-4 animate-in fade-in duration-200">
36
36
  <div
37
- className="bg-[#FFFAFE] rounded-2xl shadow-2xl p-8 max-w-sm w-full mx-auto border border-gray-100 transform transition-all scale-100 animate-in zoom-in-95 duration-200"
37
+ className="bg-[var(--sf-color-background)] rounded-2xl shadow-2xl p-8 max-w-sm w-full mx-auto border border-gray-100 transform transition-all scale-100 animate-in zoom-in-95 duration-200"
38
38
  onClick={(e) => e.stopPropagation()}
39
39
  >
40
40
  <div className="text-center">
41
41
  <div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-purple-100 mb-4">
42
- <svg className="h-6 w-6 text-[#8B5AB1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
42
+ <svg className="h-6 w-6 text-[var(--sf-btn-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
43
43
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
44
44
  </svg>
45
45
  </div>
46
- <h2 className="text-xl font-bold text-gray-900 mb-2">
46
+ <h2 className="text-xl font-bold text-[var(--sf-color-text)] mb-2">
47
47
  Login Required
48
48
  </h2>
49
- <p className="text-gray-500 mb-8 text-sm leading-relaxed">
49
+ <p className="text-[var(--sf-color-text-muted)] mb-8 text-sm leading-relaxed">
50
50
  {message}
51
51
  </p>
52
52
 
53
53
  <div className="grid grid-cols-2 gap-3">
54
54
  <button
55
55
  onClick={onClose}
56
- className="px-4 py-2.5 border border-gray-200 rounded-xl font-semibold text-gray-600 hover:bg-gray-50 hover:border-gray-300 active:scale-95 transition-all duration-200"
56
+ className="px-4 py-2.5 border border-gray-200 rounded-xl font-semibold text-[var(--sf-color-text-muted)] hover:bg-[color-mix(in_srgb,var(--sf-color-text-muted)_8%,transparent)] hover:border-gray-300 active:scale-95 transition-all duration-200"
57
57
  >
58
58
  Cancel
59
59
  </button>
60
60
  <button
61
61
  onClick={handleLoginClick}
62
62
  className="px-4 py-2.5 rounded-xl font-semibold text-white transition-all duration-200 hover:shadow-lg hover:shadow-purple-100 active:scale-95"
63
- style={{ backgroundColor: '#8B5AB1' }}
63
+ style={{ backgroundColor: 'var(--sf-btn-primary)' }}
64
64
  onMouseEnter={(e) => {
65
- e.currentTarget.style.backgroundColor = '#7a4a9a'
65
+ e.currentTarget.style.backgroundColor = 'var(--sf-btn-primary-hover)'
66
66
  }}
67
67
  onMouseLeave={(e) => {
68
- e.currentTarget.style.backgroundColor = '#8B5AB1'
68
+ e.currentTarget.style.backgroundColor = 'var(--sf-btn-primary)'
69
69
  }}
70
70
  >
71
71
  Login
@@ -89,9 +89,9 @@ const Title: React.FC<{ children: React.ReactNode }> = ({ children }) => {
89
89
 
90
90
  return (
91
91
  <Dialog.Title className="flex items-center justify-between px-6 small:px-8 py-5 border-b border-gray-100 flex-shrink-0">
92
- <div className="text-xl small:text-2xl font-bold text-gray-900">{children}</div>
92
+ <div className="text-xl small:text-2xl font-bold text-[var(--sf-color-text)]">{children}</div>
93
93
  <div>
94
- <button onClick={close} data-testid="close-modal-button" className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-500">
94
+ <button onClick={close} data-testid="close-modal-button" className="p-2 hover:bg-[color-mix(in_srgb,var(--sf-color-text-muted)_10%,transparent)] rounded-full transition-colors text-[var(--sf-color-text-muted)]">
95
95
  <X size={24} />
96
96
  </button>
97
97
  </div>
@@ -42,7 +42,7 @@ const NativeSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
42
42
  onFocus={() => innerRef.current?.focus()}
43
43
  onBlur={() => innerRef.current?.blur()}
44
44
  className={clx(
45
- "relative flex items-center text-base-regular border bg-white rounded-lg hover:bg-gray-50",
45
+ "relative flex items-center text-base-regular border bg-white rounded-lg hover:bg-[color-mix(in_srgb,var(--sf-color-text-muted)_8%,transparent)]",
46
46
  className,
47
47
  {
48
48
  "text-ui-fg-muted": isPlaceholder,
@@ -60,19 +60,19 @@ const ProcessingOverlay = ({
60
60
  />
61
61
  </div>
62
62
 
63
- <h3 className="text-xl font-bold text-gray-900 mb-1 text-center">{displayTitle}</h3>
63
+ <h3 className="text-xl font-bold text-[var(--sf-color-text)] mb-1 text-center">{displayTitle}</h3>
64
64
 
65
65
  <div className="h-6 flex items-center justify-center mb-2 text-center px-4">
66
- <p key={messageIndex} className="text-[#8B5AB1] font-semibold text-sm animate-in slide-in-from-bottom-2 fade-in duration-500">
66
+ <p key={messageIndex} className="text-[var(--sf-btn-primary)] font-semibold text-sm animate-in slide-in-from-bottom-2 fade-in duration-500">
67
67
  {messages[messageIndex]}
68
68
  </p>
69
69
  </div>
70
70
 
71
71
  <div className="w-full bg-gray-100 h-1.5 rounded-full overflow-hidden mb-4">
72
- <div className="h-full bg-[#8B5AB1] rounded-full animate-progress transition-all duration-500 ease-out"></div>
72
+ <div className="h-full bg-[var(--sf-btn-primary)] rounded-full animate-progress transition-all duration-500 ease-out"></div>
73
73
  </div>
74
74
 
75
- <p className="text-xs text-gray-400 text-center italic leading-relaxed">
75
+ <p className="text-xs text-[var(--sf-color-text-muted)] text-center italic leading-relaxed">
76
76
  {subtitle}
77
77
  </p>
78
78
  </div>
@@ -35,16 +35,16 @@ const SidePanel: React.FC<SidePanelProps> = ({ isOpen, onClose, title, children
35
35
  {/* Panel */}
36
36
  <div
37
37
  className={clx(
38
- "fixed top-0 right-0 h-full w-[90%] min-[400px]:w-[350px] sm:w-[400px] md:w-[450px] lg:w-[480px] bg-[#FFFAFE] shadow-2xl transition-transform duration-300 ease-in-out z-[1000] flex flex-col",
38
+ "fixed top-0 right-0 h-full w-[90%] min-[400px]:w-[350px] sm:w-[400px] md:w-[450px] lg:w-[480px] bg-[var(--sf-color-background)] shadow-2xl transition-transform duration-300 ease-in-out z-[1000] flex flex-col",
39
39
  isOpen ? "translate-x-0" : "translate-x-full"
40
40
  )}
41
41
  >
42
42
  {/* Header */}
43
43
  <div className="flex items-center justify-between p-6 border-b border-gray-100">
44
- <h2 className="text-xl font-bold text-gray-900">{title}</h2>
44
+ <h2 className="text-xl font-bold text-[var(--sf-color-text)]">{title}</h2>
45
45
  <button
46
46
  onClick={onClose}
47
- className="p-2 hover:bg-gray-100 rounded-full transition-colors"
47
+ className="p-2 hover:bg-[color-mix(in_srgb,var(--sf-color-text-muted)_10%,transparent)] rounded-full transition-colors"
48
48
  >
49
49
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
50
50
  <line x1="18" y1="6" x2="6" y2="18"></line>
@@ -1,32 +1,35 @@
1
- "use client"
1
+ "use client";
2
2
 
3
- import { Button } from "@medusajs/ui"
4
- import React from "react"
5
- import { useFormStatus } from "react-dom"
3
+ import React from "react";
4
+ import { useFormStatus } from "react-dom";
5
+ import { ThemedButton, type ThemedButtonProps } from "../themed-button";
6
6
 
7
7
  export function SubmitButton({
8
8
  children,
9
9
  variant = "primary",
10
10
  className,
11
+ size = "lg",
11
12
  "data-testid": dataTestId,
12
13
  }: {
13
- children: React.ReactNode
14
- variant?: "primary" | "secondary" | "transparent" | "danger" | null
15
- className?: string
16
- "data-testid"?: string
14
+ children: React.ReactNode;
15
+ variant?: ThemedButtonProps["variant"];
16
+ className?: string;
17
+ size?: ThemedButtonProps["size"];
18
+ "data-testid"?: string;
17
19
  }) {
18
- const { pending } = useFormStatus()
20
+ const { pending } = useFormStatus();
19
21
 
20
22
  return (
21
- <Button
22
- size="large"
23
+ <ThemedButton
24
+ size={size}
23
25
  className={className}
24
26
  type="submit"
25
27
  isLoading={pending}
26
- variant={variant || "primary"}
28
+ variant={variant}
27
29
  data-testid={dataTestId}
30
+ fullWidth
28
31
  >
29
32
  {children}
30
- </Button>
31
- )
33
+ </ThemedButton>
34
+ );
32
35
  }
@@ -0,0 +1,63 @@
1
+ "use client";
2
+
3
+ import { clx } from "@medusajs/ui";
4
+ import React, { forwardRef } from "react";
5
+ import {
6
+ useButtonClassName,
7
+ type ButtonSize,
8
+ type ButtonTheme,
9
+ type ButtonVariant,
10
+ } from "../../../context/button-theme-context";
11
+ import Spinner from "../../icons/spinner";
12
+
13
+ export type ThemedButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
14
+ variant?: ButtonVariant;
15
+ size?: ButtonSize;
16
+ /** Override site theme for this button only (e.g. import `minimalButtonTheme`). */
17
+ theme?: ButtonTheme;
18
+ isLoading?: boolean;
19
+ fullWidth?: boolean;
20
+ };
21
+
22
+ export const ThemedButton = forwardRef<HTMLButtonElement, ThemedButtonProps>(
23
+ function ThemedButton(
24
+ {
25
+ variant = "primary",
26
+ size = "md",
27
+ theme,
28
+ className,
29
+ children,
30
+ disabled,
31
+ isLoading,
32
+ fullWidth,
33
+ type = "button",
34
+ ...props
35
+ },
36
+ ref,
37
+ ) {
38
+ const resolvedClassName = useButtonClassName(variant, {
39
+ size,
40
+ theme,
41
+ className: clx(fullWidth && "w-full", className),
42
+ });
43
+
44
+ return (
45
+ <button
46
+ ref={ref}
47
+ type={type}
48
+ disabled={disabled || isLoading}
49
+ className={resolvedClassName}
50
+ {...props}
51
+ >
52
+ {isLoading ? (
53
+ <span className="inline-flex items-center justify-center gap-2">
54
+ <Spinner />
55
+ {children}
56
+ </span>
57
+ ) : (
58
+ children
59
+ )}
60
+ </button>
61
+ );
62
+ },
63
+ );
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { clx } from "@medusajs/ui";
12
+ import {
13
+ defaultButtonTheme,
14
+ type ButtonSize,
15
+ type ButtonTheme,
16
+ type ButtonVariant,
17
+ } from "medusa-storefront-theme-base/button-theme";
18
+ import { buildButtonCssVars } from "../util/button-css-vars";
19
+
20
+ const fallbackButtonCssVars = buildButtonCssVars({
21
+ primary: "#8B5AB1",
22
+ primaryHover: "#7a4a9a",
23
+ secondary: "#D4AF37",
24
+ secondaryHover: "#B8942F",
25
+ black: "#111111",
26
+ blackHover: "#000000",
27
+ });
28
+
29
+ export type { ButtonSize, ButtonTheme, ButtonVariant };
30
+
31
+ type ButtonThemeContextValue = {
32
+ theme: ButtonTheme;
33
+ classNameFor: (
34
+ variant: ButtonVariant,
35
+ options?: { size?: ButtonSize; className?: string; theme?: ButtonTheme },
36
+ ) => string;
37
+ };
38
+
39
+ const ButtonThemeContext = createContext<ButtonThemeContextValue | undefined>(
40
+ undefined,
41
+ );
42
+
43
+ export type ButtonThemeProviderProps = {
44
+ children: ReactNode;
45
+ /** Site-wide button styles — import a library preset and extend in compositions/theme.ts */
46
+ theme?: ButtonTheme;
47
+ /** Injected on documentElement so all sections (banner, login, checkout) share colors */
48
+ cssVars?: Record<string, string>;
49
+ };
50
+
51
+ function applyButtonCssVars(cssVars: Record<string, string>) {
52
+ const root = document.documentElement;
53
+ Object.entries(cssVars).forEach(([key, value]) => {
54
+ root.style.setProperty(key, value);
55
+ });
56
+ }
57
+
58
+ function clearButtonCssVars(cssVars: Record<string, string>) {
59
+ const root = document.documentElement;
60
+ Object.keys(cssVars).forEach((key) => {
61
+ root.style.removeProperty(key);
62
+ });
63
+ }
64
+
65
+ export function ButtonThemeProvider({
66
+ children,
67
+ theme = defaultButtonTheme,
68
+ cssVars: cssVarsProp,
69
+ }: ButtonThemeProviderProps) {
70
+ const cssVars = cssVarsProp ?? fallbackButtonCssVars;
71
+
72
+ useEffect(() => {
73
+ if (typeof document === "undefined") return;
74
+ applyButtonCssVars(cssVars);
75
+ return () => clearButtonCssVars(cssVars);
76
+ }, [cssVars]);
77
+
78
+ const classNameFor = useCallback(
79
+ (
80
+ variant: ButtonVariant,
81
+ options?: { size?: ButtonSize; className?: string; theme?: ButtonTheme },
82
+ ) => {
83
+ const active = options?.theme ?? theme;
84
+ const size = options?.size ?? "md";
85
+ return clx(
86
+ active.base,
87
+ active.sizes?.[size],
88
+ active.variants[variant],
89
+ options?.className,
90
+ );
91
+ },
92
+ [theme],
93
+ );
94
+
95
+ const value = useMemo(
96
+ () => ({ theme, classNameFor }),
97
+ [theme, classNameFor],
98
+ );
99
+
100
+ const cssVarStyle =
101
+ cssVars &&
102
+ `:root{${Object.entries(cssVars)
103
+ .map(([k, v]) => `${k}:${v}`)
104
+ .join(";")}}`;
105
+
106
+ return (
107
+ <ButtonThemeContext.Provider value={value}>
108
+ {cssVarStyle ? (
109
+ <style dangerouslySetInnerHTML={{ __html: cssVarStyle }} />
110
+ ) : null}
111
+ {children}
112
+ </ButtonThemeContext.Provider>
113
+ );
114
+ }
115
+
116
+ export function useButtonTheme(): ButtonThemeContextValue {
117
+ const ctx = useContext(ButtonThemeContext);
118
+ if (!ctx) {
119
+ throw new Error("useButtonTheme must be used within a ButtonThemeProvider");
120
+ }
121
+ return ctx;
122
+ }
123
+
124
+ /** Resolve Tailwind classes for a variant (optionally override theme per call). */
125
+ export function useButtonClassName(
126
+ variant: ButtonVariant = "primary",
127
+ options?: { size?: ButtonSize; className?: string; theme?: ButtonTheme },
128
+ ): string {
129
+ const { classNameFor } = useButtonTheme();
130
+ return classNameFor(variant, options);
131
+ }
@@ -0,0 +1,89 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ type ReactNode,
9
+ } from "react";
10
+ import {
11
+ buildStorefrontRootCss,
12
+ buildStorefrontRootCssVars,
13
+ buildStorefrontThemeCssVars,
14
+ defaultStorefrontTheme,
15
+ type StorefrontTheme,
16
+ } from "../util/storefront-theme";
17
+
18
+ export type SiteThemeContextValue = {
19
+ theme: StorefrontTheme;
20
+ cssVars: Record<string, string>;
21
+ buttonCssVars: Record<string, string>;
22
+ };
23
+
24
+ const SiteThemeContext = createContext<SiteThemeContextValue | undefined>(
25
+ undefined,
26
+ );
27
+
28
+ function applyCssVars(cssVars: Record<string, string>) {
29
+ const root = document.documentElement;
30
+ Object.entries(cssVars).forEach(([key, value]) => {
31
+ root.style.setProperty(key, value);
32
+ });
33
+ }
34
+
35
+ function clearCssVars(cssVars: Record<string, string>) {
36
+ const root = document.documentElement;
37
+ Object.keys(cssVars).forEach((key) => {
38
+ root.style.removeProperty(key);
39
+ });
40
+ }
41
+
42
+ export type SiteThemeProviderProps = {
43
+ children: ReactNode;
44
+ /** Site theme from compositions/theme.ts */
45
+ theme?: StorefrontTheme;
46
+ };
47
+
48
+ export function SiteThemeProvider({
49
+ children,
50
+ theme = defaultStorefrontTheme,
51
+ }: SiteThemeProviderProps) {
52
+ const themeCssVars = useMemo(() => buildStorefrontThemeCssVars(theme), [theme]);
53
+ const allCssVars = useMemo(() => buildStorefrontRootCssVars(theme), [theme]);
54
+ const buttonCssVars = useMemo(
55
+ () =>
56
+ Object.fromEntries(
57
+ Object.entries(allCssVars).filter(([key]) => key.startsWith("--sf-btn-")),
58
+ ) as Record<string, string>,
59
+ [allCssVars],
60
+ );
61
+
62
+ useEffect(() => {
63
+ if (typeof document === "undefined") return;
64
+ applyCssVars(allCssVars);
65
+ return () => clearCssVars(allCssVars);
66
+ }, [allCssVars]);
67
+
68
+ const cssVarStyle = buildStorefrontRootCss(theme);
69
+
70
+ const value = useMemo(
71
+ () => ({ theme, cssVars: themeCssVars, buttonCssVars }),
72
+ [theme, themeCssVars, buttonCssVars],
73
+ );
74
+
75
+ return (
76
+ <SiteThemeContext.Provider value={value}>
77
+ <style dangerouslySetInnerHTML={{ __html: cssVarStyle }} />
78
+ {children}
79
+ </SiteThemeContext.Provider>
80
+ );
81
+ }
82
+
83
+ export function useSiteTheme(): SiteThemeContextValue {
84
+ const ctx = useContext(SiteThemeContext);
85
+ if (!ctx) {
86
+ throw new Error("useSiteTheme must be used within a SiteThemeProvider");
87
+ }
88
+ return ctx;
89
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useMemo,
7
+ type ReactNode,
8
+ } from "react";
9
+ import {
10
+ createDefaultStorefrontThemeClasses,
11
+ defaultFormThemeClassNames,
12
+ mergeFormTheme,
13
+ mergeThemeSlots,
14
+ type FormThemeClassNames,
15
+ type StorefrontThemeClasses,
16
+ } from "medusa-storefront-theme-base";
17
+
18
+ export type StorefrontThemeClassesContextValue = {
19
+ themeClasses: StorefrontThemeClasses;
20
+ form: FormThemeClassNames;
21
+ };
22
+
23
+ const StorefrontThemeClassesContext = createContext<
24
+ StorefrontThemeClassesContextValue | undefined
25
+ >(undefined);
26
+
27
+ export type StorefrontThemeClassesProviderProps = {
28
+ children: ReactNode;
29
+ themeClasses?: StorefrontThemeClasses;
30
+ form?: FormThemeClassNames;
31
+ };
32
+
33
+ export function StorefrontThemeClassesProvider({
34
+ children,
35
+ themeClasses = createDefaultStorefrontThemeClasses(),
36
+ form = defaultFormThemeClassNames,
37
+ }: StorefrontThemeClassesProviderProps) {
38
+ const value = useMemo(
39
+ () => ({ themeClasses, form }),
40
+ [themeClasses, form],
41
+ );
42
+ return (
43
+ <StorefrontThemeClassesContext.Provider value={value}>
44
+ {children}
45
+ </StorefrontThemeClassesContext.Provider>
46
+ );
47
+ }
48
+
49
+ export function useStorefrontThemeClasses(): StorefrontThemeClassesContextValue {
50
+ const ctx = useContext(StorefrontThemeClassesContext);
51
+ if (!ctx) {
52
+ return {
53
+ themeClasses: createDefaultStorefrontThemeClasses(),
54
+ form: defaultFormThemeClassNames,
55
+ };
56
+ }
57
+ return ctx;
58
+ }
59
+
60
+ /** Theme slot for a UI area (account, order, cart, …) with optional prop overrides. */
61
+ export function useThemeSection<K extends keyof StorefrontThemeClasses>(
62
+ section: K,
63
+ propOverrides?: Partial<StorefrontThemeClasses[K]>,
64
+ ): StorefrontThemeClasses[K] {
65
+ const { themeClasses } = useStorefrontThemeClasses();
66
+ return useMemo(
67
+ () =>
68
+ mergeThemeSlots(
69
+ themeClasses[section] as Record<string, unknown>,
70
+ propOverrides as Record<string, unknown> | undefined,
71
+ ) as StorefrontThemeClasses[K],
72
+ [themeClasses, section, propOverrides],
73
+ );
74
+ }
75
+
76
+ export function useFormThemeClasses(
77
+ propOverrides?: Partial<FormThemeClassNames>,
78
+ ): FormThemeClassNames {
79
+ const { form } = useStorefrontThemeClasses();
80
+ return useMemo(
81
+ () => mergeFormTheme(form, propOverrides),
82
+ [form, propOverrides],
83
+ );
84
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  /** Shared contexts */
2
2
  export { ModalProvider, useModal } from "./context/modal-context";
3
3
  export { WishlistProvider, useWishlist } from "./context/wishlist-context";
4
+ export {
5
+ ButtonThemeProvider,
6
+ useButtonClassName,
7
+ useButtonTheme,
8
+ type ButtonSize,
9
+ type ButtonTheme,
10
+ type ButtonVariant,
11
+ } from "./context/button-theme-context";
4
12
 
5
13
  /** Utilities */
6
14
  export { getBaseURL } from "./util/env";
@@ -4,7 +4,7 @@ import SkeletonOrderItems from "medusa-ui-common/skeletons/components/skeleton-o
4
4
 
5
5
  const SkeletonOrderConfirmed = () => {
6
6
  return (
7
- <div className="bg-gray-50 py-6 min-h-[calc(100vh-64px)] animate-pulse">
7
+ <div className="bg-[color-mix(in_srgb,var(--sf-color-text-muted)_8%,var(--sf-color-surface))] py-6 min-h-[calc(100vh-64px)] animate-pulse">
8
8
  <div className="content-container flex justify-center">
9
9
  <div className="max-w-4xl h-full bg-white w-full p-10">
10
10
  <SkeletonOrderConfirmedHeader />
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import {
5
+ defaultButtonTheme,
6
+ type ButtonTheme,
7
+ } from "medusa-storefront-theme-base/button-theme";
8
+ import {
9
+ ButtonThemeProvider,
10
+ useButtonClassName,
11
+ useButtonTheme,
12
+ } from "./context/button-theme-context";
13
+ import { SiteThemeProvider, useSiteTheme } from "./context/site-theme-context";
14
+ import {
15
+ StorefrontThemeClassesProvider,
16
+ useFormThemeClasses,
17
+ useStorefrontThemeClasses,
18
+ useThemeSection,
19
+ } from "./context/storefront-theme-classes-context";
20
+ import { WishlistProvider } from "./context/wishlist-context";
21
+ import {
22
+ defaultStorefrontTheme,
23
+ type StorefrontTheme,
24
+ } from "./util/storefront-theme";
25
+ import type {
26
+ FormThemeClassNames,
27
+ StorefrontThemeClasses,
28
+ } from "medusa-storefront-theme-base";
29
+
30
+ export { WishlistProvider, useWishlist } from "./context/wishlist-context";
31
+ export {
32
+ ButtonThemeProvider,
33
+ useButtonClassName,
34
+ useButtonTheme,
35
+ type ButtonSize,
36
+ type ButtonTheme,
37
+ type ButtonVariant,
38
+ } from "./context/button-theme-context";
39
+ export {
40
+ SiteThemeProvider,
41
+ useSiteTheme,
42
+ type SiteThemeContextValue,
43
+ } from "./context/site-theme-context";
44
+ export {
45
+ StorefrontThemeClassesProvider,
46
+ useStorefrontThemeClasses,
47
+ useThemeSection,
48
+ useFormThemeClasses,
49
+ } from "./context/storefront-theme-classes-context";
50
+ export {
51
+ buttonCssClasses,
52
+ buttonCssVarNames,
53
+ buildButtonCssVars,
54
+ } from "./util/button-css-vars";
55
+ export {
56
+ themeCssClasses,
57
+ themeCssVarNames,
58
+ buildStorefrontThemeCssVars,
59
+ themeColorsToButtonCssVars,
60
+ mergeStorefrontTheme,
61
+ defaultStorefrontTheme,
62
+ type StorefrontTheme,
63
+ type StorefrontThemeColors,
64
+ type StorefrontThemeFonts,
65
+ } from "./util/storefront-theme";
66
+
67
+ export type StorefrontUiProvidersProps = {
68
+ children: ReactNode;
69
+ /** Site-wide colors and fonts — compositions/theme.ts */
70
+ siteTheme?: StorefrontTheme;
71
+ /** Button Tailwind variants — derived from siteTheme.colors when omitted */
72
+ buttonTheme?: ButtonTheme;
73
+ /** Per-area className slots — from defineSiteTheme().themeClasses */
74
+ themeClasses?: StorefrontThemeClasses;
75
+ /** Form field classNames — from defineSiteTheme().siteFormTheme */
76
+ formTheme?: FormThemeClassNames;
77
+ /**
78
+ * @deprecated Prefer siteTheme — button CSS vars are derived from siteTheme.colors.
79
+ */
80
+ buttonCssVars?: Record<string, string>;
81
+ };
82
+
83
+ function ButtonThemeFromSite({
84
+ children,
85
+ buttonTheme,
86
+ legacyButtonCssVars,
87
+ }: {
88
+ children: ReactNode;
89
+ buttonTheme: ButtonTheme;
90
+ legacyButtonCssVars?: Record<string, string>;
91
+ }) {
92
+ const { buttonCssVars: fromSite } = useSiteTheme();
93
+ const cssVars = legacyButtonCssVars ?? fromSite;
94
+ return (
95
+ <ButtonThemeProvider theme={buttonTheme} cssVars={cssVars}>
96
+ {children}
97
+ </ButtonThemeProvider>
98
+ );
99
+ }
100
+
101
+ /** Wrap the storefront app (site theme, buttons, wishlist). */
102
+ export function StorefrontUiProviders({
103
+ children,
104
+ siteTheme = defaultStorefrontTheme,
105
+ buttonTheme = defaultButtonTheme,
106
+ themeClasses,
107
+ formTheme,
108
+ buttonCssVars,
109
+ }: StorefrontUiProvidersProps) {
110
+ return (
111
+ <SiteThemeProvider theme={siteTheme}>
112
+ <StorefrontThemeClassesProvider themeClasses={themeClasses} form={formTheme}>
113
+ <ButtonThemeFromSite buttonTheme={buttonTheme} legacyButtonCssVars={buttonCssVars}>
114
+ <WishlistProvider>{children}</WishlistProvider>
115
+ </ButtonThemeFromSite>
116
+ </StorefrontThemeClassesProvider>
117
+ </SiteThemeProvider>
118
+ );
119
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ buttonCssVarNames,
3
+ buttonCssClasses,
4
+ buildButtonCssVars,
5
+ type ButtonCssVarValues,
6
+ } from "medusa-storefront-theme-base/button-css-vars";
@@ -0,0 +1,13 @@
1
+ export {
2
+ themeCssVarNames,
3
+ themeCssClasses,
4
+ defaultStorefrontTheme,
5
+ buildStorefrontThemeCssVars,
6
+ buildStorefrontRootCssVars,
7
+ buildStorefrontRootCss,
8
+ themeColorsToButtonCssVars,
9
+ mergeStorefrontTheme,
10
+ type StorefrontTheme,
11
+ type StorefrontThemeColors,
12
+ type StorefrontThemeFonts,
13
+ } from "medusa-storefront-theme-base/storefront-theme";