@olwiba/ui 0.1.14 → 0.2.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.
- package/dist/index.d.ts +328 -36
- package/dist/index.js +1160 -326
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/app/AuthSection.tsx +158 -56
- package/src/app/BillingPanel.tsx +155 -0
- package/src/app/OnboardingWizard.tsx +128 -0
- package/src/app/SettingsSection.tsx +29 -0
- package/src/app/TeamMembersPanel.tsx +184 -0
- package/src/components/ActivityFeed.tsx +82 -0
- package/src/components/CommandMenu.tsx +106 -0
- package/src/components/DataTable.tsx +212 -0
- package/src/components/FileUpload.tsx +214 -0
- package/src/components/NotificationsPopover.tsx +146 -0
- package/src/components/Notify.tsx +96 -0
- package/src/index.ts +38 -2
- package/src/marketing/ContactSection.tsx +13 -2
- package/src/marketing/CtaSection.tsx +93 -9
- package/src/marketing/FeaturesSection.tsx +27 -12
- package/src/marketing/HeroSection.tsx +5 -2
- package/src/marketing/NewsletterSection.tsx +12 -2
- package/src/marketing/TeamSection.tsx +10 -2
- package/src/mechanics/Carousel.tsx +112 -0
- package/src/marketing/CarouselSection.tsx +0 -126
- package/src/marketing/CtaCardSection.tsx +0 -95
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { ArrowRight, Rocket, Sparkles } from 'lucide-react';
|
|
5
|
+
import { cn, useUIVariant } from '@olwiba/cn';
|
|
6
|
+
import { Button } from '../primitives/Button';
|
|
5
7
|
import { FadeIn } from '../motion/FadeIn';
|
|
8
|
+
import { useIntersectionObserver } from '../hooks/use-intersection-observer';
|
|
6
9
|
import type { AppShellRenderLink } from '../app/AppShell';
|
|
7
10
|
|
|
8
11
|
export interface CtaSectionProps {
|
|
9
12
|
heading: string;
|
|
10
|
-
description
|
|
13
|
+
description?: string;
|
|
11
14
|
primaryCta: { label: string; href: string };
|
|
12
15
|
secondaryCta?: { label: string; href: string };
|
|
13
16
|
footnote?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Visual treatment of the section.
|
|
19
|
+
* - `'default'` — badge icon, radial glow, primary + secondary CTAs
|
|
20
|
+
* - `'showcase'` — large watermark icon with a scroll-reveal, single pill CTA
|
|
21
|
+
* @default 'default'
|
|
22
|
+
*/
|
|
23
|
+
variant?: 'default' | 'showcase';
|
|
24
|
+
/** (showcase) Watermark icon rendered behind the content. @default <Rocket /> */
|
|
25
|
+
icon?: React.ReactNode;
|
|
14
26
|
renderLink?: AppShellRenderLink;
|
|
15
27
|
}
|
|
16
28
|
|
|
@@ -18,14 +30,79 @@ const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) =>
|
|
|
18
30
|
<a href={href} className={className}>{children}</a>
|
|
19
31
|
);
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
function ShowcaseCta({
|
|
22
34
|
heading,
|
|
23
35
|
description,
|
|
24
36
|
primaryCta,
|
|
25
|
-
secondaryCta,
|
|
26
37
|
footnote,
|
|
38
|
+
icon,
|
|
27
39
|
renderLink = defaultRenderLink,
|
|
28
|
-
|
|
40
|
+
sectionClasses,
|
|
41
|
+
}: CtaSectionProps & { sectionClasses: string }) {
|
|
42
|
+
const [ref, intersecting] = useIntersectionObserver({ threshold: 0.15 });
|
|
43
|
+
const [visible, setVisible] = React.useState(false);
|
|
44
|
+
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
if (intersecting) setVisible(true);
|
|
47
|
+
}, [intersecting]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<section
|
|
51
|
+
ref={ref as React.RefObject<HTMLElement>}
|
|
52
|
+
className={cn(
|
|
53
|
+
sectionClasses,
|
|
54
|
+
'relative transition-[opacity,transform] duration-700 ease-out',
|
|
55
|
+
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6',
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{/* Decorative watermark icon */}
|
|
59
|
+
<div
|
|
60
|
+
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
|
61
|
+
style={{
|
|
62
|
+
opacity: visible ? 0.1 : 0,
|
|
63
|
+
transform: visible ? 'scale(1) rotate(0deg)' : 'scale(0.9) rotate(-5deg)',
|
|
64
|
+
transition: 'opacity 700ms ease 200ms, transform 700ms ease 200ms',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{icon ?? <Rocket className="size-72 text-foreground" strokeWidth={0.75} />}
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="relative z-10 px-6 py-20 text-center sm:px-10 sm:py-28">
|
|
71
|
+
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
|
|
72
|
+
{heading}
|
|
73
|
+
</h2>
|
|
74
|
+
{description && (
|
|
75
|
+
<p className="mx-auto mt-4 max-w-lg text-pretty text-muted-foreground">{description}</p>
|
|
76
|
+
)}
|
|
77
|
+
<div className="mt-8">
|
|
78
|
+
{renderLink({
|
|
79
|
+
href: primaryCta.href,
|
|
80
|
+
children: (
|
|
81
|
+
<Button size="lg" className="rounded-full px-6">
|
|
82
|
+
{primaryCta.label}
|
|
83
|
+
<ArrowRight className="ml-2 size-4" />
|
|
84
|
+
</Button>
|
|
85
|
+
),
|
|
86
|
+
})}
|
|
87
|
+
</div>
|
|
88
|
+
{footnote && (
|
|
89
|
+
<p className="mt-5 text-sm text-muted-foreground">{footnote}</p>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function CtaSection(props: CtaSectionProps) {
|
|
97
|
+
const {
|
|
98
|
+
heading,
|
|
99
|
+
description,
|
|
100
|
+
primaryCta,
|
|
101
|
+
secondaryCta,
|
|
102
|
+
footnote,
|
|
103
|
+
variant = 'default',
|
|
104
|
+
renderLink = defaultRenderLink,
|
|
105
|
+
} = props;
|
|
29
106
|
const mode = useUIVariant()
|
|
30
107
|
const sectionClasses = cn(
|
|
31
108
|
'overflow-hidden bg-card',
|
|
@@ -33,6 +110,11 @@ export function CtaSection({
|
|
|
33
110
|
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
34
111
|
!mode && 'rounded-2xl border',
|
|
35
112
|
)
|
|
113
|
+
|
|
114
|
+
if (variant === 'showcase') {
|
|
115
|
+
return <ShowcaseCta {...props} sectionClasses={sectionClasses} />;
|
|
116
|
+
}
|
|
117
|
+
|
|
36
118
|
return (
|
|
37
119
|
<section className={sectionClasses}>
|
|
38
120
|
<div className="relative px-6 py-16 sm:px-10 sm:py-24">
|
|
@@ -45,9 +127,11 @@ export function CtaSection({
|
|
|
45
127
|
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
|
|
46
128
|
{heading}
|
|
47
129
|
</h2>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
130
|
+
{description && (
|
|
131
|
+
<p className="mx-auto mt-4 max-w-lg text-pretty text-muted-foreground">
|
|
132
|
+
{description}
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
51
135
|
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
52
136
|
{renderLink({
|
|
53
137
|
href: primaryCta.href,
|
|
@@ -3,14 +3,18 @@
|
|
|
3
3
|
import type { LucideIcon } from 'lucide-react';
|
|
4
4
|
import { cn, useUIVariant } from '@olwiba/cn';
|
|
5
5
|
import { FeatureCard } from '../components/FeatureCard';
|
|
6
|
+
import { Carousel } from '../mechanics/Carousel';
|
|
6
7
|
import { SectionTitle } from './SectionTitle';
|
|
7
8
|
import { StaggerChildren } from '../motion/StaggerChildren';
|
|
9
|
+
import { FadeIn } from '../motion/FadeIn';
|
|
8
10
|
|
|
9
11
|
export interface FeaturesSectionProps {
|
|
10
12
|
title?: string;
|
|
11
13
|
description?: string;
|
|
12
14
|
badge?: string;
|
|
13
15
|
features: Array<{ icon: LucideIcon; title: string; description: string; href?: string }>;
|
|
16
|
+
/** How the feature cards are arranged. @default 'grid' */
|
|
17
|
+
layout?: 'grid' | 'carousel';
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export function FeaturesSection({
|
|
@@ -18,6 +22,7 @@ export function FeaturesSection({
|
|
|
18
22
|
description = 'A complete system of components, blocks, and hooks designed to work together - and get out of your way.',
|
|
19
23
|
badge = 'Features',
|
|
20
24
|
features,
|
|
25
|
+
layout = 'grid',
|
|
21
26
|
}: FeaturesSectionProps) {
|
|
22
27
|
const mode = useUIVariant()
|
|
23
28
|
const sectionClasses = cn(
|
|
@@ -26,23 +31,33 @@ export function FeaturesSection({
|
|
|
26
31
|
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
27
32
|
!mode && 'rounded-2xl border',
|
|
28
33
|
)
|
|
34
|
+
const cards = features.map((feature) => (
|
|
35
|
+
<FeatureCard
|
|
36
|
+
key={feature.title}
|
|
37
|
+
icon={feature.icon}
|
|
38
|
+
title={feature.title}
|
|
39
|
+
description={feature.description}
|
|
40
|
+
href={feature.href}
|
|
41
|
+
/>
|
|
42
|
+
));
|
|
43
|
+
|
|
29
44
|
return (
|
|
30
45
|
<section className={sectionClasses}>
|
|
31
46
|
<div className="px-6 py-14 sm:px-10 sm:py-20">
|
|
32
|
-
<div className=
|
|
47
|
+
<div className={cn('mx-auto', layout === 'carousel' ? 'max-w-5xl' : 'max-w-4xl')}>
|
|
33
48
|
<SectionTitle title={title} description={description} badge={badge} />
|
|
34
49
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
{layout === 'carousel' ? (
|
|
51
|
+
<FadeIn direction="up">
|
|
52
|
+
<Carousel className="mt-10" ariaLabel={title}>
|
|
53
|
+
{cards}
|
|
54
|
+
</Carousel>
|
|
55
|
+
</FadeIn>
|
|
56
|
+
) : (
|
|
57
|
+
<StaggerChildren className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
|
58
|
+
{cards}
|
|
59
|
+
</StaggerChildren>
|
|
60
|
+
)}
|
|
46
61
|
</div>
|
|
47
62
|
</div>
|
|
48
63
|
</section>
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { ArrowRight } from 'lucide-react';
|
|
5
|
-
import { Avatar, AvatarFallback, AvatarImage,
|
|
5
|
+
import { Avatar, AvatarFallback, AvatarImage, cn, useUIVariant } from '@olwiba/cn';
|
|
6
|
+
import { Badge } from '../primitives/Badge';
|
|
7
|
+
import { Button } from '../primitives/Button';
|
|
6
8
|
import { FadeIn } from '../motion/FadeIn';
|
|
7
9
|
import { PhoneFrame } from '../components/PhoneFrame';
|
|
8
10
|
import type { AppShellRenderLink } from '../app/AppShell';
|
|
@@ -39,6 +41,7 @@ export function HeroSection({
|
|
|
39
41
|
socialProofText,
|
|
40
42
|
renderLink = defaultRenderLink,
|
|
41
43
|
}: HeroSectionProps) {
|
|
44
|
+
const mode = useUIVariant();
|
|
42
45
|
return (
|
|
43
46
|
<section className="overflow-hidden">
|
|
44
47
|
<div className={marketingSectionSpacing.hero}>
|
|
@@ -90,7 +93,7 @@ export function HeroSection({
|
|
|
90
93
|
</PhoneFrame>
|
|
91
94
|
</div>
|
|
92
95
|
) : (
|
|
93
|
-
<div className=
|
|
96
|
+
<div className={cn('overflow-hidden', mode === 'smooth' ? 'rounded-3xl' : 'rounded-2xl')}>
|
|
94
97
|
{heroImage}
|
|
95
98
|
</div>
|
|
96
99
|
)}
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
4
|
import { ArrowRight, Mail } from 'lucide-react';
|
|
5
|
-
import {
|
|
5
|
+
import { cn, useUIVariant } from '@olwiba/cn';
|
|
6
|
+
import { Badge } from '../primitives/Badge';
|
|
7
|
+
import { Button } from '../primitives/Button';
|
|
8
|
+
import { Input } from '../primitives/Input';
|
|
6
9
|
|
|
7
10
|
export interface NewsletterSectionProps {
|
|
8
11
|
badge?: string;
|
|
@@ -44,6 +47,13 @@ export function NewsletterSection({
|
|
|
44
47
|
}: NewsletterSectionProps = {}) {
|
|
45
48
|
const [email, setEmail] = React.useState('');
|
|
46
49
|
const [submitted, setSubmitted] = React.useState(false);
|
|
50
|
+
const mode = useUIVariant();
|
|
51
|
+
const sectionClasses = cn(
|
|
52
|
+
'overflow-hidden bg-card',
|
|
53
|
+
mode === 'smooth' && 'rounded-3xl border',
|
|
54
|
+
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
55
|
+
!mode && 'rounded-2xl border',
|
|
56
|
+
);
|
|
47
57
|
|
|
48
58
|
async function handleSubmit(e: React.FormEvent) {
|
|
49
59
|
e.preventDefault();
|
|
@@ -53,7 +63,7 @@ export function NewsletterSection({
|
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
return (
|
|
56
|
-
<section className=
|
|
66
|
+
<section className={sectionClasses}>
|
|
57
67
|
<div className="relative px-6 py-14 sm:px-10 sm:py-20">
|
|
58
68
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,hsl(var(--primary)/0.1),transparent_60%)]" />
|
|
59
69
|
<div className="relative mx-auto max-w-xl text-center">
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { Github, Linkedin, Twitter } from 'lucide-react';
|
|
4
|
-
import { Avatar, AvatarFallback, AvatarImage,
|
|
4
|
+
import { Avatar, AvatarFallback, AvatarImage, cn, useUIVariant } from '@olwiba/cn';
|
|
5
|
+
import { Badge } from '../primitives/Badge';
|
|
5
6
|
|
|
6
7
|
export interface TeamMember {
|
|
7
8
|
name: string;
|
|
@@ -56,8 +57,15 @@ export function TeamSection({
|
|
|
56
57
|
title = 'The people behind Olwiba',
|
|
57
58
|
description = 'A small team with deep experience in design systems, open source, and developer tooling.',
|
|
58
59
|
}: TeamSectionProps) {
|
|
60
|
+
const mode = useUIVariant();
|
|
61
|
+
const sectionClasses = cn(
|
|
62
|
+
'overflow-hidden bg-card',
|
|
63
|
+
mode === 'smooth' && 'rounded-3xl border',
|
|
64
|
+
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
65
|
+
!mode && 'rounded-2xl border',
|
|
66
|
+
);
|
|
59
67
|
return (
|
|
60
|
-
<section className=
|
|
68
|
+
<section className={sectionClasses}>
|
|
61
69
|
<div className="px-6 py-14 sm:px-10 sm:py-20">
|
|
62
70
|
<div className="mx-auto max-w-5xl">
|
|
63
71
|
<div className="text-center">
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
5
|
+
import { cn, useUIVariant } from '@olwiba/cn';
|
|
6
|
+
|
|
7
|
+
export interface CarouselProps {
|
|
8
|
+
/** Items to scroll through — each child becomes one snap slide. */
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
/** Width classes applied to each slide. @default 'w-[280px] sm:w-[320px]' */
|
|
11
|
+
itemClassName?: string;
|
|
12
|
+
/** Prev/next control placement. @default 'top-right' */
|
|
13
|
+
controls?: 'top-right' | 'none';
|
|
14
|
+
/** Accessible label for the scroll region. */
|
|
15
|
+
ariaLabel?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Scroll-snap carousel behavior — wraps any children in a horizontally
|
|
21
|
+
* scrolling track with prev/next controls. A mechanic, not a section:
|
|
22
|
+
* feed it cards, images, or whole blocks. No carousel dependency.
|
|
23
|
+
*/
|
|
24
|
+
export function Carousel({
|
|
25
|
+
children,
|
|
26
|
+
itemClassName = 'w-[280px] sm:w-[320px]',
|
|
27
|
+
controls = 'top-right',
|
|
28
|
+
ariaLabel,
|
|
29
|
+
className,
|
|
30
|
+
}: CarouselProps) {
|
|
31
|
+
const mode = useUIVariant();
|
|
32
|
+
const trackRef = React.useRef<HTMLDivElement>(null);
|
|
33
|
+
const [canPrev, setCanPrev] = React.useState(false);
|
|
34
|
+
const [canNext, setCanNext] = React.useState(true);
|
|
35
|
+
|
|
36
|
+
const updateScrollState = React.useCallback(() => {
|
|
37
|
+
const track = trackRef.current;
|
|
38
|
+
if (!track) return;
|
|
39
|
+
setCanPrev(track.scrollLeft > 8);
|
|
40
|
+
setCanNext(track.scrollLeft < track.scrollWidth - track.clientWidth - 8);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
React.useEffect(() => {
|
|
44
|
+
const track = trackRef.current;
|
|
45
|
+
if (!track) return;
|
|
46
|
+
updateScrollState();
|
|
47
|
+
track.addEventListener('scroll', updateScrollState, { passive: true });
|
|
48
|
+
const observer = new ResizeObserver(updateScrollState);
|
|
49
|
+
observer.observe(track);
|
|
50
|
+
return () => {
|
|
51
|
+
track.removeEventListener('scroll', updateScrollState);
|
|
52
|
+
observer.disconnect();
|
|
53
|
+
};
|
|
54
|
+
}, [updateScrollState]);
|
|
55
|
+
|
|
56
|
+
const scrollByItem = (direction: 1 | -1) => {
|
|
57
|
+
const track = trackRef.current;
|
|
58
|
+
if (!track) return;
|
|
59
|
+
const item = track.querySelector<HTMLElement>('[data-carousel-item]');
|
|
60
|
+
const gap = parseFloat(getComputedStyle(track).columnGap) || 24;
|
|
61
|
+
const step = item ? item.offsetWidth + gap : track.clientWidth * 0.8;
|
|
62
|
+
track.scrollBy({ left: direction * step, behavior: 'smooth' });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const controlClasses = cn(
|
|
66
|
+
'flex size-9 items-center justify-center border bg-background text-foreground transition-colors',
|
|
67
|
+
'hover:bg-muted disabled:opacity-40 disabled:hover:bg-background',
|
|
68
|
+
mode === 'smooth' ? 'rounded-2xl' : 'rounded-xl',
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className={className}>
|
|
73
|
+
{controls === 'top-right' && (
|
|
74
|
+
<div className="mb-4 flex items-center justify-end gap-2">
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
aria-label="Previous"
|
|
78
|
+
className={controlClasses}
|
|
79
|
+
onClick={() => scrollByItem(-1)}
|
|
80
|
+
disabled={!canPrev}
|
|
81
|
+
>
|
|
82
|
+
<ChevronLeft className="size-4" />
|
|
83
|
+
</button>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
aria-label="Next"
|
|
87
|
+
className={controlClasses}
|
|
88
|
+
onClick={() => scrollByItem(1)}
|
|
89
|
+
disabled={!canNext}
|
|
90
|
+
>
|
|
91
|
+
<ChevronRight className="size-4" />
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
<div
|
|
97
|
+
ref={trackRef}
|
|
98
|
+
role="region"
|
|
99
|
+
aria-label={ariaLabel}
|
|
100
|
+
className="flex snap-x snap-mandatory gap-6 overflow-x-auto pb-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
101
|
+
>
|
|
102
|
+
{React.Children.map(children, (child) =>
|
|
103
|
+
child == null ? null : (
|
|
104
|
+
<div data-carousel-item className={cn('shrink-0 snap-start', itemClassName)}>
|
|
105
|
+
{child}
|
|
106
|
+
</div>
|
|
107
|
+
),
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import * as React from 'react';
|
|
4
|
-
import type { LucideIcon } from 'lucide-react';
|
|
5
|
-
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
6
|
-
import { cn, useUIVariant } from '@olwiba/cn';
|
|
7
|
-
import { FeatureCard } from '../components/FeatureCard';
|
|
8
|
-
import { SectionTitle } from './SectionTitle';
|
|
9
|
-
import { FadeIn } from '../motion/FadeIn';
|
|
10
|
-
|
|
11
|
-
export interface CarouselSectionProps {
|
|
12
|
-
title?: string;
|
|
13
|
-
description?: string;
|
|
14
|
-
badge?: string;
|
|
15
|
-
features: Array<{ icon: LucideIcon; title: string; description: string; href?: string }>;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Horizontally scrolling card carousel for feature-style content.
|
|
20
|
-
* Scroll-snap based with prev/next controls — no carousel dependency.
|
|
21
|
-
*/
|
|
22
|
-
export function CarouselSection({
|
|
23
|
-
title = 'Explore the platform',
|
|
24
|
-
description,
|
|
25
|
-
badge = 'Features',
|
|
26
|
-
features,
|
|
27
|
-
}: CarouselSectionProps) {
|
|
28
|
-
const mode = useUIVariant();
|
|
29
|
-
const trackRef = React.useRef<HTMLDivElement>(null);
|
|
30
|
-
const [canPrev, setCanPrev] = React.useState(false);
|
|
31
|
-
const [canNext, setCanNext] = React.useState(true);
|
|
32
|
-
|
|
33
|
-
const updateScrollState = React.useCallback(() => {
|
|
34
|
-
const track = trackRef.current;
|
|
35
|
-
if (!track) return;
|
|
36
|
-
setCanPrev(track.scrollLeft > 8);
|
|
37
|
-
setCanNext(track.scrollLeft < track.scrollWidth - track.clientWidth - 8);
|
|
38
|
-
}, []);
|
|
39
|
-
|
|
40
|
-
React.useEffect(() => {
|
|
41
|
-
const track = trackRef.current;
|
|
42
|
-
if (!track) return;
|
|
43
|
-
updateScrollState();
|
|
44
|
-
track.addEventListener('scroll', updateScrollState, { passive: true });
|
|
45
|
-
const observer = new ResizeObserver(updateScrollState);
|
|
46
|
-
observer.observe(track);
|
|
47
|
-
return () => {
|
|
48
|
-
track.removeEventListener('scroll', updateScrollState);
|
|
49
|
-
observer.disconnect();
|
|
50
|
-
};
|
|
51
|
-
}, [updateScrollState]);
|
|
52
|
-
|
|
53
|
-
const scrollByCard = (direction: 1 | -1) => {
|
|
54
|
-
const track = trackRef.current;
|
|
55
|
-
if (!track) return;
|
|
56
|
-
const card = track.querySelector<HTMLElement>('[data-carousel-item]');
|
|
57
|
-
const step = card ? card.offsetWidth + 24 : track.clientWidth * 0.8;
|
|
58
|
-
track.scrollBy({ left: direction * step, behavior: 'smooth' });
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const sectionClasses = cn(
|
|
62
|
-
'overflow-hidden bg-card',
|
|
63
|
-
mode === 'smooth' && 'rounded-3xl border',
|
|
64
|
-
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
65
|
-
!mode && 'rounded-2xl border',
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
const controlClasses = cn(
|
|
69
|
-
'flex size-9 items-center justify-center border bg-background text-foreground transition-colors',
|
|
70
|
-
'hover:bg-muted disabled:opacity-40 disabled:hover:bg-background',
|
|
71
|
-
mode === 'smooth' ? 'rounded-2xl' : 'rounded-xl',
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
return (
|
|
75
|
-
<section className={sectionClasses}>
|
|
76
|
-
<div className="px-6 py-14 sm:px-10 sm:py-20">
|
|
77
|
-
<div className="mx-auto max-w-5xl">
|
|
78
|
-
<SectionTitle title={title} description={description} badge={badge} />
|
|
79
|
-
|
|
80
|
-
<FadeIn direction="up">
|
|
81
|
-
<div className="mt-10 flex items-center justify-end gap-2">
|
|
82
|
-
<button
|
|
83
|
-
type="button"
|
|
84
|
-
aria-label="Previous"
|
|
85
|
-
className={controlClasses}
|
|
86
|
-
onClick={() => scrollByCard(-1)}
|
|
87
|
-
disabled={!canPrev}
|
|
88
|
-
>
|
|
89
|
-
<ChevronLeft className="size-4" />
|
|
90
|
-
</button>
|
|
91
|
-
<button
|
|
92
|
-
type="button"
|
|
93
|
-
aria-label="Next"
|
|
94
|
-
className={controlClasses}
|
|
95
|
-
onClick={() => scrollByCard(1)}
|
|
96
|
-
disabled={!canNext}
|
|
97
|
-
>
|
|
98
|
-
<ChevronRight className="size-4" />
|
|
99
|
-
</button>
|
|
100
|
-
</div>
|
|
101
|
-
|
|
102
|
-
<div
|
|
103
|
-
ref={trackRef}
|
|
104
|
-
className="mt-4 flex snap-x snap-mandatory gap-6 overflow-x-auto pb-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
105
|
-
>
|
|
106
|
-
{features.map((feature) => (
|
|
107
|
-
<div
|
|
108
|
-
key={feature.title}
|
|
109
|
-
data-carousel-item
|
|
110
|
-
className="w-[280px] shrink-0 snap-start sm:w-[320px]"
|
|
111
|
-
>
|
|
112
|
-
<FeatureCard
|
|
113
|
-
icon={feature.icon}
|
|
114
|
-
title={feature.title}
|
|
115
|
-
description={feature.description}
|
|
116
|
-
href={feature.href}
|
|
117
|
-
/>
|
|
118
|
-
</div>
|
|
119
|
-
))}
|
|
120
|
-
</div>
|
|
121
|
-
</FadeIn>
|
|
122
|
-
</div>
|
|
123
|
-
</div>
|
|
124
|
-
</section>
|
|
125
|
-
);
|
|
126
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import * as React from 'react';
|
|
4
|
-
import { ArrowRight, Rocket } from 'lucide-react';
|
|
5
|
-
import { cn } from '@olwiba/cn';
|
|
6
|
-
import type { AppShellRenderLink } from '../app/AppShell';
|
|
7
|
-
|
|
8
|
-
export interface CtaCardSectionProps {
|
|
9
|
-
heading: string;
|
|
10
|
-
description?: string;
|
|
11
|
-
primaryCta: { label: string; href: string };
|
|
12
|
-
footnote?: string;
|
|
13
|
-
icon?: React.ReactNode;
|
|
14
|
-
renderLink?: AppShellRenderLink;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
|
|
18
|
-
<a href={href} className={className}>{children}</a>
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
export function CtaCardSection({
|
|
22
|
-
heading,
|
|
23
|
-
description,
|
|
24
|
-
primaryCta,
|
|
25
|
-
footnote,
|
|
26
|
-
icon,
|
|
27
|
-
renderLink = defaultRenderLink,
|
|
28
|
-
}: CtaCardSectionProps) {
|
|
29
|
-
const ref = React.useRef<HTMLElement>(null);
|
|
30
|
-
const [visible, setVisible] = React.useState(false);
|
|
31
|
-
|
|
32
|
-
React.useEffect(() => {
|
|
33
|
-
const el = ref.current;
|
|
34
|
-
if (!el) return;
|
|
35
|
-
const observer = new IntersectionObserver(
|
|
36
|
-
([entry]) => {
|
|
37
|
-
if (entry.isIntersecting) {
|
|
38
|
-
setVisible(true);
|
|
39
|
-
observer.disconnect();
|
|
40
|
-
}
|
|
41
|
-
},
|
|
42
|
-
{ threshold: 0.15 },
|
|
43
|
-
);
|
|
44
|
-
observer.observe(el);
|
|
45
|
-
return () => observer.disconnect();
|
|
46
|
-
}, []);
|
|
47
|
-
|
|
48
|
-
return (
|
|
49
|
-
<section
|
|
50
|
-
ref={ref}
|
|
51
|
-
className={cn(
|
|
52
|
-
'relative overflow-hidden rounded-3xl border border-gray-200 shadow-sm dark:border-border',
|
|
53
|
-
'bg-[#E0DFDB] dark:bg-card',
|
|
54
|
-
'transition-[opacity,transform] duration-700 ease-out',
|
|
55
|
-
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-6',
|
|
56
|
-
)}
|
|
57
|
-
>
|
|
58
|
-
{/* Decorative icon */}
|
|
59
|
-
<div
|
|
60
|
-
className="pointer-events-none absolute inset-0 flex items-center justify-center"
|
|
61
|
-
style={{
|
|
62
|
-
opacity: visible ? 0.12 : 0,
|
|
63
|
-
transform: visible ? 'scale(1) rotate(0deg)' : 'scale(0.9) rotate(-5deg)',
|
|
64
|
-
transition: 'opacity 700ms ease 200ms, transform 700ms ease 200ms',
|
|
65
|
-
}}
|
|
66
|
-
>
|
|
67
|
-
{icon ?? <Rocket className="size-72 text-foreground dark:text-foreground" strokeWidth={0.75} />}
|
|
68
|
-
</div>
|
|
69
|
-
|
|
70
|
-
{/* Content */}
|
|
71
|
-
<div className="relative z-10 px-6 py-20 text-center sm:px-10 sm:py-28">
|
|
72
|
-
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
|
|
73
|
-
{heading}
|
|
74
|
-
</h2>
|
|
75
|
-
{description && (
|
|
76
|
-
<p className="mx-auto mt-4 max-w-lg text-pretty text-muted-foreground">{description}</p>
|
|
77
|
-
)}
|
|
78
|
-
<div className="mt-8">
|
|
79
|
-
{renderLink({
|
|
80
|
-
href: primaryCta.href,
|
|
81
|
-
children: (
|
|
82
|
-
<span className="inline-flex items-center gap-2 rounded-full bg-foreground px-6 py-3 text-sm font-medium text-background transition-opacity hover:opacity-80">
|
|
83
|
-
{primaryCta.label}
|
|
84
|
-
<ArrowRight className="size-4" />
|
|
85
|
-
</span>
|
|
86
|
-
),
|
|
87
|
-
})}
|
|
88
|
-
</div>
|
|
89
|
-
{footnote && (
|
|
90
|
-
<p className="mt-5 text-sm text-muted-foreground">{footnote}</p>
|
|
91
|
-
)}
|
|
92
|
-
</div>
|
|
93
|
-
</section>
|
|
94
|
-
);
|
|
95
|
-
}
|