medusa-storefront-data 2.5.7 → 2.5.9

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 (38) hide show
  1. package/HOMEPAGE_CONFIG.md +23 -1
  2. package/dist/src/server/dynamic-config.d.ts +14 -0
  3. package/dist/src/server/dynamic-config.d.ts.map +1 -1
  4. package/dist/src/server/dynamic-config.js +95 -1
  5. package/dist/src/server/hero-banner-priority.d.ts +31 -0
  6. package/dist/src/server/hero-banner-priority.d.ts.map +1 -0
  7. package/dist/src/server/hero-banner-priority.js +26 -0
  8. package/dist/src/server/hero-banner-resolve.test.d.ts +2 -0
  9. package/dist/src/server/hero-banner-resolve.test.d.ts.map +1 -0
  10. package/dist/src/server/hero-banner-resolve.test.js +56 -0
  11. package/dist/src/server/hero-banners-config.test.d.ts +2 -0
  12. package/dist/src/server/hero-banners-config.test.d.ts.map +1 -0
  13. package/dist/src/server/hero-banners-config.test.js +44 -0
  14. package/dist/src/server/home-sections/customer-stories.d.ts +9 -0
  15. package/dist/src/server/home-sections/customer-stories.d.ts.map +1 -0
  16. package/dist/src/server/home-sections/customer-stories.js +9 -0
  17. package/dist/src/server/homepage-config.types.d.ts +16 -1
  18. package/dist/src/server/homepage-config.types.d.ts.map +1 -1
  19. package/dist/src/server/homepage-config.types.js +1 -0
  20. package/dist/src/server/homepage-section-defaults.d.ts +12 -0
  21. package/dist/src/server/homepage-section-defaults.d.ts.map +1 -1
  22. package/dist/src/server/homepage-section-defaults.js +2 -0
  23. package/dist/src/server/index.d.ts +1 -0
  24. package/dist/src/server/index.d.ts.map +1 -1
  25. package/dist/src/server/index.js +1 -0
  26. package/dist/src/server/page-input.d.ts +3 -1
  27. package/dist/src/server/page-input.d.ts.map +1 -1
  28. package/dist/src/server/page-input.js +88 -5
  29. package/dist/src/server/storefront-cms.d.ts +11 -0
  30. package/dist/src/server/storefront-cms.d.ts.map +1 -0
  31. package/dist/src/server/storefront-cms.js +24 -0
  32. package/package.json +29 -19
  33. package/src/server/dynamic-config.ts +141 -4
  34. package/src/server/hero-banner-priority.ts +63 -0
  35. package/src/server/home-sections/customer-stories.ts +23 -0
  36. package/src/server/homepage-config.types.ts +18 -0
  37. package/src/server/homepage-section-defaults.ts +2 -0
  38. package/src/server/page-input.ts +94 -5
@@ -5,6 +5,84 @@ import { normalizeHomepageConfig } from "./normalize-homepage-config";
5
5
  function asObject(v) {
6
6
  return v && typeof v === "object" && !Array.isArray(v) ? v : null;
7
7
  }
