@olwiba/ui 0.1.4 → 0.1.7
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 +246 -8
- package/dist/index.js +1126 -197
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/app/AuthSection.tsx +23 -1
- package/src/blog/ChangelogCard.tsx +73 -15
- package/src/components/BrandColorSwitchMinimal.tsx +172 -0
- package/src/components/FlowBracket.tsx +166 -0
- package/src/components/FlowConnector.tsx +135 -0
- package/src/components/PricingCard.tsx +7 -3
- package/src/components/ThemeSwitchMinimal.tsx +7 -7
- package/src/index.ts +16 -2
- package/src/marketing/ComparisonSection.tsx +109 -0
- package/src/marketing/ContactSection.tsx +74 -33
- package/src/marketing/CtaCardSection.tsx +95 -0
- package/src/marketing/FeatureMarqueeSection.tsx +120 -0
- package/src/marketing/Footer.tsx +2 -6
- package/src/marketing/GroupedFeaturesSection.tsx +71 -0
- package/src/marketing/Navbar.tsx +2 -10
- package/src/marketing/NewsletterSection.tsx +7 -6
- package/src/marketing/PricingSection.tsx +66 -39
- package/src/marketing/QualificationSection.tsx +86 -0
- package/src/marketing/StepsSection.tsx +97 -0
- package/src/marketing/TeamSection.tsx +50 -47
- package/src/marketing/TechStackSection.tsx +83 -0
- package/src/motion/AnimatedPill.tsx +71 -0
- package/src/motion/CountdownTimer.tsx +81 -0
|
@@ -17,7 +17,8 @@ export interface PricingCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
17
17
|
features: PricingFeature[];
|
|
18
18
|
cta: string;
|
|
19
19
|
highlighted?: boolean;
|
|
20
|
-
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
badge?: React.ReactNode;
|
|
21
22
|
onSelect?: () => void;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -29,6 +30,7 @@ export function PricingCard({
|
|
|
29
30
|
features,
|
|
30
31
|
cta,
|
|
31
32
|
highlighted = false,
|
|
33
|
+
disabled = false,
|
|
32
34
|
badge,
|
|
33
35
|
onSelect,
|
|
34
36
|
className,
|
|
@@ -39,20 +41,21 @@ export function PricingCard({
|
|
|
39
41
|
const cardInner = (
|
|
40
42
|
<div
|
|
41
43
|
className={cn(
|
|
42
|
-
'relative flex flex-col border p-6',
|
|
44
|
+
'relative flex flex-col border p-6 transition-opacity',
|
|
43
45
|
mode === 'playful'
|
|
44
46
|
? 'rounded-2xl rotate-[0.3deg]'
|
|
45
47
|
: mode === 'smooth'
|
|
46
48
|
? 'rounded-3xl'
|
|
47
49
|
: 'rounded-2xl',
|
|
48
50
|
highlighted ? 'border-primary bg-primary/5 shadow-sm' : 'bg-card',
|
|
51
|
+
disabled && 'pointer-events-none opacity-40 select-none',
|
|
49
52
|
mode !== 'playful' && className,
|
|
50
53
|
)}
|
|
51
54
|
{...(mode !== 'playful' ? props : {})}
|
|
52
55
|
>
|
|
53
56
|
{badge && (
|
|
54
57
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
|
55
|
-
<Badge>{badge}</Badge>
|
|
58
|
+
{typeof badge === 'string' ? <Badge>{badge}</Badge> : badge}
|
|
56
59
|
</div>
|
|
57
60
|
)}
|
|
58
61
|
|
|
@@ -69,6 +72,7 @@ export function PricingCard({
|
|
|
69
72
|
variant={highlighted ? 'default' : 'outline'}
|
|
70
73
|
className="mt-6 w-full"
|
|
71
74
|
onClick={onSelect}
|
|
75
|
+
disabled={disabled}
|
|
72
76
|
>
|
|
73
77
|
{cta}
|
|
74
78
|
</Button>
|
|
@@ -4,14 +4,14 @@ import * as React from 'react';
|
|
|
4
4
|
import { Moon, Sun } from 'lucide-react';
|
|
5
5
|
import { Button } from '@olwiba/cn';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
function getInitialTheme(): 'light' | 'dark' {
|
|
8
|
+
if (typeof document === 'undefined') return 'dark';
|
|
9
|
+
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null;
|
|
10
|
+
return stored ?? (document.documentElement.classList.contains('dark') ? 'dark' : 'light');
|
|
11
|
+
}
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const resolved = stored ?? (document.documentElement.classList.contains('dark') ? 'dark' : 'light');
|
|
13
|
-
setThemeState(resolved);
|
|
14
|
-
}, []);
|
|
13
|
+
export function ThemeSwitchMinimal() {
|
|
14
|
+
const [theme, setThemeState] = React.useState<'light' | 'dark'>(getInitialTheme);
|
|
15
15
|
|
|
16
16
|
const toggle = () => {
|
|
17
17
|
const next = theme === 'dark' ? 'light' : 'dark';
|
package/src/index.ts
CHANGED
|
@@ -54,15 +54,22 @@ export {
|
|
|
54
54
|
export { SectionTitle, type SectionTitleProps } from './marketing/SectionTitle';
|
|
55
55
|
export { HeroSection, type HeroSectionProps } from './marketing/HeroSection';
|
|
56
56
|
export { FeaturesSection, type FeaturesSectionProps } from './marketing/FeaturesSection';
|
|
57
|
+
export { GroupedFeaturesSection, type GroupedFeaturesSectionProps, type GroupedFeatureGroup } from './marketing/GroupedFeaturesSection';
|
|
58
|
+
export { StepsSection, type StepsSectionProps, type StepItem } from './marketing/StepsSection';
|
|
59
|
+
export { TechStackSection, type TechStackSectionProps, type TechStackItem } from './marketing/TechStackSection';
|
|
57
60
|
export { CarouselSection, type CarouselSectionProps } from './marketing/CarouselSection';
|
|
61
|
+
export { FeatureMarqueeSection, type FeatureMarqueeSectionProps, type FeatureMarqueeRow, type FeatureMarqueeItem } from './marketing/FeatureMarqueeSection';
|
|
58
62
|
export { CtaSection, type CtaSectionProps } from './marketing/CtaSection';
|
|
63
|
+
export { CtaCardSection, type CtaCardSectionProps } from './marketing/CtaCardSection';
|
|
59
64
|
export { PricingSection, type PricingSectionProps, type PricingPlan } from './marketing/PricingSection';
|
|
60
65
|
export { TestimonialsSection, type TestimonialsSectionProps } from './marketing/TestimonialsSection';
|
|
61
|
-
export { TeamSection } from './marketing/TeamSection';
|
|
66
|
+
export { TeamSection, type TeamMember, type TeamSectionProps } from './marketing/TeamSection';
|
|
62
67
|
export { FaqSection, type FaqSectionProps } from './marketing/FaqSection';
|
|
63
68
|
export { StatsSection, type StatsSectionProps } from './marketing/StatsSection';
|
|
64
69
|
export { NewsletterSection, type NewsletterSectionProps } from './marketing/NewsletterSection';
|
|
65
70
|
export { ContactSection, type ContactSectionProps, type ContactInfoItem } from './marketing/ContactSection';
|
|
71
|
+
export { ComparisonSection, type ComparisonSectionProps, type ComparisonColumn } from './marketing/ComparisonSection';
|
|
72
|
+
export { QualificationSection, type QualificationSectionProps, type QualificationColumn } from './marketing/QualificationSection';
|
|
66
73
|
|
|
67
74
|
// ─── Marketing — elements ─────────────────────────────────────────────────────
|
|
68
75
|
export { Navbar, type NavbarProps } from './marketing/Navbar';
|
|
@@ -77,6 +84,8 @@ export { Overlay, type OverlayProps, type OverlayVariant } from './overlays/Over
|
|
|
77
84
|
export { FadeIn, type FadeInProps } from './motion/FadeIn';
|
|
78
85
|
export { StaggerChildren, type StaggerChildrenProps } from './motion/StaggerChildren';
|
|
79
86
|
export { CountUp, type CountUpProps } from './motion/CountUp';
|
|
87
|
+
export { CountdownTimer, type CountdownTimerProps } from './motion/CountdownTimer';
|
|
88
|
+
export { AnimatedPill, type AnimatedPillProps } from './motion/AnimatedPill';
|
|
80
89
|
export { PageTransition, type PageTransitionProps } from './motion/PageTransition';
|
|
81
90
|
|
|
82
91
|
// ─── Components — interactive ────────────────────────────────────────────────
|
|
@@ -97,12 +106,17 @@ export { TestimonialCard, type TestimonialCardProps } from './components/Testimo
|
|
|
97
106
|
export { PricingCard, type PricingCardProps, type PricingFeature } from './components/PricingCard';
|
|
98
107
|
export { ImageCard, type ImageCardProps } from './components/ImageCard';
|
|
99
108
|
|
|
109
|
+
// ─── Components — diagram ────────────────────────────────────────────────────
|
|
110
|
+
export { FlowConnector, type FlowConnectorProps } from './components/FlowConnector';
|
|
111
|
+
export { FlowBracket, type FlowBracketProps } from './components/FlowBracket';
|
|
112
|
+
|
|
100
113
|
// ─── Components — utility ────────────────────────────────────────────────────
|
|
101
114
|
export { FullPageSpinner } from './components/Spinner';
|
|
102
115
|
export { PageHeader, type PageHeaderProps, type PageHeaderBreadcrumb, type PageHeaderBackButton } from './components/PageHeader';
|
|
103
116
|
export { Suspensed } from './components/Suspensed';
|
|
104
117
|
export { ThemeSwitchMinimal } from './components/ThemeSwitchMinimal';
|
|
105
118
|
export { ModeSwitchMinimal } from './components/ModeSwitchMinimal';
|
|
119
|
+
export { BrandColorSwitchMinimal } from './components/BrandColorSwitchMinimal';
|
|
106
120
|
export { ThemeColorUpdater } from './components/ThemeColorUpdater';
|
|
107
121
|
export { VersionBanner } from './components/VersionBanner';
|
|
108
122
|
export { DevBanner, type DevBannerProps } from './components/DevBanner';
|
|
@@ -113,7 +127,7 @@ export { RootErrorFallback } from './components/RootErrorFallback';
|
|
|
113
127
|
export { PostCard, type PostCardProps } from './blog/PostCard';
|
|
114
128
|
export { PostList, type PostListProps } from './blog/PostList';
|
|
115
129
|
export { ChangelogList, type ChangelogListProps } from './blog/ChangelogList';
|
|
116
|
-
export { ChangelogCard, type ChangelogCardProps } from './blog/ChangelogCard';
|
|
130
|
+
export { ChangelogCard, type ChangelogCardProps, type ChangelogHighlight, type ChangelogReleaseType } from './blog/ChangelogCard';
|
|
117
131
|
|
|
118
132
|
// ─── MDX ──────────────────────────────────────────────────────────────────────
|
|
119
133
|
export { MdxContent, type MdxContentProps } from './blog/MdxContent';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check, Minus } from 'lucide-react';
|
|
4
|
+
import { cn, useUIVariant } from '@olwiba/cn';
|
|
5
|
+
import { SectionTitle } from './SectionTitle';
|
|
6
|
+
import { StaggerChildren } from '../motion/StaggerChildren';
|
|
7
|
+
|
|
8
|
+
export interface ComparisonColumn {
|
|
9
|
+
label: string;
|
|
10
|
+
cost: string;
|
|
11
|
+
costDetail?: string;
|
|
12
|
+
items: string[];
|
|
13
|
+
highlighted?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ComparisonSectionProps {
|
|
17
|
+
badge?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
columns: ComparisonColumn[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ComparisonSection({
|
|
24
|
+
badge = 'The math',
|
|
25
|
+
title = 'The math is simple',
|
|
26
|
+
description,
|
|
27
|
+
columns,
|
|
28
|
+
}: ComparisonSectionProps) {
|
|
29
|
+
const mode = useUIVariant();
|
|
30
|
+
const sectionClasses = cn(
|
|
31
|
+
'overflow-hidden bg-card',
|
|
32
|
+
mode === 'smooth' && 'rounded-3xl border',
|
|
33
|
+
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
34
|
+
!mode && 'rounded-2xl border',
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<section className={sectionClasses}>
|
|
39
|
+
<div className="px-6 py-14 sm:px-10 sm:py-20">
|
|
40
|
+
<div className="mx-auto max-w-4xl">
|
|
41
|
+
<SectionTitle badge={badge} title={title} description={description} />
|
|
42
|
+
|
|
43
|
+
<StaggerChildren className="mt-12 grid gap-4 sm:grid-cols-3">
|
|
44
|
+
{columns.map((col) => (
|
|
45
|
+
<div
|
|
46
|
+
key={col.label}
|
|
47
|
+
className={cn(
|
|
48
|
+
'flex flex-col rounded-2xl border p-6',
|
|
49
|
+
col.highlighted
|
|
50
|
+
? 'border-foreground bg-foreground text-background'
|
|
51
|
+
: 'border-border bg-muted/40',
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
<div className="mb-6">
|
|
55
|
+
<p
|
|
56
|
+
className={cn(
|
|
57
|
+
'text-xs font-semibold uppercase tracking-widest',
|
|
58
|
+
col.highlighted ? 'text-background/60' : 'text-muted-foreground',
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{col.label}
|
|
62
|
+
</p>
|
|
63
|
+
<p
|
|
64
|
+
className={cn(
|
|
65
|
+
'mt-2 text-2xl font-bold tracking-tight',
|
|
66
|
+
col.highlighted ? 'text-background' : 'text-foreground',
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
{col.cost}
|
|
70
|
+
</p>
|
|
71
|
+
{col.costDetail && (
|
|
72
|
+
<p
|
|
73
|
+
className={cn(
|
|
74
|
+
'mt-1 text-sm',
|
|
75
|
+
col.highlighted ? 'text-background/60' : 'text-muted-foreground',
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{col.costDetail}
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<ul className="flex flex-col gap-3">
|
|
84
|
+
{col.items.map((item) => (
|
|
85
|
+
<li key={item} className="flex items-start gap-2.5">
|
|
86
|
+
{col.highlighted ? (
|
|
87
|
+
<Check className="mt-0.5 size-4 shrink-0 text-background/70" />
|
|
88
|
+
) : (
|
|
89
|
+
<Minus className="mt-0.5 size-4 shrink-0 text-muted-foreground/50" />
|
|
90
|
+
)}
|
|
91
|
+
<span
|
|
92
|
+
className={cn(
|
|
93
|
+
'text-sm leading-snug',
|
|
94
|
+
col.highlighted ? 'text-background/90' : 'text-muted-foreground',
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{item}
|
|
98
|
+
</span>
|
|
99
|
+
</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
</StaggerChildren>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</section>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
|
-
import { Mail, MapPin, MessageSquare,
|
|
5
|
-
import { Badge, Button, Input, Label, Separator, Textarea } from '@olwiba/cn';
|
|
4
|
+
import { Mail, MapPin, MessageSquare, Send, type LucideIcon } from 'lucide-react';
|
|
5
|
+
import { Badge, Button, Checkbox, Input, Label, Separator, Textarea } from '@olwiba/cn';
|
|
6
6
|
|
|
7
7
|
export type ContactInfoItem = {
|
|
8
8
|
label: string;
|
|
@@ -15,6 +15,7 @@ export interface ContactSectionProps {
|
|
|
15
15
|
title?: string;
|
|
16
16
|
description?: string;
|
|
17
17
|
contactInfo?: ContactInfoItem[];
|
|
18
|
+
privacyHref?: string;
|
|
18
19
|
successTitle?: string;
|
|
19
20
|
successDescription?: string;
|
|
20
21
|
sendAnotherLabel?: string;
|
|
@@ -23,17 +24,16 @@ export interface ContactSectionProps {
|
|
|
23
24
|
|
|
24
25
|
const defaultContactInfoResolved: ContactInfoItem[] = [
|
|
25
26
|
{ icon: Mail, label: 'Email', value: 'hello@olwiba.com' },
|
|
26
|
-
{ icon:
|
|
27
|
-
{ icon: MapPin, label: 'Office', value: 'San Francisco, CA' },
|
|
27
|
+
{ icon: MapPin, label: 'Base', value: 'Aotearoa New Zealand' },
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
const defaults = {
|
|
31
|
-
badge: 'Contact
|
|
31
|
+
badge: 'Contact',
|
|
32
32
|
title: "Let's talk",
|
|
33
33
|
description:
|
|
34
|
-
"Have a question or want to work together? Fill in the form and
|
|
34
|
+
"Have a question or want to work together? Fill in the form and you'll hear back from the person who built it.",
|
|
35
35
|
successTitle: 'Message sent',
|
|
36
|
-
successDescription: "Thanks for reaching out.
|
|
36
|
+
successDescription: "Thanks for reaching out. You'll get a reply within one business day.",
|
|
37
37
|
sendAnotherLabel: 'Send another',
|
|
38
38
|
};
|
|
39
39
|
|
|
@@ -42,6 +42,7 @@ export function ContactSection({
|
|
|
42
42
|
title = defaults.title,
|
|
43
43
|
description = defaults.description,
|
|
44
44
|
contactInfo = defaultContactInfoResolved,
|
|
45
|
+
privacyHref,
|
|
45
46
|
successTitle = defaults.successTitle,
|
|
46
47
|
successDescription = defaults.successDescription,
|
|
47
48
|
sendAnotherLabel = defaults.sendAnotherLabel,
|
|
@@ -58,62 +59,67 @@ export function ContactSection({
|
|
|
58
59
|
return (
|
|
59
60
|
<section className="overflow-hidden rounded-2xl border bg-card">
|
|
60
61
|
<div className="grid lg:grid-cols-[1fr_1.4fr]">
|
|
62
|
+
|
|
63
|
+
{/* Left panel */}
|
|
61
64
|
<div className="relative overflow-hidden bg-primary px-8 py-12 text-primary-foreground">
|
|
62
|
-
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary-foreground)/0.
|
|
63
|
-
<div className="
|
|
65
|
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary-foreground)/0.12),transparent_55%)]" />
|
|
66
|
+
<div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_left,hsl(var(--primary-foreground)/0.06),transparent_60%)]" />
|
|
67
|
+
<div className="relative flex h-full flex-col gap-8">
|
|
64
68
|
<div>
|
|
65
69
|
<Badge
|
|
66
70
|
variant="secondary"
|
|
67
|
-
className="mb-
|
|
71
|
+
className="mb-4 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/20"
|
|
68
72
|
>
|
|
69
73
|
{badge}
|
|
70
74
|
</Badge>
|
|
71
|
-
<h2 className="text-
|
|
72
|
-
<p className="mt-
|
|
75
|
+
<h2 className="text-3xl font-semibold leading-tight">{title}</h2>
|
|
76
|
+
<p className="mt-3 text-sm leading-relaxed text-primary-foreground/70">{description}</p>
|
|
73
77
|
</div>
|
|
74
78
|
|
|
75
79
|
<Separator className="bg-primary-foreground/20" />
|
|
76
80
|
|
|
77
|
-
<div className="space-y-
|
|
81
|
+
<div className="space-y-5">
|
|
78
82
|
{contactInfo.map(({ icon: Icon = Mail, label, value }) => (
|
|
79
|
-
<div key={label} className="flex items-
|
|
80
|
-
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-foreground/10">
|
|
83
|
+
<div key={label} className="flex items-start gap-3">
|
|
84
|
+
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-foreground/10">
|
|
81
85
|
<Icon className="size-4" />
|
|
82
86
|
</div>
|
|
83
87
|
<div>
|
|
84
|
-
<div className="text-xs text-primary-foreground/
|
|
85
|
-
<div className="text-sm font-medium">{value}</div>
|
|
88
|
+
<div className="text-xs font-medium uppercase tracking-wider text-primary-foreground/50">{label}</div>
|
|
89
|
+
<div className="mt-0.5 text-sm font-medium">{value}</div>
|
|
86
90
|
</div>
|
|
87
91
|
</div>
|
|
88
92
|
))}
|
|
89
93
|
</div>
|
|
94
|
+
|
|
95
|
+
<div className="mt-auto">
|
|
96
|
+
<p className="text-xs text-primary-foreground/40">
|
|
97
|
+
Response within one business day.
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
90
100
|
</div>
|
|
91
101
|
</div>
|
|
92
102
|
|
|
93
|
-
|
|
103
|
+
{/* Right panel — form */}
|
|
104
|
+
<div className="p-8 sm:p-10">
|
|
94
105
|
{submitted ? (
|
|
95
|
-
<div className="flex h-full min-h-[
|
|
96
|
-
<div className="flex h-
|
|
106
|
+
<div className="flex h-full min-h-[360px] flex-col items-center justify-center gap-5 text-center">
|
|
107
|
+
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
97
108
|
<MessageSquare className="size-6" />
|
|
98
109
|
</div>
|
|
99
110
|
<div>
|
|
100
|
-
<h3 className="font-semibold">{successTitle}</h3>
|
|
101
|
-
<p className="mt-1 text-sm text-muted-foreground">{successDescription}</p>
|
|
111
|
+
<h3 className="text-lg font-semibold">{successTitle}</h3>
|
|
112
|
+
<p className="mt-1.5 text-sm text-muted-foreground">{successDescription}</p>
|
|
102
113
|
</div>
|
|
103
|
-
<Button variant="outline" onClick={() => setSubmitted(false)}>
|
|
114
|
+
<Button variant="outline" size="sm" onClick={() => setSubmitted(false)}>
|
|
104
115
|
{sendAnotherLabel}
|
|
105
116
|
</Button>
|
|
106
117
|
</div>
|
|
107
118
|
) : (
|
|
108
119
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
tabIndex={-1}
|
|
113
|
-
autoComplete="off"
|
|
114
|
-
aria-hidden="true"
|
|
115
|
-
className="hidden"
|
|
116
|
-
/>
|
|
120
|
+
{/* honeypot */}
|
|
121
|
+
<input type="text" name="_hp" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hidden" />
|
|
122
|
+
|
|
117
123
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
118
124
|
<div className="space-y-1.5">
|
|
119
125
|
<Label htmlFor="contact-first">First name</Label>
|
|
@@ -124,24 +130,58 @@ export function ContactSection({
|
|
|
124
130
|
<Input id="contact-last" name="lastName" placeholder="Reed" required />
|
|
125
131
|
</div>
|
|
126
132
|
</div>
|
|
133
|
+
|
|
134
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
135
|
+
<div className="space-y-1.5">
|
|
136
|
+
<Label htmlFor="contact-company" className="flex gap-1">
|
|
137
|
+
Company
|
|
138
|
+
<span className="text-muted-foreground">(optional)</span>
|
|
139
|
+
</Label>
|
|
140
|
+
<Input id="contact-company" name="company" placeholder="Acme Inc." />
|
|
141
|
+
</div>
|
|
142
|
+
<div className="space-y-1.5">
|
|
143
|
+
<Label htmlFor="contact-phone" className="flex gap-1">
|
|
144
|
+
Phone
|
|
145
|
+
<span className="text-muted-foreground">(optional)</span>
|
|
146
|
+
</Label>
|
|
147
|
+
<Input id="contact-phone" name="phone" type="tel" placeholder="+1 (555) 000-0000" />
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
127
151
|
<div className="space-y-1.5">
|
|
128
|
-
<Label htmlFor="contact-email">
|
|
152
|
+
<Label htmlFor="contact-email">Work email</Label>
|
|
129
153
|
<Input id="contact-email" name="email" type="email" placeholder="olivia@company.com" required />
|
|
130
154
|
</div>
|
|
155
|
+
|
|
131
156
|
<div className="space-y-1.5">
|
|
132
157
|
<Label htmlFor="contact-subject">Subject</Label>
|
|
133
158
|
<Input id="contact-subject" name="subject" placeholder="How can we help?" required />
|
|
134
159
|
</div>
|
|
160
|
+
|
|
135
161
|
<div className="space-y-1.5">
|
|
136
162
|
<Label htmlFor="contact-message">Message</Label>
|
|
137
163
|
<Textarea
|
|
138
164
|
id="contact-message"
|
|
139
165
|
name="message"
|
|
140
166
|
placeholder="Tell us what you're working on..."
|
|
141
|
-
className="min-h-
|
|
167
|
+
className="min-h-32 resize-none"
|
|
142
168
|
required
|
|
143
169
|
/>
|
|
144
170
|
</div>
|
|
171
|
+
|
|
172
|
+
{privacyHref && (
|
|
173
|
+
<div className="flex items-start gap-3">
|
|
174
|
+
<Checkbox id="contact-privacy" name="privacy" required className="mt-0.5" />
|
|
175
|
+
<Label htmlFor="contact-privacy" className="text-sm font-normal leading-snug text-muted-foreground">
|
|
176
|
+
By submitting this form you agree to our{' '}
|
|
177
|
+
<a href={privacyHref} className="font-medium text-foreground underline-offset-2 hover:underline">
|
|
178
|
+
privacy policy
|
|
179
|
+
</a>
|
|
180
|
+
.
|
|
181
|
+
</Label>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
145
185
|
<Button type="submit" className="w-full">
|
|
146
186
|
Send message
|
|
147
187
|
<Send className="ml-2 size-4" />
|
|
@@ -149,6 +189,7 @@ export function ContactSection({
|
|
|
149
189
|
</form>
|
|
150
190
|
)}
|
|
151
191
|
</div>
|
|
192
|
+
|
|
152
193
|
</div>
|
|
153
194
|
</section>
|
|
154
195
|
);
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { LucideIcon } from 'lucide-react';
|
|
4
|
+
import { cn, useUIVariant } from '@olwiba/cn';
|
|
5
|
+
import { SectionTitle } from './SectionTitle';
|
|
6
|
+
import { FadeIn } from '../motion/FadeIn';
|
|
7
|
+
|
|
8
|
+
export interface FeatureMarqueeItem {
|
|
9
|
+
icon: LucideIcon;
|
|
10
|
+
title: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FeatureMarqueeRow {
|
|
14
|
+
items: FeatureMarqueeItem[];
|
|
15
|
+
direction?: 'left' | 'right';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FeatureMarqueeSectionProps {
|
|
19
|
+
badge?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
rows: FeatureMarqueeRow[];
|
|
23
|
+
speed?: 'slow' | 'normal' | 'fast';
|
|
24
|
+
pauseOnHover?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const speedDuration = {
|
|
28
|
+
slow: '280s',
|
|
29
|
+
normal: '180s',
|
|
30
|
+
fast: '100s',
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export function FeatureMarqueeSection({
|
|
34
|
+
badge,
|
|
35
|
+
title,
|
|
36
|
+
description,
|
|
37
|
+
rows,
|
|
38
|
+
speed = 'normal',
|
|
39
|
+
pauseOnHover = true,
|
|
40
|
+
}: FeatureMarqueeSectionProps) {
|
|
41
|
+
const mode = useUIVariant();
|
|
42
|
+
const duration = speedDuration[speed];
|
|
43
|
+
|
|
44
|
+
const sectionClasses = cn(
|
|
45
|
+
'overflow-hidden bg-card',
|
|
46
|
+
mode === 'smooth' && 'rounded-3xl border',
|
|
47
|
+
mode === 'playful' && 'rounded-2xl border-primary/25 border',
|
|
48
|
+
!mode && 'rounded-2xl border',
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<section className={sectionClasses}>
|
|
53
|
+
<div className="py-14 sm:py-20">
|
|
54
|
+
{(title || badge || description) && (
|
|
55
|
+
<FadeIn direction="up">
|
|
56
|
+
<div className="mx-auto mb-10 max-w-4xl px-6 sm:px-10">
|
|
57
|
+
<SectionTitle title={title ?? ''} description={description} badge={badge} />
|
|
58
|
+
</div>
|
|
59
|
+
</FadeIn>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
<div className="space-y-3">
|
|
63
|
+
{rows.map((row, i) => {
|
|
64
|
+
const direction = row.direction ?? (i % 2 === 0 ? 'left' : 'right');
|
|
65
|
+
const reversed = direction === 'right';
|
|
66
|
+
// For right-scroll rows: reverse source order so item 0 starts on the right
|
|
67
|
+
// and items enter from the right in natural reading order.
|
|
68
|
+
const source = reversed ? [...row.items].reverse() : row.items;
|
|
69
|
+
// Repeat enough that one set exceeds 4K viewport (~3840px).
|
|
70
|
+
const set = Array.from({ length: 4 }, () => source).flat();
|
|
71
|
+
const items = [...set, ...set];
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
key={i}
|
|
76
|
+
className="group relative overflow-hidden"
|
|
77
|
+
style={{
|
|
78
|
+
maskImage:
|
|
79
|
+
'linear-gradient(to right, transparent, black 8%, black 92%, transparent)',
|
|
80
|
+
WebkitMaskImage:
|
|
81
|
+
'linear-gradient(to right, transparent, black 8%, black 92%, transparent)',
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<div
|
|
85
|
+
className={cn(
|
|
86
|
+
'flex w-max gap-3 py-0.5',
|
|
87
|
+
pauseOnHover && 'group-hover:[animation-play-state:paused]',
|
|
88
|
+
)}
|
|
89
|
+
style={{
|
|
90
|
+
animation: `feature-marquee-left ${duration} linear infinite`,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{items.map((item, j) => {
|
|
94
|
+
const Icon = item.icon;
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
key={`${item.title}-${j}`}
|
|
98
|
+
className="flex shrink-0 items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm shadow-sm"
|
|
99
|
+
>
|
|
100
|
+
<Icon className="size-3.5 shrink-0 text-primary" />
|
|
101
|
+
<span className="whitespace-nowrap text-foreground">{item.title}</span>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<style>{`
|
|
113
|
+
@keyframes feature-marquee-left {
|
|
114
|
+
from { transform: translateX(0); }
|
|
115
|
+
to { transform: translateX(-50%); }
|
|
116
|
+
}
|
|
117
|
+
`}</style>
|
|
118
|
+
</section>
|
|
119
|
+
);
|
|
120
|
+
}
|