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 +14 -19
- package/src/home/catalog/components/hero-banner.tsx +58 -50
- package/src/home/catalog/page.tsx +5 -35
- package/src/home/sections/customerStories/component/index.tsx +286 -0
- package/src/home/sections/customerStories/component/section-header.tsx +43 -0
- package/src/home/sections/customerStories/component/types.ts +14 -0
- package/src/home/sections/customerStories/copy-defaults.ts +15 -0
- package/src/home/sections/customerStories/index.ts +17 -0
- package/src/home/sections/customerStories/section.tsx +29 -0
- package/src/home/sections/customerStories/theme.ts +67 -0
- package/src/theme/default-home-theme.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "medusa-ui-home",
|
|
3
|
-
"version": "3.0.
|
|
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.
|
|
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.
|
|
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": "
|
|
196
|
-
"medusa-storefront-data": "
|
|
197
|
-
"medusa-storefront-theme-base": "
|
|
198
|
-
"medusa-ui-common": "
|
|
199
|
-
"medusa-ui-product": "
|
|
200
|
-
"medusa-ui-home-config": "
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 ||
|
|
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
|
|
52
|
-
const
|
|
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 ??
|
|
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 =
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
{
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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}>“{story.text}”</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,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 = {
|