@olwiba/ui 0.1.8 → 0.1.10
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 +9 -4
- package/dist/index.js +234 -133
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/FlowBracket.tsx +56 -23
- package/src/marketing/ComparisonSection.tsx +8 -19
- package/src/marketing/ContactSection.tsx +181 -102
- package/src/marketing/FeatureMarqueeSection.tsx +6 -5
- package/src/marketing/Footer.tsx +18 -0
package/package.json
CHANGED
|
@@ -18,7 +18,7 @@ export interface FlowBracketProps {
|
|
|
18
18
|
* Defaults to 22.
|
|
19
19
|
*/
|
|
20
20
|
extent?: number;
|
|
21
|
-
/** Show open-chevron arrowhead at the
|
|
21
|
+
/** Show open-chevron arrowhead at the destination end. Default: true. */
|
|
22
22
|
arrow?: boolean;
|
|
23
23
|
/** CSS color for both bracket lines and animated dots. Defaults to muted slate. */
|
|
24
24
|
color?: string;
|
|
@@ -46,16 +46,14 @@ export interface FlowBracketProps {
|
|
|
46
46
|
children: React.ReactNode;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
const MARKER_L = 'fb-ml';
|
|
50
|
-
const MARKER_R = 'fb-mr';
|
|
51
|
-
|
|
52
49
|
/**
|
|
53
50
|
* Wraps children in a relative container and draws square bracket lines on the
|
|
54
51
|
* left and/or right outside edges — connecting the top of the first child down
|
|
55
52
|
* to the bottom of the last child (e.g. docs → SYNC direction).
|
|
56
53
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
54
|
+
* Arrowheads are rendered as absolutely-positioned SVGs (not SVG markers) so
|
|
55
|
+
* they render at a consistent pixel size regardless of the bracket SVG's aspect
|
|
56
|
+
* ratio (which spans the full section height with preserveAspectRatio="none").
|
|
59
57
|
*/
|
|
60
58
|
export function FlowBracket({
|
|
61
59
|
anchorTop = 8,
|
|
@@ -80,17 +78,15 @@ export function FlowBracket({
|
|
|
80
78
|
const cl = colorLeft ?? color;
|
|
81
79
|
const cr = colorRight ?? color;
|
|
82
80
|
|
|
83
|
-
// Left bracket: bottom → top (arrowhead at top, pointing into genesis)
|
|
81
|
+
// Left bracket: bottom → top (arrowhead at top, pointing right into genesis)
|
|
84
82
|
const leftD = `M 0,${anchorBottom} L ${L},${anchorBottom} L ${L},${anchorTop} L 0,${anchorTop}`;
|
|
85
|
-
// Right bracket: reversed = top → bottom (arrowhead at bottom, pointing into sync)
|
|
83
|
+
// Right bracket: reversed = top → bottom (arrowhead at bottom, pointing left into sync)
|
|
86
84
|
const rightD = reverseRight
|
|
87
85
|
? `M 1000,${anchorTop} L ${R},${anchorTop} L ${R},${anchorBottom} L 1000,${anchorBottom}`
|
|
88
86
|
: `M 1000,${anchorBottom} L ${R},${anchorBottom} L ${R},${anchorTop} L 1000,${anchorTop}`;
|
|
89
87
|
|
|
90
88
|
// CSS dot: extent as % of container width
|
|
91
89
|
const ep = `${(extent / 10).toFixed(1)}%`;
|
|
92
|
-
// Dots travel bottom → top (sync → genesis/docs direction), fade in/out at card edges
|
|
93
|
-
// fb-drr: reversed — top → bottom (docs → sync direction) used when reverseRight=true
|
|
94
90
|
const dotKeyframes = animate ? `
|
|
95
91
|
@keyframes fb-dl{
|
|
96
92
|
0%{top:${anchorBottom}%;left:0;opacity:0}
|
|
@@ -127,6 +123,25 @@ export function FlowBracket({
|
|
|
127
123
|
opacity: 0,
|
|
128
124
|
};
|
|
129
125
|
|
|
126
|
+
// Shared arrowhead polyline props — same chevron shape as ecosystem connector markers
|
|
127
|
+
const chevronProps = {
|
|
128
|
+
fill: 'none',
|
|
129
|
+
strokeWidth: '1.5',
|
|
130
|
+
strokeLinecap: 'round' as const,
|
|
131
|
+
strokeLinejoin: 'round' as const,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Arrowhead SVG style — positioned in CSS space so it renders at a fixed pixel size
|
|
135
|
+
// regardless of the bracket SVG's non-uniform scale (preserveAspectRatio="none")
|
|
136
|
+
const arrowBase: React.CSSProperties = {
|
|
137
|
+
position: 'absolute',
|
|
138
|
+
width: 10,
|
|
139
|
+
height: 8,
|
|
140
|
+
overflow: 'visible',
|
|
141
|
+
pointerEvents: 'none',
|
|
142
|
+
transform: 'translate(-50%, -50%)',
|
|
143
|
+
};
|
|
144
|
+
|
|
130
145
|
return (
|
|
131
146
|
<div className={className} style={{ position: 'relative', ...style }}>
|
|
132
147
|
{animate && <style>{dotKeyframes}</style>}
|
|
@@ -137,24 +152,42 @@ export function FlowBracket({
|
|
|
137
152
|
className="pointer-events-none absolute inset-0"
|
|
138
153
|
style={{ width: '100%', height: '100%', overflow: 'visible' }}
|
|
139
154
|
>
|
|
140
|
-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
</marker>
|
|
144
|
-
<marker id={MARKER_R} markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
|
|
145
|
-
<polyline points="1,1 8,4 1,7" fill="none" stroke={cr} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
146
|
-
</marker>
|
|
147
|
-
</defs>
|
|
148
|
-
{left && (
|
|
149
|
-
<path d={leftD} fill="none" stroke={cl} strokeWidth={1.5} strokeDasharray="5,4"
|
|
150
|
-
vectorEffect="non-scaling-stroke" {...(arrow ? { markerEnd: `url(#${MARKER_L})` } : {})} />
|
|
155
|
+
{left && (
|
|
156
|
+
<path d={leftD} fill="none" style={{ stroke: cl }} strokeWidth={1.5} strokeDasharray="5,4"
|
|
157
|
+
vectorEffect="non-scaling-stroke" />
|
|
151
158
|
)}
|
|
152
159
|
{right && (
|
|
153
|
-
<path d={rightD} fill="none"
|
|
154
|
-
vectorEffect="non-scaling-stroke"
|
|
160
|
+
<path d={rightD} fill="none" style={{ stroke: cr }} strokeWidth={1.5} strokeDasharray="5,4"
|
|
161
|
+
vectorEffect="non-scaling-stroke" />
|
|
155
162
|
)}
|
|
156
163
|
</svg>
|
|
157
164
|
|
|
165
|
+
{/* Left bracket arrowhead — tip points right (→) into the genesis/docs tier */}
|
|
166
|
+
{arrow && left && (
|
|
167
|
+
<svg
|
|
168
|
+
aria-hidden
|
|
169
|
+
viewBox="0 0 10 8"
|
|
170
|
+
style={{ ...arrowBase, top: `${anchorTop}%`, left: 0 }}
|
|
171
|
+
>
|
|
172
|
+
<polyline points="1,1 8,4 1,7" style={{ stroke: cl }} {...chevronProps} />
|
|
173
|
+
</svg>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* Right bracket arrowhead — tip points left (←) into the destination tier */}
|
|
177
|
+
{arrow && right && (
|
|
178
|
+
<svg
|
|
179
|
+
aria-hidden
|
|
180
|
+
viewBox="0 0 10 8"
|
|
181
|
+
style={{
|
|
182
|
+
...arrowBase,
|
|
183
|
+
top: reverseRight ? `${anchorBottom}%` : `${anchorTop}%`,
|
|
184
|
+
left: '100%',
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<polyline points="9,1 2,4 9,7" style={{ stroke: cr }} {...chevronProps} />
|
|
188
|
+
</svg>
|
|
189
|
+
)}
|
|
190
|
+
|
|
158
191
|
{children}
|
|
159
192
|
|
|
160
193
|
{/* Dots placed after children so card stacking contexts (z-index ≥ 1) render above */}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Check, Minus } from 'lucide-react';
|
|
4
3
|
import { cn, useUIVariant } from '@olwiba/cn';
|
|
5
4
|
import { SectionTitle } from './SectionTitle';
|
|
6
5
|
import { StaggerChildren } from '../motion/StaggerChildren';
|
|
@@ -45,9 +44,9 @@ export function ComparisonSection({
|
|
|
45
44
|
<div
|
|
46
45
|
key={col.label}
|
|
47
46
|
className={cn(
|
|
48
|
-
'flex flex-col rounded-2xl border p-6',
|
|
47
|
+
'flex h-full flex-col rounded-2xl border p-6',
|
|
49
48
|
col.highlighted
|
|
50
|
-
? 'border-
|
|
49
|
+
? 'border-primary bg-primary/10 ring-1 ring-primary/20'
|
|
51
50
|
: 'border-border bg-muted/40',
|
|
52
51
|
)}
|
|
53
52
|
>
|
|
@@ -55,26 +54,16 @@ export function ComparisonSection({
|
|
|
55
54
|
<p
|
|
56
55
|
className={cn(
|
|
57
56
|
'text-xs font-semibold uppercase tracking-widest',
|
|
58
|
-
col.highlighted ? 'text-
|
|
57
|
+
col.highlighted ? 'text-primary/80' : 'text-muted-foreground',
|
|
59
58
|
)}
|
|
60
59
|
>
|
|
61
60
|
{col.label}
|
|
62
61
|
</p>
|
|
63
|
-
<p
|
|
64
|
-
className={cn(
|
|
65
|
-
'mt-2 text-2xl font-bold tracking-tight',
|
|
66
|
-
col.highlighted ? 'text-background' : 'text-foreground',
|
|
67
|
-
)}
|
|
68
|
-
>
|
|
62
|
+
<p className="mt-2 text-2xl font-bold tracking-tight text-foreground">
|
|
69
63
|
{col.cost}
|
|
70
64
|
</p>
|
|
71
65
|
{col.costDetail && (
|
|
72
|
-
<p
|
|
73
|
-
className={cn(
|
|
74
|
-
'mt-1 text-sm',
|
|
75
|
-
col.highlighted ? 'text-background/60' : 'text-muted-foreground',
|
|
76
|
-
)}
|
|
77
|
-
>
|
|
66
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
78
67
|
{col.costDetail}
|
|
79
68
|
</p>
|
|
80
69
|
)}
|
|
@@ -84,14 +73,14 @@ export function ComparisonSection({
|
|
|
84
73
|
{col.items.map((item) => (
|
|
85
74
|
<li key={item} className="flex items-start gap-2.5">
|
|
86
75
|
{col.highlighted ? (
|
|
87
|
-
<
|
|
76
|
+
<span className="mt-0.5 shrink-0 text-sm leading-none">⚡</span>
|
|
88
77
|
) : (
|
|
89
|
-
<
|
|
78
|
+
<span className="mt-0.5 shrink-0 text-sm leading-none">❌</span>
|
|
90
79
|
)}
|
|
91
80
|
<span
|
|
92
81
|
className={cn(
|
|
93
82
|
'text-sm leading-snug',
|
|
94
|
-
col.highlighted ? 'text-
|
|
83
|
+
col.highlighted ? 'text-foreground' : 'text-muted-foreground',
|
|
95
84
|
)}
|
|
96
85
|
>
|
|
97
86
|
{item}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import * as React from 'react';
|
|
4
|
-
import {
|
|
5
|
-
import { Badge, Button,
|
|
4
|
+
import { ChevronDown, Mail, MessageSquare, Send, type LucideIcon } from 'lucide-react';
|
|
5
|
+
import { Badge, Button, Input, Label, Textarea } from '@olwiba/cn';
|
|
6
6
|
|
|
7
7
|
export type ContactInfoItem = {
|
|
8
8
|
label: string;
|
|
@@ -22,9 +22,13 @@ export interface ContactSectionProps {
|
|
|
22
22
|
onSubmit?: (event: React.FormEvent<HTMLFormElement>) => void | Promise<void>;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
const
|
|
26
|
-
{
|
|
27
|
-
{
|
|
25
|
+
const PHONE_PREFIXES = [
|
|
26
|
+
{ label: 'US +1', value: '+1-US' },
|
|
27
|
+
{ label: 'NZ +64', value: '+64-NZ' },
|
|
28
|
+
{ label: 'AU +61', value: '+61-AU' },
|
|
29
|
+
{ label: 'UK +44', value: '+44-UK' },
|
|
30
|
+
{ label: 'CA +1', value: '+1-CA' },
|
|
31
|
+
{ label: 'EU', value: '+0-EU' },
|
|
28
32
|
];
|
|
29
33
|
|
|
30
34
|
const defaults = {
|
|
@@ -41,7 +45,7 @@ export function ContactSection({
|
|
|
41
45
|
badge = defaults.badge,
|
|
42
46
|
title = defaults.title,
|
|
43
47
|
description = defaults.description,
|
|
44
|
-
contactInfo
|
|
48
|
+
contactInfo,
|
|
45
49
|
privacyHref,
|
|
46
50
|
successTitle = defaults.successTitle,
|
|
47
51
|
successDescription = defaults.successDescription,
|
|
@@ -58,137 +62,212 @@ export function ContactSection({
|
|
|
58
62
|
|
|
59
63
|
return (
|
|
60
64
|
<section className="overflow-hidden rounded-2xl border bg-card">
|
|
61
|
-
<div className="
|
|
62
|
-
|
|
63
|
-
{/* Left panel */}
|
|
64
|
-
<div className="relative overflow-hidden bg-primary px-8 py-12 text-primary-foreground">
|
|
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">
|
|
68
|
-
<div>
|
|
69
|
-
<Badge
|
|
70
|
-
variant="secondary"
|
|
71
|
-
className="mb-4 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/20"
|
|
72
|
-
>
|
|
73
|
-
{badge}
|
|
74
|
-
</Badge>
|
|
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>
|
|
77
|
-
</div>
|
|
65
|
+
<div className="px-6 py-14 sm:px-10 sm:py-20">
|
|
78
66
|
|
|
79
|
-
|
|
67
|
+
{/* Header */}
|
|
68
|
+
<div className="mx-auto max-w-2xl text-center">
|
|
69
|
+
{badge && (
|
|
70
|
+
<div className="mb-4 flex justify-center">
|
|
71
|
+
<Badge variant="secondary">{badge}</Badge>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
<h2 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl">
|
|
75
|
+
{title}
|
|
76
|
+
</h2>
|
|
77
|
+
<p className="mt-4 text-lg text-muted-foreground">{description}</p>
|
|
80
78
|
|
|
81
|
-
|
|
79
|
+
{contactInfo && contactInfo.length > 0 && (
|
|
80
|
+
<div className="mt-8 flex flex-wrap justify-center gap-6">
|
|
82
81
|
{contactInfo.map(({ icon: Icon = Mail, label, value }) => (
|
|
83
|
-
<div key={label} className="flex items-
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
</div>
|
|
87
|
-
<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>
|
|
90
|
-
</div>
|
|
82
|
+
<div key={label} className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
83
|
+
<Icon className="size-4 shrink-0" />
|
|
84
|
+
<span>{value}</span>
|
|
91
85
|
</div>
|
|
92
86
|
))}
|
|
93
87
|
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
94
90
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
{/* Body */}
|
|
92
|
+
{submitted ? (
|
|
93
|
+
<div className="mx-auto mt-16 max-w-xl text-center">
|
|
94
|
+
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
95
|
+
<MessageSquare className="size-6" />
|
|
99
96
|
</div>
|
|
97
|
+
<h3 className="mt-5 text-lg font-semibold">{successTitle}</h3>
|
|
98
|
+
<p className="mt-2 text-sm text-muted-foreground">{successDescription}</p>
|
|
99
|
+
<Button variant="outline" size="sm" className="mt-6" onClick={() => setSubmitted(false)}>
|
|
100
|
+
{sendAnotherLabel}
|
|
101
|
+
</Button>
|
|
100
102
|
</div>
|
|
101
|
-
|
|
103
|
+
) : (
|
|
104
|
+
<form onSubmit={handleSubmit} className="mx-auto mt-16 max-w-xl sm:mt-20">
|
|
105
|
+
{/* Honeypot */}
|
|
106
|
+
<input
|
|
107
|
+
type="text"
|
|
108
|
+
name="_hp"
|
|
109
|
+
tabIndex={-1}
|
|
110
|
+
autoComplete="off"
|
|
111
|
+
aria-hidden="true"
|
|
112
|
+
className="hidden"
|
|
113
|
+
/>
|
|
102
114
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{submitted ? (
|
|
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">
|
|
108
|
-
<MessageSquare className="size-6" />
|
|
109
|
-
</div>
|
|
115
|
+
<div className="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
|
|
116
|
+
{/* First name */}
|
|
110
117
|
<div>
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
<div className="grid gap-4 sm:grid-cols-2">
|
|
124
|
-
<div className="space-y-1.5">
|
|
125
|
-
<Label htmlFor="contact-first">First name</Label>
|
|
126
|
-
<Input id="contact-first" name="firstName" placeholder="Olivia" required />
|
|
127
|
-
</div>
|
|
128
|
-
<div className="space-y-1.5">
|
|
129
|
-
<Label htmlFor="contact-last">Last name</Label>
|
|
130
|
-
<Input id="contact-last" name="lastName" placeholder="Reed" required />
|
|
118
|
+
<Label htmlFor="contact-first" className="block text-sm/6 font-semibold">
|
|
119
|
+
First name
|
|
120
|
+
</Label>
|
|
121
|
+
<div className="mt-2.5">
|
|
122
|
+
<Input
|
|
123
|
+
id="contact-first"
|
|
124
|
+
name="firstName"
|
|
125
|
+
autoComplete="given-name"
|
|
126
|
+
placeholder="First"
|
|
127
|
+
required
|
|
128
|
+
/>
|
|
131
129
|
</div>
|
|
132
130
|
</div>
|
|
133
131
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<Input
|
|
132
|
+
{/* Last name */}
|
|
133
|
+
<div>
|
|
134
|
+
<Label htmlFor="contact-last" className="block text-sm/6 font-semibold">
|
|
135
|
+
Last name
|
|
136
|
+
</Label>
|
|
137
|
+
<div className="mt-2.5">
|
|
138
|
+
<Input
|
|
139
|
+
id="contact-last"
|
|
140
|
+
name="lastName"
|
|
141
|
+
autoComplete="family-name"
|
|
142
|
+
placeholder="Last"
|
|
143
|
+
required
|
|
144
|
+
/>
|
|
141
145
|
</div>
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{/* Company */}
|
|
149
|
+
<div className="sm:col-span-2">
|
|
150
|
+
<Label htmlFor="contact-company" className="block text-sm/6 font-semibold">
|
|
151
|
+
Company{' '}
|
|
152
|
+
<span className="font-normal text-muted-foreground">(optional)</span>
|
|
153
|
+
</Label>
|
|
154
|
+
<div className="mt-2.5">
|
|
155
|
+
<Input
|
|
156
|
+
id="contact-company"
|
|
157
|
+
name="company"
|
|
158
|
+
autoComplete="organization"
|
|
159
|
+
placeholder="Acme Inc."
|
|
160
|
+
/>
|
|
148
161
|
</div>
|
|
149
162
|
</div>
|
|
150
163
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<
|
|
164
|
+
{/* Email */}
|
|
165
|
+
<div className="sm:col-span-2">
|
|
166
|
+
<Label htmlFor="contact-email" className="block text-sm/6 font-semibold">
|
|
167
|
+
Email
|
|
168
|
+
</Label>
|
|
169
|
+
<div className="mt-2.5">
|
|
170
|
+
<Input
|
|
171
|
+
id="contact-email"
|
|
172
|
+
name="email"
|
|
173
|
+
type="email"
|
|
174
|
+
autoComplete="email"
|
|
175
|
+
placeholder="you@company.com"
|
|
176
|
+
required
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
154
179
|
</div>
|
|
155
180
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
<
|
|
181
|
+
{/* Phone */}
|
|
182
|
+
<div className="sm:col-span-2">
|
|
183
|
+
<Label htmlFor="contact-phone" className="block text-sm/6 font-semibold">
|
|
184
|
+
Phone number{' '}
|
|
185
|
+
<span className="font-normal text-muted-foreground">(optional)</span>
|
|
186
|
+
</Label>
|
|
187
|
+
<div className="mt-2.5 flex overflow-hidden rounded-md border border-input bg-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
|
188
|
+
<div className="relative flex shrink-0 items-center border-r border-input">
|
|
189
|
+
<select
|
|
190
|
+
name="countryCode"
|
|
191
|
+
aria-label="Country code"
|
|
192
|
+
defaultValue="+1-US"
|
|
193
|
+
className="h-full appearance-none bg-transparent py-2 pl-3 pr-7 text-sm text-foreground focus:outline-none"
|
|
194
|
+
>
|
|
195
|
+
{PHONE_PREFIXES.map(({ label, value }) => (
|
|
196
|
+
<option key={value} value={value}>
|
|
197
|
+
{label}
|
|
198
|
+
</option>
|
|
199
|
+
))}
|
|
200
|
+
</select>
|
|
201
|
+
<ChevronDown className="pointer-events-none absolute right-1.5 size-3.5 shrink-0 text-muted-foreground" />
|
|
202
|
+
</div>
|
|
203
|
+
<input
|
|
204
|
+
id="contact-phone"
|
|
205
|
+
name="phone"
|
|
206
|
+
type="tel"
|
|
207
|
+
placeholder="123-456-7890"
|
|
208
|
+
className="min-w-0 grow bg-transparent px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
159
211
|
</div>
|
|
160
212
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
213
|
+
{/* Message */}
|
|
214
|
+
<div className="sm:col-span-2">
|
|
215
|
+
<Label htmlFor="contact-message" className="block text-sm/6 font-semibold">
|
|
216
|
+
Message
|
|
217
|
+
</Label>
|
|
218
|
+
<div className="mt-2.5">
|
|
219
|
+
<Textarea
|
|
220
|
+
id="contact-message"
|
|
221
|
+
name="message"
|
|
222
|
+
rows={4}
|
|
223
|
+
placeholder="Tell us what you're working on..."
|
|
224
|
+
className="resize-none"
|
|
225
|
+
required
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
170
228
|
</div>
|
|
171
229
|
|
|
230
|
+
{/* Privacy toggle */}
|
|
172
231
|
{privacyHref && (
|
|
173
|
-
<div className="flex
|
|
174
|
-
<
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
232
|
+
<div className="flex gap-x-4 sm:col-span-2">
|
|
233
|
+
<div className="flex h-6 items-center">
|
|
234
|
+
<div className="group relative inline-flex h-5 w-8 shrink-0 cursor-pointer rounded-full border border-input bg-muted p-px transition-colors duration-200 ease-in-out focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 has-[:checked]:border-primary has-[:checked]:bg-primary">
|
|
235
|
+
<span className="size-4 rounded-full bg-background shadow-sm transition-transform duration-200 ease-in-out group-has-[:checked]:translate-x-3" />
|
|
236
|
+
<input
|
|
237
|
+
id="contact-privacy"
|
|
238
|
+
name="privacy"
|
|
239
|
+
type="checkbox"
|
|
240
|
+
required
|
|
241
|
+
aria-label="Agree to privacy policy"
|
|
242
|
+
className="absolute inset-0 size-full cursor-pointer appearance-none focus:outline-none"
|
|
243
|
+
/>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
<Label
|
|
247
|
+
htmlFor="contact-privacy"
|
|
248
|
+
className="cursor-pointer text-sm font-normal leading-snug text-muted-foreground"
|
|
249
|
+
>
|
|
250
|
+
By selecting this, you agree to our{' '}
|
|
251
|
+
<a
|
|
252
|
+
href={privacyHref}
|
|
253
|
+
className="font-medium text-foreground underline-offset-2 hover:underline"
|
|
254
|
+
>
|
|
178
255
|
privacy policy
|
|
179
256
|
</a>
|
|
180
257
|
.
|
|
181
258
|
</Label>
|
|
182
259
|
</div>
|
|
183
260
|
)}
|
|
261
|
+
</div>
|
|
184
262
|
|
|
263
|
+
<div className="mt-10">
|
|
185
264
|
<Button type="submit" className="w-full">
|
|
186
|
-
|
|
265
|
+
Let's talk
|
|
187
266
|
<Send className="ml-2 size-4" />
|
|
188
267
|
</Button>
|
|
189
|
-
</
|
|
190
|
-
|
|
191
|
-
|
|
268
|
+
</div>
|
|
269
|
+
</form>
|
|
270
|
+
)}
|
|
192
271
|
|
|
193
272
|
</div>
|
|
194
273
|
</section>
|
|
@@ -63,11 +63,8 @@ export function FeatureMarqueeSection({
|
|
|
63
63
|
{rows.map((row, i) => {
|
|
64
64
|
const direction = row.direction ?? (i % 2 === 0 ? 'left' : 'right');
|
|
65
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
66
|
// Repeat enough that one set exceeds 4K viewport (~3840px).
|
|
70
|
-
const set = Array.from({ length: 4 }, () =>
|
|
67
|
+
const set = Array.from({ length: 4 }, () => row.items).flat();
|
|
71
68
|
const items = [...set, ...set];
|
|
72
69
|
|
|
73
70
|
return (
|
|
@@ -87,7 +84,7 @@ export function FeatureMarqueeSection({
|
|
|
87
84
|
pauseOnHover && 'group-hover:[animation-play-state:paused]',
|
|
88
85
|
)}
|
|
89
86
|
style={{
|
|
90
|
-
animation:
|
|
87
|
+
animation: `${reversed ? 'feature-marquee-right' : 'feature-marquee-left'} ${duration} linear infinite`,
|
|
91
88
|
}}
|
|
92
89
|
>
|
|
93
90
|
{items.map((item, j) => {
|
|
@@ -114,6 +111,10 @@ export function FeatureMarqueeSection({
|
|
|
114
111
|
from { transform: translateX(0); }
|
|
115
112
|
to { transform: translateX(-50%); }
|
|
116
113
|
}
|
|
114
|
+
@keyframes feature-marquee-right {
|
|
115
|
+
from { transform: translateX(-50%); }
|
|
116
|
+
to { transform: translateX(0); }
|
|
117
|
+
}
|
|
117
118
|
`}</style>
|
|
118
119
|
</section>
|
|
119
120
|
);
|
package/src/marketing/Footer.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
|
+
import { StatusIndicator } from '@olwiba/cn';
|
|
4
5
|
import type { AppShellRenderLink } from '../app/AppShell';
|
|
5
6
|
|
|
6
7
|
export interface FooterProps {
|
|
@@ -8,6 +9,7 @@ export interface FooterProps {
|
|
|
8
9
|
navLinks?: Array<{ label: string; href: string }>;
|
|
9
10
|
socialLinks?: Array<{ label: string; href: string; icon: ReactNode }>;
|
|
10
11
|
legal?: string;
|
|
12
|
+
status?: { label: string; operational?: boolean };
|
|
11
13
|
renderLink?: AppShellRenderLink;
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -20,6 +22,7 @@ export function Footer({
|
|
|
20
22
|
navLinks,
|
|
21
23
|
socialLinks,
|
|
22
24
|
legal,
|
|
25
|
+
status,
|
|
23
26
|
renderLink = defaultRenderLink,
|
|
24
27
|
}: FooterProps) {
|
|
25
28
|
const brandHref = brand.href ?? '/';
|
|
@@ -77,6 +80,21 @@ export function Footer({
|
|
|
77
80
|
<p className="mt-10 text-center text-sm/6 text-muted-foreground/70">
|
|
78
81
|
{copyrightText}
|
|
79
82
|
</p>
|
|
83
|
+
|
|
84
|
+
{/* Status */}
|
|
85
|
+
{status && (
|
|
86
|
+
<div className="mt-4 flex justify-center">
|
|
87
|
+
<div className="inline-flex items-center rounded-full border bg-muted/40 px-3 py-1 text-xs text-muted-foreground">
|
|
88
|
+
<StatusIndicator
|
|
89
|
+
pulse={status.operational !== false}
|
|
90
|
+
dotClassName={status.operational !== false ? 'text-emerald-500' : 'text-destructive'}
|
|
91
|
+
size="sm"
|
|
92
|
+
>
|
|
93
|
+
{status.label}
|
|
94
|
+
</StatusIndicator>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
80
98
|
</div>
|
|
81
99
|
</footer>
|
|
82
100
|
);
|