blocks-dusted 0.1.0

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