medusa-ui-home 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "medusa-ui-home",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
4
4
  "description": "Homepage sections.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,11 +21,11 @@
21
21
  "lodash": ">=4",
22
22
  "lucide-react": "*",
23
23
  "medusa-plugin-dynamic-config": ">=0.0.31",
24
- "medusa-ui-home-config": "^1.1.0",
24
+ "medusa-ui-home-config": "^1.0.0",
25
25
  "medusa-contact-logic-plugin": "^2.0.0",
26
26
  "medusa-reviews-logic": "^2.0.0",
27
27
  "medusa-storefront-analytics": "^1.0.0",
28
- "medusa-storefront-data": "^2.5.3",
28
+ "medusa-storefront-data": "^2.0.0",
29
29
  "medusa-storefront-hooks": "^1.0.0",
30
30
  "medusa-wishlist-logic": "^2.0.0",
31
31
  "medusa-storefront-theme-base": "^3.0.0",
@@ -122,6 +122,11 @@
122
122
  "import": "./src/home/sections/testimonials/index.ts",
123
123
  "default": "./src/home/sections/testimonials/index.ts"
124
124
  },
125
+ "./sections/customerStories": {
126
+ "types": "./src/home/sections/customerStories/index.ts",
127
+ "import": "./src/home/sections/customerStories/index.ts",
128
+ "default": "./src/home/sections/customerStories/index.ts"
129
+ },
125
130
  "./sections/brandPillars": {
126
131
  "types": "./src/home/sections/brandPillars/index.ts",
127
132
  "import": "./src/home/sections/brandPillars/index.ts",
@@ -161,16 +166,6 @@
161
166
  "types": "./src/home/sections/blogPosts/index.ts",
162
167
  "import": "./src/home/sections/blogPosts/index.ts",
163
168
  "default": "./src/home/sections/blogPosts/index.ts"
164
- },
165
- "./catalog/page": {
166
- "types": "./src/home/catalog/page.tsx",
167
- "import": "./src/home/catalog/page.tsx",
168
- "default": "./src/home/catalog/page.tsx"
169
- },
170
- "./components/home-page-json-ld": {
171
- "types": "./src/home/components/home-page-json-ld.tsx",
172
- "import": "./src/home/components/home-page-json-ld.tsx",
173
- "default": "./src/home/components/home-page-json-ld.tsx"
174
169
  }
175
170
  },
176
171
  "typesVersions": {
@@ -192,11 +187,11 @@
192
187
  "@types/react-dom": "^18.3.5",
193
188
  "next": "^15.0.0",
194
189
  "typescript": "^5.6.0",
195
- "medusa-storefront-analytics": "workspace:*",
196
- "medusa-storefront-data": "workspace:*",
197
- "medusa-storefront-theme-base": "workspace:*",
198
- "medusa-ui-common": "workspace:*",
199
- "medusa-ui-product": "workspace:*",
200
- "medusa-ui-home-config": "workspace:*"
190
+ "medusa-storefront-analytics": "1.5.1",
191
+ "medusa-storefront-data": "2.5.9",
192
+ "medusa-storefront-theme-base": "3.0.1",
193
+ "medusa-ui-common": "3.0.1",
194
+ "medusa-ui-product": "3.0.2",
195
+ "medusa-ui-home-config": "1.1.3"
201
196
  }
202
197
  }
@@ -2,21 +2,23 @@ import Image from "next/image"
2
2
  import LocalizedClientLink from "medusa-ui-common/common/components/localized-client-link"
3
3
  import type { HomepageBanner } from "medusa-storefront-data/dynamic-config"
4
4
 
5
+ export type CatalogHeroCopy = {
6
+ badge?: string
7
+ desktopHeadingLine1?: string
8
+ desktopHeadingHighlight?: string
9
+ description?: string
10
+ primaryCtaLabel?: string
11
+ primaryCtaHref?: string
12
+ secondaryCtaLabel?: string
13
+ secondaryCtaHref?: string
14
+ mobilePrimaryCtaLabel?: string
15
+ mobileSecondaryCtaLabel?: string
16
+ }
17
+
5
18
  type CatalogHeroBannerProps = {
6
19
  homeBanners?: HomepageBanner[] | null
7
20
  appBanners?: HomepageBanner[] | null
8
- copy?: {
9
- badge?: string
10
- desktopHeadingLine1?: string
11
- desktopHeadingHighlight?: string
12
- description?: string
13
- primaryCtaLabel?: string
14
- primaryCtaHref?: string
15
- secondaryCtaLabel?: string
16
- secondaryCtaHref?: string
17
- mobilePrimaryCtaLabel?: string
18
- mobileSecondaryCtaLabel?: string
19
- }
21
+ copy?: CatalogHeroCopy
20
22
  }
