medusa-ui-home 2.3.0 → 2.4.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 (72) hide show
  1. package/package.json +131 -7
  2. package/src/home/components/baptism-picks/index.tsx +2 -70
  3. package/src/home/components/category-pills/index.tsx +126 -44
  4. package/src/home/components/category-pills/utils.ts +132 -0
  5. package/src/home/components/dynamic-banner/index.tsx +48 -43
  6. package/src/home/components/hero/index.tsx +11 -2
  7. package/src/home/components/instagram-posts/index.tsx +199 -0
  8. package/src/home/components/new-arrivals/classic.tsx +65 -0
  9. package/src/home/components/new-arrivals/grid.tsx +58 -0
  10. package/src/home/components/new-arrivals/index.tsx +21 -55
  11. package/src/home/components/new-arrivals/types.ts +13 -0
  12. package/src/home/components/promo-announcements/index.tsx +3 -5
  13. package/src/home/components/promo-countdown/index.tsx +56 -36
  14. package/src/home/components/shared/product-grid-section.tsx +10 -9
  15. package/src/home/components/shoppable-gallery/index.tsx +232 -0
  16. package/src/home/components/testimonials/index.tsx +139 -686
  17. package/src/home/components/why-choose-us/dynamic-features.tsx +13 -7
  18. package/src/home/components/why-choose-us/index.tsx +16 -2
  19. package/src/home/home-content.tsx +4 -1
  20. package/src/home/lib/section-copy.ts +8 -0
  21. package/src/home/register-sections.ts +52 -43
  22. package/src/home/sections/about-brand-section.tsx +20 -14
  23. package/src/home/sections/adapt-home-section.tsx +13 -0
  24. package/src/home/sections/baptism-picks-section.tsx +18 -6
  25. package/src/home/sections/baptism-section.tsx +29 -9
  26. package/src/home/sections/blog-posts-section.tsx +17 -5
  27. package/src/home/sections/brand-marquee-section.tsx +14 -9
  28. package/src/home/sections/brand-pillars-section.tsx +19 -11
  29. package/src/home/sections/category-pills-section.tsx +13 -3
  30. package/src/home/sections/celebrity-trust-section.tsx +23 -11
  31. package/src/home/sections/features-section.tsx +20 -13
  32. package/src/home/sections/hero-section.tsx +16 -3
  33. package/src/home/sections/instagram-posts-section.tsx +32 -0
  34. package/src/home/sections/loved-by-moms-section.tsx +26 -12
  35. package/src/home/sections/luxe-favourites-section.tsx +23 -7
  36. package/src/home/sections/new-arrivals-classic-section.tsx +36 -0
  37. package/src/home/sections/new-arrivals-section.tsx +37 -8
  38. package/src/home/sections/promo-announcements-section.tsx +13 -3
  39. package/src/home/sections/promo-countdown-section.tsx +14 -3
  40. package/src/home/sections/shop-by-age-section.tsx +18 -10
  41. package/src/home/sections/shop-by-category-section.tsx +17 -9
  42. package/src/home/sections/testimonials-section.tsx +23 -20
  43. package/src/home/sections/theme-dresses-section.tsx +26 -12
  44. package/src/home/sections/video-stories-section.tsx +17 -9
  45. package/src/home/sections/why-choose-us-section.tsx +26 -5
  46. package/src/home/segment-data/_comment.json +1 -0
  47. package/src/home/segment-data/aboutBrand.json +20 -0
  48. package/src/home/segment-data/baptism.json +3 -0
  49. package/src/home/segment-data/baptismPicks.json +4 -0
  50. package/src/home/segment-data/blogPosts.json +11 -0
  51. package/src/home/segment-data/brandMarquee.json +3 -0
  52. package/src/home/segment-data/brandPillars.json +10 -0
  53. package/src/home/segment-data/categoryPills.json +1 -0
  54. package/src/home/segment-data/celebrityTrust.json +4 -0
  55. package/src/home/segment-data/features.json +14 -0
  56. package/src/home/segment-data/hero.json +1 -0
  57. package/src/home/segment-data/homepage-config.json +57 -0
  58. package/src/home/segment-data/index.ts +62 -0
  59. package/src/home/segment-data/instagramPosts.json +7 -0
  60. package/src/home/segment-data/lovedByMoms.json +4 -0
  61. package/src/home/segment-data/luxeFavourites.json +3 -0
  62. package/src/home/segment-data/newArrivals.json +5 -0
  63. package/src/home/segment-data/newArrivalsClassic.json +5 -0
  64. package/src/home/segment-data/promoAnnouncements.json +8 -0
  65. package/src/home/segment-data/promoCountdown.json +8 -0
  66. package/src/home/segment-data/shopByAge.json +3 -0
  67. package/src/home/segment-data/shopByCategory.json +4 -0
  68. package/src/home/segment-data/testimonials.json +14 -0
  69. package/src/home/segment-data/themeDresses.json +4 -0
  70. package/src/home/segment-data/videoStories.json +4 -0
  71. package/src/home/segment-data/whyChooseUs.json +9 -0
  72. package/src/theme/default-home-theme.ts +71 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "medusa-ui-home",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Homepage sections.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,10 +46,125 @@
