@tuturuuu/ui 0.29.2 → 0.30.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/CHANGELOG.md +10 -0
- package/package.json +5 -4
- package/src/components/ui/command.tsx +9 -1
- package/src/components/ui/marketing/accents.ts +139 -0
- package/src/components/ui/marketing/atmosphere.tsx +155 -0
- package/src/components/ui/marketing/cta.tsx +150 -0
- package/src/components/ui/marketing/faq-list.tsx +55 -0
- package/src/components/ui/marketing/fonts.ts +33 -0
- package/src/components/ui/marketing/index.ts +37 -0
- package/src/components/ui/marketing/marketing-footer.tsx +90 -0
- package/src/components/ui/marketing/marketing-nav.tsx +157 -0
- package/src/components/ui/marketing/section-shell.tsx +148 -0
- package/src/components/ui/marketing/stat-band.tsx +46 -0
- package/src/components/ui/marketing/surface-card.tsx +144 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.30.0](https://github.com/tutur3u/platform/compare/ui-v0.29.2...ui-v0.30.0) (2026-08-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **command:** build task-first command center ([cb29d33](https://github.com/tutur3u/platform/commit/cb29d33953745b1a61f09d8c8390459f983d30d7))
|
|
9
|
+
* **command:** build task-first command center ([#5144](https://github.com/tutur3u/platform/issues/5144)) ([292c739](https://github.com/tutur3u/platform/commit/292c7398e2abc3097c2b4ab85522370c302fb030))
|
|
10
|
+
* **forms:** add landing page and per-form SEO controls ([99c5fda](https://github.com/tutur3u/platform/commit/99c5fda3a7d15649a3571b9ccc72759cd815a7ec))
|
|
11
|
+
* **forms:** add landing page, per-form SEO, realtime collaboration, and embeds ([#5145](https://github.com/tutur3u/platform/issues/5145)) ([6896515](https://github.com/tutur3u/platform/commit/6896515b099b28bbebc873f98733879bf108ef89))
|
|
12
|
+
|
|
3
13
|
## [0.29.2](https://github.com/tutur3u/platform/compare/ui-v0.29.1...ui-v0.29.2) (2026-08-25)
|
|
4
14
|
|
|
5
15
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -91,9 +91,9 @@
|
|
|
91
91
|
"@tuturuuu/apis": "0.11.3",
|
|
92
92
|
"@tuturuuu/hooks": "0.1.1",
|
|
93
93
|
"@tuturuuu/icons": "0.1.0",
|
|
94
|
-
"@tuturuuu/internal-api": "0.
|
|
94
|
+
"@tuturuuu/internal-api": "0.33.0",
|
|
95
95
|
"@tuturuuu/supabase": "0.5.1",
|
|
96
|
-
"@tuturuuu/utils": "0.
|
|
96
|
+
"@tuturuuu/utils": "0.26.0",
|
|
97
97
|
"@types/debug": "^4.1.13",
|
|
98
98
|
"browser-image-compression": "^2.0.2",
|
|
99
99
|
"class-variance-authority": "^0.7.1",
|
|
@@ -154,7 +154,7 @@
|
|
|
154
154
|
"@tanstack/react-table": "^9.1.2",
|
|
155
155
|
"@testing-library/jest-dom": "^7.0.1",
|
|
156
156
|
"@testing-library/react": "^16.3.2",
|
|
157
|
-
"@tuturuuu/types": "0.
|
|
157
|
+
"@tuturuuu/types": "0.28.0",
|
|
158
158
|
"@tuturuuu/typescript-config": "0.1.2",
|
|
159
159
|
"@types/html2canvas": "^1.0.0",
|
|
160
160
|
"@types/lodash": "^4.17.25",
|
|
@@ -180,6 +180,7 @@
|
|
|
180
180
|
},
|
|
181
181
|
"exports": {
|
|
182
182
|
"./globals.css": "./src/globals.css",
|
|
183
|
+
"./marketing": "./src/components/ui/marketing/index.ts",
|
|
183
184
|
"./postcss.config": "./postcss.config.mjs",
|
|
184
185
|
"./badge": {
|
|
185
186
|
"types": "./src/components/ui/badge.tsx",
|
|
@@ -34,12 +34,14 @@ function CommandDialog({
|
|
|
34
34
|
children,
|
|
35
35
|
showCloseButton = true,
|
|
36
36
|
contentClassName,
|
|
37
|
+
commandProps,
|
|
37
38
|
...props
|
|
38
39
|
}: React.ComponentProps<typeof Dialog> & {
|
|
39
40
|
title?: string;
|
|
40
41
|
description?: string;
|
|
41
42
|
showCloseButton?: boolean;
|
|
42
43
|
contentClassName?: string;
|
|
44
|
+
commandProps?: React.ComponentProps<typeof CommandPrimitive>;
|
|
43
45
|
}) {
|
|
44
46
|
return (
|
|
45
47
|
<Dialog {...props}>
|
|
@@ -51,7 +53,13 @@ function CommandDialog({
|
|
|
51
53
|
className={cn('overflow-hidden p-0', contentClassName)}
|
|
52
54
|
showCloseButton={showCloseButton}
|
|
53
55
|
>
|
|
54
|
-
<Command
|
|
56
|
+
<Command
|
|
57
|
+
{...commandProps}
|
|
58
|
+
className={cn(
|
|
59
|
+
'**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5',
|
|
60
|
+
commandProps?.className
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
55
63
|
{/* TODO: Fix Bun types issue with children prop */}
|
|
56
64
|
{children as any}
|
|
57
65
|
</Command>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accent tokens for the satellite marketing kit.
|
|
3
|
+
*
|
|
4
|
+
* Tailwind cannot resolve a class name assembled at runtime, so every accent
|
|
5
|
+
* variant has to exist as a literal string somewhere in the source. Keeping the
|
|
6
|
+
* full set in one map means a new accent is added once and every marketing
|
|
7
|
+
* primitive picks it up, instead of each component carrying its own partial
|
|
8
|
+
* copy of the same lookup table.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type MarketingAccent =
|
|
12
|
+
| 'blue'
|
|
13
|
+
| 'cyan'
|
|
14
|
+
| 'green'
|
|
15
|
+
| 'indigo'
|
|
16
|
+
| 'orange'
|
|
17
|
+
| 'pink'
|
|
18
|
+
| 'purple'
|
|
19
|
+
| 'red'
|
|
20
|
+
| 'yellow';
|
|
21
|
+
|
|
22
|
+
export interface MarketingAccentTokens {
|
|
23
|
+
/** Foreground colour for icons and eyebrow labels. */
|
|
24
|
+
text: string;
|
|
25
|
+
/** Mid-stop for a hairline rule that fades out at both ends. */
|
|
26
|
+
rule: string;
|
|
27
|
+
/** Soft corner glow behind a hovered surface. */
|
|
28
|
+
bloom: string;
|
|
29
|
+
/** Filled chip / dot background. */
|
|
30
|
+
solid: string;
|
|
31
|
+
/** Tinted surface for badges and inline highlights. */
|
|
32
|
+
soft: string;
|
|
33
|
+
/** Border tint that pairs with `soft`. */
|
|
34
|
+
border: string;
|
|
35
|
+
/** Two-stop gradient for primary calls to action. */
|
|
36
|
+
gradient: string;
|
|
37
|
+
/** Radial bloom colour used by `SectionBloom`, referencing the raw token. */
|
|
38
|
+
radial: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const MARKETING_ACCENTS: Record<MarketingAccent, MarketingAccentTokens> =
|
|
42
|
+
{
|
|
43
|
+
blue: {
|
|
44
|
+
text: 'text-dynamic-blue',
|
|
45
|
+
rule: 'via-dynamic-blue/45',
|
|
46
|
+
bloom: 'bg-dynamic-blue/20',
|
|
47
|
+
solid: 'bg-dynamic-blue',
|
|
48
|
+
soft: 'bg-dynamic-blue/10',
|
|
49
|
+
border: 'border-dynamic-blue/25',
|
|
50
|
+
gradient: 'from-dynamic-blue to-dynamic-cyan',
|
|
51
|
+
radial: 'bg-[radial-gradient(closest-side,var(--blue),transparent)]',
|
|
52
|
+
},
|
|
53
|
+
cyan: {
|
|
54
|
+
text: 'text-dynamic-cyan',
|
|
55
|
+
rule: 'via-dynamic-cyan/45',
|
|
56
|
+
bloom: 'bg-dynamic-cyan/20',
|
|
57
|
+
solid: 'bg-dynamic-cyan',
|
|
58
|
+
soft: 'bg-dynamic-cyan/10',
|
|
59
|
+
border: 'border-dynamic-cyan/25',
|
|
60
|
+
gradient: 'from-dynamic-cyan to-dynamic-blue',
|
|
61
|
+
radial: 'bg-[radial-gradient(closest-side,var(--cyan),transparent)]',
|
|
62
|
+
},
|
|
63
|
+
green: {
|
|
64
|
+
text: 'text-dynamic-green',
|
|
65
|
+
rule: 'via-dynamic-green/45',
|
|
66
|
+
bloom: 'bg-dynamic-green/20',
|
|
67
|
+
solid: 'bg-dynamic-green',
|
|
68
|
+
soft: 'bg-dynamic-green/10',
|
|
69
|
+
border: 'border-dynamic-green/25',
|
|
70
|
+
gradient: 'from-dynamic-green to-dynamic-cyan',
|
|
71
|
+
radial: 'bg-[radial-gradient(closest-side,var(--green),transparent)]',
|
|
72
|
+
},
|
|
73
|
+
indigo: {
|
|
74
|
+
text: 'text-dynamic-indigo',
|
|
75
|
+
rule: 'via-dynamic-indigo/45',
|
|
76
|
+
bloom: 'bg-dynamic-indigo/20',
|
|
77
|
+
solid: 'bg-dynamic-indigo',
|
|
78
|
+
soft: 'bg-dynamic-indigo/10',
|
|
79
|
+
border: 'border-dynamic-indigo/25',
|
|
80
|
+
gradient: 'from-dynamic-indigo to-dynamic-purple',
|
|
81
|
+
radial: 'bg-[radial-gradient(closest-side,var(--indigo),transparent)]',
|
|
82
|
+
},
|
|
83
|
+
orange: {
|
|
84
|
+
text: 'text-dynamic-orange',
|
|
85
|
+
rule: 'via-dynamic-orange/45',
|
|
86
|
+
bloom: 'bg-dynamic-orange/20',
|
|
87
|
+
solid: 'bg-dynamic-orange',
|
|
88
|
+
soft: 'bg-dynamic-orange/10',
|
|
89
|
+
border: 'border-dynamic-orange/25',
|
|
90
|
+
gradient: 'from-dynamic-orange to-dynamic-pink',
|
|
91
|
+
radial: 'bg-[radial-gradient(closest-side,var(--orange),transparent)]',
|
|
92
|
+
},
|
|
93
|
+
pink: {
|
|
94
|
+
text: 'text-dynamic-pink',
|
|
95
|
+
rule: 'via-dynamic-pink/45',
|
|
96
|
+
bloom: 'bg-dynamic-pink/20',
|
|
97
|
+
solid: 'bg-dynamic-pink',
|
|
98
|
+
soft: 'bg-dynamic-pink/10',
|
|
99
|
+
border: 'border-dynamic-pink/25',
|
|
100
|
+
gradient: 'from-dynamic-pink to-dynamic-purple',
|
|
101
|
+
radial: 'bg-[radial-gradient(closest-side,var(--pink),transparent)]',
|
|
102
|
+
},
|
|
103
|
+
purple: {
|
|
104
|
+
text: 'text-dynamic-purple',
|
|
105
|
+
rule: 'via-dynamic-purple/45',
|
|
106
|
+
bloom: 'bg-dynamic-purple/20',
|
|
107
|
+
solid: 'bg-dynamic-purple',
|
|
108
|
+
soft: 'bg-dynamic-purple/10',
|
|
109
|
+
border: 'border-dynamic-purple/25',
|
|
110
|
+
gradient: 'from-dynamic-purple to-dynamic-blue',
|
|
111
|
+
radial: 'bg-[radial-gradient(closest-side,var(--purple),transparent)]',
|
|
112
|
+
},
|
|
113
|
+
red: {
|
|
114
|
+
text: 'text-dynamic-red',
|
|
115
|
+
rule: 'via-dynamic-red/45',
|
|
116
|
+
bloom: 'bg-dynamic-red/20',
|
|
117
|
+
solid: 'bg-dynamic-red',
|
|
118
|
+
soft: 'bg-dynamic-red/10',
|
|
119
|
+
border: 'border-dynamic-red/25',
|
|
120
|
+
gradient: 'from-dynamic-red to-dynamic-orange',
|
|
121
|
+
radial: 'bg-[radial-gradient(closest-side,var(--red),transparent)]',
|
|
122
|
+
},
|
|
123
|
+
yellow: {
|
|
124
|
+
text: 'text-dynamic-yellow',
|
|
125
|
+
rule: 'via-dynamic-yellow/45',
|
|
126
|
+
bloom: 'bg-dynamic-yellow/20',
|
|
127
|
+
solid: 'bg-dynamic-yellow',
|
|
128
|
+
soft: 'bg-dynamic-yellow/10',
|
|
129
|
+
border: 'border-dynamic-yellow/25',
|
|
130
|
+
gradient: 'from-dynamic-yellow to-dynamic-orange',
|
|
131
|
+
radial: 'bg-[radial-gradient(closest-side,var(--yellow),transparent)]',
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export function getMarketingAccent(
|
|
136
|
+
accent: MarketingAccent
|
|
137
|
+
): MarketingAccentTokens {
|
|
138
|
+
return MARKETING_ACCENTS[accent];
|
|
139
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
2
|
+
import { getMarketingAccent, type MarketingAccent } from './accents';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Grain overlay.
|
|
6
|
+
*
|
|
7
|
+
* A single inlined SVG turbulence tile — no network request, no image asset.
|
|
8
|
+
* Keeps large flat areas from banding and gives surfaces a filmic tooth.
|
|
9
|
+
*/
|
|
10
|
+
export function Grain({ className }: { className?: string }) {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
aria-hidden
|
|
14
|
+
className={cn(
|
|
15
|
+
'pointer-events-none absolute inset-0 opacity-[0.035] mix-blend-overlay dark:opacity-[0.055]',
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
style={{
|
|
19
|
+
backgroundImage:
|
|
20
|
+
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
|
|
21
|
+
}}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fine engineering grid, masked to fade at the edges so it reads as a substrate
|
|
28
|
+
* rather than graph paper.
|
|
29
|
+
*/
|
|
30
|
+
export function GridSubstrate({
|
|
31
|
+
className,
|
|
32
|
+
size = '64px',
|
|
33
|
+
}: {
|
|
34
|
+
className?: string;
|
|
35
|
+
size?: string;
|
|
36
|
+
}) {
|
|
37
|
+
const mask =
|
|
38
|
+
'radial-gradient(ellipse 80% 60% at 50% 40%, black 20%, transparent 75%)';
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
aria-hidden
|
|
43
|
+
className={cn(
|
|
44
|
+
'pointer-events-none absolute inset-0 opacity-[0.5] dark:opacity-[0.35]',
|
|
45
|
+
className
|
|
46
|
+
)}
|
|
47
|
+
style={{
|
|
48
|
+
backgroundImage:
|
|
49
|
+
'linear-gradient(to right, color-mix(in oklab, var(--foreground) 6%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in oklab, var(--foreground) 6%, transparent) 1px, transparent 1px)',
|
|
50
|
+
backgroundSize: `${size} ${size}`,
|
|
51
|
+
maskImage: mask,
|
|
52
|
+
WebkitMaskImage: mask,
|
|
53
|
+
}}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface HeroAtmosphereProps {
|
|
59
|
+
/** Bloom behind the headline. */
|
|
60
|
+
primary?: MarketingAccent;
|
|
61
|
+
/** Cool counterweight in the upper right. */
|
|
62
|
+
secondary?: MarketingAccent;
|
|
63
|
+
/** Accent under the product frame. */
|
|
64
|
+
tertiary?: MarketingAccent;
|
|
65
|
+
/** Fades the rig into the page background at the bottom edge. */
|
|
66
|
+
settle?: boolean;
|
|
67
|
+
className?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The hero light rig: three placed blooms rather than a full-bleed animated
|
|
72
|
+
* wash, which smears muddy brown across the viewport in dark mode. Each bloom
|
|
73
|
+
* drifts slowly and independently; all motion stops under
|
|
74
|
+
* `prefers-reduced-motion`.
|
|
75
|
+
*/
|
|
76
|
+
export function HeroAtmosphere({
|
|
77
|
+
primary = 'purple',
|
|
78
|
+
secondary = 'blue',
|
|
79
|
+
tertiary = 'cyan',
|
|
80
|
+
settle = true,
|
|
81
|
+
className,
|
|
82
|
+
}: HeroAtmosphereProps = {}) {
|
|
83
|
+
const bloom = (accent: MarketingAccent, strength: number) =>
|
|
84
|
+
`radial-gradient(closest-side,color-mix(in oklab,var(--${accent}) ${strength}%,transparent),transparent)`;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
aria-hidden
|
|
89
|
+
className={cn('pointer-events-none absolute inset-0 -z-10', className)}
|
|
90
|
+
>
|
|
91
|
+
{/* Primary bloom — sits behind the headline */}
|
|
92
|
+
<div
|
|
93
|
+
className="absolute -top-32 left-1/2 h-[30rem] w-[34rem] -translate-x-1/2 rounded-full opacity-45 blur-2xl motion-reduce:animate-none sm:-top-40 sm:h-[42rem] sm:w-[52rem] sm:animate-bloom-drift sm:opacity-50 sm:blur-3xl dark:opacity-55 dark:sm:opacity-60"
|
|
94
|
+
style={{ backgroundImage: bloom(primary, 34) }}
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
{/* Cool counterweight — upper right */}
|
|
98
|
+
<div
|
|
99
|
+
className="absolute -top-20 right-[8%] hidden h-[30rem] w-[34rem] animate-bloom-drift-slow rounded-full opacity-45 blur-3xl motion-reduce:animate-none sm:block dark:opacity-55"
|
|
100
|
+
style={{ backgroundImage: bloom(secondary, 34) }}
|
|
101
|
+
/>
|
|
102
|
+
|
|
103
|
+
{/* Warm-cool accent — under the product frame */}
|
|
104
|
+
<div
|
|
105
|
+
className="absolute top-[38%] left-[6%] hidden h-[26rem] w-[30rem] animate-bloom-drift rounded-full opacity-40 blur-3xl [animation-delay:-8s] motion-reduce:animate-none sm:block dark:opacity-50"
|
|
106
|
+
style={{ backgroundImage: bloom(tertiary, 28) }}
|
|
107
|
+
/>
|
|
108
|
+
|
|
109
|
+
{/* Light seam across the top — the horizon of the rig */}
|
|
110
|
+
<div
|
|
111
|
+
className="absolute inset-x-0 top-0 h-px motion-reduce:animate-none sm:animate-sheen"
|
|
112
|
+
style={{
|
|
113
|
+
backgroundImage: `linear-gradient(90deg,transparent,color-mix(in oklab,var(--${primary}) 55%,transparent) 35%,color-mix(in oklab,var(--${tertiary}) 55%,transparent) 65%,transparent)`,
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
<GridSubstrate />
|
|
118
|
+
|
|
119
|
+
{settle ? (
|
|
120
|
+
<div className="absolute inset-x-0 bottom-0 h-64 bg-gradient-to-b from-transparent to-background" />
|
|
121
|
+
) : null}
|
|
122
|
+
|
|
123
|
+
<Grain />
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Quieter bloom for sections below the fold — keeps colour continuity down the
|
|
130
|
+
* page without competing with the hero.
|
|
131
|
+
*/
|
|
132
|
+
export function SectionBloom({
|
|
133
|
+
tone = 'purple',
|
|
134
|
+
align = 'center',
|
|
135
|
+
}: {
|
|
136
|
+
tone?: MarketingAccent;
|
|
137
|
+
align?: 'center' | 'left' | 'right';
|
|
138
|
+
}) {
|
|
139
|
+
const positions = {
|
|
140
|
+
center: 'left-1/2 -translate-x-1/2',
|
|
141
|
+
left: 'left-[-10%]',
|
|
142
|
+
right: 'right-[-10%]',
|
|
143
|
+
} as const;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
aria-hidden
|
|
148
|
+
className={cn(
|
|
149
|
+
'pointer-events-none absolute top-0 -z-10 h-[28rem] w-[40rem] rounded-full opacity-[0.07] blur-3xl dark:opacity-[0.12]',
|
|
150
|
+
getMarketingAccent(tone).radial,
|
|
151
|
+
positions[align]
|
|
152
|
+
)}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
2
|
+
import type { AnchorHTMLAttributes, ReactNode } from 'react';
|
|
3
|
+
import { getMarketingAccent, type MarketingAccent } from './accents';
|
|
4
|
+
|
|
5
|
+
type AnchorProps = Omit<
|
|
6
|
+
AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
7
|
+
'className' | 'href'
|
|
8
|
+
>;
|
|
9
|
+
|
|
10
|
+
interface CtaLinkProps extends AnchorProps {
|
|
11
|
+
href: string;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
/** Opens in a new tab with the matching `rel` hardening. */
|
|
14
|
+
external?: boolean;
|
|
15
|
+
/** Stretches to the container on small screens; `auto` never does. */
|
|
16
|
+
width?: 'responsive' | 'auto';
|
|
17
|
+
size?: 'md' | 'lg';
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sizes = {
|
|
22
|
+
md: 'h-11 px-6 text-sm',
|
|
23
|
+
lg: 'h-12 px-8',
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
function externalProps(external?: boolean) {
|
|
27
|
+
return external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Filled gradient call to action with a sheen that sweeps on hover.
|
|
32
|
+
*
|
|
33
|
+
* The sweep is a translated pseudo-layer rather than an animated
|
|
34
|
+
* `background-position`, so it composites on the GPU and does not repaint the
|
|
35
|
+
* button's text on every frame.
|
|
36
|
+
*/
|
|
37
|
+
export function PrimaryCta({
|
|
38
|
+
href,
|
|
39
|
+
children,
|
|
40
|
+
accent = 'purple',
|
|
41
|
+
external,
|
|
42
|
+
width = 'responsive',
|
|
43
|
+
size = 'lg',
|
|
44
|
+
className,
|
|
45
|
+
...props
|
|
46
|
+
}: CtaLinkProps & { accent?: MarketingAccent }) {
|
|
47
|
+
return (
|
|
48
|
+
<a
|
|
49
|
+
className={cn(
|
|
50
|
+
'group relative inline-flex items-center justify-center overflow-hidden rounded-full bg-gradient-to-r font-medium text-white shadow-lg transition-all duration-300 hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
51
|
+
width === 'responsive' && 'w-full sm:w-auto',
|
|
52
|
+
sizes[size],
|
|
53
|
+
getMarketingAccent(accent).gradient,
|
|
54
|
+
className
|
|
55
|
+
)}
|
|
56
|
+
href={href}
|
|
57
|
+
{...externalProps(external)}
|
|
58
|
+
{...props}
|
|
59
|
+
>
|
|
60
|
+
<span
|
|
61
|
+
aria-hidden
|
|
62
|
+
className="absolute inset-0 -translate-x-full bg-[linear-gradient(90deg,transparent,rgb(255_255_255/0.3),transparent)] transition-transform duration-700 group-hover:translate-x-full motion-reduce:hidden"
|
|
63
|
+
/>
|
|
64
|
+
<span className="relative flex items-center gap-2">{children}</span>
|
|
65
|
+
</a>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Quiet, glassy counterpart to `PrimaryCta`. */
|
|
70
|
+
export function SecondaryCta({
|
|
71
|
+
href,
|
|
72
|
+
children,
|
|
73
|
+
external,
|
|
74
|
+
width = 'responsive',
|
|
75
|
+
size = 'lg',
|
|
76
|
+
className,
|
|
77
|
+
...props
|
|
78
|
+
}: CtaLinkProps) {
|
|
79
|
+
return (
|
|
80
|
+
<a
|
|
81
|
+
className={cn(
|
|
82
|
+
'inline-flex items-center justify-center gap-2 rounded-full border border-foreground/12 bg-background/40 font-medium text-foreground/75 backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-foreground/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
83
|
+
width === 'responsive' && 'w-full sm:w-auto',
|
|
84
|
+
sizes[size],
|
|
85
|
+
className
|
|
86
|
+
)}
|
|
87
|
+
href={href}
|
|
88
|
+
{...externalProps(external)}
|
|
89
|
+
{...props}
|
|
90
|
+
>
|
|
91
|
+
{children}
|
|
92
|
+
</a>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Rounded pill label used above hero headlines and inside product frames. */
|
|
97
|
+
export function MarketingPill({
|
|
98
|
+
accent = 'purple',
|
|
99
|
+
icon: Icon,
|
|
100
|
+
children,
|
|
101
|
+
className,
|
|
102
|
+
}: {
|
|
103
|
+
accent?: MarketingAccent;
|
|
104
|
+
icon?: React.ComponentType<{ className?: string }>;
|
|
105
|
+
children: ReactNode;
|
|
106
|
+
className?: string;
|
|
107
|
+
}) {
|
|
108
|
+
return (
|
|
109
|
+
<span
|
|
110
|
+
className={cn(
|
|
111
|
+
'inline-flex items-center gap-2 rounded-full border border-foreground/10 bg-background/40 py-1.5 pr-4 pl-2.5 font-mono-ui text-[0.65rem] uppercase tracking-[0.2em] backdrop-blur-md',
|
|
112
|
+
getMarketingAccent(accent).text,
|
|
113
|
+
className
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
|
|
117
|
+
{children}
|
|
118
|
+
</span>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The marketing nav's filled action button.
|
|
124
|
+
*
|
|
125
|
+
* Lives here rather than in `marketing-nav.tsx` so it stays a server component:
|
|
126
|
+
* the nav itself is `'use client'` (it tracks scroll), but the action is often
|
|
127
|
+
* rendered inside a `<Suspense>` boundary on the server and passed in as a
|
|
128
|
+
* slot.
|
|
129
|
+
*/
|
|
130
|
+
export function MarketingNavAction({
|
|
131
|
+
accent = 'purple',
|
|
132
|
+
href,
|
|
133
|
+
label,
|
|
134
|
+
}: {
|
|
135
|
+
accent?: MarketingAccent;
|
|
136
|
+
href: string;
|
|
137
|
+
label: string;
|
|
138
|
+
}) {
|
|
139
|
+
return (
|
|
140
|
+
<a
|
|
141
|
+
className={cn(
|
|
142
|
+
'inline-flex h-9 items-center justify-center whitespace-nowrap rounded-full bg-gradient-to-r px-4 font-medium text-sm text-white shadow-sm transition-transform duration-300 hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
143
|
+
getMarketingAccent(accent).gradient
|
|
144
|
+
)}
|
|
145
|
+
href={href}
|
|
146
|
+
>
|
|
147
|
+
{label}
|
|
148
|
+
</a>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
Accordion,
|
|
7
|
+
AccordionContent,
|
|
8
|
+
AccordionItem,
|
|
9
|
+
AccordionTrigger,
|
|
10
|
+
} from '../accordion';
|
|
11
|
+
|
|
12
|
+
export interface MarketingFaqItem {
|
|
13
|
+
/** Stable value for the accordion item; also the React key. */
|
|
14
|
+
id: string;
|
|
15
|
+
question: ReactNode;
|
|
16
|
+
answer: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Marketing FAQ.
|
|
21
|
+
*
|
|
22
|
+
* A single-open accordion rather than a static definition list: an FAQ block
|
|
23
|
+
* long enough to be useful is long enough to bury the closing CTA if every
|
|
24
|
+
* answer is expanded at once.
|
|
25
|
+
*/
|
|
26
|
+
export function MarketingFaqList({
|
|
27
|
+
items,
|
|
28
|
+
className,
|
|
29
|
+
}: {
|
|
30
|
+
items: MarketingFaqItem[];
|
|
31
|
+
className?: string;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<Accordion
|
|
35
|
+
className={cn('mx-auto w-full max-w-3xl', className)}
|
|
36
|
+
collapsible
|
|
37
|
+
type="single"
|
|
38
|
+
>
|
|
39
|
+
{items.map((item) => (
|
|
40
|
+
<AccordionItem
|
|
41
|
+
className="border-foreground/[0.08] border-b"
|
|
42
|
+
key={item.id}
|
|
43
|
+
value={item.id}
|
|
44
|
+
>
|
|
45
|
+
<AccordionTrigger className="py-5 text-left font-display font-medium text-base tracking-[-0.01em] hover:no-underline sm:text-lg">
|
|
46
|
+
{item.question}
|
|
47
|
+
</AccordionTrigger>
|
|
48
|
+
<AccordionContent className="pb-5 text-foreground/55 text-sm leading-relaxed sm:text-base">
|
|
49
|
+
{item.answer}
|
|
50
|
+
</AccordionContent>
|
|
51
|
+
</AccordionItem>
|
|
52
|
+
))}
|
|
53
|
+
</Accordion>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Be_Vietnam_Pro, JetBrains_Mono } from 'next/font/google';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Display face for marketing headlines.
|
|
5
|
+
*
|
|
6
|
+
* Be Vietnam Pro is drawn for Vietnamese typography, so `vi` headlines keep
|
|
7
|
+
* their diacritics properly fitted instead of falling back mid-headline — and
|
|
8
|
+
* it carries far more character than the body face at large sizes.
|
|
9
|
+
*/
|
|
10
|
+
export const marketingDisplayFont = Be_Vietnam_Pro({
|
|
11
|
+
subsets: ['latin', 'vietnamese'],
|
|
12
|
+
weight: ['500', '600', '700', '800'],
|
|
13
|
+
display: 'swap',
|
|
14
|
+
variable: '--font-be-vietnam-pro',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Micro-label / numeric face: section eyebrows, indices, stat values. Also
|
|
19
|
+
* covers Vietnamese so translated eyebrows stay in the same voice.
|
|
20
|
+
*/
|
|
21
|
+
export const marketingMonoFont = JetBrains_Mono({
|
|
22
|
+
subsets: ['latin', 'vietnamese'],
|
|
23
|
+
weight: ['400', '500', '600'],
|
|
24
|
+
display: 'swap',
|
|
25
|
+
variable: '--font-jetbrains-mono',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Apply to the marketing layout's root element. `--font-display` and
|
|
30
|
+
* `--font-mono-ui` in `globals.css` resolve through these variables and fall
|
|
31
|
+
* back to the app font wherever the marketing faces are not loaded.
|
|
32
|
+
*/
|
|
33
|
+
export const marketingFontVariables = `${marketingDisplayFont.variable} ${marketingMonoFont.variable}`;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export {
|
|
2
|
+
getMarketingAccent,
|
|
3
|
+
MARKETING_ACCENTS,
|
|
4
|
+
type MarketingAccent,
|
|
5
|
+
type MarketingAccentTokens,
|
|
6
|
+
} from './accents';
|
|
7
|
+
export {
|
|
8
|
+
Grain,
|
|
9
|
+
GridSubstrate,
|
|
10
|
+
HeroAtmosphere,
|
|
11
|
+
SectionBloom,
|
|
12
|
+
} from './atmosphere';
|
|
13
|
+
export {
|
|
14
|
+
MarketingNavAction,
|
|
15
|
+
MarketingPill,
|
|
16
|
+
PrimaryCta,
|
|
17
|
+
SecondaryCta,
|
|
18
|
+
} from './cta';
|
|
19
|
+
export { type MarketingFaqItem, MarketingFaqList } from './faq-list';
|
|
20
|
+
export {
|
|
21
|
+
marketingDisplayFont,
|
|
22
|
+
marketingFontVariables,
|
|
23
|
+
marketingMonoFont,
|
|
24
|
+
} from './fonts';
|
|
25
|
+
export {
|
|
26
|
+
MarketingFooter,
|
|
27
|
+
type MarketingFooterColumn,
|
|
28
|
+
type MarketingFooterLink,
|
|
29
|
+
} from './marketing-footer';
|
|
30
|
+
export { MarketingNav, type MarketingNavLink } from './marketing-nav';
|
|
31
|
+
export {
|
|
32
|
+
SectionEyebrow,
|
|
33
|
+
SectionShell,
|
|
34
|
+
type SectionWidth,
|
|
35
|
+
} from './section-shell';
|
|
36
|
+
export { type MarketingStat, MarketingStatBand } from './stat-band';
|
|
37
|
+
export { SurfaceCard } from './surface-card';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { TuturuuLogo } from '../custom/tuturuuu-logo';
|
|
4
|
+
|
|
5
|
+
export interface MarketingFooterLink {
|
|
6
|
+
href: string;
|
|
7
|
+
label: string;
|
|
8
|
+
external?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MarketingFooterColumn {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
links: MarketingFooterLink[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface MarketingFooterProps {
|
|
18
|
+
appName: string;
|
|
19
|
+
/** One-line positioning statement under the wordmark. */
|
|
20
|
+
tagline: string;
|
|
21
|
+
columns: MarketingFooterColumn[];
|
|
22
|
+
/** Rendered at the bottom left, e.g. "© 2026 Tuturuuu". */
|
|
23
|
+
legal: ReactNode;
|
|
24
|
+
/** Locale switcher or social row, supplied by the host app. */
|
|
25
|
+
utilities?: ReactNode;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Shared satellite marketing footer: wordmark column plus link columns. */
|
|
30
|
+
export function MarketingFooter({
|
|
31
|
+
appName,
|
|
32
|
+
tagline,
|
|
33
|
+
columns,
|
|
34
|
+
legal,
|
|
35
|
+
utilities,
|
|
36
|
+
className,
|
|
37
|
+
}: MarketingFooterProps) {
|
|
38
|
+
return (
|
|
39
|
+
<footer
|
|
40
|
+
className={cn(
|
|
41
|
+
'relative border-foreground/[0.08] border-t px-4 py-16 sm:px-6 lg:px-8',
|
|
42
|
+
className
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
<div className="mx-auto grid w-full max-w-6xl gap-12 lg:grid-cols-[1.5fr_repeat(3,1fr)]">
|
|
46
|
+
<div className="max-w-sm">
|
|
47
|
+
<div className="flex items-center gap-2.5">
|
|
48
|
+
<TuturuuLogo className="h-7 w-7" height={28} width={28} />
|
|
49
|
+
<span className="font-display font-semibold text-[0.95rem] tracking-[-0.01em]">
|
|
50
|
+
{appName}
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
<p className="mt-4 text-foreground/50 text-sm leading-relaxed">
|
|
54
|
+
{tagline}
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{columns.map((column) => (
|
|
59
|
+
<nav aria-label={column.title} key={column.id}>
|
|
60
|
+
<h3 className="font-mono-ui text-[0.65rem] text-foreground/40 uppercase tracking-[0.2em]">
|
|
61
|
+
{column.title}
|
|
62
|
+
</h3>
|
|
63
|
+
<ul className="mt-4 space-y-2.5">
|
|
64
|
+
{column.links.map((link) => (
|
|
65
|
+
<li key={`${column.id}-${link.href}`}>
|
|
66
|
+
<a
|
|
67
|
+
className="text-foreground/55 text-sm transition-colors hover:text-foreground"
|
|
68
|
+
href={link.href}
|
|
69
|
+
{...(link.external
|
|
70
|
+
? { target: '_blank', rel: 'noopener noreferrer' }
|
|
71
|
+
: {})}
|
|
72
|
+
>
|
|
73
|
+
{link.label}
|
|
74
|
+
</a>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ul>
|
|
78
|
+
</nav>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="mx-auto mt-14 flex w-full max-w-6xl flex-col items-center justify-between gap-4 border-foreground/[0.06] border-t pt-8 sm:flex-row">
|
|
83
|
+
<p className="text-foreground/40 text-xs">{legal}</p>
|
|
84
|
+
{utilities ? (
|
|
85
|
+
<div className="flex items-center gap-3">{utilities}</div>
|
|
86
|
+
) : null}
|
|
87
|
+
</div>
|
|
88
|
+
</footer>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Menu, X } from '@tuturuuu/icons';
|
|
4
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
5
|
+
import { type ReactNode, useEffect, useState } from 'react';
|
|
6
|
+
import { TuturuuLogo } from '../custom/tuturuuu-logo';
|
|
7
|
+
|
|
8
|
+
export interface MarketingNavLink {
|
|
9
|
+
/** In-page anchor (`#pricing`) or absolute URL. */
|
|
10
|
+
href: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MarketingNavProps {
|
|
15
|
+
/** App name shown next to the logo, e.g. "Forms". */
|
|
16
|
+
appName: string;
|
|
17
|
+
/** Where the wordmark links back to. Defaults to the landing root. */
|
|
18
|
+
homeHref?: string;
|
|
19
|
+
links: MarketingNavLink[];
|
|
20
|
+
/**
|
|
21
|
+
* Primary action button. Taken as a node rather than `{href,label}` so the
|
|
22
|
+
* host can wrap it in `<Suspense>` and let a session-dependent label stream
|
|
23
|
+
* in without making the whole marketing page dynamic. Render
|
|
24
|
+
* `MarketingNavAction` for the standard treatment.
|
|
25
|
+
*/
|
|
26
|
+
action: ReactNode;
|
|
27
|
+
/** Secondary text action shown beside the primary on desktop. */
|
|
28
|
+
secondaryAction?: { href: string; label: string };
|
|
29
|
+
/** Locale/theme switchers supplied by the host app. */
|
|
30
|
+
utilities?: ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sticky marketing header.
|
|
35
|
+
*
|
|
36
|
+
* Starts transparent over the hero and only fades in its border and blur once
|
|
37
|
+
* the page scrolls, so the hero light rig is not cropped by a hard bar on load.
|
|
38
|
+
*/
|
|
39
|
+
export function MarketingNav({
|
|
40
|
+
appName,
|
|
41
|
+
homeHref = '/',
|
|
42
|
+
links,
|
|
43
|
+
action,
|
|
44
|
+
secondaryAction,
|
|
45
|
+
utilities,
|
|
46
|
+
}: MarketingNavProps) {
|
|
47
|
+
const [scrolled, setScrolled] = useState(false);
|
|
48
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const onScroll = () => setScrolled(window.scrollY > 12);
|
|
52
|
+
onScroll();
|
|
53
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
54
|
+
return () => window.removeEventListener('scroll', onScroll);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
// A menu left open while the viewport grows to desktop would keep the page
|
|
58
|
+
// scroll-locked behind an invisible panel.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!menuOpen) return;
|
|
61
|
+
const media = window.matchMedia('(min-width: 768px)');
|
|
62
|
+
const close = () => media.matches && setMenuOpen(false);
|
|
63
|
+
close();
|
|
64
|
+
media.addEventListener('change', close);
|
|
65
|
+
return () => media.removeEventListener('change', close);
|
|
66
|
+
}, [menuOpen]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<header
|
|
70
|
+
className={cn(
|
|
71
|
+
'fixed inset-x-0 top-0 z-50 transition-all duration-300',
|
|
72
|
+
scrolled || menuOpen
|
|
73
|
+
? 'border-foreground/[0.08] border-b bg-background/80 backdrop-blur-xl'
|
|
74
|
+
: 'border-transparent border-b bg-transparent'
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
<nav
|
|
78
|
+
aria-label={appName}
|
|
79
|
+
className="mx-auto flex h-16 w-full max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8"
|
|
80
|
+
>
|
|
81
|
+
<a
|
|
82
|
+
className="flex shrink-0 items-center gap-2.5 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
83
|
+
href={homeHref}
|
|
84
|
+
>
|
|
85
|
+
<TuturuuLogo className="h-7 w-7" height={28} width={28} />
|
|
86
|
+
<span className="font-display font-semibold text-[0.95rem] tracking-[-0.01em]">
|
|
87
|
+
{appName}
|
|
88
|
+
</span>
|
|
89
|
+
</a>
|
|
90
|
+
|
|
91
|
+
<div className="hidden items-center gap-1 md:flex">
|
|
92
|
+
{links.map((link) => (
|
|
93
|
+
<a
|
|
94
|
+
className="rounded-full px-3.5 py-2 text-foreground/55 text-sm transition-colors hover:bg-foreground/[0.04] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
95
|
+
href={link.href}
|
|
96
|
+
key={link.href}
|
|
97
|
+
>
|
|
98
|
+
{link.label}
|
|
99
|
+
</a>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div className="flex items-center gap-2">
|
|
104
|
+
{utilities}
|
|
105
|
+
{secondaryAction ? (
|
|
106
|
+
<a
|
|
107
|
+
className="hidden rounded-full px-3.5 py-2 text-foreground/55 text-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:inline-flex"
|
|
108
|
+
href={secondaryAction.href}
|
|
109
|
+
>
|
|
110
|
+
{secondaryAction.label}
|
|
111
|
+
</a>
|
|
112
|
+
) : null}
|
|
113
|
+
{action}
|
|
114
|
+
<button
|
|
115
|
+
aria-expanded={menuOpen}
|
|
116
|
+
aria-label={appName}
|
|
117
|
+
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-foreground/10 text-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden"
|
|
118
|
+
onClick={() => setMenuOpen((open) => !open)}
|
|
119
|
+
type="button"
|
|
120
|
+
>
|
|
121
|
+
{menuOpen ? (
|
|
122
|
+
<X className="h-4 w-4" />
|
|
123
|
+
) : (
|
|
124
|
+
<Menu className="h-4 w-4" />
|
|
125
|
+
)}
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
</nav>
|
|
129
|
+
|
|
130
|
+
{menuOpen ? (
|
|
131
|
+
<div className="border-foreground/[0.06] border-t px-4 pb-4 md:hidden">
|
|
132
|
+
<div className="flex flex-col py-2">
|
|
133
|
+
{links.map((link) => (
|
|
134
|
+
<a
|
|
135
|
+
className="rounded-lg px-3 py-3 text-foreground/70 text-sm transition-colors hover:bg-foreground/[0.04] hover:text-foreground"
|
|
136
|
+
href={link.href}
|
|
137
|
+
key={link.href}
|
|
138
|
+
onClick={() => setMenuOpen(false)}
|
|
139
|
+
>
|
|
140
|
+
{link.label}
|
|
141
|
+
</a>
|
|
142
|
+
))}
|
|
143
|
+
{secondaryAction ? (
|
|
144
|
+
<a
|
|
145
|
+
className="rounded-lg px-3 py-3 text-foreground/70 text-sm transition-colors hover:bg-foreground/[0.04] hover:text-foreground"
|
|
146
|
+
href={secondaryAction.href}
|
|
147
|
+
onClick={() => setMenuOpen(false)}
|
|
148
|
+
>
|
|
149
|
+
{secondaryAction.label}
|
|
150
|
+
</a>
|
|
151
|
+
) : null}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
) : null}
|
|
155
|
+
</header>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import type { MarketingAccent } from './accents';
|
|
4
|
+
import { SectionBloom } from './atmosphere';
|
|
5
|
+
|
|
6
|
+
const widths = {
|
|
7
|
+
narrow: 'max-w-4xl',
|
|
8
|
+
default: 'max-w-6xl',
|
|
9
|
+
wide: 'max-w-7xl',
|
|
10
|
+
full: 'max-w-none',
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type SectionWidth = keyof typeof widths;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The mono eyebrow, optionally preceded by a two-digit section index sitting in
|
|
17
|
+
* its own hairline rule. Exported on its own because hero sections want the
|
|
18
|
+
* label without the surrounding section chrome.
|
|
19
|
+
*/
|
|
20
|
+
export function SectionEyebrow({
|
|
21
|
+
children,
|
|
22
|
+
index,
|
|
23
|
+
className,
|
|
24
|
+
}: {
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
index?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<span
|
|
31
|
+
className={cn(
|
|
32
|
+
'inline-flex items-center gap-3 font-mono-ui text-[0.65rem] text-foreground/45 uppercase tracking-[0.24em]',
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
{index ? (
|
|
37
|
+
<>
|
|
38
|
+
<span className="text-foreground/30 tabular-nums">{index}</span>
|
|
39
|
+
<span aria-hidden className="h-px w-8 bg-foreground/15" />
|
|
40
|
+
</>
|
|
41
|
+
) : null}
|
|
42
|
+
{children}
|
|
43
|
+
</span>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SectionShellProps {
|
|
48
|
+
id?: string;
|
|
49
|
+
/** Two-digit index rendered in the rule beside the eyebrow, e.g. "02". */
|
|
50
|
+
index?: string;
|
|
51
|
+
eyebrow?: ReactNode;
|
|
52
|
+
title: ReactNode;
|
|
53
|
+
subtitle?: ReactNode;
|
|
54
|
+
children: ReactNode;
|
|
55
|
+
/** Constrains the inner content. Defaults to the 6xl marketing column. */
|
|
56
|
+
width?: SectionWidth;
|
|
57
|
+
align?: 'center' | 'start';
|
|
58
|
+
/** Ambient brand bloom behind the section header. */
|
|
59
|
+
bloom?: MarketingAccent | 'none';
|
|
60
|
+
/** Hairline rule across the top of the section. */
|
|
61
|
+
rule?: boolean;
|
|
62
|
+
/** Vertical rhythm. `tight` is for stacked sections that read as one unit. */
|
|
63
|
+
spacing?: 'tight' | 'default';
|
|
64
|
+
className?: string;
|
|
65
|
+
headerClassName?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Shared vertical rhythm and header treatment for every marketing section.
|
|
70
|
+
*
|
|
71
|
+
* Keeps eyebrow/title/subtitle typography and section padding identical down
|
|
72
|
+
* the page so sections read as one system instead of N variations.
|
|
73
|
+
*/
|
|
74
|
+
export function SectionShell({
|
|
75
|
+
id,
|
|
76
|
+
index,
|
|
77
|
+
eyebrow,
|
|
78
|
+
title,
|
|
79
|
+
subtitle,
|
|
80
|
+
children,
|
|
81
|
+
width = 'default',
|
|
82
|
+
align = 'center',
|
|
83
|
+
bloom = 'none',
|
|
84
|
+
rule = true,
|
|
85
|
+
spacing = 'default',
|
|
86
|
+
className,
|
|
87
|
+
headerClassName,
|
|
88
|
+
}: SectionShellProps) {
|
|
89
|
+
return (
|
|
90
|
+
<section
|
|
91
|
+
className={cn(
|
|
92
|
+
// `isolate` is load-bearing: `SectionBloom` paints at `-z-10`, and
|
|
93
|
+
// without a local stacking context it slides behind any ancestor that
|
|
94
|
+
// has its own background and vanishes.
|
|
95
|
+
'relative isolate scroll-mt-24 px-4 sm:px-6 lg:px-8',
|
|
96
|
+
spacing === 'tight'
|
|
97
|
+
? 'py-14 sm:py-18 lg:py-20'
|
|
98
|
+
: 'py-20 sm:py-28 lg:py-32',
|
|
99
|
+
className
|
|
100
|
+
)}
|
|
101
|
+
id={id}
|
|
102
|
+
>
|
|
103
|
+
{rule ? (
|
|
104
|
+
<div
|
|
105
|
+
aria-hidden
|
|
106
|
+
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--foreground)_12%,transparent)_25%,color-mix(in_oklab,var(--foreground)_12%,transparent)_75%,transparent)]"
|
|
107
|
+
/>
|
|
108
|
+
) : null}
|
|
109
|
+
{bloom === 'none' ? null : <SectionBloom tone={bloom} />}
|
|
110
|
+
|
|
111
|
+
<div className={cn('relative mx-auto w-full', widths[width])}>
|
|
112
|
+
<header
|
|
113
|
+
className={cn(
|
|
114
|
+
'mb-14 flex flex-col sm:mb-18',
|
|
115
|
+
align === 'center' ? 'items-center text-center' : 'items-start',
|
|
116
|
+
headerClassName
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{eyebrow ? (
|
|
120
|
+
<SectionEyebrow index={index}>{eyebrow}</SectionEyebrow>
|
|
121
|
+
) : null}
|
|
122
|
+
|
|
123
|
+
<h2
|
|
124
|
+
className={cn(
|
|
125
|
+
'mt-6 max-w-3xl text-balance font-display font-semibold text-4xl tracking-[-0.03em] sm:text-5xl lg:text-[3.5rem] lg:leading-[1.05]',
|
|
126
|
+
align === 'center' && 'mx-auto'
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
{title}
|
|
130
|
+
</h2>
|
|
131
|
+
|
|
132
|
+
{subtitle ? (
|
|
133
|
+
<p
|
|
134
|
+
className={cn(
|
|
135
|
+
'mt-5 max-w-2xl text-balance text-base text-foreground/55 leading-relaxed sm:text-lg',
|
|
136
|
+
align === 'center' && 'mx-auto'
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
{subtitle}
|
|
140
|
+
</p>
|
|
141
|
+
) : null}
|
|
142
|
+
</header>
|
|
143
|
+
|
|
144
|
+
{children}
|
|
145
|
+
</div>
|
|
146
|
+
</section>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
export interface MarketingStat {
|
|
5
|
+
id: string;
|
|
6
|
+
/** The number itself — kept short so the row stays on one line on mobile. */
|
|
7
|
+
value: ReactNode;
|
|
8
|
+
label: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Row of headline figures under a hero.
|
|
13
|
+
*
|
|
14
|
+
* Values render in the mono face with `tabular-nums` so a band that animates or
|
|
15
|
+
* re-renders (live counters, locale switches) does not reflow its neighbours.
|
|
16
|
+
*/
|
|
17
|
+
export function MarketingStatBand({
|
|
18
|
+
stats,
|
|
19
|
+
className,
|
|
20
|
+
}: {
|
|
21
|
+
stats: MarketingStat[];
|
|
22
|
+
className?: string;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<dl
|
|
26
|
+
className={cn(
|
|
27
|
+
'grid w-full grid-cols-2 gap-px overflow-hidden rounded-2xl border border-foreground/[0.08] bg-foreground/[0.06] sm:grid-cols-4',
|
|
28
|
+
className
|
|
29
|
+
)}
|
|
30
|
+
>
|
|
31
|
+
{stats.map((stat) => (
|
|
32
|
+
<div
|
|
33
|
+
className="flex flex-col items-center gap-1 bg-background px-4 py-6 text-center"
|
|
34
|
+
key={stat.id}
|
|
35
|
+
>
|
|
36
|
+
<dt className="order-2 text-[0.7rem] text-foreground/45 uppercase tracking-[0.16em]">
|
|
37
|
+
{stat.label}
|
|
38
|
+
</dt>
|
|
39
|
+
<dd className="order-1 font-display font-semibold text-2xl tabular-nums tracking-[-0.02em] sm:text-3xl">
|
|
40
|
+
{stat.value}
|
|
41
|
+
</dd>
|
|
42
|
+
</div>
|
|
43
|
+
))}
|
|
44
|
+
</dl>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ArrowUpRight } from '@tuturuuu/icons';
|
|
2
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
3
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
4
|
+
import { getMarketingAccent, type MarketingAccent } from './accents';
|
|
5
|
+
|
|
6
|
+
interface SurfaceCardProps {
|
|
7
|
+
accent: MarketingAccent;
|
|
8
|
+
icon?: ComponentType<{ className?: string }>;
|
|
9
|
+
title: ReactNode;
|
|
10
|
+
description: ReactNode;
|
|
11
|
+
/** Small mono label above the title (a role, a category). */
|
|
12
|
+
eyebrow?: ReactNode;
|
|
13
|
+
/** Renders the card as a link and reveals a corner arrow. */
|
|
14
|
+
href?: string;
|
|
15
|
+
/** Opens the link in a new tab — used for apps on their own domain. */
|
|
16
|
+
external?: boolean;
|
|
17
|
+
/** `stack` puts the icon above the copy; `inline` places it alongside. */
|
|
18
|
+
layout?: 'stack' | 'inline';
|
|
19
|
+
size?: 'md' | 'lg';
|
|
20
|
+
/** Extra content rendered under the description (a chip row, a mini preview). */
|
|
21
|
+
footer?: ReactNode;
|
|
22
|
+
className?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The marketing card surface: border, lit top edge, corner bloom, hover lift.
|
|
27
|
+
*
|
|
28
|
+
* Every feature grid, use-case tile and app link on a satellite landing page
|
|
29
|
+
* routes through this, so a change to the treatment lands everywhere at once
|
|
30
|
+
* instead of being re-typed per section.
|
|
31
|
+
*/
|
|
32
|
+
export function SurfaceCard({
|
|
33
|
+
accent,
|
|
34
|
+
icon: Icon,
|
|
35
|
+
title,
|
|
36
|
+
description,
|
|
37
|
+
eyebrow,
|
|
38
|
+
href,
|
|
39
|
+
external,
|
|
40
|
+
layout = 'stack',
|
|
41
|
+
size = 'md',
|
|
42
|
+
footer,
|
|
43
|
+
className,
|
|
44
|
+
}: SurfaceCardProps) {
|
|
45
|
+
const styles = getMarketingAccent(accent);
|
|
46
|
+
const Root = href ? 'a' : 'div';
|
|
47
|
+
const isLarge = size === 'lg';
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Root
|
|
51
|
+
{...(href ? { href } : {})}
|
|
52
|
+
{...(href && external
|
|
53
|
+
? { target: '_blank', rel: 'noopener noreferrer' }
|
|
54
|
+
: {})}
|
|
55
|
+
className={cn(
|
|
56
|
+
'group relative flex h-full overflow-hidden rounded-2xl border border-foreground/[0.08] bg-foreground/[0.015] transition-all duration-500',
|
|
57
|
+
'hover:-translate-y-1 hover:border-foreground/15 hover:bg-foreground/[0.03]',
|
|
58
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
59
|
+
layout === 'inline' ? 'items-start gap-3 p-4' : 'flex-col',
|
|
60
|
+
layout === 'stack' && (isLarge ? 'p-6 sm:p-7' : 'p-5'),
|
|
61
|
+
className
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<span
|
|
65
|
+
aria-hidden
|
|
66
|
+
className={cn(
|
|
67
|
+
'pointer-events-none absolute inset-x-6 top-0 h-px bg-gradient-to-r from-transparent to-transparent transition-opacity duration-500 group-hover:opacity-100',
|
|
68
|
+
layout === 'inline' ? 'opacity-0' : 'opacity-40',
|
|
69
|
+
styles.rule
|
|
70
|
+
)}
|
|
71
|
+
/>
|
|
72
|
+
<span
|
|
73
|
+
aria-hidden
|
|
74
|
+
className={cn(
|
|
75
|
+
'pointer-events-none absolute -top-16 -right-10 h-40 w-40 rounded-full opacity-0 blur-3xl transition-opacity duration-700 group-hover:opacity-100',
|
|
76
|
+
styles.bloom
|
|
77
|
+
)}
|
|
78
|
+
/>
|
|
79
|
+
|
|
80
|
+
{Icon ? (
|
|
81
|
+
layout === 'inline' ? (
|
|
82
|
+
<span
|
|
83
|
+
className={cn(
|
|
84
|
+
'relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-foreground/10 bg-foreground/[0.03] transition-transform duration-500 group-hover:scale-105',
|
|
85
|
+
styles.text
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
<Icon className="h-3.5 w-3.5" />
|
|
89
|
+
</span>
|
|
90
|
+
) : (
|
|
91
|
+
<Icon
|
|
92
|
+
className={cn(
|
|
93
|
+
'relative transition-transform duration-500 group-hover:scale-110',
|
|
94
|
+
isLarge ? 'h-5 w-5' : 'h-4 w-4',
|
|
95
|
+
styles.text
|
|
96
|
+
)}
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
) : null}
|
|
100
|
+
|
|
101
|
+
<span className={cn('relative min-w-0', layout === 'inline' && 'flex-1')}>
|
|
102
|
+
{eyebrow ? (
|
|
103
|
+
<span
|
|
104
|
+
className={cn(
|
|
105
|
+
'block font-mono-ui text-[0.6rem] uppercase tracking-[0.18em]',
|
|
106
|
+
layout === 'stack' && Icon && 'mt-4',
|
|
107
|
+
styles.text
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
{eyebrow}
|
|
111
|
+
</span>
|
|
112
|
+
) : null}
|
|
113
|
+
|
|
114
|
+
<span
|
|
115
|
+
className={cn(
|
|
116
|
+
'flex items-center gap-1.5 font-display font-semibold tracking-[-0.01em]',
|
|
117
|
+
layout === 'stack' &&
|
|
118
|
+
!eyebrow &&
|
|
119
|
+
Icon &&
|
|
120
|
+
(isLarge ? 'mt-5' : 'mt-4'),
|
|
121
|
+
eyebrow && 'mt-1.5',
|
|
122
|
+
isLarge ? 'text-xl sm:text-2xl' : 'text-[0.95rem]'
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{title}
|
|
126
|
+
{href ? (
|
|
127
|
+
<ArrowUpRight className="h-3 w-3 shrink-0 text-foreground/25 transition-all duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:text-foreground/50" />
|
|
128
|
+
) : null}
|
|
129
|
+
</span>
|
|
130
|
+
|
|
131
|
+
<span
|
|
132
|
+
className={cn(
|
|
133
|
+
'mt-2 block text-foreground/50 leading-relaxed',
|
|
134
|
+
isLarge ? 'max-w-md text-sm' : 'text-xs'
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
{description}
|
|
138
|
+
</span>
|
|
139
|
+
|
|
140
|
+
{footer ? <span className="relative mt-4 block">{footer}</span> : null}
|
|
141
|
+
</span>
|
|
142
|
+
</Root>
|
|
143
|
+
);
|
|
144
|
+
}
|