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.
Files changed (79) hide show
  1. package/package.json +93 -0
  2. package/src/common/components/breadcrumb/index.tsx +43 -0
  3. package/src/common/components/cart-totals/index.tsx +562 -0
  4. package/src/common/components/checkbox/index.tsx +98 -0
  5. package/src/common/components/delete-button/index.tsx +158 -0
  6. package/src/common/components/discount-code/index.tsx +220 -0
  7. package/src/common/components/divider/index.tsx +9 -0
  8. package/src/common/components/error-message/index.tsx +13 -0
  9. package/src/common/components/filter-checkbox-group/index.tsx +134 -0
  10. package/src/common/components/filter-radio-group/index.tsx +62 -0
  11. package/src/common/components/input/index.tsx +79 -0
  12. package/src/common/components/interactive-link/index.tsx +33 -0
  13. package/src/common/components/line-item-options/index.tsx +26 -0
  14. package/src/common/components/line-item-price/index.tsx +64 -0
  15. package/src/common/components/line-item-unit-price/index.tsx +64 -0
  16. package/src/common/components/localized-client-link/index.tsx +32 -0
  17. package/src/common/components/login-popup/index.tsx +78 -0
  18. package/src/common/components/modal/index.tsx +123 -0
  19. package/src/common/components/native-select/index.tsx +75 -0
  20. package/src/common/components/obfuscated-email/index.tsx +30 -0
  21. package/src/common/components/processing-overlay/index.tsx +83 -0
  22. package/src/common/components/radio/index.tsx +27 -0
  23. package/src/common/components/side-panel/index.tsx +65 -0
  24. package/src/common/components/submit-button/index.tsx +32 -0
  25. package/src/common/icons/arrow-left.tsx +36 -0
  26. package/src/common/icons/back.tsx +37 -0
  27. package/src/common/icons/bancontact.tsx +26 -0
  28. package/src/common/icons/chevron-down.tsx +30 -0
  29. package/src/common/icons/delivered.tsx +29 -0
  30. package/src/common/icons/envelope.tsx +27 -0
  31. package/src/common/icons/eye-off.tsx +37 -0
  32. package/src/common/icons/eye.tsx +37 -0
  33. package/src/common/icons/fast-delivery.tsx +65 -0
  34. package/src/common/icons/ideal.tsx +26 -0
  35. package/src/common/icons/lock.tsx +31 -0
  36. package/src/common/icons/map-pin.tsx +37 -0
  37. package/src/common/icons/medusa.tsx +27 -0
  38. package/src/common/icons/menu.tsx +45 -0
  39. package/src/common/icons/nextjs.tsx +27 -0
  40. package/src/common/icons/package.tsx +44 -0
  41. package/src/common/icons/paypal.tsx +30 -0
  42. package/src/common/icons/phone.tsx +30 -0
  43. package/src/common/icons/placeholder-image.tsx +44 -0
  44. package/src/common/icons/refresh.tsx +51 -0
  45. package/src/common/icons/spinner.tsx +37 -0
  46. package/src/common/icons/trash.tsx +51 -0
  47. package/src/common/icons/user.tsx +37 -0
  48. package/src/common/icons/x.tsx +37 -0
  49. package/src/constants/payments.tsx +31 -0
  50. package/src/context/modal-context.tsx +37 -0
  51. package/src/context/wishlist-context.tsx +83 -0
  52. package/src/index.ts +16 -0
  53. package/src/skeletons/components/skeleton-button/index.tsx +5 -0
  54. package/src/skeletons/components/skeleton-card-details/index.tsx +10 -0
  55. package/src/skeletons/components/skeleton-cart-item/index.tsx +35 -0
  56. package/src/skeletons/components/skeleton-cart-totals/index.tsx +30 -0
  57. package/src/skeletons/components/skeleton-code-form/index.tsx +13 -0
  58. package/src/skeletons/components/skeleton-line-item/index.tsx +35 -0
  59. package/src/skeletons/components/skeleton-order-confirmed-header/index.tsx +14 -0
  60. package/src/skeletons/components/skeleton-order-information/index.tsx +36 -0
  61. package/src/skeletons/components/skeleton-order-items/index.tsx +43 -0
  62. package/src/skeletons/components/skeleton-order-summary/index.tsx +15 -0
  63. package/src/skeletons/components/skeleton-product-preview/index.tsx +15 -0
  64. package/src/skeletons/templates/skeleton-cart-page/index.tsx +65 -0
  65. package/src/skeletons/templates/skeleton-order-confirmed/index.tsx +21 -0
  66. package/src/skeletons/templates/skeleton-product-grid/index.tsx +23 -0
  67. package/src/skeletons/templates/skeleton-related-products/index.tsx +25 -0
  68. package/src/types/global.ts +24 -0
  69. package/src/types/icon.ts +6 -0
  70. package/src/util/checkout-dom.ts +65 -0
  71. package/src/util/compare-addresses.ts +28 -0
  72. package/src/util/env.ts +3 -0
  73. package/src/util/get-percentage-diff.ts +6 -0
  74. package/src/util/get-product-price.ts +79 -0
  75. package/src/util/isEmpty.ts +11 -0
  76. package/src/util/money.ts +26 -0
  77. package/src/util/product.ts +86 -0
  78. package/src/util/repeat.ts +5 -0
  79. package/src/util/returns.ts +72 -0
