@pradip1995/segment-vertical-collection-scroll 0.1.1

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 ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@pradip1995/segment-vertical-collection-scroll",
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "sideEffects": [
9
+ "src/segment.css"
10
+ ],
11
+ "files": [
12
+ "src",
13
+ "public"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./manifest": "./src/manifest.ts"
18
+ },
19
+ "peerDependencies": {
20
+ "@pradip1995/plugin-sdk": "^0.2.0",
21
+ "react": ">=19",
22
+ "react-dom": ">=19",
23
+ "next": ">=15"
24
+ },
25
+ "dependencies": {
26
+ "@pradip1995/segment-primitives": "^0.4.11",
27
+ "@pradip1995/segment-tokens": "^0.3.14"
28
+ },
29
+ "devDependencies": {
30
+ "@medusajs/types": "^2.0.0",
31
+ "@pradip1995/plugin-sdk": "^0.2.0",
32
+ "@types/react": "^19",
33
+ "react": "19.0.3",
34
+ "typescript": "^5.7.2"
35
+ },
36
+ "scripts": {
37
+ "typecheck": "tsc --noEmit",
38
+ "lint": "tsc --noEmit"
39
+ }
40
+ }
@@ -0,0 +1,10 @@
1
+ declare module "@pradip1995/commerce-core/data/collections" {
2
+ import type { HttpTypes } from "@medusajs/types"
3
+
4
+ export function listCollections(queryParams?: Record<string, any>): Promise<{
5
+ collections: HttpTypes.StoreCollection[]
6
+ count: number
7
+ offset: number
8
+ limit: number
9
+ }>
10
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { default } from "./segment"
2
+ export { default as manifest } from "./manifest"
3
+ export type { VerticalCollectionSlide, VerticalCollectionScrollProps } from "./types"
@@ -0,0 +1,11 @@
1
+ import type { SegmentManifest } from "@pradip1995/plugin-sdk"
2
+
3
+ const manifest: SegmentManifest = {
4
+ id: "vertical-collection-scroll",
5
+ type: "segment",
6
+ version: "0.1.0",
7
+ compatibleFramework: ["^1.0.0"],
8
+ dataKey: "verticalCollectionScroll",
9
+ }
10
+
11
+ export default manifest
@@ -0,0 +1,261 @@
1
+ import { resolveMediaUrl } from "@pradip1995/segment-primitives/resolve-media-url"
2
+ import type { HttpTypes } from "@medusajs/types"
3
+ import type { VerticalCollectionSlide } from "./types"
4
+
5
+ /**
6
+ * Fallback luxury imagery when a collection in Admin does not yet have an image uploaded.
7
+ */
8
+ function getMatchingFallbackImage(title: string, index: number): string {
9
+ const t = title.toLowerCase()
10
+ if (t.includes("bridal") || t.includes("wedding")) {
11
+ return "/images/bridal/bridal-hero.png"
12
+ }
13
+ if (t.includes("diamond")) {
14
+ return "/images/collections/white-diamond.jpg"
15
+ }
16
+ if (t.includes("gold") || t.includes("antique")) {
17
+ return "/images/Old_Cut_Jewelry_02_copy_1_1.webp"
18
+ }
19
+ if (t.includes("lab grown")) {
20
+ return "/images/collections/yellow-diamond.jpg"
21
+ }
22
+ const defaults = [
23
+ "/images/collections/emerald-sapphire.jpg",
24
+ "/images/collections/white-diamond.jpg",
25
+ "/images/bridal/bridal-hero.png",
26
+ "/images/Old_Cut_Jewelry_02_copy_1_1.webp",
27
+ "/images/collections/yellow-diamond.jpg",
28
+ ]
29
+ return defaults[index % defaults.length]
30
+ }
31
+
32
+ export function extractMediaUrl(val: unknown): string | null {
33
+ if (!val || typeof val !== "string") return null
34
+ const trimmed = val.trim()
35
+ if (!trimmed) return null
36
+ const resolved = resolveMediaUrl(trimmed)
37
+ return resolved || trimmed
38
+ }
39
+
40
+ function getCollectionMetadata(collection: HttpTypes.StoreCollection) {
41
+ return (collection.metadata || {}) as Record<string, unknown>
42
+ }
43
+
44
+ function isHomeFeatured(collection: HttpTypes.StoreCollection) {
45
+ const value = getCollectionMetadata(collection).home_featured
46
+ return value === true || value === "true" || value === "1"
47
+ }
48
+
49
+ function getHomeSort(collection: HttpTypes.StoreCollection) {
50
+ const raw = getCollectionMetadata(collection).home_sort
51
+ const num = Number.parseInt(String(raw ?? ""), 10)
52
+ return Number.isFinite(num) ? num : 999
53
+ }
54
+
55
+ function readParallaxText(meta: Record<string, unknown>, keys: string[]) {
56
+ for (const key of keys) {
57
+ const val = meta[key]
58
+ if (typeof val === "string" && val.trim()) return val.trim()
59
+ }
60
+ return ""
61
+ }
62
+
63
+ export function getAdminCollectionImage(col: any, index: number): string {
64
+ if (!col) return getMatchingFallbackImage("", index)
65
+
66
+ const meta = (col.metadata || {}) as Record<string, unknown>
67
+
68
+ // Priority 1: Collection Banner uploaded in Medusa Admin
69
+ const banner =
70
+ extractMediaUrl(meta.collection_Banner) ||
71
+ extractMediaUrl(meta.collection_banner) ||
72
+ extractMediaUrl(meta.Collection_Banner) ||
73
+ extractMediaUrl(meta.banner)
74
+ if (banner) return banner
75
+
76
+ // Priority 2: Collection Image uploaded in Medusa Admin
77
+ const image =
78
+ extractMediaUrl(meta.collection_image) ||
79
+ extractMediaUrl(meta.collection_Image) ||
80
+ extractMediaUrl(meta["Collection Image"]) ||
81
+ extractMediaUrl(meta["collection image"]) ||
82
+ extractMediaUrl(meta.Collection_Image) ||
83
+ extractMediaUrl(meta.image) ||
84
+ extractMediaUrl(meta.imageUrl) ||
85
+ extractMediaUrl(meta.photo) ||
86
+ extractMediaUrl(meta.thumbnail)
87
+ if (image) return image
88
+
89
+ // Priority 3: Collection Icon uploaded in Medusa Admin
90
+ const icon = extractMediaUrl(meta.collection_icon) || extractMediaUrl(meta.icon)
91
+ if (icon) return icon
92
+
93
+ // Priority 4: Collection thumbnail / image field
94
+ const direct = extractMediaUrl(col.thumbnail) || extractMediaUrl(col.image)
95
+ if (direct) return direct
96
+
97
+ // Priority 5: First product image in the collection
98
+ if (Array.isArray(col.products) && col.products.length > 0) {
99
+ const p = col.products[0]
100
+ const prodImg =
101
+ extractMediaUrl(p.thumbnail) ||
102
+ (Array.isArray(p.images) && p.images[0]?.url ? extractMediaUrl(p.images[0].url) : null)
103
+ if (prodImg) return prodImg
104
+ }
105
+
106
+ // Priority 6: Matching fallback image
107
+ return getMatchingFallbackImage(col.title || "", index)
108
+ }
109
+
110
+ export function resolveAdminCollections(
111
+ collections?: HttpTypes.StoreCollection[],
112
+ customItems?: VerticalCollectionSlide[]
113
+ ): VerticalCollectionSlide[] {
114
+ // If user passes explicit customItems, use them
115
+ if (customItems && customItems.length > 0) {
116
+ return customItems.map((item, index) => {
117
+ const title = (item.title || item.name || "Collection").trim()
118
+ const image =
119
+ extractMediaUrl(item.image) ||
120
+ extractMediaUrl(item.imageUrl) ||
121
+ extractMediaUrl(item.photo) ||
122
+ extractMediaUrl(item.thumbnail) ||
123
+ getMatchingFallbackImage(title, index)
124
+
125
+ return {
126
+ id: item.id || `col-${index}`,
127
+ title,
128
+ subtitle: item.subtitle || item.description || "Discover the handcrafted collection.",
129
+ eyebrow: item.eyebrow || item.tag || "COLLECTION",
130
+ buttonText: item.buttonText || item.ctaText || "DISCOVER",
131
+ buttonLink:
132
+ item.buttonLink ||
133
+ item.link ||
134
+ item.href ||
135
+ (item.handle ? `/store?collection=${item.handle}` : "/store"),
136
+ image,
137
+ imageAlt: title,
138
+ theme: item.theme || (index % 2 === 1 ? "light" : "dark"),
139
+ }
140
+ })
141
+ }
142
+
143
+ // Pure dynamic mode: Read directly from Admin Collections
144
+ if (collections && collections.length > 0) {
145
+ const valid = collections.filter((c) => c.title && c.title.trim())
146
+ const featured = valid.filter(isHomeFeatured).sort(
147
+ (a, b) => getHomeSort(a) - getHomeSort(b)
148
+ )
149
+ const source = featured.length > 0 ? featured : valid
150
+
151
+ return source.map((col, index) => {
152
+ const title = col.title.trim()
153
+ const image = getAdminCollectionImage(col, index)
154
+ const meta = getCollectionMetadata(col)
155
+
156
+ const subtitle =
157
+ readParallaxText(meta, [
158
+ "collection_subtitle",
159
+ "subtitle",
160
+ "description",
161
+ "home_parallax_description",
162
+ ]) || `Explore ${title} — handcrafted luxury by H.Ankit Jewels.`
163
+
164
+ const eyebrow =
165
+ readParallaxText(meta, ["collection_eyebrow", "eyebrow", "tag"]) ||
166
+ "COLLECTION"
167
+
168
+ const buttonText =
169
+ readParallaxText(meta, [
170
+ "collection_button_text",
171
+ "buttonText",
172
+ "ctaText",
173
+ ]) || "DISCOVER"
174
+
175
+ const themeRaw = readParallaxText(meta, [
176
+ "home_parallax_theme",
177
+ "theme",
178
+ "parallax_theme",
179
+ ])
180
+
181
+ return {
182
+ id: col.id || `col-${index}`,
183
+ title,
184
+ subtitle,
185
+ eyebrow,
186
+ buttonText,
187
+ buttonLink: `/store?collection=${col.handle || col.id}`,
188
+ image,
189
+ imageAlt: title,
190
+ theme:
191
+ themeRaw === "light"
192
+ ? "light"
193
+ : themeRaw === "dark"
194
+ ? "dark"
195
+ : index % 2 === 1
196
+ ? "light"
197
+ : "dark",
198
+ }
199
+ })
200
+ }
201
+
202
+ // Fallback high-luxury collections when Admin collections are empty
203
+ return [
204
+ {
205
+ id: "fallback-col-silver",
206
+ title: "Silver Collection",
207
+ subtitle: "Effortlessly elegant sterling silver jewellery designed for contemporary sophistication.",
208
+ eyebrow: "EXCLUSIVE COLLECTION",
209
+ buttonText: "DISCOVER",
210
+ buttonLink: "/store?collection=silver-collection",
211
+ image: "/images/collections/emerald-sapphire.jpg",
212
+ imageAlt: "Silver Collection",
213
+ theme: "dark" as const,
214
+ },
215
+ {
216
+ id: "fallback-col-lab-grown",
217
+ title: "Lab Grown Diamonds",
218
+ subtitle: "Brilliant, conflict-free diamond creations sculpted with flawless ethical precision.",
219
+ eyebrow: "SUSTAINABLE LUXURY",
220
+ buttonText: "DISCOVER",
221
+ buttonLink: "/store?collection=lab-grown-collection",
222
+ image: "/images/collections/yellow-diamond.jpg",
223
+ imageAlt: "Lab Grown Diamonds",
224
+ theme: "light" as const,
225
+ },
226
+ {
227
+ id: "fallback-col-bridal",
228
+ title: "Bridal Suite",
229
+ subtitle: "Celebrate your eternal union with handcrafted heirloom jewellery crafted to transcend time.",
230
+ eyebrow: "CELEBRATE LOVE",
231
+ buttonText: "DISCOVER",
232
+ buttonLink: "/store?collection=-bridal-collection",
233
+ image: "/images/bridal/bridal-hero.png",
234
+ imageAlt: "Bridal Suite",
235
+ theme: "dark" as const,
236
+ },
237
+ {
238
+ id: "fallback-col-gold",
239
+ title: "Gold Heritage",
240
+ subtitle: "Intricate antique gold craftsmanship echoing the grandeur of timeless royalty.",
241
+ eyebrow: "ROYAL HERITAGE",
242
+ buttonText: "DISCOVER",
243
+ buttonLink: "/store?collection=gold-collection",
244
+ image: "/images/Old_Cut_Jewelry_02_copy_1_1.webp",
245
+ imageAlt: "Gold Heritage",
246
+ theme: "light" as const,
247
+ },
248
+ {
249
+ id: "fallback-col-diamond",
250
+ title: "High Jewellery Diamonds",
251
+ subtitle: "Magnificent diamond statement pieces radiating unmatched fire, light, and brilliance.",
252
+ eyebrow: "THE APEX OF LUXURY",
253
+ buttonText: "DISCOVER",
254
+ buttonLink: "/store?collection=diamond-collection",
255
+ image: "/images/collections/white-diamond.jpg",
256
+ imageAlt: "High Jewellery Diamonds",
257
+ theme: "dark" as const,
258
+ },
259
+ ]
260
+ }
261
+
@@ -0,0 +1,398 @@
1
+ /* ==========================================================================
2
+ Segment: Graff Multiple-Layered Sticky Collection Scroll
3
+ Identical to https://www.graff.com/international-en/home/
4
+ ========================================================================== */
5
+
6
+ .graff-parallax-section {
7
+ position: relative;
8
+ width: 100%;
9
+ margin: 0;
10
+ padding: 0;
11
+ overscroll-behavior: none;
12
+ }
13
+
14
+ /* --------------------------------------------------------------------------
15
+ Sticky Stacking Slides: Each slide is sticky at top:0 with stacking z-index
16
+ As user scrolls, Slide 2 rolls UP and OVER Slide 1 seamlessly!
17
+ -------------------------------------------------------------------------- */
18
+ .graff-parallax__slide {
19
+ position: sticky;
20
+ top: 0;
21
+ left: 0;
22
+ width: 100%;
23
+ height: 100vh;
24
+ height: 100dvh;
25
+ overflow: hidden;
26
+ background-color: #06090e;
27
+ color: #ffffff;
28
+ user-select: none;
29
+ font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
30
+ box-shadow: none;
31
+ isolation: isolate;
32
+ transform: translateZ(0);
33
+ backface-visibility: hidden;
34
+ }
35
+
36
+ .graff-parallax__slide--light {
37
+ background-color: #f7f5f2;
38
+ }
39
+
40
+ .graff-parallax__slide-inner {
41
+ position: relative;
42
+ width: 100%;
43
+ height: 100%;
44
+ overflow: hidden;
45
+ display: flex;
46
+ align-items: center;
47
+ }
48
+
49
+ /* --------------------------------------------------------------------------
50
+ Background Media
51
+ -------------------------------------------------------------------------- */
52
+ .graff-parallax__bg {
53
+ position: absolute;
54
+ inset: 0;
55
+ width: 100%;
56
+ height: 100%;
57
+ z-index: 1;
58
+ overflow: hidden;
59
+ }
60
+
61
+ /* Graff: images stay fixed — no zoom on scroll or slide change */
62
+ .graff-parallax__image {
63
+ object-fit: cover;
64
+ object-position: center center;
65
+ transform: none;
66
+ animation: none;
67
+ transition: none;
68
+ }
69
+
70
+ .graff-parallax__overlay {
71
+ display: none;
72
+ }
73
+
74
+
75
+ /* --------------------------------------------------------------------------
76
+ Editorial Content & Typography (Graff Style)
77
+ -------------------------------------------------------------------------- */
78
+ .graff-parallax__content-wrap {
79
+ position: relative;
80
+ z-index: 10;
81
+ width: 100%;
82
+ max-width: var(--container-max, 1440px);
83
+ margin-left: auto;
84
+ margin-right: auto;
85
+ padding-left: 2rem;
86
+ padding-right: 5rem;
87
+ pointer-events: none;
88
+ }
89
+
90
+ @media (min-width: 768px) {
91
+ .graff-parallax__content-wrap {
92
+ padding-left: 4.5rem;
93
+ padding-right: 6rem;
94
+ }
95
+ }
96
+
97
+ @media (min-width: 1280px) {
98
+ .graff-parallax__content-wrap {
99
+ padding-left: 6.5rem;
100
+ padding-right: 7rem;
101
+ }
102
+ }
103
+
104
+ .graff-parallax__content {
105
+ max-width: 620px;
106
+ pointer-events: auto;
107
+ display: flex;
108
+ flex-direction: column;
109
+ align-items: flex-start;
110
+ opacity: 0;
111
+ visibility: hidden;
112
+ transform: none;
113
+ transition:
114
+ opacity 0.4s ease-out,
115
+ visibility 0s linear 0.4s;
116
+ }
117
+
118
+ .graff-parallax__slide.is-active .graff-parallax__content {
119
+ opacity: 1;
120
+ visibility: visible;
121
+ transition:
122
+ opacity 0.4s ease-out,
123
+ visibility 0s linear 0s;
124
+ }
125
+
126
+ /* First slide copy visible as soon as section enters view (Graff hero read) */
127
+ .graff-parallax-section.is-ready
128
+ .graff-parallax__slide:first-child.is-active
129
+ .graff-parallax__content {
130
+ opacity: 1;
131
+ visibility: visible;
132
+ }
133
+
134
+ @media (prefers-reduced-motion: reduce) {
135
+ .graff-parallax__content,
136
+ .graff-parallax__slide.is-active .graff-parallax__content {
137
+ transition: none;
138
+ opacity: 1;
139
+ visibility: visible;
140
+ }
141
+ }
142
+
143
+ /* Eyebrow tag */
144
+ .graff-parallax__eyebrow {
145
+ display: inline-block;
146
+ font-size: 0.75rem;
147
+ font-weight: 600;
148
+ letter-spacing: 0.25em;
149
+ text-transform: uppercase;
150
+ margin-bottom: 0.875rem;
151
+ }
152
+
153
+ .graff-parallax__slide--dark .graff-parallax__eyebrow {
154
+ color: #c9a86c;
155
+ }
156
+
157
+ .graff-parallax__slide--light .graff-parallax__eyebrow {
158
+ color: #8c7349;
159
+ }
160
+
161
+ /* Luxury Serif Title (Identical to Graff font styling) */
162
+ .graff-parallax__title {
163
+ font-family: var(--font-heading, "Cormorant Garamond", Georgia, serif);
164
+ font-size: 2.75rem;
165
+ line-height: 1.12;
166
+ font-weight: 400;
167
+ letter-spacing: -0.015em;
168
+ margin: 0 0 1.25rem;
169
+ text-transform: none;
170
+ font-style: normal;
171
+ word-break: break-word;
172
+ }
173
+
174
+ @media (min-width: 768px) {
175
+ .graff-parallax__title {
176
+ font-size: 3.75rem;
177
+ }
178
+ }
179
+
180
+ @media (min-width: 1280px) {
181
+ .graff-parallax__title {
182
+ font-size: 4.5rem;
183
+ }
184
+ }
185
+
186
+ .graff-parallax__slide--dark .graff-parallax__title {
187
+ color: #ffffff;
188
+ text-shadow: 0 2px 14px rgba(0, 0, 0, 0.4);
189
+ }
190
+
191
+ .graff-parallax__slide--light .graff-parallax__title {
192
+ color: #121417;
193
+ text-shadow: 0 1px 6px rgba(255, 255, 255, 0.8);
194
+ }
195
+
196
+ /* Subtitle */
197
+ .graff-parallax__subtitle {
198
+ font-family: var(--font-sans, -apple-system, sans-serif);
199
+ font-size: 1rem;
200
+ line-height: 1.65;
201
+ font-weight: 300;
202
+ margin-bottom: 2rem;
203
+ max-width: 520px;
204
+ }
205
+
206
+ @media (min-width: 768px) {
207
+ .graff-parallax__subtitle {
208
+ font-size: 1.125rem;
209
+ }
210
+ }
211
+
212
+ .graff-parallax__slide--dark .graff-parallax__subtitle {
213
+ color: rgba(255, 255, 255, 0.85);
214
+ text-shadow: 0 1px 8px rgba(0, 0, 0, 0.3);
215
+ }
216
+
217
+ .graff-parallax__slide--light .graff-parallax__subtitle {
218
+ color: #383e47;
219
+ }
220
+
221
+ /* Discover CTA Button */
222
+ .graff-parallax__btn {
223
+ display: inline-flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ padding: 0.95rem 2.25rem;
227
+ font-size: 0.8125rem;
228
+ font-weight: 500;
229
+ letter-spacing: 0.22em;
230
+ text-transform: uppercase;
231
+ text-decoration: none;
232
+ border-radius: 0;
233
+ position: relative;
234
+ overflow: hidden;
235
+ transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
236
+ box-shadow: 0 4px 18px rgba(0, 0, 0, 0.15);
237
+ }
238
+
239
+ .graff-parallax__slide--dark .graff-parallax__btn {
240
+ background: rgba(181, 148, 97, 0.92);
241
+ color: #ffffff;
242
+ border: 1px solid rgba(201, 168, 108, 0.7);
243
+ backdrop-filter: blur(8px);
244
+ }
245
+
246
+ .graff-parallax__slide--dark .graff-parallax__btn:hover {
247
+ background: #ffffff;
248
+ color: #0b0f17;
249
+ border-color: #ffffff;
250
+ transform: translateY(-2px);
251
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
252
+ }
253
+
254
+ .graff-parallax__slide--light .graff-parallax__btn {
255
+ background: #13264d;
256
+ color: #ffffff;
257
+ border: 1px solid #13264d;
258
+ }
259
+
260
+ .graff-parallax__slide--light .graff-parallax__btn:hover {
261
+ background: #0b0f17;
262
+ border-color: #0b0f17;
263
+ color: #ffffff;
264
+ transform: translateY(-2px);
265
+ box-shadow: 0 8px 22px rgba(19, 38, 77, 0.3);
266
+ }
267
+
268
+ /* --------------------------------------------------------------------------
269
+ Floating Pagination Dots on Right (Identical to Graff .b-parallax__pagination)
270
+ -------------------------------------------------------------------------- */
271
+ .graff-parallax__pagination {
272
+ position: fixed;
273
+ right: clamp(18px, 2.4vw, 30px);
274
+ top: 50%;
275
+ transform: translateY(-50%);
276
+ z-index: 1000;
277
+ display: flex;
278
+ flex-direction: column;
279
+ align-items: center;
280
+ gap: 15px;
281
+ opacity: 0;
282
+ visibility: hidden;
283
+ pointer-events: none;
284
+ transition: opacity 0.4s ease, visibility 0.4s ease;
285
+ }
286
+
287
+ .graff-parallax__pagination.is-visible {
288
+ opacity: 1;
289
+ visibility: visible;
290
+ pointer-events: auto;
291
+ }
292
+
293
+ .graff-parallax__pagination-dot {
294
+ position: relative;
295
+ flex-shrink: 0;
296
+ width: 7.5px;
297
+ height: 7.5px;
298
+ border-radius: 50%;
299
+ border: 2px solid #ffffff;
300
+ background: #ffffff;
301
+ cursor: pointer;
302
+ padding: 0;
303
+ transition:
304
+ transform 0.3s cubic-bezier(0.16, 1, 0.3, 1),
305
+ background 0.3s ease,
306
+ border-color 0.3s ease;
307
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
308
+ }
309
+
310
+ .graff-parallax__pagination-dot:hover {
311
+ transform: scale(1.15);
312
+ }
313
+
314
+ .graff-parallax__pagination-dot.is-active {
315
+ background: transparent;
316
+ border: 2px solid #ffffff;
317
+ transform: scale(1);
318
+ }
319
+
320
+ .graff-parallax__dot-tooltip {
321
+ display: none;
322
+ }
323
+
324
+ /* --------------------------------------------------------------------------
325
+ Bottom Scroll Down Indicator
326
+ -------------------------------------------------------------------------- */
327
+ .graff-parallax__scroll-hint {
328
+ position: absolute;
329
+ bottom: 2rem;
330
+ left: 50%;
331
+ transform: translateX(-50%);
332
+ z-index: 20;
333
+ display: flex;
334
+ flex-direction: column;
335
+ align-items: center;
336
+ gap: 0.5rem;
337
+ background: transparent;
338
+ border: none;
339
+ cursor: pointer;
340
+ padding: 0.5rem;
341
+ transition: opacity 0.4s ease;
342
+ }
343
+
344
+ .graff-parallax__scroll-hint-text {
345
+ font-size: 0.6875rem;
346
+ letter-spacing: 0.25em;
347
+ text-transform: uppercase;
348
+ font-weight: 500;
349
+ color: rgba(255, 255, 255, 0.7);
350
+ }
351
+
352
+ .graff-parallax__slide--light .graff-parallax__scroll-hint-text {
353
+ color: rgba(18, 20, 23, 0.7);
354
+ }
355
+
356
+ .graff-parallax__scroll-hint-line {
357
+ width: 1px;
358
+ height: 28px;
359
+ position: relative;
360
+ overflow: hidden;
361
+ background-color: rgba(255, 255, 255, 0.25);
362
+ }
363
+
364
+ .graff-parallax__slide--light .graff-parallax__scroll-hint-line {
365
+ background-color: rgba(18, 20, 23, 0.25);
366
+ }
367
+
368
+ .graff-parallax__scroll-hint-line::after {
369
+ content: "";
370
+ position: absolute;
371
+ top: 0;
372
+ left: 0;
373
+ width: 100%;
374
+ height: 40%;
375
+ background-color: #ffffff;
376
+ animation: graffParallaxScrollDrop 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
377
+ }
378
+
379
+ .graff-parallax__slide--light .graff-parallax__scroll-hint-line::after {
380
+ background-color: #121417;
381
+ }
382
+
383
+ @keyframes graffParallaxScrollDrop {
384
+ 0% {
385
+ transform: translateY(-100%);
386
+ opacity: 0;
387
+ }
388
+ 35% {
389
+ opacity: 1;
390
+ }
391
+ 75% {
392
+ opacity: 1;
393
+ }
394
+ 100% {
395
+ transform: translateY(280%);
396
+ opacity: 0;
397
+ }
398
+ }
@@ -0,0 +1,51 @@
1
+ import "./segment.css"
2
+ import React from "react"
3
+ import VerticalScrollClient from "./vertical-scroll-client"
4
+ import { resolveAdminCollections } from "./resolve-collection"
5
+ import { listCollections } from "@pradip1995/commerce-core/data/collections"
6
+ import type { VerticalCollectionScrollProps } from "./types"
7
+ import type { HttpTypes } from "@medusajs/types"
8
+
9
+ export default async function VerticalCollectionScroll({
10
+ collections: propCollections,
11
+ nav,
12
+ items,
13
+ title,
14
+ eyebrow,
15
+ ariaLabel,
16
+ }: VerticalCollectionScrollProps & {
17
+ nav?: { collections?: HttpTypes.StoreCollection[] }
18
+ }) {
19
+ let collections = propCollections || nav?.collections
20
+
21
+ // If collections are not provided in props, dynamically fetch from Medusa
22
+ if (!collections || collections.length === 0) {
23
+ try {
24
+ const res = await listCollections({
25
+ limit: "100",
26
+ fields: "id,handle,title,+metadata",
27
+ })
28
+ if (res && res.collections && res.collections.length > 0) {
29
+ collections = res.collections
30
+ }
31
+ } catch {
32
+ // Fallback handled in resolveAdminCollections
33
+ }
34
+ }
35
+
36
+ // Resolve collection slides dynamically from Admin or fallback defaults
37
+ const resolvedItems = resolveAdminCollections(collections, items)
38
+
39
+ if (resolvedItems.length === 0) return null
40
+
41
+ return (
42
+ <VerticalScrollClient
43
+ items={resolvedItems}
44
+ title={title}
45
+ eyebrow={eyebrow}
46
+ ariaLabel={ariaLabel}
47
+ />
48
+ )
49
+ }
50
+
51
+
package/src/types.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { HttpTypes } from "@medusajs/types"
2
+
3
+ export type VerticalCollectionSlide = {
4
+ id?: string
5
+ title?: string
6
+ name?: string
7
+ subtitle?: string
8
+ description?: string
9
+ eyebrow?: string
10
+ tag?: string
11
+ category?: string
12
+ buttonText?: string
13
+ ctaText?: string
14
+ buttonLink?: string
15
+ link?: string
16
+ href?: string
17
+ image?: string
18
+ imageUrl?: string
19
+ photo?: string
20
+ thumbnail?: string
21
+ banner?: string
22
+ imageAlt?: string
23
+ theme?: "dark" | "light"
24
+ handle?: string
25
+ collectionHandle?: string
26
+ accentColor?: string
27
+ }
28
+
29
+ export type VerticalCollectionScrollProps = {
30
+ items?: VerticalCollectionSlide[]
31
+ collections?: HttpTypes.StoreCollection[]
32
+ categories?: any[]
33
+ title?: string
34
+ eyebrow?: string
35
+ ariaLabel?: string
36
+ showPaginationDots?: boolean
37
+ }
@@ -0,0 +1,216 @@
1
+ "use client"
2
+
3
+ import React, { useState, useEffect, useRef } from "react"
4
+ import Image from "next/image"
5
+ import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
6
+ import type { VerticalCollectionSlide } from "./types"
7
+
8
+ export default function VerticalScrollClient({
9
+ items,
10
+ title,
11
+ eyebrow,
12
+ ariaLabel,
13
+ }: {
14
+ items: VerticalCollectionSlide[]
15
+ title?: string
16
+ eyebrow?: string
17
+ ariaLabel?: string
18
+ }) {
19
+ const [activeIndex, setActiveIndex] = useState(0)
20
+ const [isNavVisible, setIsNavVisible] = useState(false)
21
+ const [isReady, setIsReady] = useState(false)
22
+ const sectionRef = useRef<HTMLDivElement>(null)
23
+ const slideRefs = useRef<(HTMLDivElement | null)[]>([])
24
+ const activeIdxRef = useRef(0)
25
+
26
+ const count = items.length
27
+
28
+ useEffect(() => {
29
+ setIsReady(true)
30
+ }, [])
31
+
32
+ const scrollToSlide = (index: number) => {
33
+ const section = sectionRef.current
34
+ if (!section || typeof window === "undefined") return
35
+
36
+ const vh = window.innerHeight
37
+ const sectionTop = section.getBoundingClientRect().top + window.scrollY
38
+ window.scrollTo({
39
+ top: sectionTop + index * vh,
40
+ behavior: "smooth",
41
+ })
42
+ }
43
+
44
+ // Sticky stacked slides: IntersectionObserver fires for multiple slides at once.
45
+ // Use scroll position within the section (same as Graff parallax pin range).
46
+ useEffect(() => {
47
+ if (typeof window === "undefined" || count === 0) return
48
+
49
+ let raf = 0
50
+ const syncFromScroll = () => {
51
+ raf = 0
52
+ const section = sectionRef.current
53
+ if (!section) return
54
+
55
+ const rect = section.getBoundingClientRect()
56
+ const vh = window.innerHeight
57
+ if (vh <= 0) return
58
+
59
+ const sectionInView = rect.bottom > 0 && rect.top < vh
60
+ const inParallax = rect.top <= 0 && rect.bottom >= vh - 1
61
+
62
+ setIsNavVisible(sectionInView && count > 1)
63
+
64
+ const scrolled = Math.max(0, -rect.top)
65
+ const slideProgress = scrolled / vh
66
+
67
+ // Graff: 1:1 wheel scroll through pinned layers — stable index with hysteresis (no flicker).
68
+ let idx = activeIdxRef.current
69
+ if (inParallax) {
70
+ const p = slideProgress
71
+ if (p >= idx + 0.5 && idx < count - 1) {
72
+ idx += 1
73
+ } else if (idx > 0 && p < idx - 0.35) {
74
+ idx -= 1
75
+ }
76
+ if (rect.bottom <= vh + 2) {
77
+ idx = count - 1
78
+ }
79
+ } else if (sectionInView) {
80
+ idx = 0
81
+ }
82
+
83
+ activeIdxRef.current = idx
84
+ setActiveIndex(idx)
85
+ }
86
+
87
+ const onScroll = () => {
88
+ if (raf) return
89
+ raf = window.requestAnimationFrame(syncFromScroll)
90
+ }
91
+
92
+ syncFromScroll()
93
+ window.addEventListener("scroll", onScroll, { passive: true })
94
+ window.addEventListener("resize", onScroll, { passive: true })
95
+
96
+ return () => {
97
+ if (raf) window.cancelAnimationFrame(raf)
98
+ window.removeEventListener("scroll", onScroll)
99
+ window.removeEventListener("resize", onScroll)
100
+ }
101
+ }, [count])
102
+
103
+ if (count === 0) return null
104
+
105
+ return (
106
+ <section
107
+ ref={sectionRef}
108
+ className={`graff-parallax-section${isReady ? " is-ready" : ""}`}
109
+ aria-label={ariaLabel || title || "Luxury Collections Showcase"}
110
+ >
111
+ {/* Floating Right-Side Pagination Dots (Identical to Graff .b-parallax__pagination) */}
112
+ {count > 1 && (
113
+ <nav
114
+ className={`graff-parallax__pagination ${
115
+ isNavVisible ? "is-visible" : ""
116
+ }`}
117
+ aria-label="Collection slides navigation"
118
+ >
119
+ {items.map((slide, idx) => {
120
+ const isActive = idx === activeIndex
121
+ return (
122
+ <button
123
+ key={slide.id || `dot-${idx}`}
124
+ type="button"
125
+ className={`graff-parallax__pagination-dot ${
126
+ isActive ? "is-active" : ""
127
+ }`}
128
+ onClick={() => scrollToSlide(idx)}
129
+ aria-label={`Scroll to slide ${idx + 1}: ${slide.title}`}
130
+ aria-current={isActive ? "true" : undefined}
131
+ title={slide.title}
132
+ />
133
+ )
134
+ })}
135
+ </nav>
136
+ )}
137
+
138
+ {/* Multiple Layered Sticky Slides (Identical to Graff .b-parallax__container pin:true, pinSpacing:false) */}
139
+ {items.map((slide, idx) => {
140
+ const theme = slide.theme || (idx % 2 === 1 ? "light" : "dark")
141
+ const imgUrl = slide.image || "/images/collections/emerald-sapphire.jpg"
142
+ const isActive = idx === activeIndex
143
+
144
+ return (
145
+ <div
146
+ key={slide.id || `slide-${idx}`}
147
+ ref={(el) => {
148
+ slideRefs.current[idx] = el
149
+ }}
150
+ className={`graff-parallax__slide graff-parallax__slide--${theme} ${
151
+ isActive ? "is-active" : ""
152
+ }`}
153
+ style={{
154
+ zIndex: idx + 1,
155
+ }}
156
+ >
157
+ <div className="graff-parallax__slide-inner">
158
+ {/* Full Bleed Image Background */}
159
+ <div className="graff-parallax__bg">
160
+ <Image
161
+ src={imgUrl}
162
+ alt={slide.imageAlt || slide.title || "Collection image"}
163
+ fill
164
+ priority={idx <= 1}
165
+ quality={90}
166
+ sizes="100vw"
167
+ className="graff-parallax__image"
168
+ unoptimized
169
+ />
170
+ </div>
171
+
172
+ {/* Editorial Typography Content */}
173
+ <div className="graff-parallax__content-wrap">
174
+ <div className="graff-parallax__content">
175
+ {slide.eyebrow ? (
176
+ <span className="graff-parallax__eyebrow">
177
+ {slide.eyebrow}
178
+ </span>
179
+ ) : null}
180
+
181
+ <h2 className="graff-parallax__title">{slide.title}</h2>
182
+
183
+ {slide.subtitle ? (
184
+ <p className="graff-parallax__subtitle">{slide.subtitle}</p>
185
+ ) : null}
186
+
187
+ <LocalizedLink
188
+ href={slide.buttonLink || "/store"}
189
+ className="graff-parallax__btn"
190
+ >
191
+ {slide.buttonText || "DISCOVER"}
192
+ </LocalizedLink>
193
+ </div>
194
+ </div>
195
+
196
+ {/* Bottom Scroll Indicator on first slide */}
197
+ {idx === 0 && count > 1 && activeIndex === 0 && (
198
+ <button
199
+ type="button"
200
+ className="graff-parallax__scroll-hint"
201
+ onClick={() => scrollToSlide(1)}
202
+ aria-label="Scroll to next collection"
203
+ >
204
+ <span className="graff-parallax__scroll-hint-text">
205
+ SCROLL
206
+ </span>
207
+ <div className="graff-parallax__scroll-hint-line" />
208
+ </button>
209
+ )}
210
+ </div>
211
+ </div>
212
+ )
213
+ })}
214
+ </section>
215
+ )
216
+ }