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.
- package/package.json +5 -2
- package/src/cli.js +39 -3
- package/src/commands/shop.js +59 -0
- package/src/commands/warehouse.js +73 -0
- package/templates/blocks/DD-BookCall/Component.tsx +535 -534
- package/templates/blocks/DD-CardTemplate01/Component.tsx +140 -139
- package/templates/blocks/DD-Carousel-A/Component.tsx +1 -1
- package/templates/blocks/DD-Carousel-B/Component.tsx +1 -1
- package/templates/blocks/DD-Chip/Component.tsx +14 -14
- package/templates/blocks/DD-ComparisonTable/Component.tsx +1 -1
- package/templates/blocks/DD-Contact/Component.tsx +8 -7
- package/templates/blocks/DD-Counter/Component.tsx +10 -10
- package/templates/blocks/DD-FeatCat/Component.tsx +1 -1
- package/templates/blocks/DD-FeatStrip/Component.tsx +1 -1
- package/templates/blocks/DD-Hero/Component.tsx +297 -320
- package/templates/blocks/DD-HorizontalScroll/Component.tsx +936 -938
- package/templates/blocks/DD-IframeMedia/Component.tsx +14 -10
- package/templates/blocks/DD-MasonaryMedia/Component.tsx +8 -10
- package/templates/blocks/DD-Pricing/Component.tsx +369 -372
- package/templates/blocks/DD-Process/Component.tsx +147 -149
- package/templates/blocks/DD-Progress/Component.tsx +156 -141
- package/templates/blocks/DD-ProjSlider/config.ts +2 -2
- package/templates/blocks/DD-Section/Component.tsx +1 -1
- package/templates/blocks/DD-Services/Component.tsx +1 -1
- package/templates/blocks/DD-ShowcaseGrid/Component.tsx +13 -13
- package/templates/blocks/DD-Slider-A/Component.tsx +237 -237
- package/templates/blocks/DD-StackCards/Component.tsx +473 -473
- package/templates/blocks/DD-Team/Component.tsx +1 -1
- package/templates/blocks/DD-TechStack/Component.tsx +22 -28
- package/templates/blocks/DD-Testimonials/Component.tsx +35 -43
- package/templates/blocks/DD-TextMarq/Component.tsx +157 -157
- package/templates/blocks/DD-Thanks/Component.tsx +501 -500
- package/templates/blocks/DD-Work/Component.tsx +4 -4
- package/templates/blocks/DD-Work-B/Component.tsx +1 -1
- package/templates/blocks/DDHG-CTASection2/Component.tsx +6 -6
- package/templates/blocks/DDHG-HighlightArc/Component.tsx +243 -243
- package/templates/blocks/DDHG-LogoMarquee/Component.tsx +143 -93
- package/templates/blocks/DDHG-LogoMarquee/config.ts +154 -66
- package/templates/blocks/DDHG-NavCodeGrid/Component.tsx +227 -213
- package/templates/blocks/DDHG-ProcessSec/Component.tsx +38 -39
- package/templates/blocks/DDHG-ServiceDetails/Component.tsx +11 -11
- package/templates/blocks/DDHG-ServiceDetails/ServiceDetailCard.tsx +24 -28
- package/templates/blocks/DDHG-ServiceHero/Component.tsx +1 -1
- package/templates/blocks/DDHG-ServiceOverview/Component.tsx +23 -23
- package/templates/blocks/DDHG-StatsFAQ/Component.tsx +40 -40
- package/templates/blocks/DDHG-TeamCards/Component.tsx +169 -131
|
@@ -31,7 +31,7 @@ interface ScrollColumnProps {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function isLocalImageSrc(src?: string | null): src is string {
|
|
34
|
-
return typeof src === 'string' &&
|
|
34
|
+
return typeof src === 'string' && src.startsWith('/api/media/file/')
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function ScrollColumn({ images, speed, initialOffset, priority = false }: ScrollColumnProps) {
|
|
@@ -198,19 +198,19 @@ export const DDShowcaseGrid: React.FC<DDShowcaseGridProps> = ({
|
|
|
198
198
|
.studio-showcase-grid {
|
|
199
199
|
--sg-perspective: 900px;
|
|
200
200
|
--sg-perspective-origin: 50% 40%;
|
|
201
|
-
--sg-transform: rotateX(
|
|
202
|
-
--sg-top: -
|
|
203
|
-
--sg-bottom: -
|
|
204
|
-
--sg-sides: -
|
|
201
|
+
--sg-transform: rotateX(52deg) rotateZ(-42deg);
|
|
202
|
+
--sg-top: -160%;
|
|
203
|
+
--sg-bottom: -90%;
|
|
204
|
+
--sg-sides: -30%;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
@media (max-width: 1023px) {
|
|
208
208
|
.studio-showcase-grid {
|
|
209
209
|
--sg-perspective-origin: 50% 40%;
|
|
210
210
|
--sg-transform: rotateX(52deg) rotateZ(-42deg);
|
|
211
|
-
--sg-top: -
|
|
212
|
-
--sg-bottom: -
|
|
213
|
-
--sg-sides: -
|
|
211
|
+
--sg-top: -90%;
|
|
212
|
+
--sg-bottom: -30%;
|
|
213
|
+
--sg-sides: -50%;
|
|
214
214
|
max-height: 420px;
|
|
215
215
|
}
|
|
216
216
|
}
|
|
@@ -220,8 +220,8 @@ export const DDShowcaseGrid: React.FC<DDShowcaseGridProps> = ({
|
|
|
220
220
|
--sg-perspective-origin: 50% 40%;
|
|
221
221
|
--sg-transform: rotateX(52deg) rotateZ(-42deg);
|
|
222
222
|
--sg-top: -60%;
|
|
223
|
-
--sg-bottom:
|
|
224
|
-
--sg-sides: -
|
|
223
|
+
--sg-bottom: 0%;
|
|
224
|
+
--sg-sides: -90%;
|
|
225
225
|
max-height: 300px;
|
|
226
226
|
}
|
|
227
227
|
}
|
|
@@ -250,9 +250,9 @@ export const DDShowcaseGrid: React.FC<DDShowcaseGridProps> = ({
|
|
|
250
250
|
className="studio-showcase-grid relative z-0! overflow-hidden"
|
|
251
251
|
style={{ height: `${sectionHeight}px` }}
|
|
252
252
|
>
|
|
253
|
-
|
|
254
|
-
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-
|
|
255
|
-
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-
|
|
253
|
+
#BlockName-DDShowcaseGrid
|
|
254
|
+
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-80 bg-linear-to-b from-white to-transparent dark:from-[#0a0a0a]" />
|
|
255
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-80 bg-linear-to-t from-white to-transparent dark:from-[#0a0a0a]" />
|
|
256
256
|
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-40 bg-linear-to-r from-white to-transparent dark:from-[#0a0a0a]" />
|
|
257
257
|
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-40 bg-linear-to-l from-white to-transparent dark:from-[#0a0a0a]" />
|
|
258
258
|
|
|
@@ -1,237 +1,237 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import React, { useState, useCallback, useEffect } from 'react'
|
|
4
|
-
import Image from 'next/image'
|
|
5
|
-
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
6
|
-
import { cn } from '@/utilities/ui'
|
|
7
|
-
import useEmblaCarousel from 'embla-carousel-react'
|
|
8
|
-
|
|
9
|
-
type MediaObj = { url?: string | null; alt?: string | null }
|
|
10
|
-
|
|
11
|
-
type Slide = {
|
|
12
|
-
id?: string | null
|
|
13
|
-
title: string
|
|
14
|
-
category?: string | null
|
|
15
|
-
description?: string | null
|
|
16
|
-
image?: MediaObj | string | null
|
|
17
|
-
imageFallbackUrl?: string | null
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type DDSliderAProps = {
|
|
21
|
-
blockType: 'DD-Slider-A'
|
|
22
|
-
blockName?: string | null
|
|
23
|
-
eyebrow?: string | null
|
|
24
|
-
heading?: string | null
|
|
25
|
-
subtext?: string | null
|
|
26
|
-
slides?: Slide[] | null
|
|
27
|
-
sectionId?: string | null
|
|
28
|
-
customCSS?: string | null
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function isValidImageSrc(src?: string | null): src is string {
|
|
32
|
-
return Boolean(src && src.startsWith('/api/media/file/'))
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function getImageSrc(slide: Slide): string {
|
|
36
|
-
const candidateSrc =
|
|
37
|
-
slide.image && typeof slide.image === 'object' && (slide.image as MediaObj).url
|
|
38
|
-
? (slide.image as MediaObj).url!
|
|
39
|
-
: (slide.imageFallbackUrl ?? '')
|
|
40
|
-
|
|
41
|
-
return isValidImageSrc(candidateSrc) ? candidateSrc : ''
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export const DDSliderA: React.FC<DDSliderAProps> = ({
|
|
45
|
-
eyebrow,
|
|
46
|
-
heading,
|
|
47
|
-
subtext,
|
|
48
|
-
slides: slidesProp,
|
|
49
|
-
sectionId,
|
|
50
|
-
customCSS,
|
|
51
|
-
}) => {
|
|
52
|
-
const slides = slidesProp ?? []
|
|
53
|
-
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true })
|
|
54
|
-
const [activeIdx, setActiveIdx] = useState(0)
|
|
55
|
-
const [loadedSlides, setLoadedSlides] = useState<Record<number, boolean>>({})
|
|
56
|
-
const [loadedThumbs, setLoadedThumbs] = useState<Record<number, boolean>>({})
|
|
57
|
-
|
|
58
|
-
const onSelect = useCallback(() => {
|
|
59
|
-
if (!emblaApi) return
|
|
60
|
-
setActiveIdx(emblaApi.selectedScrollSnap())
|
|
61
|
-
}, [emblaApi])
|
|
62
|
-
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (!emblaApi) return
|
|
65
|
-
onSelect()
|
|
66
|
-
emblaApi.on('select', onSelect)
|
|
67
|
-
emblaApi.on('reInit', onSelect)
|
|
68
|
-
return () => {
|
|
69
|
-
emblaApi.off('select', onSelect)
|
|
70
|
-
emblaApi.off('reInit', onSelect)
|
|
71
|
-
}
|
|
72
|
-
}, [emblaApi, onSelect])
|
|
73
|
-
|
|
74
|
-
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi])
|
|
75
|
-
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi])
|
|
76
|
-
const scrollTo = useCallback((i: number) => emblaApi?.scrollTo(i), [emblaApi])
|
|
77
|
-
|
|
78
|
-
if (!slides.length) return null
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
<section id={sectionId ?? undefined} className="py-20 overflow-hidden">
|
|
82
|
-
{customCSS && <style dangerouslySetInnerHTML={{ __html: customCSS }} />}
|
|
83
|
-
<div className="max-w-6xl lg:max-w-7xl mx-auto px-4 md:px-6">
|
|
84
|
-
{/* Optional section header */}
|
|
85
|
-
|
|
86
|
-
{(eyebrow || heading || subtext) && (
|
|
87
|
-
<div className="mb-10 text-center">
|
|
88
|
-
{eyebrow && (
|
|
89
|
-
<p className="text-lime-500 text-xs font-bold uppercase tracking-widest mb-2">
|
|
90
|
-
{eyebrow}
|
|
91
|
-
</p>
|
|
92
|
-
)}
|
|
93
|
-
{heading && (
|
|
94
|
-
<h2 className="text-4xl lg:text-5xl font-bold text-stone-900 dark:text-stone-50 leading-tight">
|
|
95
|
-
{heading}
|
|
96
|
-
</h2>
|
|
97
|
-
)}
|
|
98
|
-
{subtext && (
|
|
99
|
-
<p className="text-stone-400 mt-3 text-base max-w-2xl mx-auto">{subtext}</p>
|
|
100
|
-
)}
|
|
101
|
-
</div>
|
|
102
|
-
)}
|
|
103
|
-
<div className="relative rounded-3xl overflow-hidden">
|
|
104
|
-
{/* Embla viewport */}
|
|
105
|
-
<div
|
|
106
|
-
ref={emblaRef}
|
|
107
|
-
className="overflow-hidden select-none cursor-grab active:cursor-grabbing h-[480px] lg:h-[560px]"
|
|
108
|
-
>
|
|
109
|
-
<div className="flex h-full">
|
|
110
|
-
{slides.map((slide, i) => {
|
|
111
|
-
const imgSrc = getImageSrc(slide)
|
|
112
|
-
return (
|
|
113
|
-
<div key={slide.id ?? i} className="flex-none w-full h-full relative">
|
|
114
|
-
{imgSrc ? (
|
|
115
|
-
<>
|
|
116
|
-
{!loadedSlides[i] && (
|
|
117
|
-
<div className="absolute inset-0 bg-stone-900 animate-pulse" />
|
|
118
|
-
)}
|
|
119
|
-
<Image
|
|
120
|
-
src={imgSrc}
|
|
121
|
-
alt={slide.title}
|
|
122
|
-
fill
|
|
123
|
-
sizes="100vw"
|
|
124
|
-
priority={i === 0}
|
|
125
|
-
className="w-full h-full object-cover object-top"
|
|
126
|
-
draggable={false}
|
|
127
|
-
onLoad={() => setLoadedSlides((prev) => ({ ...prev, [i]: true }))}
|
|
128
|
-
/>
|
|
129
|
-
</>
|
|
130
|
-
) : (
|
|
131
|
-
<div className="w-full h-full bg-
|
|
132
|
-
)}
|
|
133
|
-
|
|
134
|
-
{/* Gradient overlay */}
|
|
135
|
-
<div className="absolute inset-0 bg-gradient-to-r from-stone-950/80 via-stone-950/40 to-transparent pointer-events-none" />
|
|
136
|
-
|
|
137
|
-
{/* Slide content */}
|
|
138
|
-
<div className="absolute bottom-0 left-0 right-0 p-8 lg:p-12 pointer-events-none">
|
|
139
|
-
<div className="max-w-lg">
|
|
140
|
-
{slide.category && (
|
|
141
|
-
<span className="inline-block bg-lime-500/50 border-lime-500 border text-stone-200 text-xs font-bold px-3 py-1 rounded-full mb-3 uppercase tracking-widest scale-90 origin-top-left">
|
|
142
|
-
{slide.category}
|
|
143
|
-
</span>
|
|
144
|
-
)}
|
|
145
|
-
<h3 className="text-stone-50 font-black text-3xl lg:text-4xl mb-3">
|
|
146
|
-
{slide.title}
|
|
147
|
-
</h3>
|
|
148
|
-
{slide.description && (
|
|
149
|
-
<p className="text-stone-50/70 text-base leading-relaxed">
|
|
150
|
-
{slide.description}
|
|
151
|
-
</p>
|
|
152
|
-
)}
|
|
153
|
-
</div>
|
|
154
|
-
</div>
|
|
155
|
-
</div>
|
|
156
|
-
)
|
|
157
|
-
})}
|
|
158
|
-
</div>
|
|
159
|
-
</div>
|
|
160
|
-
|
|
161
|
-
{/* Navigation arrows */}
|
|
162
|
-
<button
|
|
163
|
-
onClick={scrollPrev}
|
|
164
|
-
aria-label="Previous slide"
|
|
165
|
-
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 w-12 h-12 flex items-center justify-center rounded-full bg-stone-50/5 hover:bg-lime-500 text-stone-50 backdrop-blur-sm transition-all cursor-pointer"
|
|
166
|
-
>
|
|
167
|
-
<ChevronLeft className="w-5 h-5" />
|
|
168
|
-
</button>
|
|
169
|
-
<button
|
|
170
|
-
onClick={scrollNext}
|
|
171
|
-
aria-label="Next slide"
|
|
172
|
-
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 w-12 h-12 flex items-center justify-center rounded-full bg-stone-50/5 hover:bg-lime-500 text-stone-50 backdrop-blur-sm transition-all cursor-pointer"
|
|
173
|
-
>
|
|
174
|
-
<ChevronRight className="w-5 h-5" />
|
|
175
|
-
</button>
|
|
176
|
-
|
|
177
|
-
{/* Thumbnail strip */}
|
|
178
|
-
<div className="absolute bottom-4 right-4 lg:right-8 z-20 flex gap-2">
|
|
179
|
-
{slides.map((slide, i) => {
|
|
180
|
-
const imgSrc = getImageSrc(slide)
|
|
181
|
-
return (
|
|
182
|
-
<button
|
|
183
|
-
key={slide.id ?? i}
|
|
184
|
-
onClick={() => scrollTo(i)}
|
|
185
|
-
aria-label={`Go to slide: ${slide.title}`}
|
|
186
|
-
className={cn(
|
|
187
|
-
'relative w-16 h-12 rounded-lg overflow-hidden border-2 transition-all cursor-pointer',
|
|
188
|
-
i === activeIdx
|
|
189
|
-
? 'border-lime-500 scale-110'
|
|
190
|
-
: 'border-white/30 opacity-60 hover:opacity-100',
|
|
191
|
-
)}
|
|
192
|
-
>
|
|
193
|
-
{imgSrc ? (
|
|
194
|
-
<>
|
|
195
|
-
{!loadedThumbs[i] && (
|
|
196
|
-
<div className="absolute inset-0 bg-stone-800 animate-pulse" />
|
|
197
|
-
)}
|
|
198
|
-
<Image
|
|
199
|
-
src={imgSrc}
|
|
200
|
-
alt={slide.title}
|
|
201
|
-
fill
|
|
202
|
-
sizes="64px"
|
|
203
|
-
className="w-full h-full object-cover object-top"
|
|
204
|
-
draggable={false}
|
|
205
|
-
onLoad={() => setLoadedThumbs((prev) => ({ ...prev, [i]: true }))}
|
|
206
|
-
/>
|
|
207
|
-
</>
|
|
208
|
-
) : (
|
|
209
|
-
<div className="w-full h-full bg-stone-800" />
|
|
210
|
-
)}
|
|
211
|
-
</button>
|
|
212
|
-
)
|
|
213
|
-
})}
|
|
214
|
-
</div>
|
|
215
|
-
</div>
|
|
216
|
-
{/* Dot indicators */}
|
|
217
|
-
{slides.length > 1 && (
|
|
218
|
-
<div className="flex justify-center gap-2 mt-6">
|
|
219
|
-
{slides.map((_, i) => (
|
|
220
|
-
<button
|
|
221
|
-
key={i}
|
|
222
|
-
onClick={() => scrollTo(i)}
|
|
223
|
-
aria-label={`Go to slide ${i + 1}`}
|
|
224
|
-
className={cn(
|
|
225
|
-
'rounded-full transition-all duration-300 cursor-pointer',
|
|
226
|
-
i === activeIdx
|
|
227
|
-
? 'w-8 h-2.5 bg-lime-500'
|
|
228
|
-
: 'w-2.5 h-2.5 bg-stone-600 hover:bg-stone-400',
|
|
229
|
-
)}
|
|
230
|
-
/>
|
|
231
|
-
))}
|
|
232
|
-
</div>
|
|
233
|
-
)}
|
|
234
|
-
</div>
|
|
235
|
-
</section>
|
|
236
|
-
)
|
|
237
|
-
}
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback, useEffect } from 'react'
|
|
4
|
+
import Image from 'next/image'
|
|
5
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
6
|
+
import { cn } from '@/utilities/ui'
|
|
7
|
+
import useEmblaCarousel from 'embla-carousel-react'
|
|
8
|
+
|
|
9
|
+
type MediaObj = { url?: string | null; alt?: string | null }
|
|
10
|
+
|
|
11
|
+
type Slide = {
|
|
12
|
+
id?: string | null
|
|
13
|
+
title: string
|
|
14
|
+
category?: string | null
|
|
15
|
+
description?: string | null
|
|
16
|
+
image?: MediaObj | string | null
|
|
17
|
+
imageFallbackUrl?: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DDSliderAProps = {
|
|
21
|
+
blockType: 'DD-Slider-A'
|
|
22
|
+
blockName?: string | null
|
|
23
|
+
eyebrow?: string | null
|
|
24
|
+
heading?: string | null
|
|
25
|
+
subtext?: string | null
|
|
26
|
+
slides?: Slide[] | null
|
|
27
|
+
sectionId?: string | null
|
|
28
|
+
customCSS?: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isValidImageSrc(src?: string | null): src is string {
|
|
32
|
+
return Boolean(src && src.startsWith('/api/media/file/'))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getImageSrc(slide: Slide): string {
|
|
36
|
+
const candidateSrc =
|
|
37
|
+
slide.image && typeof slide.image === 'object' && (slide.image as MediaObj).url
|
|
38
|
+
? (slide.image as MediaObj).url!
|
|
39
|
+
: (slide.imageFallbackUrl ?? '')
|
|
40
|
+
|
|
41
|
+
return isValidImageSrc(candidateSrc) ? candidateSrc : ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DDSliderA: React.FC<DDSliderAProps> = ({
|
|
45
|
+
eyebrow,
|
|
46
|
+
heading,
|
|
47
|
+
subtext,
|
|
48
|
+
slides: slidesProp,
|
|
49
|
+
sectionId,
|
|
50
|
+
customCSS,
|
|
51
|
+
}) => {
|
|
52
|
+
const slides = slidesProp ?? []
|
|
53
|
+
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true })
|
|
54
|
+
const [activeIdx, setActiveIdx] = useState(0)
|
|
55
|
+
const [loadedSlides, setLoadedSlides] = useState<Record<number, boolean>>({})
|
|
56
|
+
const [loadedThumbs, setLoadedThumbs] = useState<Record<number, boolean>>({})
|
|
57
|
+
|
|
58
|
+
const onSelect = useCallback(() => {
|
|
59
|
+
if (!emblaApi) return
|
|
60
|
+
setActiveIdx(emblaApi.selectedScrollSnap())
|
|
61
|
+
}, [emblaApi])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!emblaApi) return
|
|
65
|
+
onSelect()
|
|
66
|
+
emblaApi.on('select', onSelect)
|
|
67
|
+
emblaApi.on('reInit', onSelect)
|
|
68
|
+
return () => {
|
|
69
|
+
emblaApi.off('select', onSelect)
|
|
70
|
+
emblaApi.off('reInit', onSelect)
|
|
71
|
+
}
|
|
72
|
+
}, [emblaApi, onSelect])
|
|
73
|
+
|
|
74
|
+
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi])
|
|
75
|
+
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi])
|
|
76
|
+
const scrollTo = useCallback((i: number) => emblaApi?.scrollTo(i), [emblaApi])
|
|
77
|
+
|
|
78
|
+
if (!slides.length) return null
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<section id={sectionId ?? undefined} className="py-20 overflow-hidden">
|
|
82
|
+
{customCSS && <style dangerouslySetInnerHTML={{ __html: customCSS }} />}
|
|
83
|
+
<div className="max-w-6xl lg:max-w-7xl mx-auto px-4 md:px-6">
|
|
84
|
+
{/* Optional section header */}
|
|
85
|
+
#BlockName-DDSliderA
|
|
86
|
+
{(eyebrow || heading || subtext) && (
|
|
87
|
+
<div className="mb-10 text-center">
|
|
88
|
+
{eyebrow && (
|
|
89
|
+
<p className="text-lime-500 text-xs font-bold uppercase tracking-widest mb-2">
|
|
90
|
+
{eyebrow}
|
|
91
|
+
</p>
|
|
92
|
+
)}
|
|
93
|
+
{heading && (
|
|
94
|
+
<h2 className="text-4xl lg:text-5xl font-bold text-stone-900 dark:text-stone-50 leading-tight">
|
|
95
|
+
{heading}
|
|
96
|
+
</h2>
|
|
97
|
+
)}
|
|
98
|
+
{subtext && (
|
|
99
|
+
<p className="text-stone-400 mt-3 text-base max-w-2xl mx-auto">{subtext}</p>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
<div className="relative rounded-3xl overflow-hidden">
|
|
104
|
+
{/* Embla viewport */}
|
|
105
|
+
<div
|
|
106
|
+
ref={emblaRef}
|
|
107
|
+
className="overflow-hidden select-none cursor-grab active:cursor-grabbing h-[480px] lg:h-[560px]"
|
|
108
|
+
>
|
|
109
|
+
<div className="flex h-full">
|
|
110
|
+
{slides.map((slide, i) => {
|
|
111
|
+
const imgSrc = getImageSrc(slide)
|
|
112
|
+
return (
|
|
113
|
+
<div key={slide.id ?? i} className="flex-none w-full h-full relative">
|
|
114
|
+
{imgSrc ? (
|
|
115
|
+
<>
|
|
116
|
+
{!loadedSlides[i] && (
|
|
117
|
+
<div className="absolute inset-0 bg-stone-900 animate-pulse" />
|
|
118
|
+
)}
|
|
119
|
+
<Image
|
|
120
|
+
src={imgSrc}
|
|
121
|
+
alt={slide.title}
|
|
122
|
+
fill
|
|
123
|
+
sizes="100vw"
|
|
124
|
+
priority={i === 0}
|
|
125
|
+
className="w-full h-full object-cover object-top"
|
|
126
|
+
draggable={false}
|
|
127
|
+
onLoad={() => setLoadedSlides((prev) => ({ ...prev, [i]: true }))}
|
|
128
|
+
/>
|
|
129
|
+
</>
|
|
130
|
+
) : (
|
|
131
|
+
<div className="w-full h-full bg-linear-to-br from-violet-950 to-indigo-950" />
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Gradient overlay */}
|
|
135
|
+
<div className="absolute inset-0 bg-gradient-to-r from-stone-950/80 via-stone-950/40 to-transparent pointer-events-none" />
|
|
136
|
+
|
|
137
|
+
{/* Slide content */}
|
|
138
|
+
<div className="absolute bottom-0 left-0 right-0 p-8 lg:p-12 pointer-events-none">
|
|
139
|
+
<div className="max-w-lg">
|
|
140
|
+
{slide.category && (
|
|
141
|
+
<span className="inline-block bg-lime-500/50 border-lime-500 border text-stone-200 text-xs font-bold px-3 py-1 rounded-full mb-3 uppercase tracking-widest scale-90 origin-top-left">
|
|
142
|
+
{slide.category}
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
<h3 className="text-stone-50 font-black text-3xl lg:text-4xl mb-3">
|
|
146
|
+
{slide.title}
|
|
147
|
+
</h3>
|
|
148
|
+
{slide.description && (
|
|
149
|
+
<p className="text-stone-50/70 text-base leading-relaxed">
|
|
150
|
+
{slide.description}
|
|
151
|
+
</p>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
})}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{/* Navigation arrows */}
|
|
162
|
+
<button
|
|
163
|
+
onClick={scrollPrev}
|
|
164
|
+
aria-label="Previous slide"
|
|
165
|
+
className="absolute left-1 top-1/2 -translate-y-1/2 z-20 w-12 h-12 flex items-center justify-center rounded-full bg-stone-50/5 hover:bg-lime-500 text-stone-50 backdrop-blur-sm transition-all cursor-pointer"
|
|
166
|
+
>
|
|
167
|
+
<ChevronLeft className="w-5 h-5" />
|
|
168
|
+
</button>
|
|
169
|
+
<button
|
|
170
|
+
onClick={scrollNext}
|
|
171
|
+
aria-label="Next slide"
|
|
172
|
+
className="absolute right-1 top-1/2 -translate-y-1/2 z-20 w-12 h-12 flex items-center justify-center rounded-full bg-stone-50/5 hover:bg-lime-500 text-stone-50 backdrop-blur-sm transition-all cursor-pointer"
|
|
173
|
+
>
|
|
174
|
+
<ChevronRight className="w-5 h-5" />
|
|
175
|
+
</button>
|
|
176
|
+
|
|
177
|
+
{/* Thumbnail strip */}
|
|
178
|
+
<div className="absolute bottom-4 right-4 lg:right-8 z-20 flex gap-2">
|
|
179
|
+
{slides.map((slide, i) => {
|
|
180
|
+
const imgSrc = getImageSrc(slide)
|
|
181
|
+
return (
|
|
182
|
+
<button
|
|
183
|
+
key={slide.id ?? i}
|
|
184
|
+
onClick={() => scrollTo(i)}
|
|
185
|
+
aria-label={`Go to slide: ${slide.title}`}
|
|
186
|
+
className={cn(
|
|
187
|
+
'relative w-16 h-12 rounded-lg overflow-hidden border-2 transition-all cursor-pointer',
|
|
188
|
+
i === activeIdx
|
|
189
|
+
? 'border-lime-500 scale-110'
|
|
190
|
+
: 'border-white/30 opacity-60 hover:opacity-100',
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
{imgSrc ? (
|
|
194
|
+
<>
|
|
195
|
+
{!loadedThumbs[i] && (
|
|
196
|
+
<div className="absolute inset-0 bg-stone-800 animate-pulse" />
|
|
197
|
+
)}
|
|
198
|
+
<Image
|
|
199
|
+
src={imgSrc}
|
|
200
|
+
alt={slide.title}
|
|
201
|
+
fill
|
|
202
|
+
sizes="64px"
|
|
203
|
+
className="w-full h-full object-cover object-top"
|
|
204
|
+
draggable={false}
|
|
205
|
+
onLoad={() => setLoadedThumbs((prev) => ({ ...prev, [i]: true }))}
|
|
206
|
+
/>
|
|
207
|
+
</>
|
|
208
|
+
) : (
|
|
209
|
+
<div className="w-full h-full bg-stone-800" />
|
|
210
|
+
)}
|
|
211
|
+
</button>
|
|
212
|
+
)
|
|
213
|
+
})}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
{/* Dot indicators */}
|
|
217
|
+
{slides.length > 1 && (
|
|
218
|
+
<div className="flex justify-center gap-2 mt-6">
|
|
219
|
+
{slides.map((_, i) => (
|
|
220
|
+
<button
|
|
221
|
+
key={i}
|
|
222
|
+
onClick={() => scrollTo(i)}
|
|
223
|
+
aria-label={`Go to slide ${i + 1}`}
|
|
224
|
+
className={cn(
|
|
225
|
+
'rounded-full transition-all duration-300 cursor-pointer',
|
|
226
|
+
i === activeIdx
|
|
227
|
+
? 'w-8 h-2.5 bg-lime-500'
|
|
228
|
+
: 'w-2.5 h-2.5 bg-stone-600 hover:bg-stone-400',
|
|
229
|
+
)}
|
|
230
|
+
/>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
</section>
|
|
236
|
+
)
|
|
237
|
+
}
|