keystone-design-bootstrap 1.0.7 → 1.0.8
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 +1 -1
- package/src/design_system/elements/carousel/carousel-section-wrapper.tsx +49 -43
- package/src/design_system/elements/index.tsx +4 -0
- package/src/design_system/elements/video-modal.tsx +56 -0
- package/src/design_system/elements/video-play-button.tsx +25 -0
- package/src/design_system/sections/hero-home.aman.tsx +22 -3
- package/src/design_system/sections/hero-home.tsx +21 -2
package/package.json
CHANGED
|
@@ -1,59 +1,65 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React
|
|
4
|
-
import { Carousel } from '
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Carousel } from './carousel';
|
|
5
5
|
import { ChevronLeft, ChevronRight } from '@untitledui/icons';
|
|
6
6
|
|
|
7
7
|
interface CarouselSectionWrapperProps {
|
|
8
|
-
title
|
|
9
|
-
|
|
8
|
+
title: string;
|
|
9
|
+
subtitle?: string;
|
|
10
10
|
hasItems: boolean;
|
|
11
|
+
children: React.ReactNode;
|
|
11
12
|
emptyMessage?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export const CarouselSectionWrapper = ({
|
|
15
|
-
title
|
|
16
|
-
|
|
16
|
+
title,
|
|
17
|
+
subtitle,
|
|
17
18
|
hasItems,
|
|
18
|
-
|
|
19
|
+
children,
|
|
20
|
+
emptyMessage = "No items available",
|
|
19
21
|
}: CarouselSectionWrapperProps) => {
|
|
20
|
-
if (!hasItems) {
|
|
21
|
-
return (
|
|
22
|
-
<>
|
|
23
|
-
{title && (
|
|
24
|
-
<div className="mb-12">
|
|
25
|
-
<h2 className="font-display text-4xl font-normal text-fg-primary md:text-5xl">
|
|
26
|
-
{title}
|
|
27
|
-
</h2>
|
|
28
|
-
</div>
|
|
29
|
-
)}
|
|
30
|
-
<div className="text-center py-12">
|
|
31
|
-
<p className="font-body text-base text-tertiary">{emptyMessage}</p>
|
|
32
|
-
</div>
|
|
33
|
-
</>
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
22
|
return (
|
|
38
|
-
<
|
|
39
|
-
<div className="
|
|
40
|
-
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
23
|
+
<section>
|
|
24
|
+
<div className="mx-auto max-w-container px-4 md:px-8">
|
|
25
|
+
{hasItems ? (
|
|
26
|
+
<Carousel.Root opts={{ align: "start", loop: true }}>
|
|
27
|
+
<div className="flex items-center justify-between mb-12">
|
|
28
|
+
<h2 className="font-display text-4xl font-normal text-fg-primary md:text-5xl">
|
|
29
|
+
{title}
|
|
30
|
+
</h2>
|
|
31
|
+
<div className="flex gap-2">
|
|
32
|
+
<Carousel.PrevTrigger className="rounded-full p-2 border border-secondary hover:bg-primary_hover transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
|
|
33
|
+
<ChevronLeft className="w-6 h-6 text-fg-primary" />
|
|
34
|
+
</Carousel.PrevTrigger>
|
|
35
|
+
<Carousel.NextTrigger className="rounded-full p-2 border border-secondary hover:bg-primary_hover transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
|
|
36
|
+
<ChevronRight className="w-6 h-6 text-fg-primary" />
|
|
37
|
+
</Carousel.NextTrigger>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<Carousel.Content className="-ml-4">
|
|
42
|
+
{children}
|
|
43
|
+
</Carousel.Content>
|
|
44
|
+
</Carousel.Root>
|
|
45
|
+
) : (
|
|
46
|
+
<>
|
|
47
|
+
<div className="mb-12">
|
|
48
|
+
<h2 className="font-display text-4xl font-normal text-fg-primary md:text-5xl">
|
|
49
|
+
{title}
|
|
50
|
+
</h2>
|
|
51
|
+
{subtitle && (
|
|
52
|
+
<p className="mt-4 text-lg text-tertiary md:mt-6 md:text-xl">
|
|
53
|
+
{subtitle}
|
|
54
|
+
</p>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
<div className="text-center py-12">
|
|
58
|
+
<p className="font-body text-base text-tertiary">{emptyMessage}</p>
|
|
59
|
+
</div>
|
|
60
|
+
</>
|
|
61
|
+
)}
|
|
51
62
|
</div>
|
|
52
|
-
|
|
53
|
-
<Carousel.Content className="-ml-4">
|
|
54
|
-
{children}
|
|
55
|
-
</Carousel.Content>
|
|
56
|
-
</Carousel.Root>
|
|
63
|
+
</section>
|
|
57
64
|
);
|
|
58
65
|
};
|
|
59
|
-
|
|
@@ -157,3 +157,7 @@ export { CarouselSectionWrapper } from './carousel/carousel-section-wrapper';
|
|
|
157
157
|
|
|
158
158
|
// Re-export combobox (still exists in select directory but not wrapped with theming)
|
|
159
159
|
export { ComboBox } from './select/combobox';
|
|
160
|
+
|
|
161
|
+
// Video components (no theming needed)
|
|
162
|
+
export { VideoModal } from './video-modal';
|
|
163
|
+
export { VideoPlayButton } from './video-play-button';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface VideoModalProps {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
videoUrl: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function VideoModal({ isOpen, onClose, videoUrl }: VideoModalProps) {
|
|
12
|
+
// Prevent body scroll when modal is open
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (isOpen) {
|
|
15
|
+
document.body.style.overflow = 'hidden';
|
|
16
|
+
} else {
|
|
17
|
+
document.body.style.overflow = '';
|
|
18
|
+
}
|
|
19
|
+
return () => {
|
|
20
|
+
document.body.style.overflow = '';
|
|
21
|
+
};
|
|
22
|
+
}, [isOpen]);
|
|
23
|
+
|
|
24
|
+
if (!isOpen) return null;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
className="fixed inset-0 z-[60] flex items-center justify-center p-4 backdrop-blur-md bg-black/50"
|
|
29
|
+
onClick={onClose}
|
|
30
|
+
>
|
|
31
|
+
{/* Close button */}
|
|
32
|
+
<button
|
|
33
|
+
onClick={onClose}
|
|
34
|
+
className="absolute top-4 right-4 md:top-8 md:right-8 text-white text-4xl md:text-5xl hover:text-gray-300 z-10 transition-colors"
|
|
35
|
+
aria-label="Close video"
|
|
36
|
+
>
|
|
37
|
+
×
|
|
38
|
+
</button>
|
|
39
|
+
|
|
40
|
+
{/* Video container */}
|
|
41
|
+
<div
|
|
42
|
+
className="relative w-full max-w-5xl aspect-video"
|
|
43
|
+
onClick={(e) => e.stopPropagation()}
|
|
44
|
+
>
|
|
45
|
+
<iframe
|
|
46
|
+
src={videoUrl}
|
|
47
|
+
title="Video player"
|
|
48
|
+
className="w-full h-full rounded-lg shadow-2xl"
|
|
49
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
50
|
+
allowFullScreen
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
interface VideoPlayButtonProps {
|
|
4
|
+
onClick: () => void;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function VideoPlayButton({ onClick, className = '' }: VideoPlayButtonProps) {
|
|
9
|
+
return (
|
|
10
|
+
<button
|
|
11
|
+
onClick={onClick}
|
|
12
|
+
className={`group flex items-center justify-center w-24 h-24 md:w-32 md:h-32 rounded-full bg-black/40 backdrop-blur-sm hover:bg-black/50 transition-all hover:scale-105 ${className}`}
|
|
13
|
+
aria-label="Play video"
|
|
14
|
+
>
|
|
15
|
+
<svg
|
|
16
|
+
className="w-10 h-10 md:w-14 md:h-14 text-white group-hover:scale-110 transition-transform ml-1"
|
|
17
|
+
fill="currentColor"
|
|
18
|
+
viewBox="0 0 24 24"
|
|
19
|
+
>
|
|
20
|
+
<path d="M8 5v14l11-7z" />
|
|
21
|
+
</svg>
|
|
22
|
+
</button>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Fragment } from "react";
|
|
3
|
+
import { Fragment, useState } from "react";
|
|
4
4
|
import React from "react";
|
|
5
|
-
import { PhotoWithFallback } from '../elements';
|
|
5
|
+
import { PhotoWithFallback, VideoModal, VideoPlayButton } from '../elements';
|
|
6
6
|
import type { WebsitePhotos } from '../../types/api/website-photos';
|
|
7
7
|
|
|
8
8
|
interface HeroHomeProps {
|
|
@@ -13,6 +13,7 @@ interface HeroHomeProps {
|
|
|
13
13
|
ctaHref?: string;
|
|
14
14
|
reviews?: { rating: number; count: number };
|
|
15
15
|
onEmailSubmit?: (email: string) => void;
|
|
16
|
+
videoUrl?: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export const HeroHome = ({
|
|
@@ -21,7 +22,9 @@ export const HeroHome = ({
|
|
|
21
22
|
subhead = "",
|
|
22
23
|
ctaText = "",
|
|
23
24
|
ctaHref = "/contact",
|
|
25
|
+
videoUrl,
|
|
24
26
|
}: HeroHomeProps) => {
|
|
27
|
+
const [showVideo, setShowVideo] = useState(false);
|
|
25
28
|
|
|
26
29
|
const heroImage = {
|
|
27
30
|
url: websitePhotos?.hero?.url || (websitePhotos as any)?.brand?.url || '',
|
|
@@ -54,16 +57,32 @@ export const HeroHome = ({
|
|
|
54
57
|
|
|
55
58
|
<section>
|
|
56
59
|
<div className="mx-auto max-w-container px-4 md:px-8">
|
|
57
|
-
<div className="w-full h-[400px] md:h-[500px] lg:h-[600px]">
|
|
60
|
+
<div className="relative w-full h-[400px] md:h-[500px] lg:h-[600px]">
|
|
58
61
|
<PhotoWithFallback
|
|
59
62
|
photoUrl={heroImage.url}
|
|
60
63
|
photoAlt={heroImage.alt}
|
|
61
64
|
fallbackId="hero-home-brand"
|
|
62
65
|
className="w-full h-full object-cover"
|
|
63
66
|
/>
|
|
67
|
+
|
|
68
|
+
{/* Video play button overlay */}
|
|
69
|
+
{videoUrl && (
|
|
70
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
71
|
+
<VideoPlayButton onClick={() => setShowVideo(true)} />
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
64
74
|
</div>
|
|
65
75
|
</div>
|
|
66
76
|
</section>
|
|
77
|
+
|
|
78
|
+
{/* Video modal */}
|
|
79
|
+
{videoUrl && (
|
|
80
|
+
<VideoModal
|
|
81
|
+
isOpen={showVideo}
|
|
82
|
+
onClose={() => setShowVideo(false)}
|
|
83
|
+
videoUrl={videoUrl}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
67
86
|
</Fragment>
|
|
68
87
|
);
|
|
69
88
|
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Fragment, useMemo } from "react";
|
|
3
|
+
import { Fragment, useMemo, useState } from "react";
|
|
4
4
|
import React from "react";
|
|
5
|
-
import { Button, Form, Input, PhotoWithFallback } from '../elements';
|
|
5
|
+
import { Button, Form, Input, PhotoWithFallback, VideoModal, VideoPlayButton } from '../elements';
|
|
6
6
|
import { cx } from '../../utils/cx';
|
|
7
7
|
import { mapIcon } from '../utils/icon-mapping';
|
|
8
8
|
import type { FC } from "react";
|
|
@@ -33,6 +33,7 @@ interface HeroHomeProps {
|
|
|
33
33
|
statistics?: Statistic[];
|
|
34
34
|
email_signup?: EmailSignup;
|
|
35
35
|
onEmailSubmit?: (email: string) => void;
|
|
36
|
+
videoUrl?: string;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
const AvatarsWithReview = ({
|
|
@@ -140,7 +141,9 @@ export const HeroHome = ({
|
|
|
140
141
|
statistics = [],
|
|
141
142
|
email_signup,
|
|
142
143
|
onEmailSubmit,
|
|
144
|
+
videoUrl,
|
|
143
145
|
}: HeroHomeProps) => {
|
|
146
|
+
const [showVideo, setShowVideo] = useState(false);
|
|
144
147
|
|
|
145
148
|
// Get hero image from props
|
|
146
149
|
const heroImage = {
|
|
@@ -241,9 +244,25 @@ export const HeroHome = ({
|
|
|
241
244
|
fallbackId="hero-home-image"
|
|
242
245
|
className="inset-0 h-70 w-full object-cover md:h-110 lg:absolute lg:h-full"
|
|
243
246
|
/>
|
|
247
|
+
|
|
248
|
+
{/* Video play button overlay */}
|
|
249
|
+
{videoUrl && (
|
|
250
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
251
|
+
<VideoPlayButton onClick={() => setShowVideo(true)} />
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
244
254
|
</div>
|
|
245
255
|
</div>
|
|
246
256
|
</section>
|
|
257
|
+
|
|
258
|
+
{/* Video modal */}
|
|
259
|
+
{videoUrl && (
|
|
260
|
+
<VideoModal
|
|
261
|
+
isOpen={showVideo}
|
|
262
|
+
onClose={() => setShowVideo(false)}
|
|
263
|
+
videoUrl={videoUrl}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
247
266
|
</Fragment>
|
|
248
267
|
);
|
|
249
268
|
};
|