46
46
  "import": "./src/theme/index.ts",
47
47
  "default": "./src/theme/index.ts"
48
48
  },
49
- "./home/*": {
50
- "types": "./src/home/*",
51
- "import": "./src/home/*",
52
- "default": "./src/home/*"
49
+ "./sections/promoAnnouncements": {
50
+ "types": "./src/home/sections/promo-announcements-section.tsx",
51
+ "import": "./src/home/sections/promo-announcements-section.tsx",
52
+ "default": "./src/home/sections/promo-announcements-section.tsx"
53
+ },
54
+ "./sections/hero": {
55
+ "types": "./src/home/sections/hero-section.tsx",
56
+ "import": "./src/home/sections/hero-section.tsx",
57
+ "default": "./src/home/sections/hero-section.tsx"
58
+ },
59
+ "./sections/categoryPills": {
60
+ "types": "./src/home/sections/category-pills-section.tsx",
61
+ "import": "./src/home/sections/category-pills-section.tsx",
62
+ "default": "./src/home/sections/category-pills-section.tsx"
63
+ },
64
+ "./sections/newArrivals": {
65
+ "types": "./src/home/sections/new-arrivals-section.tsx",
66
+ "import": "./src/home/sections/new-arrivals-section.tsx",
67
+ "default": "./src/home/sections/new-arrivals-section.tsx"
68
+ },
69
+ "./sections/newArrivalsClassic": {
70
+ "types": "./src/home/sections/new-arrivals-classic-section.tsx",
71
+ "import": "./src/home/sections/new-arrivals-classic-section.tsx",
72
+ "default": "./src/home/sections/new-arrivals-classic-section.tsx"
73
+ },
74
+ "./sections/promoCountdown": {
75
+ "types": "./src/home/sections/promo-countdown-section.tsx",
76
+ "import": "./src/home/sections/promo-countdown-section.tsx",
77
+ "default": "./src/home/sections/promo-countdown-section.tsx"
78
+ },
79
+ "./sections/celebrityTrust": {
80
+ "types": "./src/home/sections/celebrity-trust-section.tsx",
81
+ "import": "./src/home/sections/celebrity-trust-section.tsx",
82
+ "default": "./src/home/sections/celebrity-trust-section.tsx"
83
+ },
84
+ "./sections/luxeFavourites": {
85
+ "types": "./src/home/sections/luxe-favourites-section.tsx",
86
+ "import": "./src/home/sections/luxe-favourites-section.tsx",
87
+ "default": "./src/home/sections/luxe-favourites-section.tsx"
88
+ },
89
+ "./sections/shopByCategory": {
90
+ "types": "./src/home/sections/shop-by-category-section.tsx",
91
+ "import": "./src/home/sections/shop-by-category-section.tsx",
92
+ "default": "./src/home/sections/shop-by-category-section.tsx"
93
+ },
94
+ "./sections/baptism": {
95
+ "types": "./src/home/sections/baptism-section.tsx",
96
+ "import": "./src/home/sections/baptism-section.tsx",
97
+ "default": "./src/home/sections/baptism-section.tsx"
98
+ },
99
+ "./sections/baptismPicks": {
100
+ "types": "./src/home/sections/baptism-picks-section.tsx",
101
+ "import": "./src/home/sections/baptism-picks-section.tsx",
102
+ "default": "./src/home/sections/baptism-picks-section.tsx"
103
+ },
104
+ "./sections/themeDresses": {
105
+ "types": "./src/home/sections/theme-dresses-section.tsx",
106
+ "import": "./src/home/sections/theme-dresses-section.tsx",
107
+ "default": "./src/home/sections/theme-dresses-section.tsx"
108
+ },
109
+ "./sections/brandMarquee": {
110
+ "types": "./src/home/sections/brand-marquee-section.tsx",
111
+ "import": "./src/home/sections/brand-marquee-section.tsx",
112
+ "default": "./src/home/sections/brand-marquee-section.tsx"
113
+ },
114
+ "./sections/aboutBrand": {
115
+ "types": "./src/home/sections/about-brand-section.tsx",
116
+ "import": "./src/home/sections/about-brand-section.tsx",
117
+ "default": "./src/home/sections/about-brand-section.tsx"
118
+ },
119
+ "./sections/testimonials": {
120
+ "types": "./src/home/sections/testimonials-section.tsx",
121
+ "import": "./src/home/sections/testimonials-section.tsx",
122
+ "default": "./src/home/sections/testimonials-section.tsx"
123
+ },
124
+ "./sections/brandPillars": {
125
+ "types": "./src/home/sections/brand-pillars-section.tsx",
126
+ "import": "./src/home/sections/brand-pillars-section.tsx",
127
+ "default": "./src/home/sections/brand-pillars-section.tsx"
128
+ },
129
+ "./sections/instagramPosts": {
130
+ "types": "./src/home/sections/instagram-posts-section.tsx",
131
+ "import": "./src/home/sections/instagram-posts-section.tsx",
132
+ "default": "./src/home/sections/instagram-posts-section.tsx"
133
+ },
134
+ "./sections/features": {
135
+ "types": "./src/home/sections/features-section.tsx",
136
+ "import": "./src/home/sections/features-section.tsx",
137
+ "default": "./src/home/sections/features-section.tsx"
138
+ },
139
+ "./sections/shopByAge": {
140
+ "types": "./src/home/sections/shop-by-age-section.tsx",
141
+ "import": "./src/home/sections/shop-by-age-section.tsx",
142
+ "default": "./src/home/sections/shop-by-age-section.tsx"
143
+ },
144
+ "./sections/whyChooseUs": {
145
+ "types": "./src/home/sections/why-choose-us-section.tsx",
146
+ "import": "./src/home/sections/why-choose-us-section.tsx",
147
+ "default": "./src/home/sections/why-choose-us-section.tsx"
148
+ },
149
+ "./sections/lovedByMoms": {
150
+ "types": "./src/home/sections/loved-by-moms-section.tsx",
151
+ "import": "./src/home/sections/loved-by-moms-section.tsx",
152
+ "default": "./src/home/sections/loved-by-moms-section.tsx"
153
+ },
154
+ "./sections/videoStories": {
155
+ "types": "./src/home/sections/video-stories-section.tsx",
156
+ "import": "./src/home/sections/video-stories-section.tsx",
157
+ "default": "./src/home/sections/video-stories-section.tsx"
158
+ },
159
+ "./sections/blogPosts": {
160
+ "types": "./src/home/sections/blog-posts-section.tsx",
161
+ "import": "./src/home/sections/blog-posts-section.tsx",
162
+ "default": "./src/home/sections/blog-posts-section.tsx"
163
+ },
164
+ "./sections/adapt-home-section": {
165
+ "types": "./src/home/sections/adapt-home-section.tsx",
166
+ "import": "./src/home/sections/adapt-home-section.tsx",
167
+ "default": "./src/home/sections/adapt-home-section.tsx"
53
168
  },
54
169
  "./home/register-sections": {
55
170
  "types": "./src/home/register-sections.ts",
@@ -64,8 +179,17 @@
64
179
  },
65
180
  "typesVersions": {
66
181
  "*": {
67
- "*": [
68
- "src/*"
182
+ "sections/*": [
183
+ "src/home/sections/*-section.tsx"
184
+ ],
185
+ "theme": [
186
+ "src/theme/index.ts"
187
+ ],
188
+ "home/register-sections": [
189
+ "src/home/register-sections.ts"
190
+ ],
191
+ "home/home-content": [
192
+ "src/home/home-content.tsx"
69
193
  ]
70
194
  }
71
195
  },
@@ -1,70 +1,2 @@
1
- "use client"
2
-
3
- import Image from "next/image"
4
- import { HttpTypes } from "@medusajs/types"
5
- import LocalizedClientLink from "medusa-ui-common/common/components/localized-client-link"
6
- import type { HomeThemeClassNames } from "medusa-storefront-theme-base"
7
- import { useThemeSection } from "medusa-ui-common/providers"
8
-
9
- type BaptismPicksProps = {
10
- title?: string
11
- products: HttpTypes.StoreProduct[]
12
- classNames?: Partial<HomeThemeClassNames>
13
- }
14
-
15
- export default function BaptismPicks({
16
- title,
17
- products,
18
- classNames: classNamesProp,
19
- }: BaptismPicksProps) {
20
- const cn = useThemeSection("home", classNamesProp)
21
- const items = (products ?? []).slice(0, 5)
22
- if (items.length === 0) return null
23
-
24
- return (
25
- <section className={`${cn.sectionPad} ${cn.section}`}>
26
- <div className="px-4 sm:px-6 md:px-8">
27
- <div className="mx-auto max-w-[1360px]">
28
- {title ? <h2 className={`${cn.sectionTitle} mb-8`}>{title}</h2> : null}
29
- <div className="flex gap-4 sm:gap-6 overflow-x-auto pb-2 justify-center">
30
- {items.map((product, index) => {
31
- const thumb = product.thumbnail || product.images?.[0]?.url
32
- return (
33
- <LocalizedClientLink
34
- key={product.id}
35
- href={`/products/${product.handle}`}
36
- className="shrink-0 flex flex-col items-center gap-3 group"
37
- >
38
- <div className="relative">
39
- <span className="absolute -top-2 -right-2 z-10 flex h-7 w-7 items-center justify-center rounded-full bg-[var(--sf-btn-primary)] text-xs font-bold text-[var(--sf-color-text-on-primary)]">
40
- {index + 1}
41
- </span>
42
- <div
43
- className={`w-24 h-24 sm:w-28 sm:h-28 rounded-2xl overflow-hidden ring-2 ring-[var(--sf-color-border)] group-hover:ring-[var(--sf-color-primary)] ${cn.cardSurface}`}
44
- >
45
- {thumb ? (
46
- <Image
47
- src={thumb}
48
- alt={product.title ?? ""}
49
- width={112}
50
- height={112}
51
- className="w-full h-full object-cover"
52
- unoptimized
53
- />
54
- ) : (
55
- <div className={`w-full h-full ${cn.imagePlaceholder}`} />
56
- )}
57
- </div>
58
- </div>
59
- <span className={`text-[11px] sm:text-xs text-center max-w-[100px] line-clamp-2 ${cn.itemTitle}`}>
60
- {product.title}
61
- </span>
62
- </LocalizedClientLink>
63
- )
64
- })}
65
- </div>
66
- </div>
67
- </div>
68
- </section>
69
- )
70
- }
1
+ export { default } from "../shoppable-gallery"
2
+ export type { ShoppableLook } from "../shoppable-gallery"
@@ -1,72 +1,154 @@
1
1
  "use client"
2
2
 
3
+ import { useCallback, useRef, useState, useEffect } from "react"
3
4
  import Image from "next/image"
4
5
  import { HttpTypes } from "@medusajs/types"
5
6
  import LocalizedClientLink from "medusa-ui-common/common/components/localized-client-link"
6
7
  import type { HomeThemeClassNames } from "medusa-storefront-theme-base"
7
8
  import { useThemeSection } from "medusa-ui-common/providers"
9
+ import {
10
+ formatCategoryLabel,
11
+ pickCategoryPillItems,
12
+ resolveCategoryImage,
13
+ } from "./utils"
8
14
 
9
15
  type CategoryPillsProps = {
10
16
  categories: HttpTypes.StoreProductCategory[]
17
+ collections?: Array<Record<string, unknown>>
11
18
  classNames?: Partial<HomeThemeClassNames>
12
19
  }
13
20
 
21
+ function ChevronIcon({ dir }: { dir: "left" | "right" }) {
22
+ return (
23
+ <svg
24
+ width="20"
25
+ height="20"
26
+ viewBox="0 0 24 24"
27
+ fill="none"
28
+ stroke="currentColor"
29
+ strokeWidth="2"
30
+ strokeLinecap="round"
31
+ strokeLinejoin="round"
32
+ aria-hidden
33
+ >
34
+ {dir === "left" ? (
35
+ <path d="M15 18l-6-6 6-6" />
36
+ ) : (
37
+ <path d="M9 18l6-6-6-6" />
38
+ )}
39
+ </svg>
40
+ )
41
+ }
42
+
14
43
  export default function CategoryPills({
15
44
  categories,
45
+ collections,
16
46
  classNames: classNamesProp,
17
47
  }: CategoryPillsProps) {
18
48
  const cn = useThemeSection("home", classNamesProp)
49
+ const trackRef = useRef<HTMLDivElement>(null)
50
+ const [canScrollLeft, setCanScrollLeft] = useState(false)
51
+ const [canScrollRight, setCanScrollRight] = useState(false)
52
+ const [hasOverflow, setHasOverflow] = useState(false)
53
+
54
+ const pills = pickCategoryPillItems(categories, collections)
19
55
 
20
- const pills = (categories ?? [])
21
- .filter((c) => c.name && c.handle)
22
- .slice(0, 14)
56
+ const updateScrollState = useCallback(() => {
57
+ const el = trackRef.current
58
+ if (!el) return
59
+ const { scrollLeft, scrollWidth, clientWidth } = el
60
+ setCanScrollLeft(scrollLeft > 4)
61
+ setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 4)
62
+ setHasOverflow(scrollWidth > clientWidth + 4)
63
+ }, [])
64
+
65
+ useEffect(() => {
66
+ updateScrollState()
67
+ const el = trackRef.current
68
+ if (!el) return
69
+ const ro = new ResizeObserver(updateScrollState)
70
+ ro.observe(el)
71
+ return () => ro.disconnect()
72
+ }, [pills.length, updateScrollState])
73
+
74
+ const scrollBy = (direction: "left" | "right") => {
75
+ const el = trackRef.current
76
+ if (!el) return
77
+ const amount = Math.max(el.clientWidth * 0.55, 320)
78
+ el.scrollBy({
79
+ left: direction === "left" ? -amount : amount,
80
+ behavior: "smooth",
81
+ })
82
+ }
23
83
 
24
84
  if (pills.length === 0) return null
25
85
 
26
86
  return (
27
- <section className={`w-full py-6 sm:py-8 ${cn.section}`}>
28
- <div className="px-4 sm:px-6 md:px-8">
29
- <div className="mx-auto max-w-[1360px]">
30
- <div className="flex gap-3 sm:gap-4 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
31
- {pills.map((category) => {
32
- const thumb =
33
- (category as { thumbnail?: string }).thumbnail ||
34
- (category.metadata as { image?: string } | undefined)?.image
87
+ <section className={`w-full py-10 sm:py-12 md:py-14 ${cn.section}`}>
88
+ <div className="px-4 sm:px-6 md:px-8 lg:px-12">
89
+ <div className="relative mx-auto max-w-[1360px]">
90
+ {hasOverflow ? (
91
+ <>
92
+ <button
93
+ type="button"
94
+ onClick={() => scrollBy("left")}
95
+ disabled={!canScrollLeft}
96
+ className={`absolute left-0 top-[72px] z-10 -translate-x-1/2 sm:top-[84px] md:top-[92px] disabled:opacity-35 ${cn.categoryPillNavButton}`}
97
+ aria-label="Scroll categories left"
98
+ >
99
+ <ChevronIcon dir="left" />
100
+ </button>
101
+ <button
102
+ type="button"
103
+ onClick={() => scrollBy("right")}
104
+ disabled={!canScrollRight}
105
+ className={`absolute right-0 top-[72px] z-10 translate-x-1/2 sm:top-[84px] md:top-[92px] disabled:opacity-35 ${cn.categoryPillNavButton}`}
106
+ aria-label="Scroll categories right"
107
+ >
108
+ <ChevronIcon dir="right" />
109
+ </button>
110
+ </>
111
+ ) : null}
35
112
 
36
- return (
37
- <LocalizedClientLink
38
- key={category.id}
39
- href={`/categories/${category.handle}`}
40
- className={`snap-start shrink-0 flex flex-col items-center gap-2 min-w-[88px] sm:min-w-[100px] group`}
41
- >
42
- <div
43
- className={`w-16 h-16 sm:w-20 sm:h-20 rounded-full overflow-hidden ring-2 ring-[var(--sf-color-border)] group-hover:ring-[var(--sf-color-primary)] transition-all ${cn.cardSurface}`}
44
- >
45
- {thumb ? (
46
- <Image
47
- src={thumb}
48
- alt={category.name ?? ""}
49
- width={80}
50
- height={80}
51
- className="w-full h-full object-cover"
52
- unoptimized
53
- />
54
- ) : (
55
- <div
56
- className={`w-full h-full flex items-center justify-center text-lg font-bold ${cn.imagePlaceholder} text-[var(--sf-color-primary)]`}
57
- >
58
- {category.name?.charAt(0)}
59
- </div>
60
- )}
61
- </div>
62
- <span
63
- className={`text-[11px] sm:text-xs text-center font-medium leading-tight max-w-[92px] ${cn.categoryTitle}`}
113
+ <div
114
+ ref={trackRef}
115
+ onScroll={updateScrollState}
116
+ className={cn.categoryPillScroll}
117
+ >
118
+ <div className={`flex w-max min-w-full ${cn.categoryPillTrack}`}>
119
+ {pills.map((category) => {
120
+ const thumb = resolveCategoryImage(category, collections)
121
+ const label = formatCategoryLabel(category.name ?? "")
122
+
123
+ return (
124
+ <LocalizedClientLink
125
+ key={category.id}
126
+ href={`/categories/${category.handle}`}
127
+ className={`group ${cn.categoryPillItem}`}
64
128
  >
65
- {category.name}
66
- </span>
67
- </LocalizedClientLink>
68
- )
69
- })}
129
+ <div className={cn.categoryPillImageWrap}>
130
+ {thumb ? (
131
+ <Image
132
+ src={thumb}
133
+ alt={label}
134
+ width={160}
135
+ height={160}
136
+ className={cn.categoryPillImage}
137
+ unoptimized
138
+ />
139
+ ) : (
140
+ <div
141
+ className={`flex h-full w-full items-center justify-center text-2xl font-semibold sm:text-3xl ${cn.imagePlaceholder} text-[var(--sf-color-primary)]`}
142
+ >
143
+ {label.charAt(0)}
144
+ </div>
145
+ )}
146
+ </div>
147
+ <span className={cn.categoryPillLabel}>{label}</span>
148
+ </LocalizedClientLink>
149
+ )
150
+ })}
151
+ </div>
70
152
  </div>
71
153
  </div>
72
154
  </div>
@@ -0,0 +1,132 @@
1
+ import { HttpTypes } from "@medusajs/types"
2
+
3
+ type CategoryMetadata = {
4
+ category_image?: string
5
+ image?: string
6
+ thumbnail?: string
7
+ }
8
+
9
+ type CollectionMetadata = {
10
+ image?: string
11
+ thumbnail?: string
12
+ category_image?: string
13
+ }
14
+
15
+ function normalizeKey(value: string): string {
16
+ return value
17
+ .toLowerCase()
18
+ .trim()
19
+ .replace(/&/g, "and")
20
+ .replace(/[^a-z0-9]+/g, "-")
21
+ .replace(/^-+|-+$/g, "")
22
+ }
23
+
24
+ export function formatCategoryLabel(name: string | undefined): string {
25
+ if (!name) return ""
26
+ const trimmed = name.trim()
27
+ if (!trimmed) return ""
28
+ if (trimmed === trimmed.toUpperCase() && /[A-Z]/.test(trimmed)) {
29
+ return trimmed
30
+ .toLowerCase()
31
+ .split(/\s+/)
32
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
33
+ .join(" ")
34
+ }
35
+ return trimmed
36
+ }
37
+
38
+ function collectionImageForCategory(
39
+ collections: Array<Record<string, unknown>> | undefined,
40
+ category: HttpTypes.StoreProductCategory
41
+ ): string | null {
42
+ if (!collections?.length) return null
43
+
44
+ const handleKey = category.handle ? normalizeKey(category.handle) : ""
45
+ const nameKey = category.name ? normalizeKey(category.name) : ""
46
+
47
+ for (const entry of collections) {
48
+ const meta = (entry.metadata as CollectionMetadata | undefined) ?? {}
49
+ const image = meta.category_image || meta.image || meta.thumbnail
50
+ if (!image) continue
51
+
52
+ const cHandle = (entry.handle as string | undefined) ?? ""
53
+ const cTitle = (entry.title as string | undefined) ?? ""
54
+ const handleMatch = handleKey && normalizeKey(cHandle) === handleKey
55
+ const titleMatch = nameKey && normalizeKey(cTitle) === nameKey
56
+
57
+ if (handleMatch || titleMatch) return image
58
+ }
59
+
60
+ return null
61
+ }
62
+
63
+ function imageFromCategoryRecord(
64
+ category: HttpTypes.StoreProductCategory
65
+ ): string | null {
66
+ const meta = (category.metadata as CategoryMetadata | undefined) ?? {}
67
+ return (
68
+ meta.category_image ||
69
+ meta.image ||
70
+ meta.thumbnail ||
71
+ (category as { thumbnail?: string }).thumbnail ||
72
+ null
73
+ )
74
+ }
75
+
76
+ export function resolveCategoryImage(
77
+ category: HttpTypes.StoreProductCategory,
78
+ collections?: Array<Record<string, unknown>>
79
+ ): string | null {
80
+ const fromCategory = imageFromCategoryRecord(category)
81
+ if (fromCategory) return fromCategory
82
+
83
+ const parent = (
84
+ category as HttpTypes.StoreProductCategory & {
85
+ parent_category?: HttpTypes.StoreProductCategory;
86
+ }
87
+ ).parent_category
88
+ if (parent) {
89
+ const fromParent = imageFromCategoryRecord(parent)
90
+ if (fromParent) return fromParent
91
+ }
92
+
93
+ return collectionImageForCategory(collections, category)
94
+ }
95
+
96
+ export function hasCategoryImage(
97
+ category: HttpTypes.StoreProductCategory,
98
+ collections?: Array<Record<string, unknown>>
99
+ ): boolean {
100
+ return Boolean(resolveCategoryImage(category, collections))
101
+ }
102
+
103
+ export function pickCategoryPillItems(
104
+ categories: HttpTypes.StoreProductCategory[],
105
+ collections?: Array<Record<string, unknown>>,
106
+ limit = 12
107
+ ): HttpTypes.StoreProductCategory[] {
108
+ const valid = (categories ?? []).filter((c) => c.name && c.handle)
109
+ const withImage = valid.filter((c) => hasCategoryImage(c, collections))
110
+ const topLevel = valid.filter((c) => !c.parent_category_id)
111
+ const topLevelWithImage = withImage.filter((c) => !c.parent_category_id)
112
+
113
+ let pool = valid
114
+ if (withImage.length >= 4) {
115
+ pool =
116
+ topLevelWithImage.length >= 4
117
+ ? topLevelWithImage
118
+ : withImage
119
+ } else if (topLevel.length >= 4) {
120
+ pool = topLevel
121
+ }
122
+
123
+ return [...pool]
124
+ .sort(
125
+ (a, b) =>
126
+ Number(hasCategoryImage(b, collections)) -
127
+ Number(hasCategoryImage(a, collections)) ||
128
+ Number(!b.parent_category_id) - Number(!a.parent_category_id) ||
129
+ (a.name ?? "").localeCompare(b.name ?? "")
130
+ )
131
+ .slice(0, limit)
132
+ }