8
+ function asArray(v) {
9
+ return Array.isArray(v) ? v : [];
10
+ }
11
+ /** `hero-banners.homeBanners[].slide` from medusa-plugin-dynamic-config. */
12
+ function heroSlideRow(row) {
13
+ const rowObj = asObject(row);
14
+ if (!rowObj)
15
+ return null;
16
+ const slide = asObject(rowObj.slide) ?? rowObj;
17
+ const image = slide.image;
18
+ if (image == null || image === "")
19
+ return null;
20
+ return {
21
+ image,
22
+ title: slide.title,
23
+ subtitle: slide.subtitle,
24
+ description: slide.description,
25
+ buttonText: slide.buttonText ?? slide.buttonName,
26
+ buttonLink: slide.buttonLink,
27
+ };
28
+ }
29
+ function simplifiedConfigFromHeroBanners(response) {
30
+ if (!response || typeof response !== "object")
31
+ return null;
32
+ const root = asObject(response["hero-banners"]);
33
+ if (!root)
34
+ return null;
35
+ const desktop = asArray(root.homeBanners)
36
+ .map(heroSlideRow)
37
+ .filter((row) => row !== null);
38
+ const mobile = asArray(root.appBanners)
39
+ .map(heroSlideRow)
40
+ .filter((row) => row !== null);
41
+ if (!desktop.length && !mobile.length)
42
+ return null;
43
+ const banners = {};
44
+ if (desktop.length)
45
+ banners.desktop = desktop;
46
+ if (mobile.length)
47
+ banners.mobile = mobile;
48
+ return { site: { banners } };
49
+ }
50
+ function mergeHomepageConfigLayers(...layers) {
51
+ const defined = layers.filter(Boolean);
52
+ if (!defined.length)
53
+ return null;
54
+ if (defined.length === 1)
55
+ return defined[0];
56
+ const merged = {};
57
+ for (const layer of defined) {
58
+ const site = asObject(layer.site) ?? {};
59
+ const mergedSite = asObject(merged.site) ?? {};
60
+ const layerBanners = asObject(site.banners);
61
+ if (layerBanners) {
62
+ mergedSite.banners = {
63
+ ...(asObject(mergedSite.banners) ?? {}),
64
+ ...layerBanners,
65
+ };
66
+ }
67
+ for (const [key, value] of Object.entries(site)) {
68
+ if (key !== "banners" && value !== undefined)
69
+ mergedSite[key] = value;
70
+ }
71
+ if (Object.keys(mergedSite).length)
72
+ merged.site = mergedSite;
73
+ const sections = asObject(layer.sections);
74
+ if (sections) {
75
+ merged.sections = { ...(asObject(merged.sections) ?? {}), ...sections };
76
+ }
77
+ for (const [key, value] of Object.entries(layer)) {
78
+ if (key === "site" || key === "sections")
79
+ continue;
80
+ if (value !== undefined)
81
+ merged[key] = value;
82
+ }
83
+ }
84
+ return merged;
85
+ }
8
86
  export const DEFAULT_PAGE_INPUT = defaultPageInputJson;
9
87
  /**
10
88
  * Merge CMS / main-project overrides onto package JSON defaults.
@@ -76,14 +154,19 @@ export function simplifiedHomepageConfigFromDynamicResponse(response) {
76
154
  ? simplified
77
155
  : null;
78
156
  }
157
+ /** CMS-only homepage config (no package JSON defaults). */
158
+ export function homepageConfigFromDynamicResponseNormalized(response) {
159
+ const legacy = response?.["homepage-config"] ?? null;
160
+ const fromSplit = simplifiedHomepageConfigFromDynamicResponse(response);
161
+ const fromHeroBanners = simplifiedConfigFromHeroBanners(response);
162
+ const raw = mergeHomepageConfigLayers(fromSplit, legacy, fromHeroBanners);
163
+ return normalizeHomepageConfig(raw);
164
+ }
79
165
  /**
80
166
  * Build `homepage-config` from Medusa `GET /store/dynamic-config` (main project only).
81
- * Supports legacy `homepage-config` or split plugin keys.
167
+ * Supports legacy `homepage-config`, split plugin keys, and `hero-banners`.
82
168
  */
83
169
  export function homepageConfigFromDynamicResponse(response) {
84
- const legacy = response?.["homepage-config"] ?? null;
85
- const fromSplit = simplifiedHomepageConfigFromDynamicResponse(response);
86
- const raw = fromSplit ?? legacy;
87
- const normalized = normalizeHomepageConfig(raw);
170
+ const normalized = homepageConfigFromDynamicResponseNormalized(response);
88
171
  return mergePageInputWithDefaults(normalized);
89
172
  }