21
23
 
22
24
  export default function CatalogHeroBanner({
@@ -24,23 +26,15 @@ export default function CatalogHeroBanner({
24
26
  appBanners,
25
27
  copy,
26
28
  }: CatalogHeroBannerProps) {
27
- const fallbackBanner: HomepageBanner = {
28
- image: "/newbanner.png",
29
- title: "Premium Kids Shop",
30
- subtitle: "Little Trends, Quality Kids Wear",
31
- description:
32
- "Shop stylish kids wear for little ones and children of all years - from newborn up to 15 years.",
33
- buttonName: "Shop Kids Wear",
34
- buttonLink: "/store",
35
- }
29
+ const homeBanner = homeBanners?.[0]
30
+ const firstAppBanner = appBanners?.[0]
36
31
 
37
- const homeBanner =
38
- homeBanners && homeBanners.length > 0 ? homeBanners[0] : fallbackBanner
39
- const firstAppBanner = appBanners && appBanners.length > 0 ? appBanners[0] : null
32
+ if (!homeBanner?.image) {
33
+ return null
34
+ }
40
35
 
41
- const homeBannerImage = homeBanner.image || fallbackBanner.image
42
36
  const appBanner = {
43
- image: firstAppBanner?.image || "/appbanner.png",
37
+ image: firstAppBanner?.image || homeBanner.image,
44
38
  title: firstAppBanner?.title || homeBanner.title,
45
39
  subtitle: firstAppBanner?.subtitle || homeBanner.subtitle,
46
40
  description: firstAppBanner?.description || homeBanner.description,
@@ -48,35 +42,35 @@ export default function CatalogHeroBanner({
48
42
  buttonLink: firstAppBanner?.buttonLink || homeBanner.buttonLink,
49
43
  }
50
44
 
51
- const heroBadge = copy?.badge ?? "BEST QUALITY"
52
- const desktopHeadingLine1 = copy?.desktopHeadingLine1 ?? "Electronics &"
45
+ const headlineFromCms = Boolean(homeBanner.subtitle?.trim())
46
+ const heroBadge = homeBanner.title ?? copy?.badge ?? "BEST QUALITY"
47
+ const desktopHeadingLine1 =
48
+ homeBanner.subtitle ?? copy?.desktopHeadingLine1 ?? "Electronics &"
53
49
  const desktopHeadingHighlight = copy?.desktopHeadingHighlight ?? "Essentials"
54
50
  const heroDescription =
51
+ homeBanner.description ??
55
52
  copy?.description ??
56
53
  "Discover dependable tools, components, devices, and everyday warehouse-ready stock built for fast-moving stores with large inventories."
57
- const primaryCtaLabel = copy?.primaryCtaLabel ?? "Add to Cart"
58
- const primaryCtaHref = copy?.primaryCtaHref ?? homeBanner.buttonLink ?? "/store"
54
+ const primaryCtaLabel = homeBanner.buttonName ?? copy?.primaryCtaLabel ?? "Add to Cart"
55
+ const primaryCtaHref = homeBanner.buttonLink ?? copy?.primaryCtaHref ?? "/store"
59
56
  const secondaryCtaLabel = copy?.secondaryCtaLabel ?? "Explore categories"
60
57
  const secondaryCtaHref = copy?.secondaryCtaHref ?? "/categories"
61
- const mobilePrimaryCtaLabel = copy?.mobilePrimaryCtaLabel ?? "Shop now"
58
+ const mobilePrimaryCtaLabel =
59
+ homeBanner.buttonName ?? copy?.mobilePrimaryCtaLabel ?? "Shop now"
62
60
  const mobileSecondaryCtaLabel = copy?.mobileSecondaryCtaLabel ?? secondaryCtaLabel
63
61
 
64
62
  return (
65
63
  <div className="relative w-full overflow-x-hidden">
66
64
  <div className="relative hidden sm:block">
67
- {homeBannerImage ? (
68
- <Image
69
- src={homeBannerImage}
70
- alt={homeBanner.title || "Home Banner"}
71
- width={1920}
72
- height={800}
73
- sizes="100vw"
74
- className="h-[420px] w-full object-cover md:h-[520px] lg:h-[580px] xl:h-[620px]"
75
- priority
76
- />
77
- ) : (
78
- <div className="h-[420px] w-full bg-white md:h-[520px] lg:h-[580px] xl:h-[620px]" />
79
- )}
65
+ <Image
66
+ src={homeBanner.image}
67
+ alt={homeBanner.title || "Home Banner"}
68
+ width={1920}
69
+ height={800}
70
+ sizes="100vw"
71
+ className="h-[420px] w-full object-cover md:h-[520px] lg:h-[580px] xl:h-[620px]"
72
+ priority
73
+ />
80
74
 
81
75
  <div className="absolute inset-y-0 left-0 w-[82%] bg-[linear-gradient(90deg,rgba(255,255,255,0.97)_0%,rgba(255,255,255,0.92)_52%,rgba(255,255,255,0.18)_82%,transparent_100%)] md:w-[72%] lg:w-[64%] xl:w-[60%]" />
82
76
 
@@ -89,9 +83,16 @@ export default function CatalogHeroBanner({
89
83
  </div>
90
84
 
91
85
  <h1 className="max-w-[13ch] text-[clamp(1.8rem,4vw,3.75rem)] font-bold leading-[1.04] text-[#04122C]">
92
- {desktopHeadingLine1}
93
- <br />
94
- Hardware <span className="text-[#2A8BFF]">{desktopHeadingHighlight}</span>
86
+ {headlineFromCms ? (
87
+ desktopHeadingLine1
88
+ ) : (
89
+ <>
90
+ {desktopHeadingLine1}
91
+ <br />
92
+ Hardware{" "}
93
+ <span className="text-[#2A8BFF]">{desktopHeadingHighlight}</span>
94
+ </>
95
+ )}
95
96
  </h1>
96
97
 
97
98
  <p className="max-w-[44ch] text-[12px] font-medium leading-relaxed text-[#4B5B76] md:text-sm lg:text-base">
@@ -155,9 +156,16 @@ export default function CatalogHeroBanner({
155
156
  </div>
156
157
 
157
158
  <h1 className="max-w-[12ch] text-[clamp(1.9rem,8vw,2.5rem)] font-bold leading-[1.06] text-[#04122C]">
158
- {desktopHeadingLine1}
159
- <br />
160
- Hardware <span className="text-[#2A8BFF]">{desktopHeadingHighlight}</span>
159
+ {headlineFromCms ? (
160
+ desktopHeadingLine1
161
+ ) : (
162
+ <>
163
+ {desktopHeadingLine1}
164
+ <br />
165
+ Hardware{" "}
166
+ <span className="text-[#2A8BFF]">{desktopHeadingHighlight}</span>
167
+ </>
168
+ )}
161
169
  </h1>
162
170
 
163
171
  <p className="text-[12px] font-medium leading-relaxed text-[#4B5B76] line-clamp-4">
@@ -1,5 +1,4 @@
1
1
  import dynamic from "next/dynamic"
2
- import type { HomepageBanner } from "medusa-storefront-data/dynamic-config"
3
2
  import {
4
3
  getRegion,
5
4
  listCategories,
@@ -7,13 +6,12 @@ import {
7
6
  listProductsWithSort,
8
7
  getProductsByTag,
9
8
  loadStorefrontPageInput,
10
- getHomeBannersFromPageInput,
11
- getAppBannersFromPageInput,
12
9
  getTestimonialsFromPageInput,
13
10
  } from "medusa-storefront-data"
11
+ import { loadHeroSectionData } from "medusa-storefront-data/home/sections/hero"
14
12
  import { fetchRatings } from "medusa-reviews-logic/server"
15
13
  import { HomePageJsonLd } from "../components/home-page-json-ld"
16
- import CatalogHeroBanner from "./components/hero-banner"
14
+ import CatalogHeroBanner, { type CatalogHeroCopy } from "./components/hero-banner"
17
15
  import TrustStrip from "./components/trust-strip"
18
16
  import ShopByCategory from "./components/shop-by-category"
19
17
  import ShopByAge from "./components/shop-by-age"
@@ -23,11 +21,6 @@ const WhyChooseUs = dynamic(() => import("./components/why-choose-us"))
23
21
  const LovedByMoms = dynamic(() => import("./components/loved-by-moms"))
24
22
  const Testimonials = dynamic(() => import("./components/testimonials"))
25
23
 
26
- export type CatalogHomeFallbackBanners = {
27
- home: HomepageBanner
28
- app: HomepageBanner
29
- }
30
-
31
24
  export type CatalogHomeJsonLd = {
32
25
  website: Record<string, unknown>
33
26
  organization: Record<string, unknown>
@@ -35,27 +28,13 @@ export type CatalogHomeJsonLd = {
35
28
 
36
29
  export type CatalogHomePageProps = {
37
30
  params: Promise<{ countryCode: string }>
38
- /** Used when CMS has no banner images. */
39
- fallbackBanners: CatalogHomeFallbackBanners
40
- heroCopy?: {
41
- badge?: string
42
- desktopHeadingLine1?: string
43
- desktopHeadingHighlight?: string
44
- description?: string
45
- primaryCtaLabel?: string
46
- primaryCtaHref?: string
47
- secondaryCtaLabel?: string
48
- secondaryCtaHref?: string
49
- mobilePrimaryCtaLabel?: string
50
- mobileSecondaryCtaLabel?: string
51
- }
31
+ heroCopy?: CatalogHeroCopy
52
32
  jsonLd: CatalogHomeJsonLd
53
33
  className?: string
54
34
  }
55
35
 
56
36
  export default async function CatalogHomePage({
57
37
  params,
58
- fallbackBanners,
59
38
  heroCopy,
60
39
  jsonLd,
61
40
  className = "bg-white w-full",
@@ -69,17 +48,6 @@ export default async function CatalogHomePage({
69
48
 
70
49
  const pageInput = await loadStorefrontPageInput()
71
50
 
72
- const hero = (() => {
73
- const home = getHomeBannersFromPageInput(pageInput) ?? []
74
- const app = getAppBannersFromPageInput(pageInput) ?? []
75
- const hasHomeImage = home.length > 0 && Boolean(home[0]?.image)
76
- const hasAppImage = app.length > 0 && Boolean(app[0]?.image)
77
- return {
78
- homeBanners: hasHomeImage ? home : [fallbackBanners.home],
79
- appBanners: hasAppImage ? app : [fallbackBanners.app],
80
- }
81
- })()
82
-
83
51
  const [
84
52
  categories,
85
53
  newArrivalsResult,
@@ -87,6 +55,7 @@ export default async function CatalogHomePage({
87
55
  testimonialsBlock,
88
56
  allRatings,
89
57
  collectionsResult,
58
+ hero,
90
59
  ] = await Promise.all([
91
60
  listCategories({ limit: 50 }).catch(() => []),
92
61
  listProductsWithSort({
@@ -106,6 +75,7 @@ export default async function CatalogHomePage({
106
75
  collections: [],
107
76
  count: 0,
108
77
  })),
78
+ loadHeroSectionData(countryCode, { pageInput }),
109
79
  ])
110
80
 
111
81
  const newArrivals =
@@ -0,0 +1,286 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react"
4
+ import Image from "next/image"
5
+ import type { HomeThemeClassNames } from "medusa-storefront-theme-base"
6
+ import { useThemeSection } from "medusa-ui-common/providers"
7
+ import { DEFAULT_CUSTOMER_STORIES_COPY } from "../copy-defaults"
8
+ import CustomerStoriesSectionHeader from "./section-header"
9
+ import type { CustomerStoryItem } from "./types"
10
+
11
+ const GAP_PX = 20
12
+
13
+ export type CustomerStoriesProps = {
14
+ title?: string
15
+ eyebrow?: string
16
+ items?: CustomerStoryItem[]
17
+ classNames?: Partial<HomeThemeClassNames>
18
+ }
19
+
20
+ function Stars({
21
+ rating,
22
+ classNames,
23
+ }: {
24
+ rating: number
25
+ classNames: { stars: string; starFilled: string; starMuted: string }
26
+ }) {
27
+ const clamped = Math.max(0, Math.min(5, Math.round(rating)))
28
+ return (
29
+ <div className={classNames.stars} aria-label={`${clamped} out of 5 stars`}>
30
+ {Array.from({ length: 5 }, (_, i) => (
31
+ <span
32
+ key={i}
33
+ className={i < clamped ? classNames.starFilled : classNames.starMuted}
34
+ >
35
+
36
+ </span>
37
+ ))}
38
+ </div>
39
+ )
40
+ }
41
+
42
+ function Chevron({ dir }: { dir: "left" | "right" }) {
43
+ return (
44
+ <svg
45
+ width="18"
46
+ height="18"
47
+ viewBox="0 0 24 24"
48
+ fill="none"
49
+ stroke="currentColor"
50
+ strokeWidth="1.75"
51
+ strokeLinecap="round"
52
+ strokeLinejoin="round"
53
+ aria-hidden
54
+ >
55
+ {dir === "left" ? (
56
+ <path d="M15 18l-6-6 6-6" />
57
+ ) : (
58
+ <path d="M9 18l6-6-6-6" />
59
+ )}
60
+ </svg>
61
+ )
62
+ }
63
+
64
+ function useCardsPerView() {
65
+ const [perView, setPerView] = useState(1)
66
+
67
+ useEffect(() => {
68
+ const mq = window.matchMedia("(min-width: 768px)")
69
+ const update = () => setPerView(mq.matches ? 2 : 1)
70
+ update()
71
+ mq.addEventListener("change", update)
72
+ return () => mq.removeEventListener("change", update)
73
+ }, [])
74
+
75
+ return perView
76
+ }
77
+
78
+ function StoryCard({
79
+ story,
80
+ classNames,
81
+ }: {
82
+ story: CustomerStoryItem
83
+ classNames: {
84
+ card: string
85
+ imageWrap: string
86
+ image: string
87
+ body: string
88
+ quote: string
89
+ stars: string
90
+ starFilled: string
91
+ starMuted: string
92
+ name: string
93
+ }
94
+ }) {
95
+ const rating = Number(story.rating ?? 5)
96
+ return (
97
+ <article className={classNames.card}>
98
+ <div className={classNames.imageWrap}>
99
+ <Image
100
+ src={story.image}
101
+ alt={`${story.name} — customer story`}
102
+ width={320}
103
+ height={400}
104
+ className={classNames.image}
105
+ loading="lazy"
106
+ unoptimized
107
+ />
108
+ </div>
109
+ <div className={classNames.body}>
110
+ <p className={classNames.quote}>&ldquo;{story.text}&rdquo;</p>
111
+ <Stars
112
+ rating={Number.isFinite(rating) ? rating : 5}
113
+ classNames={{
114
+ stars: classNames.stars,
115
+ starFilled: classNames.starFilled,
116
+ starMuted: classNames.starMuted,
117
+ }}
118
+ />
119
+ <p className={classNames.name}>{story.name}</p>
120
+ </div>
121
+ </article>
122
+ )
123
+ }
124
+
125
+ export default function CustomerStories({
126
+ title: titleProp,
127
+ eyebrow: eyebrowProp,
128
+ items: itemsProp,
129
+ classNames: classNamesProp,
130
+ }: CustomerStoriesProps) {
131
+ const cn = useThemeSection("home", classNamesProp)
132
+ const title = titleProp?.trim() || DEFAULT_CUSTOMER_STORIES_COPY.title
133
+ const eyebrow = eyebrowProp?.trim() || DEFAULT_CUSTOMER_STORIES_COPY.eyebrow
134
+ /** Use CMS/server items only; do not fall back to package copy when server sends an empty list. */
135
+ const items = Array.isArray(itemsProp)
136
+ ? itemsProp
137
+ : DEFAULT_CUSTOMER_STORIES_COPY.items
138
+
139
+ const perView = useCardsPerView()
140
+ const [activeIndex, setActiveIndex] = useState(0)
141
+ const [translateX, setTranslateX] = useState(0)
142
+ const viewportRef = useRef<HTMLDivElement>(null)
143
+ const trackRef = useRef<HTMLDivElement>(null)
144
+
145
+ const maxIndex = Math.max(0, items.length - perView)
146
+ const dotCount = maxIndex + 1
147
+
148
+ const measureOffset = useCallback(() => {
149
+ const slide = trackRef.current?.querySelector<HTMLElement>("[data-story-slide]")
150
+ if (!slide) return 0
151
+ return activeIndex * (slide.offsetWidth + GAP_PX)
152
+ }, [activeIndex])
153
+
154
+ useEffect(() => {
155
+ setTranslateX(measureOffset())
156
+ }, [measureOffset, perView])
157
+
158
+ useEffect(() => {
159
+ const viewport = viewportRef.current
160
+ if (!viewport) return
161
+ const ro = new ResizeObserver(() => setTranslateX(measureOffset()))
162
+ ro.observe(viewport)
163
+ return () => ro.disconnect()
164
+ }, [measureOffset])
165
+
166
+ useEffect(() => {
167
+ setActiveIndex((i) => Math.min(i, maxIndex))
168
+ }, [maxIndex, perView])
169
+
170
+ const goTo = useCallback(
171
+ (index: number) => {
172
+ setActiveIndex(Math.min(Math.max(index, 0), maxIndex))
173
+ },
174
+ [maxIndex]
175
+ )
176
+
177
+ if (items.length === 0) return null
178
+
179
+ return (
180
+ <section
181
+ className={`${cn.customerStoriesSection ?? ""} ${cn.section ?? ""}`}
182
+ aria-labelledby="customer-stories-heading"
183
+ >
184
+ <CustomerStoriesSectionHeader
185
+ eyebrow={eyebrow}
186
+ title={title}
187
+ id="customer-stories-heading"
188
+ classNames={{
189
+ wrap: cn.customerStoriesHeaderWrap ?? "",
190
+ inner: cn.customerStoriesHeaderInner ?? "",
191
+ header: cn.customerStoriesHeader ?? "",
192
+ eyebrow: cn.customerStoriesEyebrow ?? "",
193
+ heading: cn.customerStoriesTitle ?? "",
194
+ accent: cn.customerStoriesAccent ?? "",
195
+ accentLine: cn.customerStoriesAccentLine ?? "",
196
+ accentGem: cn.customerStoriesAccentGem ?? "",
197
+ }}
198
+ />
199
+
200
+ <div className={cn.customerStoriesInner ?? ""}>
201
+ <div className={cn.customerStoriesCarouselWrap ?? ""}>
202
+ <div className={cn.customerStoriesSlidesRow ?? ""}>
203
+ {dotCount > 1 ? (
204
+ <>
205
+ <button
206
+ type="button"
207
+ className={`${cn.customerStoriesNavButton ?? ""} ${cn.customerStoriesNavPrev ?? ""}`}
208
+ onClick={() => goTo(activeIndex - 1)}
209
+ disabled={activeIndex === 0}
210
+ aria-label="Previous customer story"
211
+ >
212
+ <Chevron dir="left" />
213
+ </button>
214
+ <button
215
+ type="button"
216
+ className={`${cn.customerStoriesNavButton ?? ""} ${cn.customerStoriesNavNext ?? ""}`}
217
+ onClick={() => goTo(activeIndex + 1)}
218
+ disabled={activeIndex >= maxIndex}
219
+ aria-label="Next customer story"
220
+ >
221
+ <Chevron dir="right" />
222
+ </button>
223
+ </>
224
+ ) : null}
225
+
226
+ <div ref={viewportRef} className={cn.customerStoriesViewport ?? ""}>
227
+ <div
228
+ ref={trackRef}
229
+ className={cn.customerStoriesTrack ?? ""}
230
+ style={{ transform: `translate3d(-${translateX}px, 0, 0)` }}
231
+ >
232
+ {items.map((story, index) => (
233
+ <div
234
+ key={story.id}
235
+ data-story-slide
236
+ className={cn.customerStoriesSlide ?? ""}
237
+ aria-hidden={
238
+ index < activeIndex || index >= activeIndex + perView
239
+ }
240
+ >
241
+ <StoryCard
242
+ story={story}
243
+ classNames={{
244
+ card: cn.customerStoriesCard ?? "",
245
+ imageWrap: cn.customerStoriesImageWrap ?? "",
246
+ image: cn.customerStoriesImage ?? "",
247
+ body: cn.customerStoriesBody ?? "",
248
+ quote: cn.customerStoriesQuote ?? "",
249
+ stars: cn.customerStoriesStars ?? "",
250
+ starFilled: cn.customerStoriesStarFilled ?? "",
251
+ starMuted: cn.customerStoriesStarMuted ?? "",
252
+ name: cn.customerStoriesName ?? "",
253
+ }}
254
+ />
255
+ </div>
256
+ ))}
257
+ </div>
258
+ </div>
259
+ </div>
260
+
261
+ {dotCount > 1 ? (
262
+ <div
263
+ className={cn.customerStoriesDots ?? ""}
264
+ role="tablist"
265
+ aria-label="Story slides"
266
+ >
267
+ {Array.from({ length: dotCount }).map((_, i) => (
268
+ <button
269
+ key={i}
270
+ type="button"
271
+ role="tab"
272
+ aria-selected={i === activeIndex}
273
+ aria-label={`Go to story ${i + 1}`}
274
+ className={`${cn.customerStoriesDot ?? ""} ${
275
+ i === activeIndex ? cn.customerStoriesDotActive ?? "" : ""
276
+ }`}
277
+ onClick={() => goTo(i)}
278
+ />
279
+ ))}
280
+ </div>
281
+ ) : null}
282
+ </div>
283
+ </div>
284
+ </section>
285
+ )
286
+ }
@@ -0,0 +1,43 @@
1
+ type CustomerStoriesSectionHeaderProps = {
2
+ eyebrow?: string | null
3
+ title: string
4
+ id?: string
5
+ classNames: {
6
+ wrap: string
7
+ inner: string
8
+ header: string
9
+ eyebrow: string
10
+ heading: string
11
+ accent: string
12
+ accentLine: string
13
+ accentGem: string
14
+ }
15
+ }
16
+
17
+ export default function CustomerStoriesSectionHeader({
18
+ eyebrow,
19
+ title,
20
+ id,
21
+ classNames: cn,
22
+ }: CustomerStoriesSectionHeaderProps) {
23
+ const trimmed = title.trim()
24
+ if (!trimmed) return null
25
+
26
+ return (
27
+ <div className={cn.wrap}>
28
+ <div className={cn.inner}>
29
+ <header className={cn.header}>
30
+ {eyebrow?.trim() ? <p className={cn.eyebrow}>{eyebrow.trim()}</p> : null}
31
+ <h2 id={id} className={cn.heading}>
32
+ {trimmed}
33
+ </h2>
34
+ <div className={cn.accent} aria-hidden>
35
+ <span className={cn.accentLine} />
36
+ <span className={cn.accentGem} />
37
+ <span className={`${cn.accentLine} rotate-180`} />
38
+ </div>
39
+ </header>
40
+ </div>
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,14 @@
1
+ export type CustomerStoryItem = {
2
+ id: string
3
+ image: string
4
+ text: string
5
+ name: string
6
+ rating?: number
7
+ location?: string
8
+ }
9
+
10
+ export type CustomerStoriesContent = {
11
+ title?: string
12
+ eyebrow?: string
13
+ items: CustomerStoryItem[]
14
+ }
@@ -0,0 +1,15 @@
1
+ import { customerStoriesSectionDefaults } from "medusa-ui-home-config/sections/customerStories/defaults"
2
+ import type { CustomerStoryItem } from "./component/types"
3
+
4
+ export type CustomerStoriesCopy = {
5
+ eyebrow: string
6
+ title: string
7
+ items: CustomerStoryItem[]
8
+ }
9
+
10
+ /** UI fallback copy — sourced from medusa-ui-home-config (single canonical data.json). */
11
+ export const DEFAULT_CUSTOMER_STORIES_COPY: CustomerStoriesCopy = {
12
+ eyebrow: customerStoriesSectionDefaults.eyebrow,
13
+ title: customerStoriesSectionDefaults.title,
14
+ items: customerStoriesSectionDefaults.items as CustomerStoryItem[],
15
+ }
@@ -0,0 +1,17 @@
1
+ export { default } from "./section"
2
+ export * from "./section"
3
+ export {
4
+ defaultTheme,
5
+ mergeSectionTheme,
6
+ type ThemeOverrides,
7
+ } from "./theme"
8
+ export { sectionTheme } from "./theme"
9
+ export {
10
+ DEFAULT_CUSTOMER_STORIES_COPY,
11
+ type CustomerStoriesCopy,
12
+ } from "./copy-defaults"
13
+ export type {
14
+ CustomerStoryItem,
15
+ CustomerStoriesContent,
16
+ } from "./component/types"
17
+ export * from "medusa-ui-home-config/sections/customerStories"
@@ -0,0 +1,29 @@
1
+ import type { ThemeOverrides } from "./theme"
2
+ import type { CustomerStoriesSectionData } from "medusa-storefront-data/home/sections/customerStories"
3
+ import CustomerStories from "./component"
4
+ import {
5
+ copyEyebrow,
6
+ copyTitle,
7
+ } from "medusa-ui-home/home/lib/section-copy"
8
+
9
+ export type CustomerStoriesSectionProps = CustomerStoriesSectionData & {
10
+ /** Override class names for slots this section uses. */
11
+ theme?: ThemeOverrides
12
+ }
13
+
14
+ export function CustomerStoriesSection({
15
+ customerStories,
16
+ copy,
17
+ theme,
18
+ }: CustomerStoriesSectionProps) {
19
+ return (
20
+ <CustomerStories
21
+ classNames={theme}
22
+ title={copyTitle(copy) || customerStories.title}
23
+ eyebrow={copyEyebrow(copy) || customerStories.eyebrow}
24
+ items={customerStories.items}
25
+ />
26
+ )
27
+ }
28
+
29
+ export default CustomerStoriesSection
@@ -0,0 +1,67 @@
1
+ import { mergeThemeSlots } from "medusa-storefront-theme-base"
2
+ import { pickThemeKeys } from "../../theme/pick-theme"
3
+ import { sharedHomeTheme } from "../../theme/shared"
4
+
5
+ /** Slots unique to this section (merged into the global home theme). */
6
+ export const sectionTheme = {
7
+ customerStoriesSection:
8
+ "w-full bg-[color-mix(in_srgb,var(--sf-color-secondary)_8%,var(--sf-color-background))] py-10 md:py-12 lg:py-14",
9
+ customerStoriesInner: "mx-auto w-full max-w-[1440px] px-4 sm:px-6 lg:px-8",
10
+ customerStoriesHeaderWrap: "w-full px-4 sm:px-6 md:px-8 lg:px-12",
11
+ customerStoriesHeaderInner: "mx-auto w-full max-w-[1440px]",
12
+ customerStoriesHeader:
13
+ "mb-5 flex flex-col items-center text-center sm:mb-6 md:mb-7",
14
+ customerStoriesEyebrow:
15
+ "mb-1.5 text-[0.6rem] font-medium uppercase tracking-[0.24em] text-[var(--sf-color-text-muted)] sm:text-[0.65rem] [font-family:var(--sf-font-family)]",
16
+ customerStoriesTitle:
17
+ "text-2xl font-semibold leading-tight tracking-tight text-[var(--sf-color-primary)] sm:text-[1.75rem] md:text-3xl [font-family:var(--sf-font-family-heading)]",
18
+ customerStoriesAccent: "mx-auto mt-3 flex items-center justify-center gap-2",
19
+ customerStoriesAccentLine:
20
+ "h-px w-8 bg-gradient-to-r from-transparent to-[var(--sf-color-secondary)] sm:w-10",
21
+ customerStoriesAccentGem: "h-1 w-1 rotate-45 bg-[var(--sf-color-secondary)]",
22
+ customerStoriesCarouselWrap: "relative",
23
+ customerStoriesSlidesRow: "relative px-8 sm:px-10 md:px-12",
24
+ customerStoriesViewport: "w-full overflow-hidden",
25
+ customerStoriesTrack:
26
+ "flex gap-5 motion-safe:transition-transform motion-safe:duration-[550ms] motion-safe:ease-[cubic-bezier(0.4,0,0.2,1)]",
27
+ customerStoriesSlide: "w-full shrink-0 md:w-[calc((100%-20px)/2)]",
28
+ customerStoriesCard:
29
+ "flex h-[250px] w-full flex-row overflow-hidden rounded-[8px] border border-[var(--sf-color-border)] bg-[var(--sf-color-surface)] shadow-sm md:h-[272px]",
30
+ customerStoriesImageWrap:
31
+ "relative h-full w-[40%] shrink-0 overflow-hidden bg-[color-mix(in_srgb,var(--sf-color-text-muted)_12%,var(--sf-color-background-alt))]",
32
+ customerStoriesImage: "block h-full w-full object-cover object-top",
33
+ customerStoriesBody:
34
+ "flex min-w-0 flex-1 flex-col items-center justify-center px-6 py-5 text-center md:px-8 md:py-6",
35
+ customerStoriesQuote:
36
+ "max-w-[15.5rem] text-[13px] font-normal italic leading-[1.7] text-[var(--sf-color-text-muted)] sm:max-w-[16.5rem] sm:text-[14px] md:max-w-[17.5rem] [font-family:var(--sf-font-family-heading)]",
37
+ customerStoriesStars: "mt-3 flex items-center gap-1",
38
+ customerStoriesStarFilled: "text-sm leading-none text-[var(--sf-color-secondary)]",
39
+ customerStoriesStarMuted:
40
+ "text-sm leading-none text-[color-mix(in_srgb,var(--sf-color-text-muted)_35%,transparent)]",
41
+ customerStoriesName:
42
+ "mt-[1.125rem] text-[14px] font-bold leading-tight text-[var(--sf-color-text)] md:mt-5 md:text-[15px] [font-family:var(--sf-font-family)]",
43
+ customerStoriesNavButton:
44
+ "absolute top-1/2 z-20 flex h-8 w-8 -translate-y-1/2 items-center justify-center border-0 bg-transparent p-0 text-[var(--sf-color-text-muted)] transition-colors hover:text-[var(--sf-color-text)] disabled:pointer-events-none disabled:opacity-25",
45
+ customerStoriesNavPrev: "left-0",
46
+ customerStoriesNavNext: "right-0",
47
+ customerStoriesDots: "mt-7 flex items-center justify-center gap-2 md:mt-8",
48
+ customerStoriesDot:
49
+ "h-[7px] w-[7px] rounded-full bg-[color-mix(in_srgb,var(--sf-color-text-muted)_35%,transparent)] p-0 transition-colors duration-300",
50
+ customerStoriesDotActive: "!bg-[var(--sf-color-text)]",
51
+ } as const
52
+
53
+ export const themeKeys = ["section"] as const
54
+
55
+ const sharedDefaults = pickThemeKeys(sharedHomeTheme, themeKeys)
56
+
57
+ export const defaultTheme = {
58
+ ...sharedDefaults,
59
+ ...sectionTheme,
60
+ } as const
61
+
62
+ export type ThemeOverrides = Partial<typeof defaultTheme>
63
+
64
+ /** Merge site or page-level overrides onto this section's default theme slots. */
65
+ export function mergeSectionTheme(overrides?: ThemeOverrides) {
66
+ return mergeThemeSlots(defaultTheme, overrides)
67
+ }
@@ -8,6 +8,7 @@ import { sectionTheme as baptismPicksSectionTheme } from "../home/sections/bapti
8
8
  import { sectionTheme as testimonialsSectionTheme } from "../home/sections/testimonials/theme"
9
9
  import { sectionTheme as instagramPostsSectionTheme } from "../home/sections/instagramPosts/theme"
10
10
  import { sectionTheme as videoStoriesSectionTheme } from "../home/sections/videoStories/theme"
11
+ import { sectionTheme as customerStoriesSectionTheme } from "../home/sections/customerStories/theme"
11
12
 
12
13
  export const defaultHomeThemeClassNames = {
13
14
  ...sharedHomeTheme,
@@ -19,6 +20,7 @@ export const defaultHomeThemeClassNames = {
19
20
  ...testimonialsSectionTheme,
20
21
  ...instagramPostsSectionTheme,
21
22
  ...videoStoriesSectionTheme,
23
+ ...customerStoriesSectionTheme,
22
24
  } as const
23
25
 
24
26
  export type HomeThemeSlots = {