blocks-dusted 0.1.12 → 0.1.13

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 (148) hide show
  1. package/package.json +1 -1
  2. package/src/installers/patchRenderBlocks.js +1 -1
  3. package/src/registry/validateTemplateManifest.js +2 -1
  4. package/templates/blocks/BannerDD790432/Component.tsx +63 -0
  5. package/templates/blocks/BannerDD790432/config.ts +54 -0
  6. package/templates/blocks/BannerDD790432/manifest.json +80 -0
  7. package/templates/blocks/Button47/Component.tsx +124 -0
  8. package/templates/blocks/Button47/config.ts +75 -0
  9. package/templates/blocks/Button47/link.ts +22 -0
  10. package/templates/blocks/Button47/manifest.json +105 -0
  11. package/templates/blocks/Button47/styles.module.scss +75 -0
  12. package/templates/blocks/DD-Accordion/Component.tsx +55 -36
  13. package/templates/blocks/DD-Accordion/config.ts +71 -59
  14. package/templates/blocks/DD-AnimText/Component.tsx +50 -32
  15. package/templates/blocks/DD-AnimText/config.ts +296 -295
  16. package/templates/blocks/DD-AsideNav/Component.tsx +323 -319
  17. package/templates/blocks/DD-Banner/Component.tsx +54 -51
  18. package/templates/blocks/DD-BookCall/Component.tsx +535 -535
  19. package/templates/blocks/DD-ButtonPop/Component.tsx +118 -117
  20. package/templates/blocks/DD-Card/Component.tsx +4 -4
  21. package/templates/blocks/DD-CardTemplate01/Component.tsx +139 -140
  22. package/templates/blocks/DD-CardsWithVariantV3/Component.tsx +472 -0
  23. package/templates/blocks/DD-CardsWithVariantV3/ElectricBorder/index.tsx +203 -0
  24. package/templates/blocks/DD-CardsWithVariantV3/ElectricBorder/styles.ts +87 -0
  25. package/templates/blocks/DD-CardsWithVariantV3/ElectricBorder/useInjectStyle.ts +39 -0
  26. package/templates/blocks/DD-CardsWithVariantV3/config.ts +326 -0
  27. package/templates/blocks/DD-CardsWithVariantV3/manifest.json +110 -0
  28. package/templates/blocks/DD-Carousel-A/Component.tsx +251 -249
  29. package/templates/blocks/DD-Carousel-B/Component.tsx +405 -403
  30. package/templates/blocks/DD-ComparisonTable/Component.tsx +255 -256
  31. package/templates/blocks/DD-Contact/Component.tsx +433 -435
  32. package/templates/blocks/DD-Counter/Component.tsx +11 -6
  33. package/templates/blocks/DD-DeliCard/Component.tsx +303 -0
  34. package/templates/blocks/DD-DeliCard/config.ts +186 -0
  35. package/templates/blocks/DD-DeliCard/manifest.json +94 -0
  36. package/templates/blocks/DD-FeatCat/Component.tsx +302 -302
  37. package/templates/blocks/DD-FeatProd/CircularGallery.tsx +1179 -0
  38. package/templates/blocks/DD-FeatProd/Component.tsx +484 -0
  39. package/templates/blocks/DD-FeatProd/config.ts +173 -0
  40. package/templates/blocks/DD-FeatProd/manifest.json +93 -0
  41. package/templates/blocks/DD-FeatServ/Component.tsx +607 -0
  42. package/templates/blocks/DD-FeatServ/config.ts +213 -0
  43. package/templates/blocks/DD-FeatServ/manifest.json +113 -0
  44. package/templates/blocks/DD-FeatStrip/Component.tsx +584 -585
  45. package/templates/blocks/DD-FluidBtn/ButtonsEyeCatching.tsx +627 -628
  46. package/templates/blocks/DD-FluidBtn/Component.tsx +46 -43
  47. package/templates/blocks/DD-GooeyInfo/Component.tsx +150 -149
  48. package/templates/blocks/DD-GooeyInfo/config.ts +136 -135
  49. package/templates/blocks/DD-Hero/Component.tsx +296 -297
  50. package/templates/blocks/DD-HeroPlas/Component.tsx +50 -0
  51. package/templates/blocks/DD-HeroPlas/ThreeScene.tsx +109 -0
  52. package/templates/blocks/DD-HeroPlas/config.ts +33 -0
  53. package/templates/blocks/DD-HeroPlas/manifest.json +88 -0
  54. package/templates/blocks/DD-HorizontalScroll/Component.tsx +937 -936
  55. package/templates/blocks/DD-Icon/Component.tsx +46 -81
  56. package/templates/blocks/DD-IconButton/Component.tsx +13 -20
  57. package/templates/blocks/DD-IdTag/Component.tsx +22 -21
  58. package/templates/blocks/DD-LineAnim/Component.tsx +42 -36
  59. package/templates/blocks/DD-LogoMarq/Component.tsx +38 -48
  60. package/templates/blocks/DD-Lottie/Component.tsx +11 -3
  61. package/templates/blocks/DD-MasonaryMedia/Component.tsx +544 -545
  62. package/templates/blocks/DD-MasonaryMedia/config.ts +389 -389
  63. package/templates/blocks/DD-Masonry/Component.tsx +654 -644
  64. package/templates/blocks/DD-Masonry/config.ts +475 -475
  65. package/templates/blocks/DD-MaterialSwatches/Component.tsx +85 -0
  66. package/templates/blocks/DD-MaterialSwatches/config.ts +232 -0
  67. package/templates/blocks/DD-MaterialSwatches/manifest.json +84 -0
  68. package/templates/blocks/DD-MaterialSwatches/material.ts +107 -0
  69. package/templates/blocks/DD-NineCanvas/Component.tsx +129 -0
  70. package/templates/blocks/DD-NineCanvas/RenderBlocks.tsx +244 -0
  71. package/templates/blocks/DD-NineCanvas/allowedCanvasBlocks.ts +6 -0
  72. package/templates/blocks/DD-NineCanvas/blockComponents.tsx +8 -0
  73. package/templates/blocks/DD-NineCanvas/canvas/CanvasGrid.tsx +316 -0
  74. package/templates/blocks/DD-NineCanvas/canvas/RadialNav.tsx +432 -0
  75. package/templates/blocks/DD-NineCanvas/canvas/canvas-panels.css +889 -0
  76. package/templates/blocks/DD-NineCanvas/canvas/icons/custom-icons.tsx +364 -0
  77. package/templates/blocks/DD-NineCanvas/config.ts +61 -0
  78. package/templates/blocks/DD-NineCanvas/manifest.json +165 -0
  79. package/templates/blocks/DD-NineCanvas/styles.scss +152 -0
  80. package/templates/blocks/DD-NineCanvas/tooltip/get-strict-context.tsx +26 -0
  81. package/templates/blocks/DD-NineCanvas/tooltip/primitives.tsx +178 -0
  82. package/templates/blocks/DD-NineCanvas/tooltip/tooltip.tsx +71 -0
  83. package/templates/blocks/DD-NineCanvas/tooltip/use-controlled-state.tsx +33 -0
  84. package/templates/blocks/DD-NineCanvas/utilities/FadeOnScroll.tsx +68 -0
  85. package/templates/blocks/DD-NineCanvas/utilities/ParallaxWrapper.tsx +157 -0
  86. package/templates/blocks/DD-NineCanvas/utilities/renderCustomCSS.ts +5 -0
  87. package/templates/blocks/DD-NineCanvas/utilities/webglCleanup.ts +27 -0
  88. package/templates/blocks/DD-PayloadAIAssistant/Component.tsx +78 -0
  89. package/templates/blocks/DD-PayloadAIAssistant/DustedBot.tsx +590 -0
  90. package/templates/blocks/DD-PayloadAIAssistant/config.ts +37 -0
  91. package/templates/blocks/DD-PayloadAIAssistant/demo.css +125 -0
  92. package/templates/blocks/DD-PayloadAIAssistant/manifest.json +93 -0
  93. package/templates/blocks/DD-PayloadAIAssistant/source-defaults.ts +17 -0
  94. package/templates/blocks/DD-PayloadAIAssistant/styles.ts +306 -0
  95. package/templates/blocks/DD-Pricing/Component.tsx +2 -3
  96. package/templates/blocks/DD-Pricing/config.ts +215 -215
  97. package/templates/blocks/DD-Process/Component.tsx +2 -2
  98. package/templates/blocks/DD-ProjHeader/Component.tsx +93 -91
  99. package/templates/blocks/DD-ProjSlider/Component.tsx +1 -1
  100. package/templates/blocks/DD-Section/Component.tsx +265 -266
  101. package/templates/blocks/DD-Separator/Component.tsx +66 -66
  102. package/templates/blocks/DD-Services/Component.tsx +176 -176
  103. package/templates/blocks/DD-ShowcaseGrid/Component.tsx +306 -307
  104. package/templates/blocks/DD-Slider-A/Component.tsx +2 -2
  105. package/templates/blocks/DD-StackCards/Component.tsx +172 -171
  106. package/templates/blocks/DD-Team/Component.tsx +247 -247
  107. package/templates/blocks/DD-TeamCards/Component.tsx +251 -251
  108. package/templates/blocks/DD-TechStack/Component.tsx +173 -174
  109. package/templates/blocks/DD-TestMarq/Component.tsx +158 -132
  110. package/templates/blocks/DD-Testim/Component.tsx +247 -247
  111. package/templates/blocks/DD-Testim2/Component.tsx +265 -256
  112. package/templates/blocks/DD-Testimonials/Component.tsx +139 -139
  113. package/templates/blocks/DD-Thanks/Component.tsx +1 -1
  114. package/templates/blocks/DD-Work/Component.tsx +318 -318
  115. package/templates/blocks/DD-Work-B/Component.tsx +294 -294
  116. package/templates/blocks/DDHG-CTASection2/Component.tsx +183 -184
  117. package/templates/blocks/DDHG-HighlightArc/Component.tsx +6 -3
  118. package/templates/blocks/DDHG-LogoMarquee/Component.tsx +45 -8
  119. package/templates/blocks/DDHG-NavCodeGrid/Component.tsx +1 -3
  120. package/templates/blocks/DDHG-ServiceDetails/ServiceDetailCard.tsx +9 -22
  121. package/templates/blocks/DDHG-ServiceHero/Component.tsx +10 -10
  122. package/templates/blocks/DDHG-TeamCards/Component.tsx +6 -35
  123. package/templates/blocks/Form/Checkbox/index.tsx +45 -0
  124. package/templates/blocks/Form/Component.tsx +163 -0
  125. package/templates/blocks/Form/Country/index.tsx +65 -0
  126. package/templates/blocks/Form/Country/options.ts +982 -0
  127. package/templates/blocks/Form/Email/index.tsx +38 -0
  128. package/templates/blocks/Form/Error/index.tsx +15 -0
  129. package/templates/blocks/Form/Message/index.tsx +13 -0
  130. package/templates/blocks/Form/Number/index.tsx +36 -0
  131. package/templates/blocks/Form/Select/index.tsx +63 -0
  132. package/templates/blocks/Form/State/index.tsx +64 -0
  133. package/templates/blocks/Form/State/options.ts +52 -0
  134. package/templates/blocks/Form/Text/index.tsx +32 -0
  135. package/templates/blocks/Form/Textarea/index.tsx +40 -0
  136. package/templates/blocks/Form/Width/index.tsx +13 -0
  137. package/templates/blocks/Form/config.ts +51 -0
  138. package/templates/blocks/Form/fields.tsx +21 -0
  139. package/templates/blocks/Form/manifest.json +168 -0
  140. package/templates/blocks/MediaBlock/Component.tsx +67 -0
  141. package/templates/blocks/MediaBlock/config.ts +14 -0
  142. package/templates/blocks/MediaBlock/manifest.json +92 -0
  143. package/templates/blocks/TransitionsDustedShowcase/Component.tsx +9 -0
  144. package/templates/blocks/TransitionsDustedShowcase/config.ts +13 -0
  145. package/templates/blocks/TransitionsDustedShowcase/manifest.json +80 -0
  146. package/templates/blocks/rawHtml/Component.tsx +25 -0
  147. package/templates/blocks/rawHtml/config.ts +56 -0
  148. package/templates/blocks/rawHtml/manifest.json +80 -0