@@ -0,0 +1,11 @@
1
+ import "server-only";
2
+ import type { StorefrontPageInput } from "./page-input";
3
+ type Maybe<T> = T | null | undefined;
4
+ /** Cached CMS merge for the storefront (fetch once per request). */
5
+ export declare const loadStorefrontPageInput: () => Promise<StorefrontPageInput>;
6
+ export declare function resolveStorefrontPageInput(pageInput?: Maybe<StorefrontPageInput>): Promise<StorefrontPageInput>;
7
+ export declare function loadContactInfoFromStorefrontInput(pageInput?: Maybe<StorefrontPageInput>): Promise<import("./dynamic-config").ContactInfo | null>;
8
+ export declare function loadContactEmailFromStorefrontInput(pageInput?: Maybe<StorefrontPageInput>): Promise<string>;
9
+ export declare function loadHelpFaqFromStorefrontInput(pageInput?: Maybe<StorefrontPageInput>): Promise<import("./dynamic-config").HelpFaqConfig>;
10
+ export {};
11
+ //# sourceMappingURL=storefront-cms.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storefront-cms.d.ts","sourceRoot":"","sources":["../../../src/server/storefront-cms.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAGpB,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAOvD,KAAK,KAAK,CAAC,CAAC,IAAI,CAAC,GAAG,IAAI,GAAG,SAAS,CAAA;AAEpC,oEAAoE;AACpE,eAAO,MAAM,uBAAuB,QACxB,OAAO,CAAC,mBAAmB,CAItC,CAAA;AAED,wBAAsB,0BAA0B,CAC9C,SAAS,CAAC,EAAE,KAAK,CAAC,mBAAmB,CAAC,GACrC,OAAO,CAAC,mBAAmB,CAAC,CAE9B;AAED,wBAAsB,kCAAkC,CACtD,SAAS,CAAC,EAAE,KAAK,CAAC,mBAAmB,CAAC,0DAIvC;AAED,wBAAsB,mCAAmC,CACvD,SAAS,CAAC,EAAE,KAAK,CAAC,mBAAmB,CAAC,GACrC,OAAO,CAAC,MAAM,CAAC,CAGjB;AAED,wBAAsB,8BAA8B,CAClD,SAAS,CAAC,EAAE,KAAK,CAAC,mBAAmB,CAAC,qDAIvC"}
@@ -0,0 +1,24 @@
1
+ import "server-only";
2
+ import { cache } from "react";
3
+ import { getContactInfoFromPageInput, getHelpFaqFromPageInput, } from "./dynamic-config";
4
+ import { getDynamicConfig, homepageConfigFromDynamicResponse } from "./dynamic-config";
5
+ /** Cached CMS merge for the storefront (fetch once per request). */
6
+ export const loadStorefrontPageInput = cache(async () => {
7
+ const raw = await getDynamicConfig();
8
+ return homepageConfigFromDynamicResponse(raw);
9
+ });
10
+ export async function resolveStorefrontPageInput(pageInput) {
11
+ return (pageInput ?? (await loadStorefrontPageInput()));
12
+ }
13
+ export async function loadContactInfoFromStorefrontInput(pageInput) {
14
+ const resolved = await resolveStorefrontPageInput(pageInput);
15
+ return getContactInfoFromPageInput(resolved);
16
+ }
17
+ export async function loadContactEmailFromStorefrontInput(pageInput) {
18
+ const info = await loadContactInfoFromStorefrontInput(pageInput);
19
+ return info?.email ?? "";
20
+ }
21
+ export async function loadHelpFaqFromStorefrontInput(pageInput) {
22
+ const resolved = await resolveStorefrontPageInput(pageInput);
23
+ return getHelpFaqFromPageInput(resolved);
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "medusa-storefront-data",
3
- "version": "2.5.7",
3
+ "version": "2.5.9",
4
4
  "type": "module",
5
5
  "description": "Medusa storefront server data layer extracted from Next.js storefront",
6
6
  "license": "MIT",
@@ -17,16 +17,17 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "build": "tsc",
20
- "clean": "rm -rf dist"
20
+ "clean": "rm -rf dist",
21
+ "test": "npm run build"
21
22
  },
22
23
  "peerDependencies": {
23
- "next": ">=14",
24
- "react": ">=18",
25
24
  "@medusajs/js-sdk": ">=2",
26
25
  "@medusajs/types": ">=2",
27
26
  "medusa-reviews-logic": "*",
27
+ "medusa-services": "^1.0.0",
28
28
  "medusa-wishlist-logic": "*",
29
- "medusa-services": "^1.0.0"
29
+ "next": ">=14",
30
+ "react": ">=18"
30
31
  },
