@godxjp/ui 28.7.0 → 28.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/data-display/index.d.ts +2 -0
- package/dist/components/data-display/index.js +2 -0
- package/dist/components/data-display/marquee.d.ts +16 -0
- package/dist/components/data-display/marquee.js +155 -0
- package/dist/components/general/reveal.d.ts +23 -2
- package/dist/components/general/reveal.js +37 -7
- package/dist/components/general/typography.d.ts +4 -1
- package/dist/components/general/typography.js +14 -1
- package/dist/components/layout/affix.d.ts +86 -0
- package/dist/components/layout/affix.js +187 -0
- package/dist/components/layout/index.d.ts +4 -0
- package/dist/components/layout/index.js +4 -0
- package/dist/components/layout/legal-document-shell.js +4 -3
- package/dist/components/layout/masonry.d.ts +74 -0
- package/dist/components/layout/masonry.js +214 -0
- package/dist/components/layout/page-container.js +5 -20
- package/dist/components/navigation/anchor.d.ts +64 -0
- package/dist/components/navigation/anchor.js +284 -0
- package/dist/components/navigation/index.d.ts +4 -0
- package/dist/components/navigation/index.js +4 -0
- package/dist/components/navigation/mega-menu.d.ts +21 -0
- package/dist/components/navigation/mega-menu.js +526 -0
- package/dist/contracts/measurement.json +1 -1
- package/dist/i18n/messages/en.json +517 -0
- package/dist/i18n/messages/ja.json +513 -0
- package/dist/i18n/messages/vi.json +513 -0
- package/dist/lib/hooks.d.ts +68 -0
- package/dist/lib/hooks.js +52 -0
- package/dist/lib/platform.d.ts +14 -0
- package/dist/lib/platform.js +10 -1
- package/dist/lib/utils.d.ts +1 -1
- package/dist/lib/utils.js +3 -2
- package/dist/props/components/data-display.prop.d.ts +95 -1
- package/dist/props/components/general.prop.d.ts +47 -3
- package/dist/props/components/layout.prop.d.ts +194 -0
- package/dist/props/components/navigation.prop.d.ts +263 -0
- package/dist/props/registry.d.ts +359 -4
- package/dist/props/registry.js +472 -3
- package/dist/props/vocabulary/index.d.ts +1 -1
- package/dist/props/vocabulary/interaction.prop.d.ts +39 -2
- package/dist/styles/control.css +5 -6
- package/dist/styles/data-display-layout.css +2 -1
- package/dist/styles/density.css +4 -0
- package/dist/styles/layout.css +79 -0
- package/dist/styles/motion.css +121 -1
- package/dist/styles/navigation-layout.css +397 -1
- package/dist/styles/shell-layout.css +3 -0
- package/dist/styles/text-layout.css +52 -4
- package/dist/tokens/base.css +5 -0
- package/dist/tokens/components/affix.css +7 -0
- package/dist/tokens/components/anchor.css +17 -0
- package/dist/tokens/components/control.css +3 -3
- package/dist/tokens/components/form.css +1 -1
- package/dist/tokens/components/marquee.css +7 -0
- package/dist/tokens/components/masonry.css +6 -0
- package/dist/tokens/components/mega-menu.css +62 -0
- package/dist/tokens/components/shell.css +3 -0
- package/dist/tokens/foundation.css +11 -0
- package/dist/tokens/semantic/layout.css +7 -0
- package/docs/COMPOSITION-VS-COMPONENT.md +19 -1
- package/docs/DESIGN-AUTHORITY.md +99 -18
- package/docs/FRAME-COVERAGE-REPORT.md +7 -2
- package/docs/data-display/marquee.tsx +254 -0
- package/docs/foundation/_theme-editor-scope.ts +222 -0
- package/docs/foundation/density.tsx +12 -2
- package/docs/foundation/spacing.tsx +5 -0
- package/docs/foundation/theme-editor.tsx +645 -0
- package/docs/general/activity.tsx +65 -0
- package/docs/general/reveal.tsx +290 -22
- package/docs/general/typography.tsx +91 -1
- package/docs/layout/affix.tsx +209 -0
- package/docs/layout/masonry.tsx +291 -0
- package/docs/navigation/anchor.tsx +285 -0
- package/docs/navigation/mega-menu-panel.tsx +86 -0
- package/docs/navigation/mega-menu.tsx +254 -0
- package/docs/roadmap/website-components.md +779 -0
- package/docs/showcase/acme-website.tsx +75 -39
- package/docs/showcase/futurelastic-web.tsx +91 -49
- package/docs/showcase/marketing-page.tsx +885 -0
- package/docs/showcase/table-footer-totals.tsx +12 -2
- package/docs/showcase/theme-customization.tsx +1259 -0
- package/package.json +5 -3
- package/scripts/brand-accent.generated.mjs +27 -0
- package/scripts/ui-audit.mjs +66 -0
- package/scripts/visual-audit-rules.mjs +46 -2
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Showcase · Marketing page — the composition doctrine's own deliverable (gh#833).
|
|
3
|
+
*
|
|
4
|
+
* `docs/COMPOSITION-VS-COMPONENT.md` §3 judges Hero, Navbar, Footer, PricingTable, testimonial,
|
|
5
|
+
* logo wall, feature grid and CTA band to be COMPOSITIONS: layout + content + token configuration
|
|
6
|
+
* over real components, expressible today. The doctrine's remedy for a composition is a `docs/`
|
|
7
|
+
* showcase, not a component. Until this page existed that was an assertion. This is the
|
|
8
|
+
* demonstration, and its claim is a MEASUREMENT, not a promise:
|
|
9
|
+
*
|
|
10
|
+
* this page acme-website futurelastic-web
|
|
11
|
+
* `<style>` blocks ................ 0 1 1
|
|
12
|
+
* page-local class SELECTORS ...... 0 17 28
|
|
13
|
+
* bespoke classes ON elements ..... 0 16 25
|
|
14
|
+
* non-`ui-*` classes on elements .. 0 38 45
|
|
15
|
+
* raw px/rem/em/vw literals ....... 0 29 40
|
|
16
|
+
*
|
|
17
|
+
* Counted with comments stripped (so a docblock quoting a class or a length is not counted):
|
|
18
|
+
* selectors are `/(^|[\s,>+~])\.([a-zA-Z_][\w-]*)\s*(?=[{,.:[]|\s)/m`, classes are every token
|
|
19
|
+
* inside a `className`, literals are `/(?<![\w.$}#-])(\d*\.?\d+)(px|rem|em|ch|pt|v\w+)\b/`. The two
|
|
20
|
+
* siblings also RE-THEME, which is half of what their stylesheets carry; this page uses the shipped
|
|
21
|
+
* theme, so everything it could not express is a framework gap rather than a brand decision.
|
|
22
|
+
* `public-landing.tsx` also measures 0/0 and is not a counter-example: it is a narrow
|
|
23
|
+
* `CenteredShell preset="public-landing"` page with no full-bleed band, no display type, no
|
|
24
|
+
* pricing and no logo wall, i.e. none of the shapes that produced the classes above.
|
|
25
|
+
*
|
|
26
|
+
* Every LENGTH here is a token reference: `var(--space-section-band)`, `var(--space-section-hero)`,
|
|
27
|
+
* `var(--page-measure-wide)`, `var(--band-height-3xl)`, `var(--space-6)`, `var(--space-2)`,
|
|
28
|
+
* `var(--overlay-z-index)`. The only class written anywhere below is `.ui-brand-glow`, which the
|
|
29
|
+
* package ships (`src/styles/layout.css`).
|
|
30
|
+
*
|
|
31
|
+
* ── WHAT IS STILL HAND-WRITTEN, AND WHY (the finding this page exists to produce) ───────────────
|
|
32
|
+
*
|
|
33
|
+
* Zero classes and zero literals did NOT mean zero CSS. Six things below are inline `style`
|
|
34
|
+
* objects because no PROP reaches them. Each one is a named gap, in the order a reader meets them:
|
|
35
|
+
*
|
|
36
|
+
* 1. THE SECTION BAND — `paddingBlock: var(--space-section-band | --space-section-hero)`.
|
|
37
|
+
* gh#831 minted the tokens and nothing reads them. `Flex pad` / `ResponsiveGrid pad` take a
|
|
38
|
+
* `GapProp`, whose numeric ladder stops at `12` (`--space-12`, 48px); `--space-20` (80px) and
|
|
39
|
+
* `--space-24` (96px) are off the end of it, so the marketing rhythm is the one spacing a
|
|
40
|
+
* layout primitive cannot state. It also has no compact step: `--space-section-hero` measures
|
|
41
|
+
* 96px at 1440 AND at 320, where the package's own landing preset drops its band through
|
|
42
|
+
* `--centered-shell-landing-main-padding-block-compact`.
|
|
43
|
+
* 2. THE CENTRED COLUMN — `marginInline: auto` + `inlineSize: 100%` +
|
|
44
|
+
* `maxInlineSize: var(--page-measure-wide)` + `paddingInline: var(--space-6)`, repeated at
|
|
45
|
+
* every band. gh#831 minted `--page-measure-wide` and deliberately did not add
|
|
46
|
+
* `PageContainer measure="wide"`, reasoning that a marketing page is full-bleed sections with
|
|
47
|
+
* a centred inner column and `PageContainer` owns neither. The reasoning holds; its
|
|
48
|
+
* consequence is that NOTHING owns the centred inner column.
|
|
49
|
+
* 3. THE STICKY BAR — `position: sticky` + `insetBlockStart` + `zIndex`. `Affix` does not exist
|
|
50
|
+
* here (`docs/roadmap/website-components.md` §5.2 proposed `--affix-inset-block-start` and
|
|
51
|
+
* `--affix-z-index`; neither shipped) and `Topbar` has no `sticky` prop.
|
|
52
|
+
* 4. THE GLASS — `--topbar-background-alpha` / `--topbar-backdrop-blur-size` as inline custom
|
|
53
|
+
* properties. They are `initial` by design, so a call site MUST set them, and that part is
|
|
54
|
+
* correct. The blur is the gap: this library has no blur SCALE (no `--blur-*` tier), so the
|
|
55
|
+
* only sources for a length are a raw literal or another component's private knob
|
|
56
|
+
* (`--app-launcher-launchpad-backdrop-blur-size`). It reads `--space-2` here, which keeps the
|
|
57
|
+
* literal count at zero by borrowing a token that means something else. A `--blur-sm|md|lg`
|
|
58
|
+
* foundation tier would close it properly. Measured result: `blur(8px)`.
|
|
59
|
+
* 5. THE HALO — `position: relative` / `overflow: hidden` on the band and `position: absolute` /
|
|
60
|
+
* `inset: 0` on the `.ui-brand-glow` layer. This is the package's own idiom (both siblings
|
|
61
|
+
* write `className="ui-brand-glow absolute inset-0"`), spelled as logical inline style so
|
|
62
|
+
* `check:rtl` never has to grant an exception.
|
|
63
|
+
* 6. THE DISPLAY RAMP IS NOT RESPONSIVE, and this is the one that changed the page. gh#826 gave
|
|
64
|
+
* `Heading size` / `Text size` the display steps, and `size` takes a single scalar: there is
|
|
65
|
+
* no responsive object form, the way `Flex direction` and `ResponsiveGrid columns` both take
|
|
66
|
+
* one. `--font-size-5xl` is `--font-size-display` = 54px at 1440 and 54px at 320. Measured
|
|
67
|
+
* with a headline of ordinary marketing length (99 characters): 6 lines at 1280, 10 at 375,
|
|
68
|
+
* 12 at 320, i.e. a phone hero five screens tall. The headline in `en`/`ja`/`vi` was cut to
|
|
69
|
+
* what 54px can carry (2 lines at 1280, 4 at 375) and the long-copy edge case moved to the
|
|
70
|
+
* feature grid, where `features.auditTitle` runs several lines beside `features.ssoTitle` =
|
|
71
|
+
* "SSO". NO page-local `clamp()` was written: both siblings wrote one, and writing a third is
|
|
72
|
+
* how a framework gap becomes folklore. The package already knows the answer for exactly one
|
|
73
|
+
* preset (`--centered-shell-landing-heading-size-compact`, read only by
|
|
74
|
+
* `CenteredShell preset="public-landing"`); a composition cannot reach it.
|
|
75
|
+
*
|
|
76
|
+
* MegaMenu is deliberately ABSENT, and the absence is measured, not an oversight. A site nav and
|
|
77
|
+
* this sticky glass bar are mutually exclusive today. `docs/navigation/mega-menu.tsx` records that
|
|
78
|
+
* MegaMenu must not go in a `Topbar` slot: `topbar-center` is `display: none` below ~1280px, and
|
|
79
|
+
* `topbar-start` is `overflow: clip` with a nowrap row, which put 58 elements outside the viewport
|
|
80
|
+
* at 375px. Meanwhile `.ui-topbar` is the ONLY selector that reads the gh#831 glass knobs, and
|
|
81
|
+
* turning the blur on makes the bar a backdrop root, i.e. the containing block for its own fixed
|
|
82
|
+
* descendants, which is exactly how MegaMenu positions its panel. So the bar carries plain anchors
|
|
83
|
+
* at desktop and the narrow answer is the `Sheet`, which is what the MegaMenu doc prescribes.
|
|
84
|
+
*
|
|
85
|
+
* ── REACHING THE EDGES, AND THE TWO THINGS IT BROKE ─────────────────────────────────────────────
|
|
86
|
+
*
|
|
87
|
+
* Tidy data is why a docs site's examples never break. On purpose here: a 71-character identifier
|
|
88
|
+
* with no break opportunity in it, in three places; CJK beside Latin in one sentence, one plan row
|
|
89
|
+
* and one quote; a feature card with NO description beside five that have one; a testimonial with
|
|
90
|
+
* NO rating beside two that have one; a plan with NO badge and NO figure beside one carrying three
|
|
91
|
+
* badges; a footer column with one link beside one with six; a brand lockup long enough to outgrow
|
|
92
|
+
* the bar. Two of those broke the page, and both fixes are documented at the call site:
|
|
93
|
+
*
|
|
94
|
+
* • The id held the document open at 457px on every phone width. `Text whitespace="pre-wrap"`
|
|
95
|
+
* gives `overflow-wrap: break-word`, which does NOT reduce a box's MIN-CONTENT size, and a
|
|
96
|
+
* flex item keeps `min-width: auto` — so in the footer row it wrapped nothing. `Flex fill`
|
|
97
|
+
* (`flex: 1 1 0` + `min-inline-size: 0`) is what makes the break fire.
|
|
98
|
+
* • The lockup measured 222px against the 85px `topbar-start` had left at 375, and was clipped.
|
|
99
|
+
* `Logo` has no `truncate`; the package's own answer, in `public-landing.tsx`, is to drop the
|
|
100
|
+
* wordmark at phone width.
|
|
101
|
+
*
|
|
102
|
+
* Measured in Chromium after both, at 320 / 375 / 390 / 768 / 1024 / 1280 / 1440:
|
|
103
|
+
* `document.documentElement.scrollWidth` equals the viewport at every one, and zero unclipped
|
|
104
|
+
* elements cross the inline edge. The two boxes that do scroll are the `Marquee` viewport and the
|
|
105
|
+
* `Carousel` viewport, which is what both components are.
|
|
106
|
+
*
|
|
107
|
+
* Every string and every `aria-label` comes from `t()` (`src/i18n/messages/{en,ja,vi}.json`,
|
|
108
|
+
* namespace `marketingShowcase`), and the locale picker in the bar switches all three live. Money
|
|
109
|
+
* is `Intl.NumberFormat` with the ACTIVE locale and an ISO 4217 code that lives in data, never a
|
|
110
|
+
* symbol in the copy. The year is `Intl.DateTimeFormat`. Domains are RFC 2606 reserved
|
|
111
|
+
* (`*.example.jp`), the company is fictional, and every wordmark in the logo wall is invented.
|
|
112
|
+
*/
|
|
113
|
+
import { useMemo, useState, type CSSProperties } from "react";
|
|
114
|
+
import {
|
|
115
|
+
ArrowRight,
|
|
116
|
+
CalendarClock,
|
|
117
|
+
CircleCheck,
|
|
118
|
+
FileSearch,
|
|
119
|
+
Globe2,
|
|
120
|
+
KeyRound,
|
|
121
|
+
Menu,
|
|
122
|
+
Route,
|
|
123
|
+
Webhook,
|
|
124
|
+
} from "lucide-react";
|
|
125
|
+
|
|
126
|
+
import {
|
|
127
|
+
Avatar,
|
|
128
|
+
AvatarFallback,
|
|
129
|
+
Badge,
|
|
130
|
+
Card,
|
|
131
|
+
CardContent,
|
|
132
|
+
Carousel,
|
|
133
|
+
CarouselContent,
|
|
134
|
+
CarouselDots,
|
|
135
|
+
CarouselItem,
|
|
136
|
+
CarouselNext,
|
|
137
|
+
CarouselPrevious,
|
|
138
|
+
FeatureList,
|
|
139
|
+
Marquee,
|
|
140
|
+
StatCard,
|
|
141
|
+
} from "@godxjp/ui/data-display";
|
|
142
|
+
import { Rating, Segmented } from "@godxjp/ui/data-entry";
|
|
143
|
+
import {
|
|
144
|
+
Sheet,
|
|
145
|
+
SheetBody,
|
|
146
|
+
SheetClose,
|
|
147
|
+
SheetContent,
|
|
148
|
+
SheetDescription,
|
|
149
|
+
SheetHeader,
|
|
150
|
+
SheetTitle,
|
|
151
|
+
SheetTrigger,
|
|
152
|
+
} from "@godxjp/ui/feedback";
|
|
153
|
+
import { Button, Heading, Icon, Logo, Reveal, Text } from "@godxjp/ui/general";
|
|
154
|
+
import { useTranslation } from "@godxjp/ui/i18n";
|
|
155
|
+
import { Flex, ResponsiveGrid, Separator, Topbar } from "@godxjp/ui/layout";
|
|
156
|
+
import { AppSettingPicker } from "@godxjp/ui/navigation";
|
|
157
|
+
|
|
158
|
+
/* ── The five inline-style constants named in the docblock. Every value is a token reference; no
|
|
159
|
+
number with a unit appears anywhere in this file. ─────────────────────────────────────────── */
|
|
160
|
+
|
|
161
|
+
/** (2) The centred inner column. Nothing in the package owns this shape yet. */
|
|
162
|
+
const SHELL: CSSProperties = {
|
|
163
|
+
marginInline: "auto",
|
|
164
|
+
inlineSize: "100%",
|
|
165
|
+
maxInlineSize: "var(--page-measure-wide)",
|
|
166
|
+
paddingInline: "var(--space-6)",
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** (1) The section rhythm gh#831 named and no prop reads. */
|
|
170
|
+
const BAND: CSSProperties = { paddingBlock: "var(--space-section-band)" };
|
|
171
|
+
const HERO_BAND: CSSProperties = { paddingBlock: "var(--space-section-hero)" };
|
|
172
|
+
|
|
173
|
+
/** (5) The halo host and the halo layer. */
|
|
174
|
+
const GLOW_HOST: CSSProperties = { position: "relative", overflow: "hidden" };
|
|
175
|
+
const GLOW_LAYER: CSSProperties = { position: "absolute", inset: 0 };
|
|
176
|
+
const ABOVE_GLOW: CSSProperties = { position: "relative" };
|
|
177
|
+
|
|
178
|
+
/** (3) The sticky bar region. `Affix` does not exist; `Topbar` has no `sticky`. */
|
|
179
|
+
const STICKY_HEADER: CSSProperties = {
|
|
180
|
+
position: "sticky",
|
|
181
|
+
insetBlockStart: 0,
|
|
182
|
+
zIndex: "var(--overlay-z-index)",
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** (4) gh#831's glass knobs plus the bar geometry its own docstring recommends. */
|
|
186
|
+
const GLASS_BAR: CSSProperties = {
|
|
187
|
+
"--topbar-height": "var(--band-height-3xl)",
|
|
188
|
+
/* Full-bleed glass, content on the shell measure: the same `max(gutter, half the slack)` the
|
|
189
|
+
package writes for CenteredShell's landing preset, so no page-local media query. */
|
|
190
|
+
"--topbar-inset": "max(var(--space-6), calc((100% - var(--page-measure-wide)) / 2))",
|
|
191
|
+
"--topbar-background-alpha": "85%",
|
|
192
|
+
"--topbar-backdrop-blur-size": "var(--space-2)",
|
|
193
|
+
} as CSSProperties;
|
|
194
|
+
|
|
195
|
+
/** Per-instance halo shaping — the four `--brand-glow-*` knobs, at the call site. */
|
|
196
|
+
const HERO_GLOW: CSSProperties = {
|
|
197
|
+
"--brand-glow-size": "55% 60%",
|
|
198
|
+
"--brand-glow-position": "82% -8%",
|
|
199
|
+
"--brand-glow-alpha": "0.20",
|
|
200
|
+
} as CSSProperties;
|
|
201
|
+
|
|
202
|
+
const CTA_GLOW: CSSProperties = {
|
|
203
|
+
"--brand-glow-size": "70% 90%",
|
|
204
|
+
"--brand-glow-position": "50% 110%",
|
|
205
|
+
"--brand-glow-alpha": "0.16",
|
|
206
|
+
} as CSSProperties;
|
|
207
|
+
|
|
208
|
+
/* ── Data. RFC 2606 reserved domains, a fictional company, invented customer wordmarks. ──────── */
|
|
209
|
+
|
|
210
|
+
const SITE = "works.example.jp";
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 71 characters, and not one of them is a break opportunity: no hyphen, no underscore, no slash.
|
|
214
|
+
* It is in the hero panel AND in a feature card so the failure has two chances to show itself.
|
|
215
|
+
*/
|
|
216
|
+
const ENDPOINT_ID = "whsec0f3a8c1d2e4b5a697c8d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e";
|
|
217
|
+
|
|
218
|
+
const NAV_KEYS = ["product", "solutions", "pricing", "docs"] as const;
|
|
219
|
+
|
|
220
|
+
/** Invented wordmarks. CJK, Latin and Vietnamese diacritics in one row, plus a very long one. */
|
|
221
|
+
const CUSTOMERS = [
|
|
222
|
+
{ glyph: "A", name: "Aoba Foods" },
|
|
223
|
+
{ glyph: "北", name: "北陸精密工業" },
|
|
224
|
+
{ glyph: "M", name: "Meridian Health" },
|
|
225
|
+
{ glyph: "T", name: "Trường An" },
|
|
226
|
+
{ glyph: "K", name: "Kita Logistics and Warehousing" },
|
|
227
|
+
{ glyph: "Ö", name: "Örebro Verkstad" },
|
|
228
|
+
{ glyph: "海", name: "海運サービス" },
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
/** The currency lives in DATA, so nothing in the page hard-codes a symbol or an ISO 4217 code. */
|
|
232
|
+
const CURRENCY = "JPY";
|
|
233
|
+
|
|
234
|
+
type Plan = {
|
|
235
|
+
key: "starter" | "team" | "enterprise";
|
|
236
|
+
/** `null` = quoted: the empty slot beside two filled ones. */
|
|
237
|
+
monthly: number | null;
|
|
238
|
+
yearlyMonthly: number | null;
|
|
239
|
+
accent: boolean;
|
|
240
|
+
badges: Array<"popular" | "saving" | "fresh">;
|
|
241
|
+
features: Array<{ state: "included" | "limited" | "excluded"; key: string; limitKey?: string }>;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const PLANS: Plan[] = [
|
|
245
|
+
{
|
|
246
|
+
key: "starter",
|
|
247
|
+
monthly: 4800,
|
|
248
|
+
yearlyMonthly: 4000,
|
|
249
|
+
accent: false,
|
|
250
|
+
badges: [],
|
|
251
|
+
features: [
|
|
252
|
+
{ state: "limited", key: "featChains", limitKey: "limitedChains" },
|
|
253
|
+
{ state: "included", key: "featExport" },
|
|
254
|
+
{ state: "excluded", key: "featDelegation" },
|
|
255
|
+
{ state: "excluded", key: "featSso" },
|
|
256
|
+
{ state: "excluded", key: "featResidency" },
|
|
257
|
+
{ state: "limited", key: "featSupport", limitKey: "limitedSupport" },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
key: "team",
|
|
262
|
+
monthly: 9600,
|
|
263
|
+
yearlyMonthly: 8000,
|
|
264
|
+
accent: true,
|
|
265
|
+
badges: ["popular", "saving", "fresh"],
|
|
266
|
+
features: [
|
|
267
|
+
{ state: "included", key: "featChains" },
|
|
268
|
+
{ state: "included", key: "featExport" },
|
|
269
|
+
{ state: "included", key: "featDelegation" },
|
|
270
|
+
{ state: "included", key: "featSso" },
|
|
271
|
+
{ state: "excluded", key: "featResidency" },
|
|
272
|
+
{ state: "limited", key: "featSupport", limitKey: "limitedSupport" },
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
key: "enterprise",
|
|
277
|
+
monthly: null,
|
|
278
|
+
yearlyMonthly: null,
|
|
279
|
+
accent: false,
|
|
280
|
+
badges: [],
|
|
281
|
+
features: [
|
|
282
|
+
{ state: "included", key: "featChains" },
|
|
283
|
+
{ state: "included", key: "featExport" },
|
|
284
|
+
{ state: "included", key: "featDelegation" },
|
|
285
|
+
{ state: "included", key: "featSso" },
|
|
286
|
+
{ state: "included", key: "featResidency" },
|
|
287
|
+
{ state: "included", key: "featSupport" },
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
const FEATURES = [
|
|
293
|
+
{ key: "routing", icon: Route, hasBody: true, long: false },
|
|
294
|
+
/* The long title with NO body: an empty slot beside five filled ones. */
|
|
295
|
+
{ key: "audit", icon: FileSearch, hasBody: false, long: true },
|
|
296
|
+
{ key: "evidence", icon: CircleCheck, hasBody: true, long: false },
|
|
297
|
+
{ key: "webhook", icon: Webhook, hasBody: true, long: false },
|
|
298
|
+
{ key: "residency", icon: Globe2, hasBody: true, long: false },
|
|
299
|
+
{ key: "sso", icon: KeyRound, hasBody: true, long: false },
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
const QUOTES = [
|
|
303
|
+
{ key: "1", rating: 5 },
|
|
304
|
+
/* Two words, and no rating: the second empty slot. */
|
|
305
|
+
{ key: "2", rating: null },
|
|
306
|
+
{ key: "3", rating: 4 },
|
|
307
|
+
] as const;
|
|
308
|
+
|
|
309
|
+
const FOOTER_COLUMNS = [
|
|
310
|
+
{ key: "product", links: ["featuresLink", "pricingLink", "changelog", "status"] },
|
|
311
|
+
{ key: "company", links: ["about", "careers"] },
|
|
312
|
+
{
|
|
313
|
+
key: "legal",
|
|
314
|
+
links: ["terms", "privacy", "security", "subprocessors", "accessibility", "status"],
|
|
315
|
+
},
|
|
316
|
+
/* One link beside six. */
|
|
317
|
+
{ key: "contact", links: ["salesLine"] },
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
export default function MarketingPage() {
|
|
321
|
+
const { t, locale } = useTranslation();
|
|
322
|
+
const [cycle, setCycle] = useState("monthly");
|
|
323
|
+
const [navOpen, setNavOpen] = useState(false);
|
|
324
|
+
|
|
325
|
+
const money = useMemo(
|
|
326
|
+
() =>
|
|
327
|
+
new Intl.NumberFormat(locale, {
|
|
328
|
+
style: "currency",
|
|
329
|
+
currency: CURRENCY,
|
|
330
|
+
maximumFractionDigits: 0,
|
|
331
|
+
}),
|
|
332
|
+
[locale],
|
|
333
|
+
);
|
|
334
|
+
const count = useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
|
335
|
+
const year = useMemo(
|
|
336
|
+
() => new Intl.DateTimeFormat(locale, { year: "numeric" }).format(new Date()),
|
|
337
|
+
[locale],
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<Flex as="div" direction="col" gap="none">
|
|
342
|
+
{/* ── NAVBAR ─────────────────────────────────────────────────────────────────────────── */}
|
|
343
|
+
<header style={STICKY_HEADER}>
|
|
344
|
+
<Topbar
|
|
345
|
+
style={GLASS_BAR}
|
|
346
|
+
start={
|
|
347
|
+
<Flex align="center" gap="lg">
|
|
348
|
+
{/* The lockup measures 222px, and at 375 the start slot has 85px after the end
|
|
349
|
+
cluster takes its natural width — so `topbar-start` clipped it (measured: 93 in
|
|
350
|
+
85). The bar's shrink contract says a long brand string should truncate rather
|
|
351
|
+
than spill, and `Logo` has no `truncate`; the package's own answer, in
|
|
352
|
+
`public-landing.tsx`, is to drop the wordmark at phone width. The mark alone
|
|
353
|
+
identifies the product there, and the full name is the first thing in the Sheet. */}
|
|
354
|
+
<Flex hideBelow="sm" align="center" gap="none">
|
|
355
|
+
<Logo
|
|
356
|
+
glyph="E"
|
|
357
|
+
wordmark={t("marketingShowcase.brand.name")}
|
|
358
|
+
productSuffix={t("marketingShowcase.brand.suffix")}
|
|
359
|
+
/>
|
|
360
|
+
</Flex>
|
|
361
|
+
<Flex hideFrom="sm" align="center" gap="none">
|
|
362
|
+
<Logo glyph="E" label={t("marketingShowcase.brand.name")} />
|
|
363
|
+
</Flex>
|
|
364
|
+
<Flex
|
|
365
|
+
as="div"
|
|
366
|
+
hideBelow="lg"
|
|
367
|
+
align="center"
|
|
368
|
+
gap="xs"
|
|
369
|
+
role="navigation"
|
|
370
|
+
aria-label={t("marketingShowcase.nav.label")}
|
|
371
|
+
>
|
|
372
|
+
{NAV_KEYS.map((key, index) => (
|
|
373
|
+
<Button key={key} variant="ghost" size="sm" asChild>
|
|
374
|
+
<a href={`#${key}`} aria-current={index === 0 ? "page" : undefined}>
|
|
375
|
+
{t(`marketingShowcase.nav.${key}`)}
|
|
376
|
+
</a>
|
|
377
|
+
</Button>
|
|
378
|
+
))}
|
|
379
|
+
</Flex>
|
|
380
|
+
</Flex>
|
|
381
|
+
}
|
|
382
|
+
end={
|
|
383
|
+
<Flex align="center" gap="sm">
|
|
384
|
+
<AppSettingPicker kind="locale" />
|
|
385
|
+
<Flex hideBelow="sm" align="center" gap="sm">
|
|
386
|
+
<Button variant="ghost" size="sm" asChild>
|
|
387
|
+
<a href="#start">{t("marketingShowcase.nav.signIn")}</a>
|
|
388
|
+
</Button>
|
|
389
|
+
</Flex>
|
|
390
|
+
<Button size="sm" asChild>
|
|
391
|
+
<a href="#start">{t("marketingShowcase.nav.getStarted")}</a>
|
|
392
|
+
</Button>
|
|
393
|
+
{/* The narrow answer the MegaMenu doc prescribes. Nothing in the bar is unreachable
|
|
394
|
+
at 375px: every destination hidden above is inside this Sheet. */}
|
|
395
|
+
<Flex hideFrom="lg" align="center" gap="none">
|
|
396
|
+
<Sheet open={navOpen} onOpenChange={setNavOpen}>
|
|
397
|
+
<SheetTrigger asChild>
|
|
398
|
+
<Button
|
|
399
|
+
variant="ghost"
|
|
400
|
+
size="icon-sm"
|
|
401
|
+
aria-label={t("marketingShowcase.nav.open")}
|
|
402
|
+
>
|
|
403
|
+
<Icon as={Menu} size="sm" />
|
|
404
|
+
</Button>
|
|
405
|
+
</SheetTrigger>
|
|
406
|
+
<SheetContent side="right">
|
|
407
|
+
<SheetHeader>
|
|
408
|
+
<SheetTitle>{t("marketingShowcase.nav.title")}</SheetTitle>
|
|
409
|
+
<SheetDescription>{t("marketingShowcase.nav.description")}</SheetDescription>
|
|
410
|
+
</SheetHeader>
|
|
411
|
+
<SheetBody>
|
|
412
|
+
<Flex
|
|
413
|
+
direction="col"
|
|
414
|
+
gap="xs"
|
|
415
|
+
role="navigation"
|
|
416
|
+
aria-label={t("marketingShowcase.nav.label")}
|
|
417
|
+
>
|
|
418
|
+
{NAV_KEYS.map((key) => (
|
|
419
|
+
<SheetClose key={key} asChild>
|
|
420
|
+
<Button variant="ghost" size="sm" align="start" fullWidth asChild>
|
|
421
|
+
<a href={`#${key}`}>{t(`marketingShowcase.nav.${key}`)}</a>
|
|
422
|
+
</Button>
|
|
423
|
+
</SheetClose>
|
|
424
|
+
))}
|
|
425
|
+
<Separator space="sm" />
|
|
426
|
+
<SheetClose asChild>
|
|
427
|
+
<Button variant="ghost" size="sm" align="start" fullWidth asChild>
|
|
428
|
+
<a href="#start">{t("marketingShowcase.nav.signIn")}</a>
|
|
429
|
+
</Button>
|
|
430
|
+
</SheetClose>
|
|
431
|
+
</Flex>
|
|
432
|
+
</SheetBody>
|
|
433
|
+
</SheetContent>
|
|
434
|
+
</Sheet>
|
|
435
|
+
</Flex>
|
|
436
|
+
</Flex>
|
|
437
|
+
}
|
|
438
|
+
/>
|
|
439
|
+
</header>
|
|
440
|
+
|
|
441
|
+
<main>
|
|
442
|
+
{/* ── HERO ─────────────────────────────────────────────────────────────────────────── */}
|
|
443
|
+
<section id="product" style={GLOW_HOST}>
|
|
444
|
+
<div
|
|
445
|
+
aria-hidden="true"
|
|
446
|
+
className="ui-brand-glow"
|
|
447
|
+
style={{ ...GLOW_LAYER, ...HERO_GLOW }}
|
|
448
|
+
/>
|
|
449
|
+
<div style={{ ...SHELL, ...HERO_BAND, ...ABOVE_GLOW }}>
|
|
450
|
+
<ResponsiveGrid columns={{ base: 1, lg: 2 }} gap="xl" align="start">
|
|
451
|
+
<Reveal>
|
|
452
|
+
<Flex direction="col" gap="lg">
|
|
453
|
+
<Flex align="center" gap="sm" wrap>
|
|
454
|
+
<Badge tone="primary" shape="pill">
|
|
455
|
+
{t("marketingShowcase.hero.eyebrow")}
|
|
456
|
+
</Badge>
|
|
457
|
+
</Flex>
|
|
458
|
+
{/* A real <h1> at the display tier — gh#826's `size` beside `level`, instead of
|
|
459
|
+
the `.tx-display` class with a raw font-size both siblings used to carry. */}
|
|
460
|
+
<Heading level={1} size="5xl" weight="bold">
|
|
461
|
+
{t("marketingShowcase.hero.title")}
|
|
462
|
+
</Heading>
|
|
463
|
+
<Text size="xl" tone="muted">
|
|
464
|
+
{t("marketingShowcase.hero.lead")}
|
|
465
|
+
</Text>
|
|
466
|
+
<Flex align="center" gap="sm" wrap>
|
|
467
|
+
<Button size="lg" asChild>
|
|
468
|
+
<a href="#start">
|
|
469
|
+
{t("marketingShowcase.hero.primary")}
|
|
470
|
+
<Icon as={ArrowRight} size="sm" tone="inherit" />
|
|
471
|
+
</a>
|
|
472
|
+
</Button>
|
|
473
|
+
<Button size="lg" variant="outline" asChild>
|
|
474
|
+
<a href="#start">
|
|
475
|
+
<Icon as={CalendarClock} size="sm" tone="inherit" />
|
|
476
|
+
{t("marketingShowcase.hero.secondary")}
|
|
477
|
+
</a>
|
|
478
|
+
</Button>
|
|
479
|
+
</Flex>
|
|
480
|
+
<Text size="xs" tone="muted">
|
|
481
|
+
{t("marketingShowcase.hero.trust")}
|
|
482
|
+
</Text>
|
|
483
|
+
</Flex>
|
|
484
|
+
</Reveal>
|
|
485
|
+
|
|
486
|
+
<Reveal delay={2}>
|
|
487
|
+
<Card variant="outline">
|
|
488
|
+
<CardContent solo>
|
|
489
|
+
<Flex direction="col" gap="md">
|
|
490
|
+
<Flex direction="col" gap="none">
|
|
491
|
+
<Heading level={2} size="md">
|
|
492
|
+
{t("marketingShowcase.hero.panelTitle")}
|
|
493
|
+
</Heading>
|
|
494
|
+
<Text size="xs" tone="muted">
|
|
495
|
+
{t("marketingShowcase.hero.panelNote")}
|
|
496
|
+
</Text>
|
|
497
|
+
</Flex>
|
|
498
|
+
<ResponsiveGrid columns={{ base: 1, sm: 3 }} gap="sm">
|
|
499
|
+
<StatCard
|
|
500
|
+
label={t("marketingShowcase.hero.pending")}
|
|
501
|
+
value={count.format(18)}
|
|
502
|
+
accent="attention"
|
|
503
|
+
/>
|
|
504
|
+
<StatCard
|
|
505
|
+
label={t("marketingShowcase.hero.cleared")}
|
|
506
|
+
value={count.format(146)}
|
|
507
|
+
delta="+12"
|
|
508
|
+
/>
|
|
509
|
+
<StatCard
|
|
510
|
+
label={t("marketingShowcase.hero.median")}
|
|
511
|
+
value={t("marketingShowcase.hero.medianValue")}
|
|
512
|
+
/>
|
|
513
|
+
</ResponsiveGrid>
|
|
514
|
+
<Separator space="xs" />
|
|
515
|
+
<Flex direction="col" gap="xs">
|
|
516
|
+
<Text size="xs" weight="medium" tone="muted">
|
|
517
|
+
{t("marketingShowcase.hero.endpointLabel")}
|
|
518
|
+
</Text>
|
|
519
|
+
{/* 71 characters with nowhere to break. `whitespace="pre-wrap"` is the
|
|
520
|
+
public prop for this (`overflow-wrap: break-word`) and it is enough
|
|
521
|
+
HERE, because a column flex item has a definite inline size to break
|
|
522
|
+
against. In the footer row it was not; see the note there. */}
|
|
523
|
+
<Text as="code" chip size="2xs" whitespace="pre-wrap">
|
|
524
|
+
{ENDPOINT_ID}
|
|
525
|
+
</Text>
|
|
526
|
+
<Text size="2xs" tone="muted">
|
|
527
|
+
{t("marketingShowcase.hero.endpointNote")}
|
|
528
|
+
</Text>
|
|
529
|
+
</Flex>
|
|
530
|
+
</Flex>
|
|
531
|
+
</CardContent>
|
|
532
|
+
</Card>
|
|
533
|
+
</Reveal>
|
|
534
|
+
</ResponsiveGrid>
|
|
535
|
+
</div>
|
|
536
|
+
</section>
|
|
537
|
+
|
|
538
|
+
{/* ── LOGO WALL ────────────────────────────────────────────────────────────────────── */}
|
|
539
|
+
<section aria-labelledby="logos-title">
|
|
540
|
+
<div style={{ ...SHELL, ...BAND }}>
|
|
541
|
+
<Flex direction="col" gap="lg">
|
|
542
|
+
<Flex direction="col" gap="xs">
|
|
543
|
+
<Heading level={2} size="lg" id="logos-title">
|
|
544
|
+
{t("marketingShowcase.logos.title")}
|
|
545
|
+
</Heading>
|
|
546
|
+
<Text size="sm" tone="muted">
|
|
547
|
+
{t("marketingShowcase.logos.caption")}
|
|
548
|
+
</Text>
|
|
549
|
+
</Flex>
|
|
550
|
+
{/* `defaultPlay={false}` is the recommended posture; the pause/play control is
|
|
551
|
+
Marquee's own real Button, in the tab order, with a localized name. */}
|
|
552
|
+
<Marquee
|
|
553
|
+
defaultPlay={false}
|
|
554
|
+
speed="slow"
|
|
555
|
+
gap="xl"
|
|
556
|
+
fade
|
|
557
|
+
pauseOnHover
|
|
558
|
+
label={t("marketingShowcase.logos.marqueeLabel")}
|
|
559
|
+
>
|
|
560
|
+
{CUSTOMERS.map((customer) => (
|
|
561
|
+
<Logo
|
|
562
|
+
key={customer.name}
|
|
563
|
+
glyph={customer.glyph}
|
|
564
|
+
size="sm"
|
|
565
|
+
wordmark={customer.name}
|
|
566
|
+
/>
|
|
567
|
+
))}
|
|
568
|
+
</Marquee>
|
|
569
|
+
</Flex>
|
|
570
|
+
</div>
|
|
571
|
+
</section>
|
|
572
|
+
|
|
573
|
+
{/* ── FEATURE GRID ─────────────────────────────────────────────────────────────────── */}
|
|
574
|
+
<section id="solutions" aria-labelledby="features-title">
|
|
575
|
+
<Flex as="div" direction="col" gap="none" surface="muted">
|
|
576
|
+
<div style={{ ...SHELL, ...BAND }}>
|
|
577
|
+
<Flex direction="col" gap="xl">
|
|
578
|
+
<Flex direction="col" gap="xs">
|
|
579
|
+
<Heading level={2} size="3xl" weight="bold" id="features-title">
|
|
580
|
+
{t("marketingShowcase.features.title")}
|
|
581
|
+
</Heading>
|
|
582
|
+
<Text size="lg" tone="muted">
|
|
583
|
+
{t("marketingShowcase.features.lead")}
|
|
584
|
+
</Text>
|
|
585
|
+
</Flex>
|
|
586
|
+
<ResponsiveGrid columns={{ base: 1, sm: 2, lg: 3 }} gap="md" align="stretch">
|
|
587
|
+
{FEATURES.map((feature) => (
|
|
588
|
+
<Card key={feature.key} hoverable>
|
|
589
|
+
<CardContent solo>
|
|
590
|
+
<Flex direction="col" gap="sm">
|
|
591
|
+
{/* The doctrine's "icon medallion": Avatar square + a Lucide glyph. */}
|
|
592
|
+
<Avatar shape="square" appearance="tinted" size="md">
|
|
593
|
+
<AvatarFallback>
|
|
594
|
+
<Icon as={feature.icon} size="sm" tone="primary" />
|
|
595
|
+
</AvatarFallback>
|
|
596
|
+
</Avatar>
|
|
597
|
+
<Heading level={3} size="md">
|
|
598
|
+
{t(`marketingShowcase.features.${feature.key}Title`)}
|
|
599
|
+
</Heading>
|
|
600
|
+
{feature.hasBody ? (
|
|
601
|
+
<Text size="sm" tone="muted">
|
|
602
|
+
{t(`marketingShowcase.features.${feature.key}Body`)}
|
|
603
|
+
</Text>
|
|
604
|
+
) : null}
|
|
605
|
+
{feature.key === "webhook" ? (
|
|
606
|
+
<Text as="code" chip size="2xs" whitespace="pre-wrap">
|
|
607
|
+
{ENDPOINT_ID}
|
|
608
|
+
</Text>
|
|
609
|
+
) : null}
|
|
610
|
+
</Flex>
|
|
611
|
+
</CardContent>
|
|
612
|
+
</Card>
|
|
613
|
+
))}
|
|
614
|
+
</ResponsiveGrid>
|
|
615
|
+
</Flex>
|
|
616
|
+
</div>
|
|
617
|
+
</Flex>
|
|
618
|
+
</section>
|
|
619
|
+
|
|
620
|
+
{/* ── TESTIMONIALS ─────────────────────────────────────────────────────────────────── */}
|
|
621
|
+
<section aria-labelledby="quotes-title">
|
|
622
|
+
<div style={{ ...SHELL, ...BAND }}>
|
|
623
|
+
<Flex direction="col" gap="lg">
|
|
624
|
+
<Heading level={2} size="3xl" weight="bold" id="quotes-title">
|
|
625
|
+
{t("marketingShowcase.testimonials.title")}
|
|
626
|
+
</Heading>
|
|
627
|
+
<Carousel opts={{ align: "start", loop: true }}>
|
|
628
|
+
<CarouselContent>
|
|
629
|
+
{QUOTES.map((quote) => (
|
|
630
|
+
<CarouselItem key={quote.key}>
|
|
631
|
+
<Card variant="outline">
|
|
632
|
+
<CardContent solo>
|
|
633
|
+
<Flex direction="col" gap="md">
|
|
634
|
+
{quote.rating === null ? null : (
|
|
635
|
+
<Rating
|
|
636
|
+
readOnly
|
|
637
|
+
value={quote.rating}
|
|
638
|
+
name={`quote-${quote.key}`}
|
|
639
|
+
aria-label={t("marketingShowcase.testimonials.ratingLabel")}
|
|
640
|
+
/>
|
|
641
|
+
)}
|
|
642
|
+
<Text size="lg">
|
|
643
|
+
{t(`marketingShowcase.testimonials.q${quote.key}`)}
|
|
644
|
+
</Text>
|
|
645
|
+
<Flex align="center" gap="sm">
|
|
646
|
+
<Avatar size="sm">
|
|
647
|
+
<AvatarFallback>
|
|
648
|
+
{t(`marketingShowcase.testimonials.a${quote.key}`).slice(0, 1)}
|
|
649
|
+
</AvatarFallback>
|
|
650
|
+
</Avatar>
|
|
651
|
+
<Flex direction="col" gap="none">
|
|
652
|
+
<Text size="sm" weight="medium">
|
|
653
|
+
{t(`marketingShowcase.testimonials.a${quote.key}`)}
|
|
654
|
+
</Text>
|
|
655
|
+
<Text size="xs" tone="muted">
|
|
656
|
+
{t(`marketingShowcase.testimonials.r${quote.key}`)}
|
|
657
|
+
</Text>
|
|
658
|
+
</Flex>
|
|
659
|
+
</Flex>
|
|
660
|
+
</Flex>
|
|
661
|
+
</CardContent>
|
|
662
|
+
</Card>
|
|
663
|
+
</CarouselItem>
|
|
664
|
+
))}
|
|
665
|
+
</CarouselContent>
|
|
666
|
+
{/* The three controls are DIRECT children of Carousel because the component
|
|
667
|
+
places them itself: `.ui-carousel-previous` / `-next` are `position: absolute`
|
|
668
|
+
against `.ui-carousel`, and `.ui-carousel-dots` carries its own block padding.
|
|
669
|
+
Wrapping them in a Flex row produced an empty flex item and moved nothing.
|
|
670
|
+
Their accessible names are the component's own (`dataDisplay.carousel.*`). */}
|
|
671
|
+
<CarouselPrevious />
|
|
672
|
+
<CarouselNext />
|
|
673
|
+
<CarouselDots />
|
|
674
|
+
</Carousel>
|
|
675
|
+
</Flex>
|
|
676
|
+
</div>
|
|
677
|
+
</section>
|
|
678
|
+
|
|
679
|
+
{/* ── PRICING ──────────────────────────────────────────────────────────────────────── */}
|
|
680
|
+
<section id="pricing" aria-labelledby="pricing-title">
|
|
681
|
+
<Flex as="div" direction="col" gap="none" surface="muted">
|
|
682
|
+
<div style={{ ...SHELL, ...BAND }}>
|
|
683
|
+
<Flex direction="col" gap="xl">
|
|
684
|
+
<Flex direction="col" gap="md">
|
|
685
|
+
<Heading level={2} size="3xl" weight="bold" id="pricing-title">
|
|
686
|
+
{t("marketingShowcase.pricing.title")}
|
|
687
|
+
</Heading>
|
|
688
|
+
<Text size="lg" tone="muted">
|
|
689
|
+
{t("marketingShowcase.pricing.lead")}
|
|
690
|
+
</Text>
|
|
691
|
+
{/* FeatureList + Card accent + Segmented. The plan said pricing needs nothing
|
|
692
|
+
new, and it needed nothing new. */}
|
|
693
|
+
<Segmented
|
|
694
|
+
value={cycle}
|
|
695
|
+
onValueChange={setCycle}
|
|
696
|
+
aria-label={t("marketingShowcase.pricing.cycleLabel")}
|
|
697
|
+
options={[
|
|
698
|
+
{ value: "monthly", label: t("marketingShowcase.pricing.monthly") },
|
|
699
|
+
{
|
|
700
|
+
value: "yearly",
|
|
701
|
+
label: t("marketingShowcase.pricing.yearly"),
|
|
702
|
+
count: 2,
|
|
703
|
+
countLabel: t("marketingShowcase.pricing.saving"),
|
|
704
|
+
},
|
|
705
|
+
]}
|
|
706
|
+
/>
|
|
707
|
+
</Flex>
|
|
708
|
+
<ResponsiveGrid columns={{ base: 1, md: 3 }} gap="md" align="stretch">
|
|
709
|
+
{PLANS.map((plan) => {
|
|
710
|
+
const amount = cycle === "yearly" ? plan.yearlyMonthly : plan.monthly;
|
|
711
|
+
return (
|
|
712
|
+
<Card
|
|
713
|
+
key={plan.key}
|
|
714
|
+
accent={plan.accent ? "primary" : undefined}
|
|
715
|
+
accentPlacement={plan.accent ? "perimeter" : undefined}
|
|
716
|
+
>
|
|
717
|
+
<CardContent solo>
|
|
718
|
+
<Flex direction="col" gap="md">
|
|
719
|
+
<Flex align="center" gap="xs" wrap>
|
|
720
|
+
<Heading level={3} size="lg">
|
|
721
|
+
{t(`marketingShowcase.pricing.${plan.key}Name`)}
|
|
722
|
+
</Heading>
|
|
723
|
+
{plan.badges.map((badge) => (
|
|
724
|
+
<Badge
|
|
725
|
+
key={badge}
|
|
726
|
+
as="span"
|
|
727
|
+
shape="pill"
|
|
728
|
+
tone={badge === "popular" ? "primary" : "muted"}
|
|
729
|
+
variant={badge === "fresh" ? "outline" : "default"}
|
|
730
|
+
>
|
|
731
|
+
{t(`marketingShowcase.pricing.${badge}`)}
|
|
732
|
+
</Badge>
|
|
733
|
+
))}
|
|
734
|
+
</Flex>
|
|
735
|
+
<Flex direction="col" gap="none">
|
|
736
|
+
{/* The quoted plan has NO figure: an empty slot beside two full
|
|
737
|
+
ones, in the row where a missing number usually collapses. */}
|
|
738
|
+
<Text size="4xl" weight="bold" tabular>
|
|
739
|
+
{amount === null
|
|
740
|
+
? t("marketingShowcase.pricing.quoted")
|
|
741
|
+
: money.format(amount)}
|
|
742
|
+
</Text>
|
|
743
|
+
<Text size="xs" tone="muted">
|
|
744
|
+
{amount === null
|
|
745
|
+
? t("marketingShowcase.pricing.quotedNote")
|
|
746
|
+
: cycle === "yearly"
|
|
747
|
+
? t("marketingShowcase.pricing.billedYearly")
|
|
748
|
+
: t("marketingShowcase.pricing.perMonth")}
|
|
749
|
+
</Text>
|
|
750
|
+
</Flex>
|
|
751
|
+
<Text size="sm" tone="muted">
|
|
752
|
+
{t(`marketingShowcase.pricing.${plan.key}Note`)}
|
|
753
|
+
</Text>
|
|
754
|
+
<FeatureList
|
|
755
|
+
items={plan.features.map((feature) => ({
|
|
756
|
+
state: feature.state,
|
|
757
|
+
label: t(`marketingShowcase.pricing.${feature.key}`),
|
|
758
|
+
description: feature.limitKey
|
|
759
|
+
? t(`marketingShowcase.pricing.${feature.limitKey}`)
|
|
760
|
+
: undefined,
|
|
761
|
+
}))}
|
|
762
|
+
/>
|
|
763
|
+
<Button fullWidth variant={plan.accent ? "default" : "outline"} asChild>
|
|
764
|
+
<a href="#start">
|
|
765
|
+
{amount === null
|
|
766
|
+
? t("marketingShowcase.pricing.ctaQuote")
|
|
767
|
+
: t("marketingShowcase.pricing.cta")}
|
|
768
|
+
</a>
|
|
769
|
+
</Button>
|
|
770
|
+
</Flex>
|
|
771
|
+
</CardContent>
|
|
772
|
+
</Card>
|
|
773
|
+
);
|
|
774
|
+
})}
|
|
775
|
+
</ResponsiveGrid>
|
|
776
|
+
</Flex>
|
|
777
|
+
</div>
|
|
778
|
+
</Flex>
|
|
779
|
+
</section>
|
|
780
|
+
|
|
781
|
+
{/* ── CTA BAND ─────────────────────────────────────────────────────────────────────── */}
|
|
782
|
+
<section id="start" style={GLOW_HOST} aria-labelledby="cta-title">
|
|
783
|
+
<div
|
|
784
|
+
aria-hidden="true"
|
|
785
|
+
className="ui-brand-glow"
|
|
786
|
+
style={{ ...GLOW_LAYER, ...CTA_GLOW }}
|
|
787
|
+
/>
|
|
788
|
+
<div style={{ ...SHELL, ...BAND, ...ABOVE_GLOW }}>
|
|
789
|
+
<Reveal on="view">
|
|
790
|
+
<Flex direction="col" gap="md" align="center">
|
|
791
|
+
<Heading level={2} size="4xl" weight="bold" align="center" id="cta-title">
|
|
792
|
+
{t("marketingShowcase.cta.title")}
|
|
793
|
+
</Heading>
|
|
794
|
+
<Text size="lg" tone="muted" align="center">
|
|
795
|
+
{t("marketingShowcase.cta.lead")}
|
|
796
|
+
</Text>
|
|
797
|
+
<Flex align="center" gap="sm" wrap justify="center">
|
|
798
|
+
<Button size="lg" asChild>
|
|
799
|
+
<a href={`https://${SITE}`}>
|
|
800
|
+
{t("marketingShowcase.cta.primary")}
|
|
801
|
+
<Icon as={ArrowRight} size="sm" tone="inherit" />
|
|
802
|
+
</a>
|
|
803
|
+
</Button>
|
|
804
|
+
<Button size="lg" variant="outline" asChild>
|
|
805
|
+
<a href={`https://${SITE}`}>{t("marketingShowcase.cta.secondary")}</a>
|
|
806
|
+
</Button>
|
|
807
|
+
</Flex>
|
|
808
|
+
<Text size="xs" tone="muted" align="center">
|
|
809
|
+
{t("marketingShowcase.cta.note")}
|
|
810
|
+
</Text>
|
|
811
|
+
</Flex>
|
|
812
|
+
</Reveal>
|
|
813
|
+
</div>
|
|
814
|
+
</section>
|
|
815
|
+
</main>
|
|
816
|
+
|
|
817
|
+
{/* ── FOOTER ───────────────────────────────────────────────────────────────────────── */}
|
|
818
|
+
<footer>
|
|
819
|
+
<Flex as="div" direction="col" gap="none" surface="muted">
|
|
820
|
+
<div style={{ ...SHELL, ...BAND }}>
|
|
821
|
+
<Flex direction="col" gap="lg">
|
|
822
|
+
{/* ResponsiveGrid.Item span — the asymmetric footer both siblings hand-wrote a
|
|
823
|
+
`grid-template-columns: 1.4fr 1fr 1fr 1fr` for. It has shipped the whole time. */}
|
|
824
|
+
<ResponsiveGrid columns={12} gap="lg">
|
|
825
|
+
<ResponsiveGrid.Item span={{ base: 12, sm: 12, md: 4, lg: 4 }}>
|
|
826
|
+
<Flex direction="col" gap="sm">
|
|
827
|
+
<Logo
|
|
828
|
+
glyph="E"
|
|
829
|
+
wordmark={t("marketingShowcase.brand.name")}
|
|
830
|
+
productSuffix={t("marketingShowcase.brand.suffix")}
|
|
831
|
+
/>
|
|
832
|
+
<Text size="sm" tone="muted">
|
|
833
|
+
{t("marketingShowcase.footer.addressLabel")} ·{" "}
|
|
834
|
+
{t("marketingShowcase.footer.address")}
|
|
835
|
+
</Text>
|
|
836
|
+
<Text size="sm" tone="muted">
|
|
837
|
+
{t("marketingShowcase.footer.salesLine")} · sales@{SITE}
|
|
838
|
+
</Text>
|
|
839
|
+
</Flex>
|
|
840
|
+
</ResponsiveGrid.Item>
|
|
841
|
+
{FOOTER_COLUMNS.map((column) => (
|
|
842
|
+
<ResponsiveGrid.Item key={column.key} span={{ base: 12, sm: 6, md: 2, lg: 2 }}>
|
|
843
|
+
<Flex
|
|
844
|
+
direction="col"
|
|
845
|
+
gap="xs"
|
|
846
|
+
align="start"
|
|
847
|
+
role="navigation"
|
|
848
|
+
aria-label={t(`marketingShowcase.footer.${column.key}`)}
|
|
849
|
+
>
|
|
850
|
+
<Text size="xs" weight="medium" tone="muted">
|
|
851
|
+
{t(`marketingShowcase.footer.${column.key}`)}
|
|
852
|
+
</Text>
|
|
853
|
+
{column.links.map((link) => (
|
|
854
|
+
<Button key={link} variant="link" size="sm" asChild>
|
|
855
|
+
<a href={`https://${SITE}`}>{t(`marketingShowcase.footer.${link}`)}</a>
|
|
856
|
+
</Button>
|
|
857
|
+
))}
|
|
858
|
+
</Flex>
|
|
859
|
+
</ResponsiveGrid.Item>
|
|
860
|
+
))}
|
|
861
|
+
</ResponsiveGrid>
|
|
862
|
+
<Separator space="sm" />
|
|
863
|
+
<Flex align="center" justify="between" gap="sm" wrap>
|
|
864
|
+
<Text size="xs" tone="muted">
|
|
865
|
+
{year} · {t("marketingShowcase.footer.rights")}
|
|
866
|
+
</Text>
|
|
867
|
+
{/* The hostile case, and the one that actually broke: `whitespace="pre-wrap"`
|
|
868
|
+
gives `overflow-wrap: break-word`, which does NOT reduce a box's MIN-CONTENT
|
|
869
|
+
size, and a flex item keeps `min-width: auto`. So in this row the 71-character
|
|
870
|
+
id held the whole line open and the DOCUMENT scrolled: 457px at 390, 375 and
|
|
871
|
+
320 alike. `Flex fill` is the documented answer (`flex: 1 1 0` +
|
|
872
|
+
`min-inline-size: 0`, layout.css:167) and it is what makes the break fire. */}
|
|
873
|
+
<Flex fill direction="col" gap="none">
|
|
874
|
+
<Text size="2xs" tone="muted" whitespace="pre-wrap" align="end">
|
|
875
|
+
{ENDPOINT_ID}
|
|
876
|
+
</Text>
|
|
877
|
+
</Flex>
|
|
878
|
+
</Flex>
|
|
879
|
+
</Flex>
|
|
880
|
+
</div>
|
|
881
|
+
</Flex>
|
|
882
|
+
</footer>
|
|
883
|
+
</Flex>
|
|
884
|
+
);
|
|
885
|
+
}
|