@tuturuuu/ui 0.29.2 → 0.30.1

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.
@@ -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,91 @@
1
+ import { cn } from '@tuturuuu/utils/format';
2
+ import type { ReactNode } from 'react';
3
+ import { TuturuuuWordmark, toProductName } from '../custom/tuturuuu-wordmark';
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
+ /** Product line of the lockup; defaults to `appName` minus "Tuturuuu ". */
20
+ productName?: string;
21
+ /** One-line positioning statement under the wordmark. */
22
+ tagline: string;
23
+ columns: MarketingFooterColumn[];
24
+ /** Rendered at the bottom left, e.g. "© 2026 Tuturuuu". */
25
+ legal: ReactNode;
26
+ /** Locale switcher or social row, supplied by the host app. */
27
+ utilities?: ReactNode;
28
+ className?: string;
29
+ }
30
+
31
+ /** Shared satellite marketing footer: wordmark column plus link columns. */
32
+ export function MarketingFooter({
33
+ appName,
34
+ productName,
35
+ tagline,
36
+ columns,
37
+ legal,
38
+ utilities,
39
+ className,
40
+ }: MarketingFooterProps) {
41
+ return (
42
+ <footer
43
+ className={cn(
44
+ 'relative border-foreground/[0.08] border-t px-4 py-16 sm:px-6 lg:px-8',
45
+ className
46
+ )}
47
+ >
48
+ <div className="mx-auto grid w-full max-w-6xl gap-12 lg:grid-cols-[1.5fr_repeat(3,1fr)]">
49
+ <div className="max-w-sm">
50
+ <TuturuuuWordmark
51
+ product={productName ?? toProductName(appName)}
52
+ size="sm"
53
+ />
54
+ <p className="mt-4 text-foreground/50 text-sm leading-relaxed">
55
+ {tagline}
56
+ </p>
57
+ </div>
58
+
59
+ {columns.map((column) => (
60
+ <nav aria-label={column.title} key={column.id}>
61
+ <h3 className="font-mono-ui text-[0.65rem] text-foreground/40 uppercase tracking-[0.2em]">
62
+ {column.title}
63
+ </h3>
64
+ <ul className="mt-4 space-y-2.5">
65
+ {column.links.map((link) => (
66
+ <li key={`${column.id}-${link.href}`}>
67
+ <a
68
+ className="text-foreground/55 text-sm transition-colors hover:text-foreground"
69
+ href={link.href}
70
+ {...(link.external
71
+ ? { target: '_blank', rel: 'noopener noreferrer' }
72
+ : {})}
73
+ >
74
+ {link.label}
75
+ </a>
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ </nav>
80
+ ))}
81
+ </div>
82
+
83
+ <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">
84
+ <p className="text-foreground/40 text-xs">{legal}</p>
85
+ {utilities ? (
86
+ <div className="flex items-center gap-3">{utilities}</div>
87
+ ) : null}
88
+ </div>
89
+ </footer>
90
+ );
91
+ }
@@ -0,0 +1,167 @@
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 { TuturuuuWordmark, toProductName } from '../custom/tuturuuu-wordmark';
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
+ /**
16
+ * The full app name, e.g. "Tuturuuu Forms". Used for the accessible landmark
17
+ * label, and as the fallback source for `productName`.
18
+ */
19
+ appName: string;
20
+ /**
21
+ * The product line of the lockup — "Forms", not "Tuturuuu Forms". Defaults
22
+ * to `appName` with a leading "Tuturuuu " stripped, so callers that already
23
+ * pass the full name keep working.
24
+ */
25
+ productName?: string;
26
+ /** Where the wordmark links back to. Defaults to the landing root. */
27
+ homeHref?: string;
28
+ links: MarketingNavLink[];
29
+ /**
30
+ * Primary action button. Taken as a node rather than `{href,label}` so the
31
+ * host can wrap it in `<Suspense>` and let a session-dependent label stream
32
+ * in without making the whole marketing page dynamic. Render
33
+ * `MarketingNavAction` for the standard treatment.
34
+ */
35
+ action: ReactNode;
36
+ /** Secondary text action shown beside the primary on desktop. */
37
+ secondaryAction?: { href: string; label: string };
38
+ /** Locale/theme switchers supplied by the host app. */
39
+ utilities?: ReactNode;
40
+ }
41
+
42
+ /**
43
+ * Sticky marketing header.
44
+ *
45
+ * Starts transparent over the hero and only fades in its border and blur once
46
+ * the page scrolls, so the hero light rig is not cropped by a hard bar on load.
47
+ */
48
+ export function MarketingNav({
49
+ appName,
50
+ productName,
51
+ homeHref = '/',
52
+ links,
53
+ action,
54
+ secondaryAction,
55
+ utilities,
56
+ }: MarketingNavProps) {
57
+ const [scrolled, setScrolled] = useState(false);
58
+ const [menuOpen, setMenuOpen] = useState(false);
59
+
60
+ useEffect(() => {
61
+ const onScroll = () => setScrolled(window.scrollY > 12);
62
+ onScroll();
63
+ window.addEventListener('scroll', onScroll, { passive: true });
64
+ return () => window.removeEventListener('scroll', onScroll);
65
+ }, []);
66
+
67
+ // A menu left open while the viewport grows to desktop would keep the page
68
+ // scroll-locked behind an invisible panel.
69
+ useEffect(() => {
70
+ if (!menuOpen) return;
71
+ const media = window.matchMedia('(min-width: 768px)');
72
+ const close = () => media.matches && setMenuOpen(false);
73
+ close();
74
+ media.addEventListener('change', close);
75
+ return () => media.removeEventListener('change', close);
76
+ }, [menuOpen]);
77
+
78
+ return (
79
+ <header
80
+ className={cn(
81
+ 'fixed inset-x-0 top-0 z-50 transition-all duration-300',
82
+ scrolled || menuOpen
83
+ ? 'border-foreground/[0.08] border-b bg-background/80 backdrop-blur-xl'
84
+ : 'border-transparent border-b bg-transparent'
85
+ )}
86
+ >
87
+ <nav
88
+ aria-label={appName}
89
+ 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"
90
+ >
91
+ <a
92
+ className="flex shrink-0 items-center rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
93
+ href={homeHref}
94
+ >
95
+ <TuturuuuWordmark
96
+ product={productName ?? toProductName(appName)}
97
+ size="sm"
98
+ />
99
+ </a>
100
+
101
+ <div className="hidden items-center gap-1 md:flex">
102
+ {links.map((link) => (
103
+ <a
104
+ 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"
105
+ href={link.href}
106
+ key={link.href}
107
+ >
108
+ {link.label}
109
+ </a>
110
+ ))}
111
+ </div>
112
+
113
+ <div className="flex items-center gap-2">
114
+ {utilities}
115
+ {secondaryAction ? (
116
+ <a
117
+ 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"
118
+ href={secondaryAction.href}
119
+ >
120
+ {secondaryAction.label}
121
+ </a>
122
+ ) : null}
123
+ {action}
124
+ <button
125
+ aria-expanded={menuOpen}
126
+ aria-label={appName}
127
+ 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"
128
+ onClick={() => setMenuOpen((open) => !open)}
129
+ type="button"
130
+ >
131
+ {menuOpen ? (
132
+ <X className="h-4 w-4" />
133
+ ) : (
134
+ <Menu className="h-4 w-4" />
135
+ )}
136
+ </button>
137
+ </div>
138
+ </nav>
139
+
140
+ {menuOpen ? (
141
+ <div className="border-foreground/[0.06] border-t px-4 pb-4 md:hidden">
142
+ <div className="flex flex-col py-2">
143
+ {links.map((link) => (
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={link.href}
147
+ key={link.href}
148
+ onClick={() => setMenuOpen(false)}
149
+ >
150
+ {link.label}
151
+ </a>
152
+ ))}
153
+ {secondaryAction ? (
154
+ <a
155
+ className="rounded-lg px-3 py-3 text-foreground/70 text-sm transition-colors hover:bg-foreground/[0.04] hover:text-foreground"
156
+ href={secondaryAction.href}
157
+ onClick={() => setMenuOpen(false)}
158
+ >
159
+ {secondaryAction.label}
160
+ </a>
161
+ ) : null}
162
+ </div>
163
+ </div>
164
+ ) : null}
165
+ </header>
166
+ );
167
+ }
@@ -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
+ }