@@ -0,0 +1,14 @@
1
+ const SkeletonOrderConfirmedHeader = () => {
2
+ return (
3
+ <div className="flex flex-col gap-y-2 pb-10 animate-pulse">
4
+ <div className="w-2/5 h-4 bg-gray-100"></div>
5
+ <div className="w-3/6 h-6 bg-gray-100"></div>
6
+ <div className="flex gap-x-4">
7
+ <div className="w-16 h-4 bg-gray-100"></div>
8
+ <div className="w-12 h-4 bg-gray-100"></div>
9
+ </div>
10
+ </div>
11
+ )
12
+ }
13
+
14
+ export default SkeletonOrderConfirmedHeader
@@ -0,0 +1,36 @@
1
+ import SkeletonCartTotals from "medusa-ui-common/skeletons/components/skeleton-cart-totals"
2
+
3
+ const SkeletonOrderInformation = () => {
4
+ return (
5
+ <div>
6
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 py-10 border-b border-gray-200">
7
+ <div className="flex flex-col">
8
+ <div className="w-32 h-4 bg-gray-100 mb-4"></div>
9
+ <div className="w-2/6 h-3 bg-gray-100"></div>
10
+ <div className="w-3/6 h-3 bg-gray-100 my-2"></div>
11
+ <div className="w-1/6 h-3 bg-gray-100"></div>
12
+ </div>
13
+ <div className="flex flex-col">
14
+ <div className="w-32 h-4 bg-gray-100 mb-4"></div>
15
+ <div className="w-2/6 h-3 bg-gray-100"></div>
16
+ <div className="w-3/6 h-3 bg-gray-100 my-2"></div>
17
+ <div className="w-2/6 h-3 bg-gray-100"></div>
18
+ <div className="w-1/6 h-3 bg-gray-100 mt-2"></div>
19
+ <div className="w-32 h-4 bg-gray-100 my-4"></div>
20
+ <div className="w-1/6 h-3 bg-gray-100"></div>
21
+ </div>
22
+ </div>
23
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 py-10">
24
+ <div className="flex flex-col">
25
+ <div className="w-32 h-4 bg-gray-100 mb-4"></div>
26
+ <div className="w-2/6 h-3 bg-gray-100"></div>
27
+ <div className="w-3/6 h-3 bg-gray-100 my-4"></div>
28
+ </div>
29
+
30
+ <SkeletonCartTotals />
31
+ </div>
32
+ </div>
33
+ )
34
+ }
35
+
36
+ export default SkeletonOrderInformation
@@ -0,0 +1,43 @@
1
+ const SkeletonOrderItems = () => {
2
+ return (
3
+ <div className="flex flex-col gap-y-4 py-10 border-y border-gray-200">
4
+ <div className="grid grid-cols-[122px_1fr] gap-x-4">
5
+ <div className="w-full aspect-[29/34] bg-gray-100"></div>
6
+ <div className="flex items-start justify-between">
7
+ <div className="flex flex-col gap-y-2">
8
+ <div className="w-48 h-6 bg-gray-100"></div>
9
+ <div className="w-24 h-4 bg-gray-100"></div>
10
+ <div className="w-32 h-4 bg-gray-100"></div>
11
+ </div>
12
+ <div className="w-32 h-6 bg-gray-100"></div>
13
+ </div>
14
+ </div>
15
+
16
+ <div className="grid grid-cols-[122px_1fr] gap-x-4">
17
+ <div className="w-full aspect-[29/34] bg-gray-100"></div>
18
+ <div className="flex items-start justify-between">
19
+ <div className="flex flex-col gap-y-2">
20
+ <div className="w-48 h-6 bg-gray-100"></div>
21
+ <div className="w-24 h-4 bg-gray-100"></div>
22
+ <div className="w-32 h-4 bg-gray-100"></div>
23
+ </div>
24
+ <div className="w-32 h-6 bg-gray-100"></div>
25
+ </div>
26
+ </div>
27
+
28
+ <div className="grid grid-cols-[122px_1fr] gap-x-4">
29
+ <div className="w-full aspect-[29/34] bg-gray-100"></div>
30
+ <div className="flex items-start justify-between">
31
+ <div className="flex flex-col gap-y-2">
32
+ <div className="w-48 h-6 bg-gray-100"></div>
33
+ <div className="w-24 h-4 bg-gray-100"></div>
34
+ <div className="w-32 h-4 bg-gray-100"></div>
35
+ </div>
36
+ <div className="w-32 h-6 bg-gray-100"></div>
37
+ </div>
38
+ </div>
39
+ </div>
40
+ )
41
+ }
42
+
43
+ export default SkeletonOrderItems
@@ -0,0 +1,15 @@
1
+ import SkeletonButton from "medusa-ui-common/skeletons/components/skeleton-button"
2
+ import SkeletonCartTotals from "medusa-ui-common/skeletons/components/skeleton-cart-totals"
3
+
4
+ const SkeletonOrderSummary = () => {
5
+ return (
6
+ <div className="grid-cols-1">
7
+ <SkeletonCartTotals header={false} />
8
+ <div className="mt-4">
9
+ <SkeletonButton />
10
+ </div>
11
+ </div>
12
+ )
13
+ }
14
+
15
+ export default SkeletonOrderSummary
@@ -0,0 +1,15 @@
1
+ import { Container } from "@medusajs/ui"
2
+
3
+ const SkeletonProductPreview = () => {
4
+ return (
5
+ <div className="animate-pulse">
6
+ <Container className="aspect-[9/16] w-full bg-gray-100 bg-ui-bg-subtle" />
7
+ <div className="flex justify-between text-base-regular mt-2">
8
+ <div className="w-2/5 h-6 bg-gray-100"></div>
9
+ <div className="w-1/5 h-6 bg-gray-100"></div>
10
+ </div>
11
+ </div>
12
+ )
13
+ }
14
+
15
+ export default SkeletonProductPreview
@@ -0,0 +1,65 @@
1
+ import { Table } from "@medusajs/ui"
2
+
3
+ import repeat from "medusa-ui-common/util/repeat"
4
+ import SkeletonCartItem from "medusa-ui-common/skeletons/components/skeleton-cart-item"
5
+ import SkeletonCodeForm from "medusa-ui-common/skeletons/components/skeleton-code-form"
6
+ import SkeletonOrderSummary from "medusa-ui-common/skeletons/components/skeleton-order-summary"
7
+
8
+ const SkeletonCartPage = () => {
9
+ return (
10
+ <div className="py-12">
11
+ <div className="content-container">
12
+ <div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40">
13
+ <div className="flex flex-col bg-white p-6 gap-y-6">
14
+ <div className="bg-white flex items-start justify-between">
15
+ <div className="flex flex-col gap-y-2">
16
+ <div className="w-60 h-8 bg-gray-200 animate-pulse" />
17
+ <div className="w-48 h-6 bg-gray-200 animate-pulse" />
18
+ </div>
19
+ <div>
20
+ <div className="w-14 h-8 bg-gray-200 animate-pulse" />
21
+ </div>
22
+ </div>
23
+ <div>
24
+ <div className="pb-3 flex items-center">
25
+ <div className="w-20 h-12 bg-gray-200 animate-pulse" />
26
+ </div>
27
+ <Table>
28
+ <Table.Header className="border-t-0">
29
+ <Table.Row>
30
+ <Table.HeaderCell className="!pl-0">
31
+ <div className="w-10 h-6 bg-gray-200 animate-pulse" />
32
+ </Table.HeaderCell>
33
+ <Table.HeaderCell></Table.HeaderCell>
34
+ <Table.HeaderCell>
35
+ <div className="w-16 h-6 bg-gray-200 animate-pulse" />
36
+ </Table.HeaderCell>
37
+ <Table.HeaderCell>
38
+ <div className="w-12 h-6 bg-gray-200 animate-pulse" />
39
+ </Table.HeaderCell>
40
+ <Table.HeaderCell className="!pr-0">
41
+ <div className="flex justify-end">
42
+ <div className="w-12 h-6 bg-gray-200 animate-pulse" />
43
+ </div>
44
+ </Table.HeaderCell>
45
+ </Table.Row>
46
+ </Table.Header>
47
+ <Table.Body>
48
+ {repeat(4).map((index) => (
49
+ <SkeletonCartItem key={index} />
50
+ ))}
51
+ </Table.Body>
52
+ </Table>
53
+ </div>
54
+ </div>
55
+ <div className="flex flex-col gap-y-8">
56
+ <SkeletonOrderSummary />
57
+ <SkeletonCodeForm />
58
+ </div>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ )
63
+ }
64
+
65
+ export default SkeletonCartPage
@@ -0,0 +1,21 @@
1
+ import SkeletonOrderConfirmedHeader from "medusa-ui-common/skeletons/components/skeleton-order-confirmed-header"
2
+ import SkeletonOrderInformation from "medusa-ui-common/skeletons/components/skeleton-order-information"
3
+ import SkeletonOrderItems from "medusa-ui-common/skeletons/components/skeleton-order-items"
4
+
5
+ const SkeletonOrderConfirmed = () => {
6
+ return (
7
+ <div className="bg-gray-50 py-6 min-h-[calc(100vh-64px)] animate-pulse">
8
+ <div className="content-container flex justify-center">
9
+ <div className="max-w-4xl h-full bg-white w-full p-10">
10
+ <SkeletonOrderConfirmedHeader />
11
+
12
+ <SkeletonOrderItems />
13
+
14
+ <SkeletonOrderInformation />
15
+ </div>
16
+ </div>
17
+ </div>
18
+ )
19
+ }
20
+
21
+ export default SkeletonOrderConfirmed
@@ -0,0 +1,23 @@
1
+ import repeat from "medusa-ui-common/util/repeat"
2
+ import SkeletonProductPreview from "medusa-ui-common/skeletons/components/skeleton-product-preview"
3
+
4
+ const SkeletonProductGrid = ({
5
+ numberOfProducts = 8,
6
+ }: {
7
+ numberOfProducts?: number
8
+ }) => {
9
+ return (
10
+ <ul
11
+ className="grid grid-cols-2 small:grid-cols-3 medium:grid-cols-4 gap-x-6 gap-y-8 flex-1"
12
+ data-testid="products-list-loader"
13
+ >
14
+ {repeat(numberOfProducts).map((index) => (
15
+ <li key={index}>
16
+ <SkeletonProductPreview />
17
+ </li>
18
+ ))}
19
+ </ul>
20
+ )
21
+ }
22
+
23
+ export default SkeletonProductGrid
@@ -0,0 +1,25 @@
1
+ import repeat from "medusa-ui-common/util/repeat"
2
+ import SkeletonProductPreview from "medusa-ui-common/skeletons/components/skeleton-product-preview"
3
+
4
+ const SkeletonRelatedProducts = () => {
5
+ return (
6
+ <div className="product-page-constraint">
7
+ <div className="flex flex-col gap-8 items-center text-center mb-8">
8
+ <div className="w-20 h-6 animate-pulse bg-gray-100"></div>
9
+ <div className="flex flex-col gap-4 items-center text-center mb-16">
10
+ <div className="w-96 h-10 animate-pulse bg-gray-100"></div>
11
+ <div className="w-48 h-10 animate-pulse bg-gray-100"></div>
12
+ </div>
13
+ </div>
14
+ <ul className="grid grid-cols-2 small:grid-cols-3 medium:grid-cols-4 gap-x-6 gap-y-8 flex-1">
15
+ {repeat(3).map((index) => (
16
+ <li key={index}>
17
+ <SkeletonProductPreview />
18
+ </li>
19
+ ))}
20
+ </ul>
21
+ </div>
22
+ )
23
+ }
24
+
25
+ export default SkeletonRelatedProducts
@@ -0,0 +1,24 @@
1
+ import { StorePrice } from "@medusajs/types"
2
+
3
+ export type FeaturedProduct = {
4
+ id: string
5
+ title: string
6
+ handle: string
7
+ thumbnail?: string
8
+ }
9
+
10
+ export type VariantPrice = {
11
+ calculated_price_number: number
12
+ calculated_price: string
13
+ original_price_number: number
14
+ original_price: string
15
+ currency_code: string
16
+ price_type: string
17
+ percentage_diff: string
18
+ }
19
+
20
+ export type StoreFreeShippingPrice = StorePrice & {
21
+ target_reached: boolean
22
+ target_remaining: number
23
+ remaining_percentage: number
24
+ }
@@ -0,0 +1,6 @@
1
+ import type { SVGAttributes } from "react";
2
+
3
+ export type IconProps = {
4
+ color?: string;
5
+ size?: string | number;
6
+ } & SVGAttributes<SVGElement>;
@@ -0,0 +1,65 @@
1
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
2
+
3
+ export function normalizeCheckoutEmail(email?: string | null): string {
4
+ return (email ?? "").trim().toLowerCase()
5
+ }
6
+
7
+ export function isValidCheckoutEmail(email?: string | null): boolean {
8
+ const normalized = normalizeCheckoutEmail(email)
9
+ return normalized.length > 0 && EMAIL_PATTERN.test(normalized)
10
+ }
11
+
12
+ export function readCheckoutAddressFromDom(fallback?: {
13
+ email?: string | null
14
+ shipping_address?: Record<string, unknown> | null
15
+ }) {
16
+ const fullNameInput = document.getElementById("shipping_full_name_field") as HTMLInputElement | null
17
+ const address1Input = document.getElementById("shipping_address_1_field") as HTMLInputElement | null
18
+ const companyInput = document.getElementById("shipping_company_field") as HTMLInputElement | null
19
+ const cityInput = document.getElementById("shipping_city_field") as HTMLInputElement | null
20
+ const provinceInput = document.getElementById("shipping_province_field") as HTMLInputElement | null
21
+ const postalInput = document.getElementById("shipping_postal_code_field") as HTMLInputElement | null
22
+ const phoneInput = document.getElementById("shipping_phone_field_internal") as HTMLInputElement | null
23
+ const emailInput = document.getElementById("shipping_email_field") as HTMLInputElement | null
24
+
25
+ const fullName = fullNameInput?.value?.trim() || ""
26
+ const [firstName, ...lastNameParts] = fullName.split(/\s+/).filter(Boolean)
27
+ const lastName = lastNameParts.join(" ") || "."
28
+
29
+ const shipping = fallback?.shipping_address ?? {}
30
+
31
+ return {
32
+ email: normalizeCheckoutEmail(emailInput?.value || (fallback?.email as string) || ""),
33
+ shipping_address: {
34
+ first_name: firstName || (shipping.first_name as string) || "Visitor",
35
+ last_name: lastName || (shipping.last_name as string) || "Customer",
36
+ address_1: address1Input?.value?.trim() || (shipping.address_1 as string) || "",
37
+ company: companyInput?.value?.trim() || (shipping.company as string) || "",
38
+ city: cityInput?.value?.trim() || (shipping.city as string) || "",
39
+ province: provinceInput?.value?.trim() || (shipping.province as string) || "",
40
+ postal_code: postalInput?.value?.trim() || (shipping.postal_code as string) || "",
41
+ phone: phoneInput?.value?.trim() || (shipping.phone as string) || "",
42
+ country_code: (shipping.country_code as string) || "in",
43
+ },
44
+ }
45
+ }
46
+
47
+ export async function syncCheckoutAddressFromDom(fallback?: {
48
+ email?: string | null
49
+ shipping_address?: Record<string, unknown> | null
50
+ }) {
51
+ const payload = readCheckoutAddressFromDom(fallback)
52
+
53
+ if (!isValidCheckoutEmail(payload.email)) {
54
+ throw new Error("Please enter a valid email address before continuing.")
55
+ }
56
+
57
+ const { updateAddressSilently } = await import("medusa-storefront-data/cart")
58
+ const result = await updateAddressSilently(payload)
59
+
60
+ if (result && "success" in result && result.success === false) {
61
+ throw new Error("Could not save your delivery details. Please check your email and try again.")
62
+ }
63
+
64
+ return payload
65
+ }
@@ -0,0 +1,28 @@
1
+ import { isEqual, pick } from "lodash"
2
+
3
+ export default function compareAddresses(address1: any, address2: any) {
4
+ return isEqual(
5
+ pick(address1, [
6
+ "first_name",
7
+ "last_name",
8
+ "address_1",
9
+ "company",
10
+ "postal_code",
11
+ "city",
12
+ "country_code",
13
+ "province",
14
+ "phone",
15
+ ]),
16
+ pick(address2, [
17
+ "first_name",
18
+ "last_name",
19
+ "address_1",
20
+ "company",
21
+ "postal_code",
22
+ "city",
23
+ "country_code",
24
+ "province",
25
+ "phone",
26
+ ])
27
+ )
28
+ }
@@ -0,0 +1,3 @@
1
+ export const getBaseURL = () => {
2
+ return process.env.NEXT_PUBLIC_BASE_URL || "https://localhost:8000"
3
+ }
@@ -0,0 +1,6 @@
1
+ export const getPercentageDiff = (original: number, calculated: number) => {
2
+ const diff = original - calculated
3
+ const decrease = (diff / original) * 100
4
+
5
+ return decrease.toFixed()
6
+ }
@@ -0,0 +1,79 @@
1
+ import { HttpTypes } from "@medusajs/types"
2
+ import { getPercentageDiff } from "./get-percentage-diff"
3
+ import { convertToLocale } from "./money"
4
+
5
+ export const getPricesForVariant = (variant: any) => {
6
+ if (!variant?.calculated_price?.calculated_amount) {
7
+ return null
8
+ }
9
+
10
+ return {
11
+ calculated_price_number: variant.calculated_price.calculated_amount,
12
+ calculated_price: convertToLocale({
13
+ amount: variant.calculated_price.calculated_amount,
14
+ currency_code: variant.calculated_price.currency_code,
15
+ }),
16
+ original_price_number: variant.calculated_price.original_amount,
17
+ original_price: convertToLocale({
18
+ amount: variant.calculated_price.original_amount,
19
+ currency_code: variant.calculated_price.currency_code,
20
+ }),
21
+ currency_code: variant.calculated_price.currency_code,
22
+ price_type: variant.calculated_price.calculated_price.price_list_type,
23
+ percentage_diff: getPercentageDiff(
24
+ variant.calculated_price.original_amount,
25
+ variant.calculated_price.calculated_amount
26
+ ),
27
+ }
28
+ }
29
+
30
+ export function getProductPrice({
31
+ product,
32
+ variantId,
33
+ }: {
34
+ product: HttpTypes.StoreProduct
35
+ variantId?: string
36
+ }) {
37
+ if (!product || !product.id) {
38
+ throw new Error("No product provided")
39
+ }
40
+
41
+ const cheapestPrice = () => {
42
+ if (!product || !product.variants?.length) {
43
+ return null
44
+ }
45
+
46
+ const cheapestVariant: any = product.variants
47
+ .filter((v: any) => !!v.calculated_price)
48
+ .sort((a: any, b: any) => {
49
+ return (
50
+ a.calculated_price.calculated_amount -
51
+ b.calculated_price.calculated_amount
52
+ )
53
+ })[0]
54
+
55
+ return getPricesForVariant(cheapestVariant)
56
+ }
57
+
58
+ const variantPrice = () => {
59
+ if (!product || !variantId) {
60
+ return null
61
+ }
62
+
63
+ const variant: any = product.variants?.find(
64
+ (v) => v.id === variantId || v.sku === variantId
65
+ )
66
+
67
+ if (!variant) {
68
+ return null
69
+ }
70
+
71
+ return getPricesForVariant(variant)
72
+ }
73
+
74
+ return {
75
+ product,
76
+ cheapestPrice: cheapestPrice(),
77
+ variantPrice: variantPrice(),
78
+ }
79
+ }
@@ -0,0 +1,11 @@
1
+ export const isObject = (input: any) => input instanceof Object
2
+ export const isArray = (input: any) => Array.isArray(input)
3
+ export const isEmpty = (input: any) => {
4
+ return (
5
+ input === null ||
6
+ input === undefined ||
7
+ (isObject(input) && Object.keys(input).length === 0) ||
8
+ (isArray(input) && (input as any[]).length === 0) ||
9
+ (typeof input === "string" && input.trim().length === 0)
10
+ )
11
+ }
@@ -0,0 +1,26 @@
1
+ import { isEmpty } from "./isEmpty"
2
+
3
+ type ConvertToLocaleParams = {
4
+ amount: number
5
+ currency_code: string
6
+ minimumFractionDigits?: number
7
+ maximumFractionDigits?: number
8
+ locale?: string
9
+ }
10
+
11
+ export const convertToLocale = ({
12
+ amount,
13
+ currency_code,
14
+ minimumFractionDigits,
15
+ maximumFractionDigits,
16
+ locale = "en-US",
17
+ }: ConvertToLocaleParams) => {
18
+ return currency_code && !isEmpty(currency_code)
19
+ ? new Intl.NumberFormat(locale, {
20
+ style: "currency",
21
+ currency: currency_code,
22
+ minimumFractionDigits,
23
+ maximumFractionDigits,
24
+ }).format(amount)
25
+ : amount.toString()
26
+ }
@@ -0,0 +1,86 @@
1
+ import { HttpTypes } from "@medusajs/types";
2
+
3
+ export const isSimpleProduct = (product: HttpTypes.StoreProduct): boolean => {
4
+ return product.options?.length === 1 && product.options[0].values?.length === 1;
5
+ }
6
+
7
+ /**
8
+ * Consistently determines which images to show for a product or a specific variant.
9
+ * Used on both server and client to prevent flickering during hydration.
10
+ */
11
+ export function getImagesForVariant(
12
+ product: HttpTypes.StoreProduct,
13
+ selectedVariantId?: string
14
+ ): HttpTypes.StoreProductImage[] {
15
+ // 1. If no variant is selected, show general product images
16
+ if (!selectedVariantId || !product.variants) {
17
+ return product.images || []
18
+ }
19
+
20
+ // 2. Find the selected variant
21
+ const variant = product.variants.find((v) => v.id === selectedVariantId)
22
+
23
+ // 3. If variant has specific images assigned in the Media section
24
+ if (variant?.images && variant.images.length > 0) {
25
+ const variantImages = [...variant.images] as HttpTypes.StoreProductImage[]
26
+
27
+ // We want to avoid showing the main product thumbnail in the variant gallery
28
+ // if it's already included (Medusa often duplicates it).
29
+ const productImageUrls = new Set<string>()
30
+ const productImageIds = new Set<string>()
31
+
32
+ if (product.thumbnail) {
33
+ const cleanThumbnailUrl = product.thumbnail.split('?')[0].toLowerCase().trim()
34
+ const thumbnailPath = cleanThumbnailUrl.split('/').pop() || ''
35
+ productImageUrls.add(cleanThumbnailUrl)
36
+ if (thumbnailPath) {
37
+ productImageUrls.add(thumbnailPath.toLowerCase())
38
+ }
39
+ }
40
+
41
+ if (product.images && product.images.length > 0) {
42
+ product.images.forEach(img => {
43
+ if (img.url) {
44
+ const cleanUrl = img.url.split('?')[0].toLowerCase().trim()
45
+ const urlPath = cleanUrl.split('/').pop() || ''
46
+ productImageUrls.add(cleanUrl)
47
+ if (urlPath) {
48
+ productImageUrls.add(urlPath.toLowerCase())
49
+ }
50
+ }
51
+ if (img.id) {
52
+ productImageIds.add(img.id)
53
+ }
54
+ })
55
+ }
56
+
57
+ const filteredVariantImages = variantImages.filter(img => {
58
+ if (!img.url) return false
59
+
60
+ const cleanImgUrl = img.url.split('?')[0].toLowerCase().trim()
61
+ const imgPath = cleanImgUrl.split('/').pop() || ''
62
+ const imgId = img.id
63
+
64
+ const urlMatches = productImageUrls.has(cleanImgUrl) || (imgPath && productImageUrls.has(imgPath.toLowerCase()))
65
+ const idMatches = imgId && productImageIds.has(imgId)
66
+
67
+ return !urlMatches && !idMatches
68
+ })
69
+
70
+ // Return variant-specific images, or fallback to all variant images if filtering cleared everything
71
+ return filteredVariantImages.length > 0 ? filteredVariantImages : variantImages
72
+ }
73
+
74
+ // 4. Default Fallback: If a specific variant is selected but has NO specific media images,
75
+ // show the product thumbnail as a single image.
76
+ if (product.thumbnail) {
77
+ return [
78
+ {
79
+ id: "default-thumb",
80
+ url: product.thumbnail,
81
+ } as HttpTypes.StoreProductImage,
82
+ ]
83
+ }
84
+
85
+ return product.images || []
86
+ }
@@ -0,0 +1,5 @@
1
+ const repeat = (times: number) => {
2
+ return Array.from(Array(times).keys())
3
+ }
4
+
5
+ export default repeat