31
32
  "peerDependenciesMeta": {
32
33
  "medusa-reviews-logic": {
@@ -38,13 +39,17 @@
38
39
  "server-only": "^0.0.1"
39
40
  },
40
41
  "devDependencies": {
41
- "typescript": "^5.6.0",
42
- "@types/react": "^18.3.0",
43
- "next": "^15.0.0",
44
42
  "@medusajs/js-sdk": "^2.0.0",
45
43
  "@medusajs/types": "^2.0.0",
46
44
  "@types/node": "^22.0.0",
47
- "medusa-services": "workspace:*"
45
+ "@types/react": "^19.0.0",
46
+ "@types/react-dom": "^19.0.0",
47
+ "medusa-services": "^1.2.1",
48
+ "medusa-ui-home-config": "^1.1.3",
49
+ "next": "^15.0.0",
50
+ "react": "^19.0.0",
51
+ "react-dom": "^19.0.0",
52
+ "typescript": "^5.6.0"
48
53
  },
49
54
  "exports": {
50
55
  "./package.json": "./package.json",
@@ -53,6 +58,16 @@
53
58
  "import": "./src/server/index.ts",
54
59
  "default": "./src/server/index.ts"
55
60
  },
61
+ "./help/sections/helpFaq": {
62
+ "types": "./dist/server/help-sections/help-faq.d.ts",
63
+ "import": "./src/server/help-sections/help-faq.ts",
64
+ "default": "./src/server/help-sections/help-faq.ts"
65
+ },
66
+ "./storefront-cms": {
67
+ "types": "./src/server/storefront-cms.ts",
68
+ "import": "./src/server/storefront-cms.ts",
69
+ "default": "./src/server/storefront-cms.ts"
70
+ },
56
71
  "./home/sections/promoAnnouncements": {
57
72
  "types": "./dist/server/home-sections/promo-announcements.d.ts",
58
73
  "import": "./src/server/home-sections/promo-announcements.ts",
@@ -128,6 +143,11 @@
128
143
  "import": "./src/server/home-sections/testimonials.ts",
129
144
  "default": "./src/server/home-sections/testimonials.ts"
130
145
  },
146
+ "./home/sections/customerStories": {
147
+ "types": "./dist/server/home-sections/customer-stories.d.ts",
148
+ "import": "./src/server/home-sections/customer-stories.ts",
149
+ "default": "./src/server/home-sections/customer-stories.ts"
150
+ },
131
151
  "./home/sections/brandPillars": {
132
152
  "types": "./dist/server/home-sections/brand-pillars.d.ts",
133
153
  "import": "./src/server/home-sections/brand-pillars.ts",
@@ -167,16 +187,6 @@
167
187
  "types": "./dist/server/home-sections/blog-posts.d.ts",
168
188
  "import": "./src/server/home-sections/blog-posts.ts",
169
189
  "default": "./src/server/home-sections/blog-posts.ts"
170
- },
171
- "./help/sections/helpFaq": {
172
- "types": "./dist/server/help-sections/help-faq.d.ts",
173
- "import": "./src/server/help-sections/help-faq.ts",
174
- "default": "./src/server/help-sections/help-faq.ts"
175
- },
176
- "./storefront-cms": {
177
- "types": "./src/server/storefront-cms.ts",
178
- "import": "./src/server/storefront-cms.ts",
179
- "default": "./src/server/storefront-cms.ts"
180
190
  }
181
191
  },
182
192
  "main": "./dist/server/index.js",
@@ -9,7 +9,10 @@ import {
9
9
  } from "./homepage-section-defaults"
10
10
  import type { DynamicConfigResponse, HomepageConfig } from "./homepage-config.types"
11
11
  import { mergeSectionBlock } from "./config-merge"
12
- import { mergePageInputWithDefaults, resolvePageInput } from "./page-input"
12
+ import {
13
+ mergePageInputWithDefaults,
14
+ resolvePageInput,
15
+ } from "./page-input"
13
16
 