@@ -1,936 +1,937 @@
1
- 'use client'
2
-
3
- import { useLayoutEffect, useRef, useMemo } from 'react'
4
- import Image from 'next/image'
5
- import { gsap } from 'gsap'
6
- import { ScrollTrigger } from 'gsap/ScrollTrigger'
7
- import dynamic from 'next/dynamic'
8
-
9
- const DotLottieReact = dynamic(
10
- () => import('@lottiefiles/dotlottie-react').then((m) => m.DotLottieReact),
11
- { ssr: false, loading: () => null },
12
- )
13
- import {
14
- PencilRuler,
15
- Code2,
16
- Rocket,
17
- TrendingUp,
18
- Settings,
19
- BarChart2,
20
- Users,
21
- Globe,
22
- Zap,
23
- Layers,
24
- ShieldCheck,
25
- Lightbulb,
26
- Puzzle,
27
- Star,
28
- Check,
29
- UserPlus,
30
- Send,
31
- ArrowUp,
32
- Hammer,
33
- } from 'lucide-react'
34
- import type { LucideIcon } from 'lucide-react'
35
- type Media = { url?: string | null; alt?: string | null; [key: string]: any }
36
-
37
- if (typeof window !== 'undefined') {
38
- gsap.registerPlugin(ScrollTrigger)
39
- }
40
-
41
- /* ── Types ─────────────────────────────────────────────────────── */
42
-
43
- interface TagItem {
44
- id?: string
45
- tag: string
46
- }
47
-
48
- interface SlideItem {
49
- id?: string
50
- phase?: string | null
51
- watermark?: string | null
52
- icon?: string | null
53
- accent?: 'lime' | 'purple' | null
54
- headline?: string | null
55
- sub?: string | null
56
- body?: string | null
57
- tags?: TagItem[] | null
58
- visualType?: 'wireframe' | 'codeEditor' | 'pipeline' | 'funnel' | 'automation' | null
59
- customMedia?: Media | string | null
60
- customMediaType?: 'image' | 'video' | 'lottie' | null
61
- }
62
-
63
- interface DDHorizontalScrollProps {
64
- id?: string | null
65
- sectionLabel?: string | null
66
- slides?: SlideItem[] | null
67
- sectionId?: string | null
68
- enableCustomCSS?: boolean | null
69
- customCSS?: string | null
70
- [key: string]: unknown
71
- }
72
-
73
- /* ── Accent helpers ─────────────────────────────────────────────── */
74
-
75
- const ACCENTS = {
76
- lime: { hex: '#84cc16', rgb: '132,204,22' },
77
- purple: { hex: '#9803e2', rgb: '152,3,226' },
78
- } as const
79
-
80
- type AccentKey = keyof typeof ACCENTS
81
-
82
- function getAccent(key?: string | null) {
83
- return ACCENTS[(key as AccentKey) ?? 'lime'] ?? ACCENTS.lime
84
- }
85
-
86
- /* ── Icon map ───────────────────────────────────────────────────── */
87
-
88
- const ICON_MAP: Record<string, LucideIcon> = {
89
- penRuler: PencilRuler,
90
- code2: Code2,
91
- rocket: Rocket,
92
- trendingUp: TrendingUp,
93
- settings: Settings,
94
- barChart2: BarChart2,
95
- users: Users,
96
- globe: Globe,
97
- zap: Zap,
98
- layers: Layers,
99
- shieldCheck: ShieldCheck,
100
- lightbulb: Lightbulb,
101
- puzzle: Puzzle,
102
- star: Star,
103
- }
104
-
105
- function SlideIcon({
106
- name,
107
- size = 14,
108
- style,
109
- }: {
110
- name?: string | null
111
- size?: number
112
- style?: React.CSSProperties
113
- }) {
114
- const Icon = ICON_MAP[name ?? 'star'] ?? Star
115
- return <Icon size={size} style={style} />
116
- }
117
-
118
- /* ── Visual panels ─────────────────────────────────────────────── */
119
-
120
- function WireframeVisual({ accent }: { accent: string }) {
121
- const { hex, rgb } = getAccent(accent)
122
- return (
123
- <section className="relative w-full h-full flex items-center justify-center">
124
- <div className="relative w-64 h-64 scale-80 md:scale-100">
125
- <div
126
- className="absolute inset-0 rounded-2xl border"
127
- style={{
128
- borderColor: `rgba(${rgb},0.2)`,
129
- background: `rgba(${rgb},0.03)`,
130
- }}
131
- >
132
- <div className="p-4 space-y-3">
133
- <div className="h-3 rounded-full w-3/4" style={{ background: `rgba(${rgb},0.3)` }} />
134
- <div
135
- className="h-2 rounded-full w-full"
136
- style={{ background: 'rgba(255,255,255,0.08)' }}
137
- />
138
- <div
139
- className="h-2 rounded-full w-5/6"
140
- style={{ background: 'rgba(255,255,255,0.08)' }}
141
- />
142
- <div className="grid grid-cols-2 gap-2 mt-4">
143
- <div
144
- className="h-16 rounded-lg"
145
- style={{
146
- background: `rgba(${rgb},0.08)`,
147
- border: `1px solid rgba(${rgb},0.15)`,
148
- }}
149
- />
150
- <div
151
- className="h-16 rounded-lg"
152
- style={{
153
- background: 'rgba(255,255,255,0.04)',
154
- border: '1px solid rgba(255,255,255,0.08)',
155
- }}
156
- />
157
- </div>
158
- <div
159
- className="h-8 rounded-full mt-2"
160
- style={{
161
- background: `rgba(${rgb},0.2)`,
162
- border: `1px solid rgba(${rgb},0.4)`,
163
- }}
164
- />
165
- </div>
166
- </div>
167
- {/* Cursor dot */}
168
- <div
169
- className="absolute -right-4 top-1/3 w-5 h-5 rounded-full border-2 flex items-center justify-center"
170
- style={{ borderColor: hex, background: `rgba(${rgb},0.15)` }}
171
- >
172
- <div className="w-1.5 h-1.5 rounded-full" style={{ background: hex }} />
173
- </div>
174
- {/* Grid bg */}
175
- <div
176
- className="absolute -inset-6 pointer-events-none"
177
- style={{
178
- backgroundImage: `linear-gradient(rgba(${rgb},0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(${rgb},0.05) 1px, transparent 1px)`,
179
- backgroundSize: '24px 24px',
180
- }}
181
- />
182
- </div>
183
- </section>
184
- )
185
- }
186
-
187
- function CodeEditorVisual({ accent }: { accent: string }) {
188
- const { hex, rgb } = getAccent(accent)
189
- return (
190
- <div className="relative w-full h-full flex items-center justify-center scale-80 md:scale-100">
191
- <div
192
- className="relative w-72 rounded-xl overflow-hidden"
193
- style={{ background: '#0d0d0d', border: `1px solid rgba(${rgb},0.15)` }}
194
- >
195
- {/* Title bar */}
196
- <div
197
- className="flex items-center gap-2 px-4 py-3 border-b"
198
- style={{ borderColor: 'rgba(255,255,255,0.06)' }}
199
- >
200
- <div className="w-3 h-3 rounded-full" style={{ background: '#ff5f57' }} />
201
- <div className="w-3 h-3 rounded-full" style={{ background: '#febc2e' }} />
202
- <div className="w-3 h-3 rounded-full" style={{ background: '#28c840' }} />
203
- <span
204
- className="ml-2 text-xs"
205
- style={{ color: 'rgba(255,255,255,0.3)', fontFamily: 'monospace' }}
206
- >
207
- index.tsx
208
- </span>
209
- </div>
210
- {/* Code lines */}
211
- <div className="p-4 space-y-1.5 font-mono text-xs">
212
- <div>
213
- <span style={{ color: '#9803e2' }}>const</span> <span style={{ color: hex }}>Hero</span>{' '}
214
- <span style={{ color: 'rgba(255,255,255,0.4)' }}>=</span>{' '}
215
- <span style={{ color: '#9803e2' }}>()</span>{' '}
216
- <span style={{ color: 'rgba(255,255,255,0.4)' }}>=&gt;</span>{' '}
217
- <span style={{ color: 'rgba(255,255,255,0.4)' }}>{'{'}</span>
218
- </div>
219
- <div className="pl-4">
220
- <span style={{ color: '#9803e2' }}>return</span>{' '}
221
- <span style={{ color: 'rgba(255,255,255,0.4)' }}>(</span>
222
- </div>
223
- <div className="pl-8">
224
- <span style={{ color: 'rgba(255,255,255,0.3)' }}>&lt;</span>
225
- <span style={{ color: hex }}>section</span>{' '}
226
- <span style={{ color: '#9803e2' }}>className</span>
227
- <span style={{ color: 'rgba(255,255,255,0.3)' }}>=</span>
228
- <span style={{ color: '#febc2e' }}>"hero"</span>
229
- <span style={{ color: 'rgba(255,255,255,0.3)' }}>&gt;</span>
230
- </div>
231
- <div className="pl-12">
232
- <span style={{ color: 'rgba(255,255,255,0.3)' }}>&lt;</span>
233
- <span style={{ color: hex }}>h1</span>
234
- <span style={{ color: 'rgba(255,255,255,0.3)' }}>&gt;</span>
235
- <span style={{ color: 'rgba(255,255,255,0.6)' }}>Convert.</span>
236
- <span style={{ color: 'rgba(255,255,255,0.3)' }}>&lt;/</span>
237
- <span style={{ color: hex }}>h1</span>
238
- <span style={{ color: 'rgba(255,255,255,0.3)' }}>&gt;</span>
239
- </div>
240
- <div className="pl-8">
241
- <span style={{ color: 'rgba(255,255,255,0.3)' }}>&lt;/</span>
242
- <span style={{ color: hex }}>section</span>
243
- <span style={{ color: 'rgba(255,255,255,0.3)' }}>&gt;</span>
244
- </div>
245
- <div className="pl-4">
246
- <span style={{ color: 'rgba(255,255,255,0.4)' }}>)</span>
247
- </div>
248
- <div>
249
- <span style={{ color: 'rgba(255,255,255,0.4)' }}>{'}'}</span>
250
- </div>
251
- <div className="mt-2 flex items-center gap-2">
252
- <span style={{ color: 'rgba(255,255,255,0.2)' }}>{'// '}</span>
253
- <span className="inline-block w-2 h-4 animate-pulse" style={{ background: hex }} />
254
- </div>
255
- </div>
256
- </div>
257
- </div>
258
- )
259
- }
260
-
261
- function PipelineVisual() {
262
- const steps = [
263
- { label: 'Build', status: 'done', Icon: Hammer },
264
- { label: 'Test', status: 'done', Icon: ShieldCheck },
265
- { label: 'Deploy', status: 'active', Icon: Rocket },
266
- { label: 'Live', status: 'pending', Icon: Globe },
267
- ]
268
- return (
269
- <div className="relative w-full h-full flex items-center justify-center scale-80 md:scale-100">
270
- <div className="relative w-64">
271
- {steps.map((step, i) => (
272
- <div key={i} className="flex items-center gap-3 mb-3">
273
- <div
274
- className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
275
- style={{
276
- background:
277
- step.status === 'done'
278
- ? 'rgba(132,204,22,0.15)'
279
- : step.status === 'active'
280
- ? 'rgba(152,3,226,0.2)'
281
- : 'rgba(255,255,255,0.04)',
282
- border: `1px solid ${
283
- step.status === 'done'
284
- ? 'rgba(132,204,22,0.4)'
285
- : step.status === 'active'
286
- ? 'rgba(152,3,226,0.5)'
287
- : 'rgba(255,255,255,0.1)'
288
- }`,
289
- }}
290
- >
291
- <step.Icon
292
- size={14}
293
- style={{
294
- color:
295
- step.status === 'done'
296
- ? '#84cc16'
297
- : step.status === 'active'
298
- ? '#9803e2'
299
- : 'rgba(255,255,255,0.2)',
300
- }}
301
- />
302
- </div>
303
- <div className="flex-1">
304
- <div className="flex items-center justify-between">
305
- <span
306
- className="text-sm font-semibold"
307
- style={{
308
- color:
309
- step.status === 'pending' ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.8)',
310
- fontFamily: "'Plus Jakarta Sans', sans-serif",
311
- }}
312
- >
313
- {step.label}
314
- </span>
315
- {step.status === 'done' && <Check size={12} style={{ color: '#84cc16' }} />}
316
- {step.status === 'active' && (
317
- <span className="text-xs animate-pulse" style={{ color: '#9803e2' }}>
318
- Running…
319
- </span>
320
- )}
321
- </div>
322
- <div
323
- className="h-1 rounded-full mt-1.5"
324
- style={{ background: 'rgba(255,255,255,0.06)' }}
325
- >
326
- <div
327
- className="h-full rounded-full"
328
- style={{
329
- width:
330
- step.status === 'done' ? '100%' : step.status === 'active' ? '60%' : '0%',
331
- background: step.status === 'done' ? '#84cc16' : '#9803e2',
332
- }}
333
- />
334
- </div>
335
- </div>
336
- </div>
337
- ))}
338
- </div>
339
- </div>
340
- )
341
- }
342
-
343
- function FunnelVisual({ accent }: { accent: string }) {
344
- const { hex, rgb } = getAccent(accent)
345
- const rows = [
346
- { label: 'Visitors', value: '24,800', pct: 100 },
347
- { label: 'Engaged', value: '11,200', pct: 65 },
348
- { label: 'Leads', value: '3,840', pct: 40 },
349
- { label: 'Converted', value: '960', pct: 22 },
350
- ]
351
- return (
352
- <div className="relative w-full h-full flex items-center justify-center scale-80 md:scale-100">
353
- <div className="relative w-64 space-y-3">
354
- {rows.map((row, i) => (
355
- <div key={i}>
356
- <div
357
- className="flex justify-between text-xs mb-1"
358
- style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
359
- >
360
- <span style={{ color: 'rgba(255,255,255,0.4)' }}>{row.label}</span>
361
- <span style={{ color: 'rgba(255,255,255,0.7)' }}>{row.value}</span>
362
- </div>
363
- <div className="h-2 rounded-full" style={{ background: 'rgba(255,255,255,0.06)' }}>
364
- <div
365
- className="h-full rounded-full"
366
- style={{ width: `${row.pct}%`, background: hex }}
367
- />
368
- </div>
369
- </div>
370
- ))}
371
- <div
372
- className="mt-4 p-3 rounded-xl flex items-center gap-3"
373
- style={{
374
- background: `rgba(${rgb},0.08)`,
375
- border: `1px solid rgba(${rgb},0.2)`,
376
- }}
377
- >
378
- <ArrowUp size={18} style={{ color: hex }} />
379
- <div>
380
- <div
381
- className="text-xs font-bold"
382
- style={{
383
- color: hex,
384
- fontFamily: "'Plus Jakarta Sans', sans-serif",
385
- }}
386
- >
387
- +3.2× Conversion Rate
388
- </div>
389
- <div
390
- className="text-xs"
391
- style={{
392
- color: 'rgba(255,255,255,0.3)',
393
- fontFamily: "'Plus Jakarta Sans', sans-serif",
394
- }}
395
- >
396
- vs. industry average
397
- </div>
398
- </div>
399
- </div>
400
- </div>
401
- </div>
402
- )
403
- }
404
-
405
- function AutomationVisual() {
406
- const flows = [
407
- {
408
- from: 'New Lead',
409
- to: 'CRM',
410
- Icon: UserPlus,
411
- color: '#9803e2',
412
- rgb: '152,3,226',
413
- },
414
- {
415
- from: 'CRM',
416
- to: 'Email Seq.',
417
- Icon: Send,
418
- color: '#84cc16',
419
- rgb: '132,204,22',
420
- },
421
- {
422
- from: 'Email Seq.',
423
- to: 'Sales Team',
424
- Icon: Users,
425
- color: '#9803e2',
426
- rgb: '152,3,226',
427
- },
428
- ]
429
- return (
430
- <div className="relative w-full h-full flex items-center justify-center scale-80 md:scale-100">
431
- <div className="relative w-64">
432
- {flows.map((flow, i) => (
433
- <div key={i} className="flex items-center gap-2 mb-4">
434
- <div
435
- className="px-3 py-2 rounded-lg text-xs font-semibold whitespace-nowrap"
436
- style={{
437
- background: 'rgba(255,255,255,0.05)',
438
- border: '1px solid rgba(255,255,255,0.1)',
439
- color: 'rgba(255,255,255,0.6)',
440
- fontFamily: "'Plus Jakarta Sans', sans-serif",
441
- }}
442
- >
443
- {flow.from}
444
- </div>
445
- <div className="flex-1 flex items-center gap-1">
446
- <div className="flex-1 h-px" style={{ background: `rgba(${flow.rgb},0.4)` }} />
447
- <div
448
- className="w-6 h-6 rounded-full flex items-center justify-center"
449
- style={{
450
- background: `rgba(${flow.rgb},0.15)`,
451
- border: `1px solid ${flow.color}`,
452
- }}
453
- >
454
- <flow.Icon size={10} style={{ color: flow.color }} />
455
- </div>
456
- <div className="flex-1 h-px" style={{ background: `rgba(${flow.rgb},0.4)` }} />
457
- </div>
458
- <div
459
- className="px-3 py-2 rounded-lg text-xs font-semibold whitespace-nowrap"
460
- style={{
461
- background: `rgba(${flow.rgb},0.1)`,
462
- border: `1px solid ${flow.color}40`,
463
- color: flow.color,
464
- fontFamily: "'Plus Jakarta Sans', sans-serif",
465
- }}
466
- >
467
- {flow.to}
468
- </div>
469
- </div>
470
- ))}
471
- <div
472
- className="mt-2 p-3 rounded-xl text-center"
473
- style={{
474
- background: 'rgba(152,3,226,0.08)',
475
- border: '1px solid rgba(152,3,226,0.2)',
476
- }}
477
- >
478
- <div
479
- className="text-xs font-bold flex items-center justify-center gap-1"
480
- style={{
481
- color: '#9803e2',
482
- fontFamily: "'Plus Jakarta Sans', sans-serif",
483
- }}
484
- >
485
- <Zap size={12} />
486
- Fully automated. Zero manual steps.
487
- </div>
488
- </div>
489
- </div>
490
- </div>
491
- )
492
- }
493
-
494
- const VISUAL_MAP: Record<string, React.ComponentType<{ accent: string }>> = {
495
- wireframe: WireframeVisual,
496
- codeEditor: CodeEditorVisual,
497
- pipeline: PipelineVisual as React.ComponentType<{ accent: string }>,
498
- funnel: FunnelVisual,
499
- automation: AutomationVisual as React.ComponentType<{ accent: string }>,
500
- }
501
-
502
- /* ── Slide panel ────────────────────────────────────────────────── */
503
-
504
- interface SlidePanelProps {
505
- slide: SlideItem
506
- index: number
507
- total: number
508
- panelRef: (el: HTMLDivElement | null) => void
509
- }
510
-
511
- function isLocalMediaSrc(src?: string | null): src is string {
512
- return typeof src === 'string' && src.startsWith('/api/media/file/')
513
- }
514
-
515
- function SlidePanel({ slide, index, total, panelRef }: SlidePanelProps) {
516
- const { hex, rgb } = getAccent(slide.accent)
517
- const Visual = VISUAL_MAP[slide.visualType ?? 'wireframe'] ?? WireframeVisual
518
-
519
- // Extract media URL safely
520
- const mediaUrl: string =
521
- typeof slide.customMedia === 'string' ? slide.customMedia : (slide.customMedia?.url ?? '')
522
- const safeMediaUrl = isLocalMediaSrc(mediaUrl) ? mediaUrl : ''
523
- const mediaAlt =
524
- slide.customMedia && typeof slide.customMedia !== 'string' ? (slide.customMedia.alt ?? '') : ''
525
-
526
- return (
527
- <div
528
- ref={panelRef}
529
- className={`shs-slide shs-slide--${index} relative shrink-0 h-full flex items-center overflow-hidden`}
530
- style={{ width: '100vw' }}
531
- >
532
- {/* Watermark number */}
533
- <div
534
- className="absolute top-1/2 -translate-y-1/2 right-16 font-black select-none pointer-events-none text-stone-200/40 dark:text-stone-200/5"
535
- style={{
536
- fontSize: 'clamp(120px, 18vw, 220px)',
537
-
538
- lineHeight: 1,
539
- }}
540
- >
541
- {slide.watermark}
542
- {/* {String(index + 1).padStart(2, '0')} */}
543
- </div>
544
-
545
- {/* Vertical divider */}
546
- {index < total - 1 && (
547
- <div
548
- className="absolute right-0 top-1/4 bottom-1/4 w-px"
549
- style={{
550
- background:
551
- 'linear-gradient(to bottom, transparent, rgba(255,255,255,0.08), transparent)',
552
- }}
553
- />
554
- )}
555
-
556
- <div className="relative z-10 w-full max-w-6xl mx-auto px-6 md:px-16 grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-20 items-center">
557
- {/* ── Left: content ── */}
558
- <div className="space-y-2 md:space-y-7">
559
- {/* Phase badge */}
560
- <div className="anim-el flex items-center justify-end gap-3">
561
- <div
562
- className="w-7 h-7 md:w-8 md:h-8 rounded-full flex items-center justify-center"
563
- style={{
564
- background: `rgba(${rgb},0.12)`,
565
- border: `1px solid rgba(${rgb},0.3)`,
566
- }}
567
- >
568
- <SlideIcon name={slide.icon} size={14} style={{ color: hex }} />
569
- </div>
570
- <span
571
- className="text-xs scale-90 origin-top-left font-black tracking-widest uppercase"
572
- style={{
573
- color: hex,
574
- }}
575
- >
576
- {slide.phase}
577
- </span>
578
- </div>
579
-
580
- {/* Headline */}
581
- <h2
582
- className="anim-el font-extrabold leading-none tracking-tight text-stone-900 dark:text-stone-50"
583
- style={{
584
- fontSize: 'clamp(42px, 5vw, 72px)',
585
-
586
- whiteSpace: 'pre-line',
587
- }}
588
- >
589
- {' '}
590
- <span className="relative">{(slide.headline ?? '').replace(/\\n/g, '\n')}</span>
591
- </h2>
592
-
593
- {/* Sub */}
594
- {slide.sub && (
595
- <p
596
- className="anim-el text-sm md:text-md font-semibold"
597
- style={{
598
- color: hex,
599
- }}
600
- >
601
- {' '}
602
- <span className="relative">{slide.sub}</span>
603
- </p>
604
- )}
605
-
606
- {/* Body */}
607
- {slide.body && (
608
- <p
609
- className="anim-el text-sm md:text-md leading-relaxed max-w-md text-stone-600 dark:text-stone-200"
610
- // style={{
611
- // fontFamily: "'Plus Jakarta Sans', sans-serif",
612
- // }}
613
- >
614
- {' '}
615
- <span className="relative"> {slide.body}</span>
616
- </p>
617
- )}
618
-
619
- {/* Tags */}
620
- {slide.tags && slide.tags.length > 0 && (
621
- <div className="anim-el flex flex-wrap gap-2 scale-80 md:scale-100 origin-bottom-left">
622
- {slide.tags.map((t, ti) => (
623
- <span
624
- key={t.id ?? ti}
625
- className="px-3 py-1 rounded-full text-xs font-semibold bg-lines"
626
- style={{
627
- background: `rgba(${rgb},0.08)`,
628
- border: `1px solid rgba(${rgb},0.2)`,
629
- color: hex,
630
- fontFamily: "'Plus Jakarta Sans', sans-serif",
631
- }}
632
- >
633
- {t.tag}
634
- </span>
635
- ))}
636
- </div>
637
- )}
638
- </div>
639
-
640
- {/* ── Right: visual ── */}
641
- <div
642
- className="anim-el relative h-56 md:h-80 rounded-2xl overflow-hidden flex items-center justify-center"
643
- style={{
644
- background: `linear-gradient(135deg, rgba(${rgb},0.15) 0%, rgba(0,0,0,0) 100%)`,
645
- border: `1px solid rgba(${rgb},0.5)`,
646
- }}
647
- >
648
- {/* Glow */}
649
- <div
650
- className="absolute inset-0 pointer-events-none"
651
- style={{
652
- background: `radial-gradient(ellipse at 50% 50%, rgba(${rgb},0.08) 0%, transparent 70%)`,
653
- }}
654
- />
655
- <div className="relative z-10 w-full h-full">
656
- {slide.customMedia && safeMediaUrl ? (
657
- <>
658
- {slide.customMediaType === 'image' && (
659
- <div className="relative w-full h-full bg-dots">
660
- <Image
661
- src={safeMediaUrl}
662
- alt={mediaAlt}
663
- fill
664
- sizes="(max-width: 1024px) 100vw, 50vw"
665
- className="object-cover"
666
- priority={index === 0}
667
- quality={90}
668
- />
669
- </div>
670
- )}
671
- {slide.customMediaType === 'video' && (
672
- <video
673
- src={safeMediaUrl}
674
- autoPlay
675
- loop
676
- muted
677
- playsInline
678
- preload="metadata"
679
- className="w-full h-full object-cover bg-dots"
680
- aria-label={mediaAlt || 'Video content'}
681
- />
682
- )}
683
- {slide.customMediaType === 'lottie' && (
684
- <DotLottieReact
685
- src={safeMediaUrl}
686
- loop
687
- autoplay
688
- className="w-full h-full object-cover bg-dots"
689
- />
690
- )}
691
- </>
692
- ) : (
693
- <Visual accent={slide.accent ?? 'lime'} />
694
- )}
695
- </div>
696
- </div>
697
- </div>
698
- </div>
699
- )
700
- }
701
-
702
- /* ── Main component ─────────────────────────────────────────────── */
703
-
704
- export function DDHorizontalScroll(props: DDHorizontalScrollProps) {
705
- const { sectionLabel, slides, sectionId, enableCustomCSS, customCSS } = props
706
-
707
- const outerRef = useRef<HTMLElement>(null)
708
- const wrapperRef = useRef<HTMLDivElement>(null)
709
- const trackRef = useRef<HTMLDivElement>(null)
710
- const slideRefs = useRef<(HTMLDivElement | null)[]>([])
711
- const activeSlideRef = useRef<number>(-1)
712
-
713
- // Memoize normalized slides to avoid unnecessary re-renders
714
- const normalSlides: SlideItem[] = useMemo(() => slides ?? [], [slides])
715
- const slideCount = normalSlides.length
716
-
717
- /* ── GSAP horizontal scroll + pin + snap ── */
718
- useLayoutEffect(() => {
719
- if (typeof window === 'undefined') return
720
- if (slideCount === 0) return
721
-
722
- // Store ctx outside the timer so cleanup always reaches it
723
- let ctx: ReturnType<typeof gsap.context> | null = null
724
- let initTimer: ReturnType<typeof setTimeout>
725
- let resizeTimer: ReturnType<typeof setTimeout>
726
- let rafId: number
727
-
728
- const init = () => {
729
- const outerEl = outerRef.current
730
- const wrapper = wrapperRef.current
731
- const track = trackRef.current
732
- if (!outerEl || !wrapper || !track) return
733
-
734
- // Kill any stale ScrollTriggers on this element (e.g. from Strict-Mode double mount)
735
- ScrollTrigger.getAll()
736
- .filter((st) => st.vars.trigger === outerEl)
737
- .forEach((st) => st.kill())
738
-
739
- ctx = gsap.context(() => {
740
- ScrollTrigger.create({
741
- trigger: outerEl,
742
- start: 'top top',
743
- end: () => `+=${(slideCount - 1) * window.innerWidth}`,
744
- scrub: 0.5,
745
- invalidateOnRefresh: true,
746
- onUpdate: (self) => {
747
- gsap.set(track, {
748
- x: -(slideCount - 1) * window.innerWidth * self.progress,
749
- })
750
-
751
- const newActive = Math.round(self.progress * (slideCount - 1))
752
- if (newActive !== activeSlideRef.current) {
753
- activeSlideRef.current = newActive
754
- animateSlideIn(slideRefs.current[newActive])
755
- }
756
- },
757
- })
758
- }, outerEl)
759
-
760
- // Animate first slide in on mount
761
- animateSlideIn(slideRefs.current[1])
762
- activeSlideRef.current = 1
763
-
764
- // Refresh so ScrollTrigger measures the fully-painted layout
765
- ScrollTrigger.refresh()
766
- }
767
-
768
- // Double rAF inside a timeout: waits for SSR hydration, LocomotiveScroll init
769
- // and web-font paint before measuring. 200 ms covers the loco 100 ms delay too.
770
- initTimer = setTimeout(() => {
771
- rafId = requestAnimationFrame(() => {
772
- requestAnimationFrame(init)
773
- })
774
- }, 200)
775
-
776
- // Debounced resize — recalculates pin length and slide positions
777
- const onResize = () => {
778
- clearTimeout(resizeTimer)
779
- resizeTimer = setTimeout(() => {
780
- ScrollTrigger.refresh(true)
781
- }, 150)
782
- }
783
-
784
- window.addEventListener('resize', onResize)
785
- // Also respond to locomotive:resize dispatched by PageClient
786
- window.addEventListener('locomotive:resize', onResize)
787
-
788
- return () => {
789
- clearTimeout(initTimer)
790
- clearTimeout(resizeTimer)
791
- cancelAnimationFrame(rafId)
792
- window.removeEventListener('resize', onResize)
793
- window.removeEventListener('locomotive:resize', onResize)
794
- ctx?.revert()
795
- ctx = null
796
- }
797
- }, [slideCount, sectionId])
798
-
799
- // offset next block
800
- const getOffset = () => {
801
- if (typeof window === 'undefined') return 25
802
-
803
- if (window.matchMedia('(max-width: 420px)').matches) return 550
804
- if (window.matchMedia('(max-width: 768px)').matches) return 300
805
-
806
- return 25
807
- }
808
-
809
- const offset = getOffset()
810
- return (
811
- <section
812
- ref={outerRef}
813
- id={sectionId ?? undefined}
814
- className="studio-hscrollblock relative z-30 top-0"
815
- // !!Note: First & Last child slides set to opacity 0 to rectify scroll. offset for next block entry. Use svh to offset and FirstSlide_width(!vw)
816
- style={{
817
- height: `calc((${Math.max(0, slideCount + 2)} * 100svh + 100svh) - ${offset}svh)`,
818
- }}
819
- >
820
- <div
821
- ref={wrapperRef}
822
- className="shs-wrapper sticky overflow-hidden top-0"
823
- style={{
824
- height: '100svh',
825
- }}
826
- >
827
- {/* Custom CSS */}
828
- {enableCustomCSS && customCSS && (
829
- <style
830
- dangerouslySetInnerHTML={{
831
- __html: sectionId
832
- ? customCSS.replace(/:host/g, `#${sectionId}`)
833
- : customCSS.replace(/:host/g, '.shs-wrapper'),
834
- }}
835
- />
836
- )}
837
- {/* Subtle grid overlay */}
838
- {/* <div
839
- className="absolute inset-0 pointer-events-none z-0"
840
- style={{
841
- backgroundImage: `
842
- linear-gradient(rgba(132,204,22,0.02) 1px, transparent 1px),
843
- linear-gradient(90deg, rgba(132,204,22,0.02) 1px, transparent 1px)
844
- `,
845
- backgroundSize: '60px 60px'
846
- }}
847
- /> */}
848
- {/* Section eyebrow label */}
849
- #BlockName-DDHorizontalScroll
850
- {sectionLabel && (
851
- <div className="section-label absolute top-8 left-4 z-20 flex items-center gap-3">
852
- <div className="w-6 h-px" style={{ background: '#84cc16' }} />
853
- <span
854
- className="text-[11.5px] font-semibold tracking-wide uppercase"
855
- style={{
856
- color: '#84cc16',
857
- }}
858
- >
859
- {sectionLabel}
860
- </span>
861
- </div>
862
- )}
863
- {/* Slide dot counter */}
864
- <div className="slide-counter absolute top-16 right-6 z-20 flex items-center gap-2">
865
- {normalSlides.map((_, i) => (
866
- <div
867
- key={i}
868
- className="w-1.5 h-1.5 rounded-full hidden"
869
- style={{ background: 'rgba(255,255,255,0.2)' }}
870
- />
871
- ))}
872
- </div>
873
- {/* Horizontal track */}
874
- <div
875
- ref={trackRef}
876
- className="absolute top-0 left-0 flex h-full"
877
- style={{ willChange: 'transform' }}
878
- >
879
- {normalSlides.map((slide, i) => (
880
- <SlidePanel
881
- key={slide.id ?? i}
882
- slide={slide}
883
- index={i}
884
- total={normalSlides.length - 1}
885
- panelRef={(el) => {
886
- slideRefs.current[i] = el
887
- }}
888
- />
889
- ))}
890
- </div>
891
- {/* Scroll hint */}
892
- {/* <div
893
- className="scroll-hint absolute bottom-16 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-2"
894
- style={{ color: 'rgba(255,255,255,0.2)' }}
895
- >
896
- <span
897
- className="text-xs tracking-widest uppercase"
898
- style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
899
- >
900
- Scroll to explore
901
- </span>
902
- <div className="flex gap-1">
903
- {[0, 1, 2].map((d) => (
904
- <div
905
- key={d}
906
- className="w-1 h-1 rounded-full animate-bounce"
907
- style={{ background: 'rgba(255,255,255,0.3)', animationDelay: `${d * 0.15}s` }}
908
- />
909
- ))}
910
- </div>
911
- </div> */}
912
- </div>
913
- </section>
914
- )
915
- }
916
-
917
- /* ── Entrance animation helper ──────────────────────────────────── */
918
-
919
- function animateSlideIn(el: HTMLDivElement | null) {
920
- if (!el) return
921
- const animEls = el.querySelectorAll<HTMLElement>('.anim-el')
922
- if (!animEls.length) return
923
- gsap.fromTo(
924
- animEls,
925
- { opacity: 1, y: 0 },
926
- {
927
- opacity: 1,
928
- y: 0,
929
- stagger: 0.07,
930
- duration: 0.55,
931
- ease: 'power4.out',
932
- overwrite: true,
933
- clearProps: 'transform',
934
- },
935
- )
936
- }
1
+ 'use client'
2
+
3
+ import { useLayoutEffect, useRef, useMemo } from 'react'
4
+ import Image from 'next/image'
5
+ import { gsap } from 'gsap'
6
+ import { ScrollTrigger } from 'gsap/ScrollTrigger'
7
+ import dynamic from 'next/dynamic'
8
+
9
+ const DotLottieReact = dynamic(
10
+ () => import('@lottiefiles/dotlottie-react').then((m) => m.DotLottieReact),
11
+ { ssr: false, loading: () => null },
12
+ )
13
+ import {
14
+ PencilRuler,
15
+ Code2,
16
+ Rocket,
17
+ TrendingUp,
18
+ Settings,
19
+ BarChart2,
20
+ Users,
21
+ Globe,
22
+ Zap,
23
+ Layers,
24
+ ShieldCheck,
25
+ Lightbulb,
26
+ Puzzle,
27
+ Star,
28
+ Check,
29
+ UserPlus,
30
+ Send,
31
+ ArrowUp,
32
+ Hammer,
33
+ } from 'lucide-react'
34
+ import type { LucideIcon } from 'lucide-react'
35
+ type Media = { url?: string | null; alt?: string | null; [key: string]: any }
36
+
37
+ if (typeof window !== 'undefined') {
38
+ gsap.registerPlugin(ScrollTrigger)
39
+ }
40
+
41
+ /* ── Types ─────────────────────────────────────────────────────── */
42
+
43
+ interface TagItem {
44
+ id?: string
45
+ tag: string
46
+ }
47
+
48
+ interface SlideItem {
49
+ id?: string
50
+ phase?: string | null
51
+ watermark?: string | null
52
+ icon?: string | null
53
+ accent?: 'lime' | 'purple' | null
54
+ headline?: string | null
55
+ sub?: string | null
56
+ body?: string | null
57
+ tags?: TagItem[] | null
58
+ visualType?: 'wireframe' | 'codeEditor' | 'pipeline' | 'funnel' | 'automation' | null
59
+ customMedia?: Media | string | null
60
+ customMediaType?: 'image' | 'video' | 'lottie' | null
61
+ }
62
+
63
+ interface DDHorizontalScrollProps {
64
+ id?: string | null
65
+ sectionLabel?: string | null
66
+ slides?: SlideItem[] | null
67
+ sectionId?: string | null
68
+ enableCustomCSS?: boolean | null
69
+ customCSS?: string | null
70
+ [key: string]: unknown
71
+ }
72
+
73
+ /* ── Accent helpers ─────────────────────────────────────────────── */
74
+
75
+ const ACCENTS = {
76
+ lime: { hex: '#84cc16', rgb: '132,204,22' },
77
+ purple: { hex: '#9803e2', rgb: '152,3,226' },
78
+ } as const
79
+
80
+ type AccentKey = keyof typeof ACCENTS
81
+
82
+ function getAccent(key?: string | null) {
83
+ return ACCENTS[(key as AccentKey) ?? 'lime'] ?? ACCENTS.lime
84
+ }
85
+
86
+ /* ── Icon map ───────────────────────────────────────────────────── */
87
+
88
+ const ICON_MAP: Record<string, LucideIcon> = {
89
+ penRuler: PencilRuler,
90
+ code2: Code2,
91
+ rocket: Rocket,
92
+ trendingUp: TrendingUp,
93
+ settings: Settings,
94
+ barChart2: BarChart2,
95
+ users: Users,
96
+ globe: Globe,
97
+ zap: Zap,
98
+ layers: Layers,
99
+ shieldCheck: ShieldCheck,
100
+ lightbulb: Lightbulb,
101
+ puzzle: Puzzle,
102
+ star: Star,
103
+ }
104
+
105
+ function SlideIcon({
106
+ name,
107
+ size = 14,
108
+ style,
109
+ }: {
110
+ name?: string | null
111
+ size?: number
112
+ style?: React.CSSProperties
113
+ }) {
114
+ const Icon = ICON_MAP[name ?? 'star'] ?? Star
115
+ return <Icon size={size} style={style} />
116
+ }
117
+
118
+ /* ── Visual panels ─────────────────────────────────────────────── */
119
+
120
+ function WireframeVisual({ accent }: { accent: string }) {
121
+ const { hex, rgb } = getAccent(accent)
122
+ return (
123
+ <section className="relative w-full h-full flex items-center justify-center">
124
+ <div className="relative w-64 h-64 scale-80 md:scale-100">
125
+ <div
126
+ className="absolute inset-0 rounded-2xl border"
127
+ style={{
128
+ borderColor: `rgba(${rgb},0.2)`,
129
+ background: `rgba(${rgb},0.03)`,
130
+ }}
131
+ >
132
+ <div className="p-4 space-y-3">
133
+ <div className="h-3 rounded-full w-3/4" style={{ background: `rgba(${rgb},0.3)` }} />
134
+ <div
135
+ className="h-2 rounded-full w-full"
136
+ style={{ background: 'rgba(255,255,255,0.08)' }}
137
+ />
138
+ <div
139
+ className="h-2 rounded-full w-5/6"
140
+ style={{ background: 'rgba(255,255,255,0.08)' }}
141
+ />
142
+ <div className="grid grid-cols-2 gap-2 mt-4">
143
+ <div
144
+ className="h-16 rounded-lg"
145
+ style={{
146
+ background: `rgba(${rgb},0.08)`,
147
+ border: `1px solid rgba(${rgb},0.15)`,
148
+ }}
149
+ />
150
+ <div
151
+ className="h-16 rounded-lg"
152
+ style={{
153
+ background: 'rgba(255,255,255,0.04)',
154
+ border: '1px solid rgba(255,255,255,0.08)',
155
+ }}
156
+ />
157
+ </div>
158
+ <div
159
+ className="h-8 rounded-full mt-2"
160
+ style={{
161
+ background: `rgba(${rgb},0.2)`,
162
+ border: `1px solid rgba(${rgb},0.4)`,
163
+ }}
164
+ />
165
+ </div>
166
+ </div>
167
+ {/* Cursor dot */}
168
+ <div
169
+ className="absolute -right-4 top-1/3 w-5 h-5 rounded-full border-2 flex items-center justify-center"
170
+ style={{ borderColor: hex, background: `rgba(${rgb},0.15)` }}
171
+ >
172
+ <div className="w-1.5 h-1.5 rounded-full" style={{ background: hex }} />
173
+ </div>
174
+ {/* Grid bg */}
175
+ <div
176
+ className="absolute -inset-6 pointer-events-none"
177
+ style={{
178
+ backgroundImage: `linear-gradient(rgba(${rgb},0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(${rgb},0.05) 1px, transparent 1px)`,
179
+ backgroundSize: '24px 24px',
180
+ }}
181
+ />
182
+ </div>
183
+ </section>
184
+ )
185
+ }
186
+
187
+ function CodeEditorVisual({ accent }: { accent: string }) {
188
+ const { hex, rgb } = getAccent(accent)
189
+ return (
190
+ <div className="relative w-full h-full flex items-center justify-center scale-80 md:scale-100">
191
+ <div
192
+ className="relative w-72 rounded-xl overflow-hidden"
193
+ style={{ background: '#0d0d0d', border: `1px solid rgba(${rgb},0.15)` }}
194
+ >
195
+ {/* Title bar */}
196
+ <div
197
+ className="flex items-center gap-2 px-4 py-3 border-b"
198
+ style={{ borderColor: 'rgba(255,255,255,0.06)' }}
199
+ >
200
+ <div className="w-3 h-3 rounded-full" style={{ background: '#ff5f57' }} />
201
+ <div className="w-3 h-3 rounded-full" style={{ background: '#febc2e' }} />
202
+ <div className="w-3 h-3 rounded-full" style={{ background: '#28c840' }} />
203
+ <span
204
+ className="ml-2 text-xs"
205
+ style={{ color: 'rgba(255,255,255,0.3)', fontFamily: 'monospace' }}
206
+ >
207
+ index.tsx
208
+ </span>
209
+ </div>
210
+ {/* Code lines */}
211
+ <div className="p-4 space-y-1.5 font-mono text-xs">
212
+ <div>
213
+ <span style={{ color: '#9803e2' }}>const</span> <span style={{ color: hex }}>Hero</span>{' '}
214
+ <span style={{ color: 'rgba(255,255,255,0.4)' }}>=</span>{' '}
215
+ <span style={{ color: '#9803e2' }}>()</span>{' '}
216
+ <span style={{ color: 'rgba(255,255,255,0.4)' }}>=&gt;</span>{' '}
217
+ <span style={{ color: 'rgba(255,255,255,0.4)' }}>{'{'}</span>
218
+ </div>
219
+ <div className="pl-4">
220
+ <span style={{ color: '#9803e2' }}>return</span>{' '}
221
+ <span style={{ color: 'rgba(255,255,255,0.4)' }}>(</span>
222
+ </div>
223
+ <div className="pl-8">
224
+ <span style={{ color: 'rgba(255,255,255,0.3)' }}>&lt;</span>
225
+ <span style={{ color: hex }}>section</span>{' '}
226
+ <span style={{ color: '#9803e2' }}>className</span>
227
+ <span style={{ color: 'rgba(255,255,255,0.3)' }}>=</span>
228
+ <span style={{ color: '#febc2e' }}>"hero"</span>
229
+ <span style={{ color: 'rgba(255,255,255,0.3)' }}>&gt;</span>
230
+ </div>
231
+ <div className="pl-12">
232
+ <span style={{ color: 'rgba(255,255,255,0.3)' }}>&lt;</span>
233
+ <span style={{ color: hex }}>h1</span>
234
+ <span style={{ color: 'rgba(255,255,255,0.3)' }}>&gt;</span>
235
+ <span style={{ color: 'rgba(255,255,255,0.6)' }}>Convert.</span>
236
+ <span style={{ color: 'rgba(255,255,255,0.3)' }}>&lt;/</span>
237
+ <span style={{ color: hex }}>h1</span>
238
+ <span style={{ color: 'rgba(255,255,255,0.3)' }}>&gt;</span>
239
+ </div>
240
+ <div className="pl-8">
241
+ <span style={{ color: 'rgba(255,255,255,0.3)' }}>&lt;/</span>
242
+ <span style={{ color: hex }}>section</span>
243
+ <span style={{ color: 'rgba(255,255,255,0.3)' }}>&gt;</span>
244
+ </div>
245
+ <div className="pl-4">
246
+ <span style={{ color: 'rgba(255,255,255,0.4)' }}>)</span>
247
+ </div>
248
+ <div>
249
+ <span style={{ color: 'rgba(255,255,255,0.4)' }}>{'}'}</span>
250
+ </div>
251
+ <div className="mt-2 flex items-center gap-2">
252
+ <span style={{ color: 'rgba(255,255,255,0.2)' }}>{'// '}</span>
253
+ <span className="inline-block w-2 h-4 animate-pulse" style={{ background: hex }} />
254
+ </div>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ )
259
+ }
260
+
261
+ function PipelineVisual() {
262
+ const steps = [
263
+ { label: 'Build', status: 'done', Icon: Hammer },
264
+ { label: 'Test', status: 'done', Icon: ShieldCheck },
265
+ { label: 'Deploy', status: 'active', Icon: Rocket },
266
+ { label: 'Live', status: 'pending', Icon: Globe },
267
+ ]
268
+ return (
269
+ <div className="relative w-full h-full flex items-center justify-center scale-80 md:scale-100">
270
+ <div className="relative w-64">
271
+ {steps.map((step, i) => (
272
+ <div key={i} className="flex items-center gap-3 mb-3">
273
+ <div
274
+ className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
275
+ style={{
276
+ background:
277
+ step.status === 'done'
278
+ ? 'rgba(132,204,22,0.15)'
279
+ : step.status === 'active'
280
+ ? 'rgba(152,3,226,0.2)'
281
+ : 'rgba(255,255,255,0.04)',
282
+ border: `1px solid ${
283
+ step.status === 'done'
284
+ ? 'rgba(132,204,22,0.4)'
285
+ : step.status === 'active'
286
+ ? 'rgba(152,3,226,0.5)'
287
+ : 'rgba(255,255,255,0.1)'
288
+ }`,
289
+ }}
290
+ >
291
+ <step.Icon
292
+ size={14}
293
+ style={{
294
+ color:
295
+ step.status === 'done'
296
+ ? '#84cc16'
297
+ : step.status === 'active'
298
+ ? '#9803e2'
299
+ : 'rgba(255,255,255,0.2)',
300
+ }}
301
+ />
302
+ </div>
303
+ <div className="flex-1">
304
+ <div className="flex items-center justify-between">
305
+ <span
306
+ className="text-sm font-semibold"
307
+ style={{
308
+ color:
309
+ step.status === 'pending' ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.8)',
310
+ fontFamily: "'Plus Jakarta Sans', sans-serif",
311
+ }}
312
+ >
313
+ {step.label}
314
+ </span>
315
+ {step.status === 'done' && <Check size={12} style={{ color: '#84cc16' }} />}
316
+ {step.status === 'active' && (
317
+ <span className="text-xs animate-pulse" style={{ color: '#9803e2' }}>
318
+ Running…
319
+ </span>
320
+ )}
321
+ </div>
322
+ <div
323
+ className="h-1 rounded-full mt-1.5"
324
+ style={{ background: 'rgba(255,255,255,0.06)' }}
325
+ >
326
+ <div
327
+ className="h-full rounded-full"
328
+ style={{
329
+ width:
330
+ step.status === 'done' ? '100%' : step.status === 'active' ? '60%' : '0%',
331
+ background: step.status === 'done' ? '#84cc16' : '#9803e2',
332
+ }}
333
+ />
334
+ </div>
335
+ </div>
336
+ </div>
337
+ ))}
338
+ </div>
339
+ </div>
340
+ )
341
+ }
342
+
343
+ function FunnelVisual({ accent }: { accent: string }) {
344
+ const { hex, rgb } = getAccent(accent)
345
+ const rows = [
346
+ { label: 'Visitors', value: '24,800', pct: 100 },
347
+ { label: 'Engaged', value: '11,200', pct: 65 },
348
+ { label: 'Leads', value: '3,840', pct: 40 },
349
+ { label: 'Converted', value: '960', pct: 22 },
350
+ ]
351
+ return (
352
+ <div className="relative w-full h-full flex items-center justify-center scale-80 md:scale-100">
353
+ <div className="relative w-64 space-y-3">
354
+ {rows.map((row, i) => (
355
+ <div key={i}>
356
+ <div
357
+ className="flex justify-between text-xs mb-1"
358
+ style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
359
+ >
360
+ <span style={{ color: 'rgba(255,255,255,0.4)' }}>{row.label}</span>
361
+ <span style={{ color: 'rgba(255,255,255,0.7)' }}>{row.value}</span>
362
+ </div>
363
+ <div className="h-2 rounded-full" style={{ background: 'rgba(255,255,255,0.06)' }}>
364
+ <div
365
+ className="h-full rounded-full"
366
+ style={{ width: `${row.pct}%`, background: hex }}
367
+ />
368
+ </div>
369
+ </div>
370
+ ))}
371
+ <div
372
+ className="mt-4 p-3 rounded-xl flex items-center gap-3"
373
+ style={{
374
+ background: `rgba(${rgb},0.08)`,
375
+ border: `1px solid rgba(${rgb},0.2)`,
376
+ }}
377
+ >
378
+ <ArrowUp size={18} style={{ color: hex }} />
379
+ <div>
380
+ <div
381
+ className="text-xs font-bold"
382
+ style={{
383
+ color: hex,
384
+ fontFamily: "'Plus Jakarta Sans', sans-serif",
385
+ }}
386
+ >
387
+ +3.2× Conversion Rate
388
+ </div>
389
+ <div
390
+ className="text-xs"
391
+ style={{
392
+ color: 'rgba(255,255,255,0.3)',
393
+ fontFamily: "'Plus Jakarta Sans', sans-serif",
394
+ }}
395
+ >
396
+ vs. industry average
397
+ </div>
398
+ </div>
399
+ </div>
400
+ </div>
401
+ </div>
402
+ )
403
+ }
404
+
405
+ function AutomationVisual() {
406
+ const flows = [
407
+ {
408
+ from: 'New Lead',
409
+ to: 'CRM',
410
+ Icon: UserPlus,
411
+ color: '#9803e2',
412
+ rgb: '152,3,226',
413
+ },
414
+ {
415
+ from: 'CRM',
416
+ to: 'Email Seq.',
417
+ Icon: Send,
418
+ color: '#84cc16',
419
+ rgb: '132,204,22',
420
+ },
421
+ {
422
+ from: 'Email Seq.',
423
+ to: 'Sales Team',
424
+ Icon: Users,
425
+ color: '#9803e2',
426
+ rgb: '152,3,226',
427
+ },
428
+ ]
429
+ return (
430
+ <div className="relative w-full h-full flex items-center justify-center scale-80 md:scale-100">
431
+ <div className="relative w-64">
432
+ {flows.map((flow, i) => (
433
+ <div key={i} className="flex items-center gap-2 mb-4">
434
+ <div
435
+ className="px-3 py-2 rounded-lg text-xs font-semibold whitespace-nowrap"
436
+ style={{
437
+ background: 'rgba(255,255,255,0.05)',
438
+ border: '1px solid rgba(255,255,255,0.1)',
439
+ color: 'rgba(255,255,255,0.6)',
440
+ fontFamily: "'Plus Jakarta Sans', sans-serif",
441
+ }}
442
+ >
443
+ {flow.from}
444
+ </div>
445
+ <div className="flex-1 flex items-center gap-1">
446
+ <div className="flex-1 h-px" style={{ background: `rgba(${flow.rgb},0.4)` }} />
447
+ <div
448
+ className="w-6 h-6 rounded-full flex items-center justify-center"
449
+ style={{
450
+ background: `rgba(${flow.rgb},0.15)`,
451
+ border: `1px solid ${flow.color}`,
452
+ }}
453
+ >
454
+ <flow.Icon size={10} style={{ color: flow.color }} />
455
+ </div>
456
+ <div className="flex-1 h-px" style={{ background: `rgba(${flow.rgb},0.4)` }} />
457
+ </div>
458
+ <div
459
+ className="px-3 py-2 rounded-lg text-xs font-semibold whitespace-nowrap"
460
+ style={{
461
+ background: `rgba(${flow.rgb},0.1)`,
462
+ border: `1px solid ${flow.color}40`,
463
+ color: flow.color,
464
+ fontFamily: "'Plus Jakarta Sans', sans-serif",
465
+ }}
466
+ >
467
+ {flow.to}
468
+ </div>
469
+ </div>
470
+ ))}
471
+ <div
472
+ className="mt-2 p-3 rounded-xl text-center"
473
+ style={{
474
+ background: 'rgba(152,3,226,0.08)',
475
+ border: '1px solid rgba(152,3,226,0.2)',
476
+ }}
477
+ >
478
+ <div
479
+ className="text-xs font-bold flex items-center justify-center gap-1"
480
+ style={{
481
+ color: '#9803e2',
482
+ fontFamily: "'Plus Jakarta Sans', sans-serif",
483
+ }}
484
+ >
485
+ <Zap size={12} />
486
+ Fully automated. Zero manual steps.
487
+ </div>
488
+ </div>
489
+ </div>
490
+ </div>
491
+ )
492
+ }
493
+
494
+ const VISUAL_MAP: Record<string, React.ComponentType<{ accent: string }>> = {
495
+ wireframe: WireframeVisual,
496
+ codeEditor: CodeEditorVisual,
497
+ pipeline: PipelineVisual as React.ComponentType<{ accent: string }>,
498
+ funnel: FunnelVisual,
499
+ automation: AutomationVisual as React.ComponentType<{ accent: string }>,
500
+ }
501
+
502
+ /* ── Slide panel ────────────────────────────────────────────────── */
503
+
504
+ interface SlidePanelProps {
505
+ slide: SlideItem
506
+ index: number
507
+ total: number
508
+ panelRef: (el: HTMLDivElement | null) => void
509
+ }
510
+
511
+ function isLocalMediaSrc(src?: string | null): src is string {
512
+ return typeof src === 'string' && src.startsWith('/api/media/file/')
513
+ }
514
+
515
+ function SlidePanel({ slide, index, total, panelRef }: SlidePanelProps) {
516
+ const { hex, rgb } = getAccent(slide.accent)
517
+ const Visual = VISUAL_MAP[slide.visualType ?? 'wireframe'] ?? WireframeVisual
518
+
519
+ // Extract media URL safely
520
+ const mediaUrl: string =
521
+ typeof slide.customMedia === 'string' ? slide.customMedia : (slide.customMedia?.url ?? '')
522
+ const safeMediaUrl = isLocalMediaSrc(mediaUrl) ? mediaUrl : ''
523
+ const mediaAlt =
524
+ slide.customMedia && typeof slide.customMedia !== 'string' ? (slide.customMedia.alt ?? '') : ''
525
+
526
+ return (
527
+ <div
528
+ ref={panelRef}
529
+ className={`shs-slide shs-slide--${index} relative shrink-0 h-full flex items-center overflow-hidden`}
530
+ style={{ width: '100vw' }}
531
+ >
532
+ {/* Watermark number */}
533
+ <div
534
+ className="absolute top-1/2 -translate-y-1/2 right-16 font-black select-none pointer-events-none text-stone-200/40 dark:text-stone-200/5"
535
+ style={{
536
+ fontSize: 'clamp(120px, 18vw, 220px)',
537
+
538
+ lineHeight: 1,
539
+ }}
540
+ >
541
+ {slide.watermark}
542
+ {/* {String(index + 1).padStart(2, '0')} */}
543
+ </div>
544
+
545
+ {/* Vertical divider */}
546
+ {index < total - 1 && (
547
+ <div
548
+ className="absolute right-0 top-1/4 bottom-1/4 w-px"
549
+ style={{
550
+ background:
551
+ 'linear-gradient(to bottom, transparent, rgba(255,255,255,0.08), transparent)',
552
+ }}
553
+ />
554
+ )}
555
+
556
+ <div className="relative z-10 w-full max-w-6xl mx-auto px-6 md:px-16 grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-20 items-center">
557
+ {/* ── Left: content ── */}
558
+ <div className="space-y-2 md:space-y-7">
559
+ {/* Phase badge */}
560
+ <div className="anim-el flex items-center justify-end gap-3">
561
+ <div
562
+ className="w-7 h-7 md:w-8 md:h-8 rounded-full flex items-center justify-center"
563
+ style={{
564
+ background: `rgba(${rgb},0.12)`,
565
+ border: `1px solid rgba(${rgb},0.3)`,
566
+ }}
567
+ >
568
+ <SlideIcon name={slide.icon} size={14} style={{ color: hex }} />
569
+ </div>
570
+ <span
571
+ className="text-xs scale-90 origin-top-left font-black tracking-widest uppercase"
572
+ style={{
573
+ color: hex,
574
+ }}
575
+ >
576
+ {slide.phase}
577
+ </span>
578
+ </div>
579
+
580
+ {/* Headline */}
581
+ <h2
582
+ className="anim-el font-extrabold leading-none tracking-tight text-stone-900 dark:text-stone-50"
583
+ style={{
584
+ fontSize: 'clamp(42px, 5vw, 72px)',
585
+
586
+ whiteSpace: 'pre-line',
587
+ }}
588
+ >
589
+ {' '}
590
+ <span className="relative">{(slide.headline ?? '').replace(/\\n/g, '\n')}</span>
591
+ </h2>
592
+
593
+ {/* Sub */}
594
+ {slide.sub && (
595
+ <p
596
+ className="anim-el text-sm md:text-md font-semibold"
597
+ style={{
598
+ color: hex,
599
+ }}
600
+ >
601
+ {' '}
602
+ <span className="relative">{slide.sub}</span>
603
+ </p>
604
+ )}
605
+
606
+ {/* Body */}
607
+ {slide.body && (
608
+ <p
609
+ className="anim-el text-sm md:text-md leading-relaxed max-w-md text-stone-600 dark:text-stone-200"
610
+ // style={{
611
+ // fontFamily: "'Plus Jakarta Sans', sans-serif",
612
+ // }}
613
+ >
614
+ {' '}
615
+ <span className="relative"> {slide.body}</span>
616
+ </p>
617
+ )}
618
+
619
+ {/* Tags */}
620
+ {slide.tags && slide.tags.length > 0 && (
621
+ <div className="anim-el flex flex-wrap gap-2 scale-80 md:scale-100 origin-bottom-left">
622
+ {slide.tags.map((t, ti) => (
623
+ <span
624
+ key={t.id ?? ti}
625
+ className="px-3 py-1 rounded-full text-xs font-semibold bg-lines"
626
+ style={{
627
+ background: `rgba(${rgb},0.08)`,
628
+ border: `1px solid rgba(${rgb},0.2)`,
629
+ color: hex,
630
+ fontFamily: "'Plus Jakarta Sans', sans-serif",
631
+ }}
632
+ >
633
+ {t.tag}
634
+ </span>
635
+ ))}
636
+ </div>
637
+ )}
638
+ </div>
639
+
640
+ {/* ── Right: visual ── */}
641
+ <div
642
+ className="anim-el relative h-56 md:h-80 rounded-2xl overflow-hidden flex items-center justify-center"
643
+ style={{
644
+ background: `linear-gradient(135deg, rgba(${rgb},0.15) 0%, rgba(0,0,0,0) 100%)`,
645
+ border: `1px solid rgba(${rgb},0.5)`,
646
+ }}
647
+ >
648
+ {/* Glow */}
649
+ <div
650
+ className="absolute inset-0 pointer-events-none"
651
+ style={{
652
+ background: `radial-gradient(ellipse at 50% 50%, rgba(${rgb},0.08) 0%, transparent 70%)`,
653
+ }}
654
+ />
655
+ <div className="relative z-10 w-full h-full">
656
+ {slide.customMedia && safeMediaUrl ? (
657
+ <>
658
+ {slide.customMediaType === 'image' && (
659
+ <div className="relative w-full h-full bg-dots">
660
+ <Image
661
+ src={safeMediaUrl}
662
+ alt={mediaAlt}
663
+ fill
664
+ sizes="(max-width: 1024px) 100vw, 50vw"
665
+ className="object-cover"
666
+ priority={index === 0}
667
+ quality={90}
668
+ />
669
+ </div>
670
+ )}
671
+ {slide.customMediaType === 'video' && (
672
+ <video
673
+ src={safeMediaUrl}
674
+ autoPlay
675
+ loop
676
+ muted
677
+ playsInline
678
+ preload="metadata"
679
+ className="w-full h-full object-cover bg-dots"
680
+ aria-label={mediaAlt || 'Video content'}
681
+ />
682
+ )}
683
+ {slide.customMediaType === 'lottie' && (
684
+ <DotLottieReact
685
+ src={safeMediaUrl}
686
+ loop
687
+ autoplay
688
+ className="w-full h-full object-cover bg-dots"
689
+ />
690
+ )}
691
+ </>
692
+ ) : (
693
+ <Visual accent={slide.accent ?? 'lime'} />
694
+ )}
695
+ </div>
696
+ </div>
697
+ </div>
698
+ </div>
699
+ )
700
+ }
701
+
702
+ /* ── Main component ─────────────────────────────────────────────── */
703
+
704
+ export function DDHorizontalScroll(props: DDHorizontalScrollProps) {
705
+ const { sectionLabel, slides, sectionId, enableCustomCSS, customCSS } = props
706
+
707
+ const outerRef = useRef<HTMLElement>(null)
708
+ const wrapperRef = useRef<HTMLDivElement>(null)
709
+ const trackRef = useRef<HTMLDivElement>(null)
710
+ const slideRefs = useRef<(HTMLDivElement | null)[]>([])
711
+ const activeSlideRef = useRef<number>(-1)
712
+
713
+ // Memoize normalized slides to avoid unnecessary re-renders
714
+ const normalSlides: SlideItem[] = useMemo(() => slides ?? [], [slides])
715
+ const slideCount = normalSlides.length
716
+
717
+ /* ── GSAP horizontal scroll + pin + snap ── */
718
+ useLayoutEffect(() => {
719
+ if (typeof window === 'undefined') return
720
+ if (slideCount === 0) return
721
+
722
+ // Store ctx outside the timer so cleanup always reaches it
723
+ let ctx: ReturnType<typeof gsap.context> | null = null
724
+ let initTimer: ReturnType<typeof setTimeout>
725
+ let resizeTimer: ReturnType<typeof setTimeout>
726
+ let rafId: number
727
+
728
+ const init = () => {
729
+ const outerEl = outerRef.current
730
+ const wrapper = wrapperRef.current
731
+ const track = trackRef.current
732
+ if (!outerEl || !wrapper || !track) return
733
+
734
+ // Kill any stale ScrollTriggers on this element (e.g. from Strict-Mode double mount)
735
+ ScrollTrigger.getAll()
736
+ .filter((st) => st.vars.trigger === outerEl)
737
+ .forEach((st) => st.kill())
738
+
739
+ ctx = gsap.context(() => {
740
+ ScrollTrigger.create({
741
+ trigger: outerEl,
742
+ start: 'top top',
743
+ end: () => `+=${(slideCount - 1) * window.innerWidth}`,
744
+ scrub: 0.5,
745
+ invalidateOnRefresh: true,
746
+ onUpdate: (self) => {
747
+ gsap.set(track, {
748
+ x: -(slideCount - 1) * window.innerWidth * self.progress,
749
+ })
750
+
751
+ const newActive = Math.round(self.progress * (slideCount - 1))
752
+ if (newActive !== activeSlideRef.current) {
753
+ activeSlideRef.current = newActive
754
+ animateSlideIn(slideRefs.current[newActive])
755
+ }
756
+ },
757
+ })
758
+ }, outerEl)
759
+
760
+ // Animate first slide in on mount
761
+ animateSlideIn(slideRefs.current[1])
762
+ activeSlideRef.current = 1
763
+
764
+ // Refresh so ScrollTrigger measures the fully-painted layout
765
+ ScrollTrigger.refresh()
766
+ }
767
+
768
+ // Double rAF inside a timeout: waits for SSR hydration, LocomotiveScroll init
769
+ // and web-font paint before measuring. 200 ms covers the loco 100 ms delay too.
770
+ initTimer = setTimeout(() => {
771
+ rafId = requestAnimationFrame(() => {
772
+ requestAnimationFrame(init)
773
+ })
774
+ }, 200)
775
+
776
+ // Debounced resize — recalculates pin length and slide positions
777
+ const onResize = () => {
778
+ clearTimeout(resizeTimer)
779
+ resizeTimer = setTimeout(() => {
780
+ ScrollTrigger.refresh(true)
781
+ }, 150)
782
+ }
783
+
784
+ window.addEventListener('resize', onResize)
785
+ // Also respond to locomotive:resize dispatched by PageClient
786
+ window.addEventListener('locomotive:resize', onResize)
787
+
788
+ return () => {
789
+ clearTimeout(initTimer)
790
+ clearTimeout(resizeTimer)
791
+ cancelAnimationFrame(rafId)
792
+ window.removeEventListener('resize', onResize)
793
+ window.removeEventListener('locomotive:resize', onResize)
794
+ ctx?.revert()
795
+ ctx = null
796
+ }
797
+ }, [slideCount, sectionId])
798
+
799
+ // offset next block
800
+ const getOffset = () => {
801
+ if (typeof window === 'undefined') return 25
802
+
803
+ if (window.matchMedia('(max-width: 420px)').matches) return 550
804
+ if (window.matchMedia('(max-width: 768px)').matches) return 300
805
+
806
+ return 25
807
+ }
808
+
809
+ const offset = getOffset()
810
+ return (
811
+ <section
812
+ ref={outerRef}
813
+ id={sectionId ?? undefined}
814
+ className="studio-hscrollblock relative z-30 top-0 min-w-full w-full"
815
+ // !!Note: First & Last child slides set to opacity 0 to rectify scroll. offset for next block entry. Use svh to offset and FirstSlide_width(!vw)
816
+ style={{
817
+ height: `calc((${Math.max(0, slideCount + 2)} * 100svh + 100svh) - ${offset}svh)`,
818
+ }}
819
+ >
820
+ <style>{'body:has(.studio-hscrollblock) { overflow-x: clip; }'}</style>
821
+ <div
822
+ ref={wrapperRef}
823
+ className="shs-wrapper sticky overflow-hidden top-0"
824
+ style={{
825
+ height: '100svh',
826
+ }}
827
+ >
828
+ {/* Custom CSS */}
829
+ {enableCustomCSS && customCSS && (
830
+ <style
831
+ dangerouslySetInnerHTML={{
832
+ __html: sectionId
833
+ ? customCSS.replace(/:host/g, `#${sectionId}`)
834
+ : customCSS.replace(/:host/g, '.shs-wrapper'),
835
+ }}
836
+ />
837
+ )}
838
+ {/* Subtle grid overlay */}
839
+ {/* <div
840
+ className="absolute inset-0 pointer-events-none z-0"
841
+ style={{
842
+ backgroundImage: `
843
+ linear-gradient(rgba(132,204,22,0.02) 1px, transparent 1px),
844
+ linear-gradient(90deg, rgba(132,204,22,0.02) 1px, transparent 1px)
845
+ `,
846
+ backgroundSize: '60px 60px'
847
+ }}
848
+ /> */}
849
+ {/* Section eyebrow label */}
850
+
851
+ {sectionLabel && (
852
+ <div className="section-label absolute top-8 left-4 z-20 flex items-center gap-3">
853
+ <div className="w-6 h-px" style={{ background: '#84cc16' }} />
854
+ <span
855
+ className="text-[11.5px] font-semibold tracking-wide uppercase"
856
+ style={{
857
+ color: '#84cc16',
858
+ }}
859
+ >
860
+ {sectionLabel}
861
+ </span>
862
+ </div>
863
+ )}
864
+ {/* Slide dot counter */}
865
+ <div className="slide-counter absolute top-16 right-6 z-20 flex items-center gap-2">
866
+ {normalSlides.map((_, i) => (
867
+ <div
868
+ key={i}
869
+ className="w-1.5 h-1.5 rounded-full hidden"
870
+ style={{ background: 'rgba(255,255,255,0.2)' }}
871
+ />
872
+ ))}
873
+ </div>
874
+ {/* Horizontal track */}
875
+ <div
876
+ ref={trackRef}
877
+ className="absolute top-0 left-0 flex h-full"
878
+ style={{ willChange: 'transform' }}
879
+ >
880
+ {normalSlides.map((slide, i) => (
881
+ <SlidePanel
882
+ key={slide.id ?? i}
883
+ slide={slide}
884
+ index={i}
885
+ total={normalSlides.length - 1}
886
+ panelRef={(el) => {
887
+ slideRefs.current[i] = el
888
+ }}
889
+ />
890
+ ))}
891
+ </div>
892
+ {/* Scroll hint */}
893
+ {/* <div
894
+ className="scroll-hint absolute bottom-16 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-2"
895
+ style={{ color: 'rgba(255,255,255,0.2)' }}
896
+ >
897
+ <span
898
+ className="text-xs tracking-widest uppercase"
899
+ style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
900
+ >
901
+ Scroll to explore
902
+ </span>
903
+ <div className="flex gap-1">
904
+ {[0, 1, 2].map((d) => (
905
+ <div
906
+ key={d}
907
+ className="w-1 h-1 rounded-full animate-bounce"
908
+ style={{ background: 'rgba(255,255,255,0.3)', animationDelay: `${d * 0.15}s` }}
909
+ />
910
+ ))}
911
+ </div>
912
+ </div> */}
913
+ </div>
914
+ </section>
915
+ )
916
+ }
917
+
918
+ /* ── Entrance animation helper ──────────────────────────────────── */
919
+
920
+ function animateSlideIn(el: HTMLDivElement | null) {
921
+ if (!el) return
922
+ const animEls = el.querySelectorAll<HTMLElement>('.anim-el')
923
+ if (!animEls.length) return
924
+ gsap.fromTo(
925
+ animEls,
926
+ { opacity: 1, y: 0 },
927
+ {
928
+ opacity: 1,
929
+ y: 0,
930
+ stagger: 0.07,
931
+ duration: 0.55,
932
+ ease: 'power4.out',
933
+ overwrite: true,
934
+ clearProps: 'transform',
935
+ },
936
+ )
937
+ }