blocks-dusted 0.1.11 → 0.1.12

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 (46) hide show
  1. package/package.json +5 -2
  2. package/src/cli.js +39 -3
  3. package/src/commands/shop.js +59 -0
  4. package/src/commands/warehouse.js +73 -0
  5. package/templates/blocks/DD-BookCall/Component.tsx +535 -534
  6. package/templates/blocks/DD-CardTemplate01/Component.tsx +140 -139
  7. package/templates/blocks/DD-Carousel-A/Component.tsx +1 -1
  8. package/templates/blocks/DD-Carousel-B/Component.tsx +1 -1
  9. package/templates/blocks/DD-Chip/Component.tsx +14 -14
  10. package/templates/blocks/DD-ComparisonTable/Component.tsx +1 -1
  11. package/templates/blocks/DD-Contact/Component.tsx +8 -7
  12. package/templates/blocks/DD-Counter/Component.tsx +10 -10
  13. package/templates/blocks/DD-FeatCat/Component.tsx +1 -1
  14. package/templates/blocks/DD-FeatStrip/Component.tsx +1 -1
  15. package/templates/blocks/DD-Hero/Component.tsx +297 -320
  16. package/templates/blocks/DD-HorizontalScroll/Component.tsx +936 -938
  17. package/templates/blocks/DD-IframeMedia/Component.tsx +14 -10
  18. package/templates/blocks/DD-MasonaryMedia/Component.tsx +8 -10
  19. package/templates/blocks/DD-Pricing/Component.tsx +369 -372
  20. package/templates/blocks/DD-Process/Component.tsx +147 -149
  21. package/templates/blocks/DD-Progress/Component.tsx +156 -141
  22. package/templates/blocks/DD-ProjSlider/config.ts +2 -2
  23. package/templates/blocks/DD-Section/Component.tsx +1 -1
  24. package/templates/blocks/DD-Services/Component.tsx +1 -1
  25. package/templates/blocks/DD-ShowcaseGrid/Component.tsx +13 -13
  26. package/templates/blocks/DD-Slider-A/Component.tsx +237 -237
  27. package/templates/blocks/DD-StackCards/Component.tsx +473 -473
  28. package/templates/blocks/DD-Team/Component.tsx +1 -1
  29. package/templates/blocks/DD-TechStack/Component.tsx +22 -28
  30. package/templates/blocks/DD-Testimonials/Component.tsx +35 -43
  31. package/templates/blocks/DD-TextMarq/Component.tsx +157 -157
  32. package/templates/blocks/DD-Thanks/Component.tsx +501 -500
  33. package/templates/blocks/DD-Work/Component.tsx +4 -4
  34. package/templates/blocks/DD-Work-B/Component.tsx +1 -1
  35. package/templates/blocks/DDHG-CTASection2/Component.tsx +6 -6
  36. package/templates/blocks/DDHG-HighlightArc/Component.tsx +243 -243
  37. package/templates/blocks/DDHG-LogoMarquee/Component.tsx +143 -93
  38. package/templates/blocks/DDHG-LogoMarquee/config.ts +154 -66
  39. package/templates/blocks/DDHG-NavCodeGrid/Component.tsx +227 -213
  40. package/templates/blocks/DDHG-ProcessSec/Component.tsx +38 -39
  41. package/templates/blocks/DDHG-ServiceDetails/Component.tsx +11 -11
  42. package/templates/blocks/DDHG-ServiceDetails/ServiceDetailCard.tsx +24 -28
  43. package/templates/blocks/DDHG-ServiceHero/Component.tsx +1 -1
  44. package/templates/blocks/DDHG-ServiceOverview/Component.tsx +23 -23
  45. package/templates/blocks/DDHG-StatsFAQ/Component.tsx +40 -40
  46. package/templates/blocks/DDHG-TeamCards/Component.tsx +169 -131