14
17
  export {
15
18
  DEFAULT_PAGE_INPUT,
@@ -20,7 +23,6 @@ export {
20
23
  type StorefrontPageInput,
21
24
  } from "./page-input"
22
25
 
23
-
24
26
  export const getDynamicConfig = cache(async (): Promise<DynamicConfigResponse | null> => {
25
27
  try {
26
28
  const publishableKey = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
@@ -80,6 +82,7 @@ export function getLogoFromPageInput(
80
82
 
81
83
 
82
84
 
85
+ /** Normalized hero slide used by storefront UI (`getHomeBannersFromPageInput`). */
83
86
  export type HomepageBanner = {
84
87
  image?: string
85
88
  title?: string
@@ -131,8 +134,6 @@ export function getAppBannersFromPageInput(
131
134
  return null
132
135
  }
133
136
 
134
-
135
-
136
137
  // Legacy function for backward compatibility
137
138
 
138
139
  export function getWebsiteDescriptionFromPageInput(
@@ -504,6 +505,142 @@ export function getTestimonialsFromPageInput(
504
505
  return { title, subtitle, testimonials }
505
506
  }
506
507
 
508
+ export type CustomerStoryItem = {
509
+ id: string
510
+ image: string
511
+ text: string
512
+ name: string
513
+ rating?: number
514
+ location?: string
515
+ }
516
+
517
+ function parseCustomerStoryRow(
518
+ item: unknown,
519
+ index: number
520
+ ): CustomerStoryItem | null {
521
+ if (!item || typeof item !== "object") return null
522
+ const row =
523
+ (item as Record<string, unknown>).story ??
524
+ (item as Record<string, unknown>).review ??
525
+ item
526
+ const r = row as Record<string, unknown>
527
+ const text = String(
528
+ r.text ?? r["review-description"] ?? r.description ?? r.quote ?? ""
529
+ ).trim()
530
+ const name = String(
531
+ r.name ??
532
+ r["review-user-name"] ??
533
+ r["user-name"] ??
534
+ r.user_name ??
535
+ r.author ??
536
+ ""
537
+ ).trim()
538
+ const image = String(
539
+ r.image ??
540
+ r["review-profile-image"] ??
541
+ r["profile-image"] ??
542
+ r.profile_image ??
543
+ ""
544
+ ).trim()
545
+ /** Quote + name required; image optional (admin Ratings often has no photo). */
546
+ if (!text || !name) return null
547
+ const ratingRaw = r.rating ?? r["review-rating"]
548
+ const rating =
549
+ ratingRaw != null && ratingRaw !== ""
550
+ ? parseFloat(String(ratingRaw))
551
+ : undefined
552
+ const location = String(r.location ?? "").trim()
553
+ return {
554
+ id: String(r.id ?? `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${index + 1}`),
555
+ image: image || "/Logo.png",
556
+ text,
557
+ name,
558
+ rating: Number.isFinite(rating) ? Math.max(0, Math.min(5, rating!)) : undefined,
559
+ location: location || undefined,
560
+ }
561
+ }
562
+
563
+ function parseCustomerStoriesFromBlock(
564
+ block: Record<string, unknown>
565
+ ): CustomerStoryItem[] {
566
+ const raw =
567
+ block.items ??
568
+ block.stories ??
569
+ block.reviews ??
570
+ block["customer-stories"] ??
571
+ []
572
+ if (!Array.isArray(raw)) return []
573
+ return raw
574
+ .map((item, index) => parseCustomerStoryRow(item, index))
575
+ .filter((row): row is CustomerStoryItem => row !== null)
576
+ }
577
+
578
+ function parseCustomerStoriesFromRatings(ratings: unknown): CustomerStoryItem[] {
579
+ if (!Array.isArray(ratings)) return []
580
+ return ratings
581
+ .map((item, index) => parseCustomerStoryRow(item, index))
582
+ .filter((row): row is CustomerStoryItem => row !== null)
583
+ }
584
+
585
+ function getHomepageRatingsArray(
586
+ pageInput?: HomepageConfig | null
587
+ ): unknown[] {
588
+ const resolved = resolvePageInput(pageInput)
589
+ const fromResolved = resolved["ratings"]
590
+ if (Array.isArray(fromResolved) && fromResolved.length > 0) {
591
+ return fromResolved
592
+ }
593
+ const fromOverride = pageInput?.["ratings"]
594
+ if (Array.isArray(fromOverride) && fromOverride.length > 0) {
595
+ return fromOverride
596
+ }
597
+ return []
598
+ }
599
+
600
+ export function getCustomerStoriesFromPageInput(
601
+ pageInput?: HomepageConfig | null
602
+ ): {
603
+ title: string
604
+ eyebrow: string
605
+ items: CustomerStoryItem[]
606
+ } {
607
+ const homepageConfig = resolvePageInput(pageInput)
608
+ const block = getMergedSectionBlock("customerStories", pageInput)
609
+ const copy = parseSectionCopy(block)
610
+ const ratingTitle = String(homepageConfig["rating-title"] ?? "").trim()
611
+ const title = copy.title ?? copy.name ?? ratingTitle
612
+ const eyebrow = copy.eyebrow ?? ""
613
+ const defaults = DEFAULT_HOMEPAGE_SECTIONS.customerStories as Record<string, unknown>
614
+
615
+ /** Admin "Ratings" (`homepage-config.ratings[].review`) — always before merged section defaults. */
616
+ const ratingsItems = parseCustomerStoriesFromRatings(
617
+ getHomepageRatingsArray(pageInput)
618
+ )
619
+
620
+ /** CMS `sections.customerStories` only (no package default stories mixed in). */
621
+ const cmsSectionBlock =
622
+ findSectionBlock(getHomepageSectionsRoot(pageInput), "customerStories") ?? {}
623
+ const sectionItems = parseCustomerStoriesFromBlock(cmsSectionBlock)
624
+
625
+ let items =
626
+ ratingsItems.length > 0
627
+ ? ratingsItems
628
+ : sectionItems.length > 0
629
+ ? sectionItems
630
+ : parseCustomerStoriesFromBlock(block)
631
+ if (items.length === 0) {
632
+ items = parseCustomerStoriesFromBlock(
633
+ defaults as Record<string, unknown>
634
+ )
635
+ }
636
+
637
+ return {
638
+ title: title || String(defaults.title ?? "Customer Stories"),
639
+ eyebrow: eyebrow || String(defaults.eyebrow ?? "WHAT PARENTS SAY"),
640
+ items,
641
+ }
642
+ }
643
+
507
644
 
508
645
  export function getSocialLinksFromPageInput(
509
646
  pageInput?: HomepageConfig | null
@@ -0,0 +1,63 @@
1
+ /** Simplified banner shape used by hero sections (not legacy kebab-case CMS keys). */
2
+ export type HomepageBanner = {
3
+ image?: string
4
+ title?: string
5
+ subtitle?: string
6
+ description?: string
7
+ buttonName?: string
8
+ buttonLink?: string
9
+ }
10
+
11
+ export type ThemeHeroBannerFallbacks = {
12
+ home: HomepageBanner
13
+ app: HomepageBanner
14
+ }
15
+
16
+ export type HeroBannerSource = "dynamic-config" | "theme-copy" | "library-default"
17
+
18
+ export type ResolvedHeroBanners = {
19
+ homeBanners: HomepageBanner[]
20
+ appBanners: HomepageBanner[]
21
+ homeSource: HeroBannerSource
22
+ appSource: HeroBannerSource
23
+ }
24
+
25
+ export function hasBannerData(
26
+ banner: HomepageBanner | null | undefined
27
+ ): boolean {
28
+ if (!banner) return false
29
+ return Boolean(
30
+ banner.image?.trim() ||
31
+ banner.title?.trim() ||
32
+ banner.subtitle?.trim() ||
33
+ banner.description?.trim() ||
34
+ banner.buttonName?.trim() ||
35
+ banner.buttonLink?.trim()
36
+ )
37
+ }
38
+
39
+ /**
40
+ * Whole-banner priority (not per-field):
41
+ * 1. dynamic-config 2. theme copy.ts 3. package library defaults
42
+ */
43
+ export function pickHeroBannerBySource(
44
+ cms: HomepageBanner | null | undefined,
45
+ theme: HomepageBanner,
46
+ library: HomepageBanner
47
+ ): { banner: HomepageBanner; source: HeroBannerSource } {
48
+ if (cms && hasBannerData(cms)) {
49
+ return { banner: cms, source: "dynamic-config" }
50
+ }
51
+ if (hasBannerData(theme)) {
52
+ return { banner: theme, source: "theme-copy" }
53
+ }
54
+ return { banner: library, source: "library-default" }
55
+ }
56
+
57
+ export function resolveHeroBannerWithPriority(
58
+ cms: HomepageBanner | null | undefined,
59
+ theme: HomepageBanner,
60
+ library: HomepageBanner
61
+ ): HomepageBanner {
62
+ return pickHeroBannerBySource(cms, theme, library).banner
63
+ }
@@ -0,0 +1,23 @@
1
+ import "server-only"
2
+
3
+ import {
4
+ getCustomerStoriesFromPageInput,
5
+ getHomeSectionsCopyFromPageInput,
6
+ } from "../dynamic-config"
7
+ import type { SectionLoaderOptions } from "./shared"
8
+
9
+ export type CustomerStoriesSectionData = {
10
+ customerStories: ReturnType<typeof getCustomerStoriesFromPageInput>
11
+ copy: ReturnType<typeof getHomeSectionsCopyFromPageInput>["customerStories"]
12
+ }
13
+
14
+ export async function loadCustomerStoriesSectionData(
15
+ _countryCode: string,
16
+ options?: SectionLoaderOptions
17
+ ): Promise<CustomerStoriesSectionData> {
18
+ const pageInput = options?.pageInput ?? null
19
+ return {
20
+ customerStories: getCustomerStoriesFromPageInput(pageInput),
21
+ copy: getHomeSectionsCopyFromPageInput(pageInput).customerStories,
22
+ }
23
+ }
@@ -212,6 +212,22 @@ export type HomepageVideoStoriesSection = HomepageSectionCopyFields & {
212
212
  "video-stories"?: HomepageVideoStoryEntry[]
213
213
  }
214
214
 
215
+ export type HomepageCustomerStoryItem = {
216
+ id?: string
217
+ image?: string
218
+ text?: string
219
+ name?: string
220
+ rating?: number | string
221
+ location?: string
222
+ story?: HomepageCustomerStoryItem
223
+ review?: HomepageCustomerStoryItem
224
+ }
225
+
226
+ export type HomepageCustomerStoriesSection = HomepageSectionCopyFields & {
227
+ items?: HomepageCustomerStoryItem[]
228
+ stories?: HomepageCustomerStoryItem[]
229
+ }
230
+
215
231
  /** Per-section blocks under `homepage-config.sections`. */
216
232
  export type HomepageSectionsConfig = {
217
233
  promoAnnouncements?: HomepagePromoAnnouncementsSection
@@ -228,6 +244,7 @@ export type HomepageSectionsConfig = {
228
244
  themeDresses?: HomepageSectionCopyFields
229
245
  brandMarquee?: HomepageSectionCopyFields
230
246
  aboutBrand?: HomepageAboutBrandSection
247
+ customerStories?: HomepageCustomerStoriesSection
231
248
  testimonials?: HomepageTestimonialsSection
232
249
  brandPillars?: HomepageBrandPillarsSection
233
250
  instagramPosts?: HomepageInstagramPostsSection
@@ -457,6 +474,7 @@ export const HOMEPAGE_SECTION_IDS = [
457
474
  "themeDresses",
458
475
  "brandMarquee",
459
476
  "aboutBrand",
477
+ "customerStories",
460
478
  "testimonials",
461
479
  "brandPillars",
462
480
  "instagramPosts",
@@ -1,4 +1,5 @@
1
1
  import helpFaqDefaults from "./help-faq-section-defaults.json"
2
+ import { customerStoriesSectionDefaults } from "medusa-ui-home-config/sections/customerStories"
2
3
 
3
4
  /**
4
5
  * Default `homepage-config.sections` blocks.
@@ -71,6 +72,7 @@ export const DEFAULT_HOMEPAGE_SECTIONS = {
71
72
  { label: "Products", value: "3000+" },
72
73
  ],
73
74
  },
75
+ customerStories: customerStoriesSectionDefaults,
74
76
  testimonials: {
75
77
  title: "What Clients Talk About Us",
76
78
  description: "The Trust We've Earned",
@@ -14,6 +14,87 @@ function asObject(v: unknown): Loose | null {
14
14
  return v && typeof v === "object" && !Array.isArray(v) ? (v as Loose) : null
15
15
  }
16
16
 
17
+ function asArray(v: unknown): unknown[] {
18
+ return Array.isArray(v) ? v : []
19
+ }
20
+
21
+ /** `hero-banners.homeBanners[].slide` from medusa-plugin-dynamic-config. */
22
+ function heroSlideRow(row: unknown): Loose | null {
23
+ const rowObj = asObject(row)
24
+ if (!rowObj) return null
25
+ const slide = asObject(rowObj.slide) ?? rowObj
26
+ const image = slide.image
27
+ if (image == null || image === "") return null
28
+ return {
29
+ image,
30
+ title: slide.title,
31
+ subtitle: slide.subtitle,
32
+ description: slide.description,
33
+ buttonText: slide.buttonText ?? slide.buttonName,
34
+ buttonLink: slide.buttonLink,
35
+ }
36
+ }
37
+
38
+ function simplifiedConfigFromHeroBanners(
39
+ response: DynamicConfigResponse | null | undefined
40
+ ): HomepageConfig | null {
41
+ if (!response || typeof response !== "object") return null
42
+ const root = asObject((response as Loose)["hero-banners"])
43
+ if (!root) return null
44
+
45
+ const desktop = asArray(root.homeBanners)
46
+ .map(heroSlideRow)
47
+ .filter((row): row is Loose => row !== null)
48
+ const mobile = asArray(root.appBanners)
49
+ .map(heroSlideRow)
50
+ .filter((row): row is Loose => row !== null)
51
+
52
+ if (!desktop.length && !mobile.length) return null
53
+
54
+ const banners: Loose = {}
55
+ if (desktop.length) banners.desktop = desktop
56
+ if (mobile.length) banners.mobile = mobile
57
+
58
+ return { site: { banners } } as HomepageConfig
59
+ }
60
+
61
+ function mergeHomepageConfigLayers(
62
+ ...layers: Array<HomepageConfig | null | undefined>
63
+ ): HomepageConfig | null {
64
+ const defined = layers.filter(Boolean) as HomepageConfig[]
65
+ if (!defined.length) return null
66
+ if (defined.length === 1) return defined[0]
67
+
68
+ const merged: Loose = {}
69
+ for (const layer of defined) {
70
+ const site = asObject((layer as Loose).site) ?? {}
71
+ const mergedSite = asObject(merged.site) ?? {}
72
+ const layerBanners = asObject(site.banners)
73
+ if (layerBanners) {
74
+ mergedSite.banners = {
75
+ ...(asObject(mergedSite.banners) ?? {}),
76
+ ...layerBanners,
77
+ }
78
+ }
79
+ for (const [key, value] of Object.entries(site)) {
80
+ if (key !== "banners" && value !== undefined) mergedSite[key] = value
81
+ }
82
+ if (Object.keys(mergedSite).length) merged.site = mergedSite
83
+
84
+ const sections = asObject((layer as Loose).sections)
85
+ if (sections) {
86
+ merged.sections = { ...(asObject(merged.sections) ?? {}), ...sections }
87
+ }
88
+
89
+ for (const [key, value] of Object.entries(layer as Loose)) {
90
+ if (key === "site" || key === "sections") continue
91
+ if (value !== undefined) merged[key] = value
92
+ }
93
+ }
94
+
95
+ return merged as HomepageConfig
96
+ }
97
+
17
98
  /** Full `homepage-config` blob passed from the main project into pages and layout. */
18
99
  export type StorefrontPageInput = HomepageConfig
19
100
 
@@ -106,16 +187,24 @@ export function simplifiedHomepageConfigFromDynamicResponse(
106
187
  : null
107
188
  }
108
189
 
190
+ /** CMS-only homepage config (no package JSON defaults). */
191
+ export function homepageConfigFromDynamicResponseNormalized(
192
+ response: DynamicConfigResponse | null | undefined
193
+ ): HomepageConfig {
194
+ const legacy = response?.["homepage-config"] ?? null
195
+ const fromSplit = simplifiedHomepageConfigFromDynamicResponse(response)
196
+ const fromHeroBanners = simplifiedConfigFromHeroBanners(response)
197
+ const raw = mergeHomepageConfigLayers(fromSplit, legacy, fromHeroBanners)
198
+ return normalizeHomepageConfig(raw)
199
+ }
200
+
109
201
  /**
110
202
  * Build `homepage-config` from Medusa `GET /store/dynamic-config` (main project only).
111
- * Supports legacy `homepage-config` or split plugin keys.
203
+ * Supports legacy `homepage-config`, split plugin keys, and `hero-banners`.
112
204
  */
113
205
  export function homepageConfigFromDynamicResponse(
114
206
  response: DynamicConfigResponse | null | undefined
115
207
  ): StorefrontPageInput {
116
- const legacy = response?.["homepage-config"] ?? null
117
- const fromSplit = simplifiedHomepageConfigFromDynamicResponse(response)
118
- const raw = fromSplit ?? legacy
119
- const normalized = normalizeHomepageConfig(raw)
208
+ const normalized = homepageConfigFromDynamicResponseNormalized(response)
120
209
  return mergePageInputWithDefaults(normalized)
121
210
  }