@@ -1,938 +1,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/') || src.startsWith('/assets/') || src.startsWith('/logo') || src.startsWith('/media/') || src.startsWith('data:'))
513
- }
514
-
515
-
516
- function SlidePanel({ slide, index, total, panelRef }: SlidePanelProps) {
517
- const { hex, rgb } = getAccent(slide.accent)
518
- const Visual = VISUAL_MAP[slide.visualType ?? 'wireframe'] ?? WireframeVisual
519
-
520
- // Extract media URL safely
521
- const mediaUrl: string =
522
- typeof slide.customMedia === 'string' ? slide.customMedia : (slide.customMedia?.url ?? '')
523
- const safeMediaUrl = isLocalMediaSrc(mediaUrl) ? mediaUrl : ''
524
- const mediaAlt =
525
- slide.customMedia && typeof slide.customMedia !== 'string' ? (slide.customMedia.alt ?? '') : ''
526
-
527
- return (
528
- <div
529
- ref={panelRef}
530
- className={`shs-slide shs-slide--${index} relative shrink-0 h-full flex items-center overflow-hidden`}
531
- style={{ width: '100vw' }}
532
- >
533
- {/* Watermark number */}
534
- <div
535
- 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"
536
- style={{
537
- fontSize: 'clamp(120px, 18vw, 220px)',
538
-
539
- lineHeight: 1,
540
- }}
541
- >
542
- {slide.watermark}
543
- {/* {String(index + 1).padStart(2, '0')} */}
544
- </div>
545
-
546
- {/* Vertical divider */}
547
- {index < total - 1 && (
548
- <div
549
- className="absolute right-0 top-1/4 bottom-1/4 w-px"
550
- style={{
551
- background:
552
- 'linear-gradient(to bottom, transparent, rgba(255,255,255,0.08), transparent)',
553
- }}
554
- />
555
- )}
556
-
557
- <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">
558
- {/* ── Left: content ── */}
559
- <div className="space-y-2 md:space-y-7">
560
- {/* Phase badge */}
561
- <div className="anim-el flex items-center justify-end gap-3">
562
- <div
563
- className="w-7 h-7 md:w-8 md:h-8 rounded-full flex items-center justify-center"
564
- style={{
565
- background: `rgba(${rgb},0.12)`,
566
- border: `1px solid rgba(${rgb},0.3)`,
567
- }}
568
- >
569
- <SlideIcon name={slide.icon} size={14} style={{ color: hex }} />
570
- </div>
571
- <span
572
- className="text-xs scale-90 origin-top-left font-black tracking-widest uppercase"
573
- style={{
574
- color: hex,
575
- }}
576
- >
577
- {slide.phase}
578
- </span>
579
- </div>
580
-
581
- {/* Headline */}
582
- <h2
583
- className="anim-el font-extrabold leading-none tracking-tight text-stone-900 dark:text-stone-50"
584
- style={{
585
- fontSize: 'clamp(42px, 5vw, 72px)',
586
-
587
- whiteSpace: 'pre-line',
588
- }}
589
- >
590
- {' '}
591
- <span className="relative">{(slide.headline ?? '').replace(/\\n/g, '\n')}</span>
592
- </h2>
593
-
594
- {/* Sub */}
595
- {slide.sub && (
596
- <p
597
- className="anim-el text-sm md:text-md font-semibold"
598
- style={{
599
- color: hex,
600
- }}
601
- >
602
- {' '}
603
- <span className="relative">{slide.sub}</span>
604
- </p>
605
- )}
606
-
607
- {/* Body */}
608
- {slide.body && (
609
- <p
610
- className="anim-el text-sm md:text-md leading-relaxed max-w-md text-stone-600 dark:text-stone-200"
611
- // style={{
612
- // fontFamily: "'Plus Jakarta Sans', sans-serif",
613
- // }}
614
- >
615
- {' '}
616
- <span className="relative"> {slide.body}</span>
617
- </p>
618
- )}
619
-
620
- {/* Tags */}
621
- {slide.tags && slide.tags.length > 0 && (
622
- <div className="anim-el flex flex-wrap gap-2 scale-80 md:scale-100 origin-bottom-left">
623
- {slide.tags.map((t, ti) => (
624
- <span
625
- key={t.id ?? ti}
626
- className="px-3 py-1 rounded-full text-xs font-semibold bg-lines"
627
- style={{
628
- background: `rgba(${rgb},0.08)`,
629
- border: `1px solid rgba(${rgb},0.2)`,
630
- color: hex,
631
- fontFamily: "'Plus Jakarta Sans', sans-serif",
632
- }}
633
- >
634
- {t.tag}
635
- </span>
636
- ))}
637
- </div>
638
- )}
639
- </div>
640
-
641
- {/* ── Right: visual ── */}
642
- <div
643
- className="anim-el relative h-56 md:h-80 rounded-2xl overflow-hidden flex items-center justify-center"
644
- style={{
645
- background: `linear-gradient(135deg, rgba(${rgb},0.15) 0%, rgba(0,0,0,0) 100%)`,
646
- border: `1px solid rgba(${rgb},0.5)`,
647
- }}
648
- >
649
- {/* Glow */}
650
- <div
651
- className="absolute inset-0 pointer-events-none"
652
- style={{
653
- background: `radial-gradient(ellipse at 50% 50%, rgba(${rgb},0.08) 0%, transparent 70%)`,
654
- }}
655
- />
656
- <div className="relative z-10 w-full h-full">
657
- {slide.customMedia && safeMediaUrl ? (
658
- <>
659
- {slide.customMediaType === 'image' && (
660
- <div className="relative w-full h-full bg-dots">
661
- <Image
662
- src={safeMediaUrl}
663
- alt={mediaAlt}
664
- fill
665
- sizes="(max-width: 1024px) 100vw, 50vw"
666
- className="object-cover"
667
- priority={index === 0}
668
- quality={90}
669
- />
670
- </div>
671
- )}
672
- {slide.customMediaType === 'video' && (
673
- <video
674
- src={safeMediaUrl}
675
- autoPlay
676
- loop
677
- muted
678
- playsInline
679
- preload="metadata"
680
- className="w-full h-full object-cover bg-dots"
681
- aria-label={mediaAlt || 'Video content'}
682
- />
683
- )}
684
- {slide.customMediaType === 'lottie' && (
685
- <DotLottieReact
686
- src={safeMediaUrl}
687
- loop
688
- autoplay
689
- className="w-full h-full object-cover bg-dots"
690
- />
691
- )}
692
- </>
693
- ) : (
694
- <Visual accent={slide.accent ?? 'lime'} />
695
- )}
696
- </div>
697
- </div>
698
- </div>
699
- </div>
700
- )
701
- }
702
-
703
- /* ── Main component ─────────────────────────────────────────────── */
704
-
705
- export function DDHorizontalScroll(props: DDHorizontalScrollProps) {
706
- const { sectionLabel, slides, sectionId, enableCustomCSS, customCSS } = props
707
-
708
- const outerRef = useRef<HTMLElement>(null)
709
- const wrapperRef = useRef<HTMLDivElement>(null)
710
- const trackRef = useRef<HTMLDivElement>(null)
711
- const slideRefs = useRef<(HTMLDivElement | null)[]>([])
712
- const activeSlideRef = useRef<number>(-1)
713
-
714
- // Memoize normalized slides to avoid unnecessary re-renders
715
- const normalSlides: SlideItem[] = useMemo(() => slides ?? [], [slides])
716
- const slideCount = normalSlides.length
717
-
718
-
719
- /* ── GSAP horizontal scroll + pin + snap ── */
720
- useLayoutEffect(() => {
721
- if (typeof window === 'undefined') return
722
- if (slideCount === 0) return
723
-
724
- // Store ctx outside the timer so cleanup always reaches it
725
- let ctx: ReturnType<typeof gsap.context> | null = null
726
- let initTimer: ReturnType<typeof setTimeout>
727
- let resizeTimer: ReturnType<typeof setTimeout>
728
- let rafId: number
729
-
730
- const init = () => {
731
- const outerEl = outerRef.current
732
- const wrapper = wrapperRef.current
733
- const track = trackRef.current
734
- if (!outerEl || !wrapper || !track) return
735
-
736
- // Kill any stale ScrollTriggers on this element (e.g. from Strict-Mode double mount)
737
- ScrollTrigger.getAll()
738
- .filter((st) => st.vars.trigger === outerEl)
739
- .forEach((st) => st.kill())
740
-
741
- ctx = gsap.context(() => {
742
- ScrollTrigger.create({
743
- trigger: outerEl,
744
- start: 'top top',
745
- end: () => `+=${(slideCount - 1) * window.innerWidth}`,
746
- scrub: 0.5,
747
- invalidateOnRefresh: true,
748
- onUpdate: (self) => {
749
- gsap.set(track, {
750
- x: -(slideCount - 1) * window.innerWidth * self.progress,
751
- })
752
-
753
- const newActive = Math.round(self.progress * (slideCount - 1))
754
- if (newActive !== activeSlideRef.current) {
755
- activeSlideRef.current = newActive
756
- animateSlideIn(slideRefs.current[newActive])
757
- }
758
- },
759
- })
760
- }, outerEl)
761
-
762
- // Animate first slide in on mount
763
- animateSlideIn(slideRefs.current[1])
764
- activeSlideRef.current = 1
765
-
766
- // Refresh so ScrollTrigger measures the fully-painted layout
767
- ScrollTrigger.refresh()
768
- }
769
-
770
- // Double rAF inside a timeout: waits for SSR hydration, LocomotiveScroll init
771
- // and web-font paint before measuring. 200 ms covers the loco 100 ms delay too.
772
- initTimer = setTimeout(() => {
773
- rafId = requestAnimationFrame(() => {
774
- requestAnimationFrame(init)
775
- })
776
- }, 200)
777
-
778
- // Debounced resize — recalculates pin length and slide positions
779
- const onResize = () => {
780
- clearTimeout(resizeTimer)
781
- resizeTimer = setTimeout(() => {
782
- ScrollTrigger.refresh(true)
783
- }, 150)
784
- }
785
-
786
- window.addEventListener('resize', onResize)
787
- // Also respond to locomotive:resize dispatched by PageClient
788
- window.addEventListener('locomotive:resize', onResize)
789
-
790
- return () => {
791
- clearTimeout(initTimer)
792
- clearTimeout(resizeTimer)
793
- cancelAnimationFrame(rafId)
794
- window.removeEventListener('resize', onResize)
795
- window.removeEventListener('locomotive:resize', onResize)
796
- ctx?.revert()
797
- ctx = null
798
- }
799
- }, [slideCount, sectionId])
800
-
801
- // offset next block
802
- const getOffset = () => {
803
- if (typeof window === 'undefined') return 25
804
-
805
- if (window.matchMedia('(max-width: 420px)').matches) return 550
806
- if (window.matchMedia('(max-width: 768px)').matches) return 300
807
-
808
- return 25
809
- }
810
-
811
- const offset = getOffset()
812
- return (
813
- <section
814
- ref={outerRef}
815
- id={sectionId ?? undefined}
816
- className="studio-hscrollblock relative z-30 top-0"
817
- // !!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)
818
- style={{
819
- height: `calc((${Math.max(0, slideCount + 2)} * 100svh + 100svh) - ${offset}svh)`,
820
- }}
821
- >
822
- <div
823
- ref={wrapperRef}
824
- className="shs-wrapper sticky overflow-hidden top-0"
825
- style={{
826
- height: '100svh',
827
- }}
828
- >
829
- {/* Custom CSS */}
830
- {enableCustomCSS && customCSS && (
831
- <style
832
- dangerouslySetInnerHTML={{
833
- __html: sectionId
834
- ? customCSS.replace(/:host/g, `#${sectionId}`)
835
- : customCSS.replace(/:host/g, '.shs-wrapper'),
836
- }}
837
- />
838
- )}
839
- {/* Subtle grid overlay */}
840
- {/* <div
841
- className="absolute inset-0 pointer-events-none z-0"
842
- style={{
843
- backgroundImage: `
844
- linear-gradient(rgba(132,204,22,0.02) 1px, transparent 1px),
845
- linear-gradient(90deg, rgba(132,204,22,0.02) 1px, transparent 1px)
846
- `,
847
- backgroundSize: '60px 60px'
848
- }}
849
- /> */}
850
- {/* Section eyebrow label */}
851
-
852
- {sectionLabel && (
853
- <div className="section-label absolute top-8 left-4 z-20 flex items-center gap-3">
854
- <div className="w-6 h-px" style={{ background: '#84cc16' }} />
855
- <span
856
- className="text-[11.5px] font-semibold tracking-wide uppercase"
857
- style={{
858
- color: '#84cc16',
859
- }}
860
- >
861
- {sectionLabel}
862
- </span>
863
- </div>
864
- )}
865
- {/* Slide dot counter */}
866
- <div className="slide-counter absolute top-16 right-6 z-20 flex items-center gap-2">
867
- {normalSlides.map((_, i) => (
868
- <div
869
- key={i}
870
- className="w-1.5 h-1.5 rounded-full hidden"
871
- style={{ background: 'rgba(255,255,255,0.2)' }}
872
- />
873
- ))}
874
- </div>
875
- {/* Horizontal track */}
876
- <div
877
- ref={trackRef}
878
- className="absolute top-0 left-0 flex h-full"
879
- style={{ willChange: 'transform' }}
880
- >
881
- {normalSlides.map((slide, i) => (
882
- <SlidePanel
883
- key={slide.id ?? i}
884
- slide={slide}
885
- index={i}
886
- total={normalSlides.length - 1}
887
- panelRef={(el) => {
888
- slideRefs.current[i] = el
889
- }}
890
- />
891
- ))}
892
- </div>
893
- {/* Scroll hint */}
894
- {/* <div
895
- className="scroll-hint absolute bottom-16 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-2"
896
- style={{ color: 'rgba(255,255,255,0.2)' }}
897
- >
898
- <span
899
- className="text-xs tracking-widest uppercase"
900
- style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}
901
- >
902
- Scroll to explore
903
- </span>
904
- <div className="flex gap-1">
905
- {[0, 1, 2].map((d) => (
906
- <div
907
- key={d}
908
- className="w-1 h-1 rounded-full animate-bounce"
909
- style={{ background: 'rgba(255,255,255,0.3)', animationDelay: `${d * 0.15}s` }}
910
- />
911
- ))}
912
- </div>
913
- </div> */}
914
- </div>
915
- </section>
916
- )
917
- }
918
-
919
- /* ── Entrance animation helper ──────────────────────────────────── */
920
-
921
- function animateSlideIn(el: HTMLDivElement | null) {
922
- if (!el) return
923
- const animEls = el.querySelectorAll<HTMLElement>('.anim-el')
924
- if (!animEls.length) return
925
- gsap.fromTo(
926
- animEls,
927
- { opacity: 1, y: 0 },
928
- {
929
- opacity: 1,
930
- y: 0,
931
- stagger: 0.07,
932
- duration: 0.55,
933
- ease: 'power4.out',
934
- overwrite: true,
935
- clearProps: 'transform',
936
- },
937
- )
938
- }
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
+ }