@cronos-labs/ui 0.2.0 → 0.5.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/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # @cronos-labs/ui
2
+
3
+ Shared Header, Footer, SEO, locale and theme primitives for Cronos web
4
+ properties. Consumed by `cronos-landingpage-webui`, `cronos-bridge-webui`
5
+ and `cronos-launch-webui` so the three surfaces stay visually and
6
+ behaviourally identical without copying code between them.
7
+
8
+ The package is published publicly on npm; this repository is private.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @cronos-labs/ui
14
+ ```
15
+
16
+ ### Peer dependencies
17
+
18
+ Required — the package will not work without them:
19
+
20
+ | Package | Range |
21
+ | ------------------- | --------- |
22
+ | `react` | `^19.1.0` |
23
+ | `react-dom` | `^19.1.0` |
24
+ | `styled-components` | `^6.1.15` |
25
+ | `react-router-dom` | `^7.1.5` |
26
+
27
+ Optional — only needed if you consume the shared ESLint config
28
+ (`@cronos-labs/ui/eslint-config`):
29
+
30
+ `@eslint/js`, `eslint`, `eslint-config-prettier`, `eslint-plugin-react-hooks`,
31
+ `eslint-plugin-react-refresh`, `globals`, `typescript-eslint`.
32
+
33
+ ## Entry points
34
+
35
+ The package ships compiled ESM plus type declarations. Four subpath
36
+ patterns are exported:
37
+
38
+ | Import | Contents |
39
+ | --------------------------------- | -------------------------------------------------------------- |
40
+ | `@cronos-labs/ui` | The barrel — every component, token and helper below |
41
+ | `@cronos-labs/ui/<path>` | Any single module, e.g. `locale/config`, `locale/localization` |
42
+ | `@cronos-labs/ui/eslint-config` | `createReactEslintConfig` |
43
+ | `@cronos-labs/ui/prettier-config` | The shared Prettier config, as a default export |
44
+
45
+ ### When to use a deep import instead of the barrel
46
+
47
+ **This is the most common way to break a build.** The barrel re-exports
48
+ components that depend on `react-router-dom` and `styled-components`. Any
49
+ context that evaluates modules with Node's own ESM loader rather than a
50
+ bundler — an Astro config file, a content-collection schema, a sitemap
51
+ script — cannot load it, and fails at import time with
52
+ `styled.div is not a function`.
53
+
54
+ Reach for the single module you actually need in those places:
55
+
56
+ ```js
57
+ // astro.config.mjs — evaluated by Node, not the bundler
58
+ import { localeConfig } from '@cronos-labs/ui/locale/config';
59
+ ```
60
+
61
+ ```ts
62
+ // src/content/config.ts — content collection schema
63
+ import { Locale } from '@cronos-labs/ui/locale/localization';
64
+ ```
65
+
66
+ Application code that goes through the bundler uses the barrel normally:
67
+
68
+ ```tsx
69
+ import { Header, Footer, tokens, useCurrentLocale } from '@cronos-labs/ui';
70
+ ```
71
+
72
+ ### Bundler configuration
73
+
74
+ Vite treats the package as external during SSR by default and loads it
75
+ with Node's native ESM loader, which does not apply the `__esModule`
76
+ CJS-interop convention `styled-components` relies on. Force it through
77
+ the same transform pipeline as `styled-components` itself:
78
+
79
+ ```js
80
+ // astro.config.mjs / vite.config.ts
81
+ ssr: {
82
+ noExternal: ['styled-components', '@cronos-labs/ui'],
83
+ }
84
+ ```
85
+
86
+ For Vitest, inline the package so component tests resolve it the same way:
87
+
88
+ ```ts
89
+ test: {
90
+ server: { deps: { inline: ['@cronos-labs/ui'] } },
91
+ }
92
+ ```
93
+
94
+ ## What's in the package
95
+
96
+ ### Components
97
+
98
+ | Export | Purpose |
99
+ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
100
+ | `Header` | Site header: brand wordmark, nav with dropdowns, mobile drawer, locale selector, waitlist CTA |
101
+ | `Footer` | Site footer: link columns, legal links, cookie-preferences hook, disclaimer copy |
102
+ | `Seo` | Renders nothing; imperatively upserts `<title>`, meta and canonical tags. Creates tags that are absent rather than skipping them |
103
+ | `LocaleSelector` | Locale dropdown, shared by `Header` and `Footer` |
104
+ | `SectionAnchorNav` | In-page anchor nav that tracks the active section |
105
+ | `SearchField` | Labelled search input with optional clear button and status text |
106
+ | `PillButton`, `FilterPillRow` | Filter pill primitives |
107
+
108
+ Header and Footer are driven entirely by props — no data fetching, no
109
+ router coupling beyond `react-router-dom`. Their content strings arrive
110
+ through `HeaderContent` / `FooterContent` so each app supplies its own
111
+ localized copy.
112
+
113
+ Exported types: `HeaderPresentationVariant`, `HeaderPresentationState`,
114
+ `HeaderNavItem`, `HeaderNavChild`, `HeaderContent`, `HeaderWaitlistCta`,
115
+ `FooterLinkItem`, `FooterLinkGroup`, `FooterLegalLinkItem`,
116
+ `FooterContent`, `SeoMeta`, `RouteSeoMeta`, `SearchFieldProps`,
117
+ `SectionAnchorNavItem`.
118
+
119
+ ### Design tokens
120
+
121
+ `tokens` groups every shared value: `colors`, `sizes`, `mediaQueries`,
122
+ `borderRadius`, `fontWeight`, `typography`, `shadow`, `effects`, `motion`,
123
+ `layout`, `zIndex`. `desktopViewportQuery` is the `min-width: 1024px`
124
+ media query the components use to switch to their desktop layout.
125
+
126
+ ```ts
127
+ import { tokens, desktopViewportQuery } from '@cronos-labs/ui';
128
+ ```
129
+
130
+ ### Typography
131
+
132
+ Ready-made `styled-components` `css` blocks, so type scale stays
133
+ consistent across apps: `displayHeroTitleTypography`,
134
+ `pageHeroTitleTypography`, `articleHeroTitleTypography`,
135
+ `sectionTitleTypography`, `articleSectionTitleTypography`,
136
+ `cardTitleTypography`, `leadBodyTypography`, `articleBodyTypography`,
137
+ `compactBodyTypography`, `eyebrowTypography`, `metaTypography`.
138
+
139
+ ### Locale
140
+
141
+ Nine locales are supported: `en`, `en-in`, `en-ng`, `en-ph`, `id-id`,
142
+ `ko-kr`, `pt-br`, `es-mx`, `vi-vn`. The prefix strategy is `as-needed` —
143
+ the default locale has no path prefix, the rest do.
144
+
145
+ | Export | Purpose |
146
+ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
147
+ | `Locale` | Enum of supported locale codes |
148
+ | `localeConfig` | Full config: default, fallback, supported list, per-locale labels and text direction |
149
+ | `isSupportedLocale` | Type guard for an unknown string |
150
+ | `resolveLocale` | Resolves a value to a locale, falling back when unrecognised |
151
+ | `stripLocalePrefix` | Removes the locale segment from a pathname |
152
+ | `buildLocalizedPath` | Prefixes a path for a locale, honouring `as-needed` |
153
+ | `useCurrentLocale` | Reads the active locale from the router |
154
+ | `useApplyLocaleDocumentAttributes` | Keeps `<html lang>` and `dir` in sync |
155
+ | `setStoredLocalePreference`, `getStoredLocalePreference`, `hasStoredLocalePreference`, `localePreferenceStorageKey` | Persisted locale preference (`cronos.locale`) |
156
+
157
+ Types: `AppLocale`, `LocaleConfig`, `LocaleDefinition`,
158
+ `LocalePrefixStrategy`, `TextDirection`, `LocalizedContentDictionary`,
159
+ `LocalizedPageMetadata`.
160
+
161
+ ### Utilities
162
+
163
+ | Export | Purpose |
164
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
165
+ | `navigationContent` | Shared nav structure, with the `NavigationContent` type |
166
+ | `normalizePathname` | Trailing-slash-insensitive pathname comparison used for nav active state |
167
+ | `interpolateMessageTemplate` | Fills `{placeholder}` tokens in localized strings |
168
+ | `scrollToSectionHeading` | Smooth-scrolls to a section's first `h1`/`h2` (or `[data-section-scroll-target]`), offset 128px for the fixed header |
169
+
170
+ ### Shared lint and format config
171
+
172
+ ```js
173
+ // eslint.config.mjs
174
+ import { createReactEslintConfig } from '@cronos-labs/ui/eslint-config';
175
+
176
+ export default createReactEslintConfig({
177
+ tsconfigRootDir: import.meta.dirname,
178
+ });
179
+ ```
180
+
181
+ `createReactEslintConfig` takes `tsconfigRootDir` (required) and optional
182
+ `ignores` and `allowDefaultProject`. It composes the recommended,
183
+ type-checked and stylistic `typescript-eslint` sets with the React Hooks
184
+ and React Refresh plugins, and disables rules Prettier owns.
185
+
186
+ ```json
187
+ // package.json
188
+ "prettier": "@cronos-labs/ui/prettier-config"
189
+ ```
190
+
191
+ ## Local development
192
+
193
+ ```bash
194
+ npm ci
195
+ npm run lint
196
+ npm run typecheck
197
+ npm run test
198
+ npm run build
199
+ npm run format:check
200
+ ```
201
+
202
+ `build` compiles `src/` to `dist/` with `tsc`, then rewrites every relative
203
+ specifier in `dist/**/*.js` and `dist/**/*.d.ts` to point at the concrete
204
+ emitted file. `tsc` emits extensionless specifiers under
205
+ `moduleResolution: "bundler"`, which plain Node ESM cannot resolve at
206
+ runtime. `dist/` is generated and not committed.
207
+
208
+ The repo formats itself with the same Prettier config it exports. A unit
209
+ test asserts `.prettierrc` and `src/config/prettierConfig.ts` stay in
210
+ sync, so the two copies cannot drift.
211
+
212
+ ### Structure rule
213
+
214
+ Styled React units use `ComponentName/index.tsx` for the component and
215
+ `ComponentName/styles.ts` for its styled definitions.
216
+
217
+ ## Releasing
218
+
219
+ Releases are cut by tag. Publishing happens in CI so the tarball is always
220
+ built on the same Node 22 runner consumers' pipelines use, rather than
221
+ whatever happens to be on a laptop.
222
+
223
+ 1. Land your change on `main` through a PR.
224
+ 2. Bump `version` in `package.json` (semver).
225
+ 3. Tag the merge commit and push:
226
+
227
+ ```bash
228
+ git tag ui-v<version>
229
+ git push origin ui-v<version>
230
+ ```
231
+
232
+ The release workflow refuses to publish if the tag and `package.json`
233
+ disagree on the version. `prepack` rebuilds `dist/` as part of packing, so
234
+ a stale build can never ship.
235
+
236
+ Consumers then bump their range and run `npm install`.
237
+
238
+ ## License
239
+
240
+ UNLICENSED. Published publicly on npm for installation convenience; not
241
+ open source.
@@ -0,0 +1,32 @@
1
+ import type { ElementType, MouseEvent, ReactNode } from 'react';
2
+ export type ButtonVariant = 'light' | 'dark';
3
+ export interface ButtonProps {
4
+ label: string;
5
+ variant?: ButtonVariant;
6
+ /** Decorative mark shown before the label. Pair with `iconSrc`. */
7
+ icon?: 'google-play' | 'app-store';
8
+ /** Resolved icon URL, so each site can ship its own artwork. */
9
+ iconSrc?: string;
10
+ /** Smaller line above the label, e.g. "Download on the". */
11
+ subLabel?: ReactNode;
12
+ href?: string;
13
+ rel?: string;
14
+ target?: string;
15
+ onClick?: (event: MouseEvent<HTMLElement>) => void;
16
+ /**
17
+ * Render as a different element or component.
18
+ *
19
+ * The reason this exists: an in-app destination needs a router link, not an
20
+ * `<a>`, or the navigation becomes a full page reload. Without it, callers
21
+ * re-implemented this button's surface by hand to get a `Link`.
22
+ *
23
+ * ```tsx
24
+ * <Button as={Link} to="/create" label="Create token" />
25
+ * ```
26
+ */
27
+ as?: ElementType;
28
+ /** Destination when rendering as a router link. Forwarded verbatim. */
29
+ to?: string;
30
+ 'aria-label'?: string;
31
+ }
32
+ export declare function Button({ icon, iconSrc, href, label, rel, subLabel, target, variant, as, to, onClick, 'aria-label': ariaLabel, }: ButtonProps): React.JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { IconWrap, Label, StoreSubLabel, Surface } from './styles.js';
3
+ export function Button({ icon, iconSrc, href, label, rel, subLabel, target, variant = 'light', as, to, onClick, 'aria-label': ariaLabel, }) {
4
+ return (_jsxs(Surface, { "$variant": variant, ...(as ? { as } : {}), ...(to !== undefined ? { to } : {}), href: href, rel: rel, target: target, onClick: onClick, "aria-label": ariaLabel, children: [icon && iconSrc ? (_jsx(IconWrap, { "$variant": variant, children: _jsx("img", { src: iconSrc, alt: "", "aria-hidden": "true" }) })) : null, _jsxs(Label, { children: [subLabel ? _jsx(StoreSubLabel, { "$variant": variant, children: subLabel }) : null, _jsx("strong", { children: label })] })] }));
5
+ }
@@ -0,0 +1,18 @@
1
+ export declare const Surface: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>, "style"> & {
2
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
3
+ }, {
4
+ $variant: "light" | "dark";
5
+ }>> & string;
6
+ export declare const IconWrap: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "style"> & {
7
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
8
+ }, {
9
+ $variant: "light" | "dark";
10
+ }>> & string;
11
+ export declare const Label: import("styled-components/dist/types").IStyledComponentBase<"web", Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "style"> & {
12
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
13
+ }> & string;
14
+ export declare const StoreSubLabel: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "style"> & {
15
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
16
+ }, {
17
+ $variant: "light" | "dark";
18
+ }>> & string;
@@ -0,0 +1,83 @@
1
+ import styled from 'styled-components';
2
+ export const Surface = styled.a `
3
+ display: inline-flex;
4
+ align-items: center;
5
+ justify-content: flex-start;
6
+ gap: ${({ $variant }) => ($variant === 'dark' ? '10px' : '8px')};
7
+ min-height: 56px;
8
+ min-width: 0;
9
+ padding: 16px 20px;
10
+ border: 1px solid
11
+ ${({ $variant, theme }) => $variant === 'dark' ? theme.colors.overlayBorderStrong : 'rgb(255 255 255 / 0.4)'};
12
+ border-radius: ${({ theme }) => theme.borderRadius.panel};
13
+ background: ${({ $variant, theme }) => ($variant === 'dark' ? '#111111' : theme.colors.light)};
14
+ color: ${({ $variant, theme }) => ($variant === 'dark' ? theme.colors.light : theme.colors.ink)};
15
+ box-shadow: ${({ theme }) => theme.shadow.button};
16
+ transition:
17
+ transform ${({ theme }) => theme.motion.duration.fast}
18
+ ${({ theme }) => theme.motion.easing.standard},
19
+ box-shadow ${({ theme }) => theme.motion.duration.fast}
20
+ ${({ theme }) => theme.motion.easing.standard},
21
+ background-color ${({ theme }) => theme.motion.duration.fast}
22
+ ${({ theme }) => theme.motion.easing.standard};
23
+
24
+ &:hover {
25
+ transform: translateY(-2px);
26
+ background: ${({ $variant, theme }) => $variant === 'dark' ? theme.colors.dark : 'rgb(255 255 255 / 0.92)'};
27
+ box-shadow: ${({ theme }) => theme.shadow.md};
28
+ }
29
+
30
+ &:focus-visible {
31
+ outline: none;
32
+ box-shadow:
33
+ 0 0 0 3px ${({ theme }) => theme.colors.focusRing},
34
+ ${({ theme }) => theme.shadow.button};
35
+ }
36
+
37
+ ${({ theme }) => theme.mediaQueries.md} {
38
+ min-height: 60px;
39
+ min-width: 153px;
40
+ padding: 18px 24px;
41
+ }
42
+ `;
43
+ export const IconWrap = styled.span `
44
+ display: inline-flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ min-width: 28px;
48
+ height: 24px;
49
+ font-size: 1.2rem;
50
+ line-height: 1;
51
+
52
+ img {
53
+ display: block;
54
+ width: auto;
55
+ height: 100%;
56
+ }
57
+ `;
58
+ export const Label = styled.span `
59
+ display: grid;
60
+ justify-items: start;
61
+ text-align: left;
62
+ gap: 6px;
63
+ line-height: 1;
64
+ min-width: 0;
65
+ font-family: ${({ theme }) => theme.typography.fontFamily.ui};
66
+
67
+ ${({ theme }) => theme.mediaQueries.xs} {
68
+ min-width: max-content;
69
+ }
70
+
71
+ strong {
72
+ font-size: 0.875rem;
73
+ font-weight: ${({ theme }) => theme.fontWeight.semibold};
74
+ letter-spacing: -0.03em;
75
+ }
76
+ `;
77
+ export const StoreSubLabel = styled.span `
78
+ color: ${({ $variant, theme }) => $variant === 'dark' ? theme.colors.overlayTextMuted : 'rgb(0 0 0 / 0.4)'};
79
+ font-size: 0.625rem;
80
+ font-weight: ${({ theme }) => theme.fontWeight.semibold};
81
+ letter-spacing: -0.03em;
82
+ text-transform: uppercase;
83
+ `;
@@ -22,7 +22,8 @@ export interface FooterContent {
22
22
  disclaimer: string[];
23
23
  }
24
24
  interface FooterProps {
25
- brandWordmarkSrc: string;
25
+ /** Overrides the wordmark from `<BrandProvider>`. Optional when one is present. */
26
+ brandWordmarkSrc?: string;
26
27
  columns: FooterLinkGroup[];
27
28
  content: FooterContent;
28
29
  forceDocumentNavigation?: boolean;
@@ -4,6 +4,7 @@ import { LocaleSelector } from '../LocaleSelector/index.js';
4
4
  import { buildLocalizedPath } from '../locale/routes.js';
5
5
  import { useCurrentLocale } from '../locale/hooks.js';
6
6
  import { desktopViewportQuery } from '../tokens.js';
7
+ import { useOptionalBrand } from '../brand/index.js';
7
8
  import { FooterColumn, FooterColumnHeading, FooterContainer, FooterCopyright, FooterColumnsGrid, FooterDesktopLocaleSlot, FooterDisclaimer, FooterDisclaimerStack, FooterGrid, FooterLegalButton, FooterLegalDivider, FooterLegalLink as FooterLegalLinkStyled, FooterLegalMeta, FooterLegalNav, FooterLegalText, FooterLocaleSlot, FooterLink as FooterLinkStyled, FooterLinkList, FooterMobileCopyright, FooterMobileDivider, FooterMobileLegalDividerTrack, FooterMobileLegalMeta, FooterMobileLegalNav, FooterShell, FooterWaitlistSlot, FooterWordmark, FooterWordmarkImage, } from './styles.js';
8
9
  const buildFooterHref = (target, locale, localize = true) => {
9
10
  if (target.startsWith('/') && localize) {
@@ -18,6 +19,11 @@ const getExternalLinkProps = (external) => {
18
19
  return { target: '_blank', rel: 'noreferrer' };
19
20
  };
20
21
  export function Footer({ brandWordmarkSrc, columns, content, forceDocumentNavigation = false, legalLinks, onOpenCookiePreferences, waitlistSlot, }) {
22
+ const brand = useOptionalBrand();
23
+ const wordmarkSrc = brandWordmarkSrc ?? brand?.wordmarkSrc;
24
+ if (wordmarkSrc === undefined) {
25
+ throw new Error('<Footer> needs a brandWordmarkSrc prop or a <BrandProvider>.');
26
+ }
21
27
  const locale = useCurrentLocale();
22
28
  const [isDesktopFooterLayout, setIsDesktopFooterLayout] = useState(true);
23
29
  useEffect(() => {
@@ -44,5 +50,5 @@ export function Footer({ brandWordmarkSrc, columns, content, forceDocumentNaviga
44
50
  return (_jsx(FooterLegalLinkStyled, { href: buildFooterHref(link.target, locale), ...getExternalLinkProps(link.external), children: link.label }));
45
51
  };
46
52
  const legalMeta = isDesktopFooterLayout ? (_jsxs(FooterLegalMeta, { children: [_jsx(FooterCopyright, { children: content.copyrightText }), _jsx(FooterLegalNav, { "aria-label": content.legalNavLabel, children: legalLinks.map((link, index) => (_jsxs("span", { children: [index > 0 ? _jsx(FooterLegalDivider, { "aria-hidden": "true" }) : null, renderLegalLink(link)] }, link.target))) })] })) : undefined;
47
- return (_jsx(FooterShell, { children: _jsxs(FooterContainer, { children: [_jsxs(FooterGrid, { children: [_jsx(FooterWaitlistSlot, { children: waitlistSlot?.(legalMeta) }), _jsx(FooterMobileDivider, { "aria-hidden": "true" }), _jsxs(FooterColumnsGrid, { children: [columns.map((group) => (_jsxs(FooterColumn, { children: [_jsx(FooterColumnHeading, { children: group.group }), _jsx(FooterLinkList, { children: group.links.map((link) => renderFooterLink(link)) })] }, group.group))), _jsxs(FooterDisclaimerStack, { children: [isDesktopFooterLayout ? (_jsx(FooterDesktopLocaleSlot, { children: _jsx(LocaleSelector, { ariaLabel: content.localeSelectorAriaLabel, forceDocumentNavigation: forceDocumentNavigation }) })) : null, _jsx(FooterDisclaimer, { children: content.disclaimer.map((paragraph) => (_jsx("p", { children: paragraph }, paragraph))) })] })] }), !isDesktopFooterLayout ? (_jsxs(FooterMobileLegalMeta, { children: [_jsx(FooterLocaleSlot, { children: _jsx(LocaleSelector, { ariaLabel: content.localeSelectorAriaLabel, forceDocumentNavigation: forceDocumentNavigation }) }), _jsx(FooterMobileCopyright, { children: content.copyrightText }), _jsx(FooterMobileLegalNav, { "aria-label": content.legalNavLabel, children: legalLinks.map((link, index) => (_jsxs(Fragment, { children: [index > 0 ? (_jsx(FooterMobileLegalDividerTrack, { children: _jsx(FooterLegalDivider, { "aria-hidden": "true" }) })) : null, renderLegalLink(link)] }, link.target))) })] })) : null] }), _jsx(FooterWordmark, { "aria-hidden": "true", children: _jsx(FooterWordmarkImage, { src: brandWordmarkSrc, alt: "" }) })] }) }));
53
+ return (_jsx(FooterShell, { children: _jsxs(FooterContainer, { children: [_jsxs(FooterGrid, { children: [_jsx(FooterWaitlistSlot, { children: waitlistSlot?.(legalMeta) }), _jsx(FooterMobileDivider, { "aria-hidden": "true" }), _jsxs(FooterColumnsGrid, { children: [columns.map((group) => (_jsxs(FooterColumn, { children: [_jsx(FooterColumnHeading, { children: group.group }), _jsx(FooterLinkList, { children: group.links.map((link) => renderFooterLink(link)) })] }, group.group))), _jsxs(FooterDisclaimerStack, { children: [isDesktopFooterLayout ? (_jsx(FooterDesktopLocaleSlot, { children: _jsx(LocaleSelector, { ariaLabel: content.localeSelectorAriaLabel, forceDocumentNavigation: forceDocumentNavigation }) })) : null, _jsx(FooterDisclaimer, { children: content.disclaimer.map((paragraph) => (_jsx("p", { children: paragraph }, paragraph))) })] })] }), !isDesktopFooterLayout ? (_jsxs(FooterMobileLegalMeta, { children: [_jsx(FooterLocaleSlot, { children: _jsx(LocaleSelector, { ariaLabel: content.localeSelectorAriaLabel, forceDocumentNavigation: forceDocumentNavigation }) }), _jsx(FooterMobileCopyright, { children: content.copyrightText }), _jsx(FooterMobileLegalNav, { "aria-label": content.legalNavLabel, children: legalLinks.map((link, index) => (_jsxs(Fragment, { children: [index > 0 ? (_jsx(FooterMobileLegalDividerTrack, { children: _jsx(FooterLegalDivider, { "aria-hidden": "true" }) })) : null, renderLegalLink(link)] }, link.target))) })] })) : null] }), _jsx(FooterWordmark, { "aria-hidden": "true", children: _jsx(FooterWordmarkImage, { src: wordmarkSrc, alt: "" }) })] }) }));
48
54
  }
@@ -0,0 +1,3 @@
1
+ import type { NavigationContent } from '../navigationContent.js';
2
+ import type { WaitlistFormMessages } from '../waitlist/types.js';
3
+ export declare const resolveWaitlistFormContent: (navContent: NavigationContent) => WaitlistFormMessages;
@@ -0,0 +1,7 @@
1
+ export const resolveWaitlistFormContent = (navContent) => ({
2
+ successMessage: navContent.footer.waitlistForm.successMessage,
3
+ emptyEmailMessage: navContent.footer.waitlistForm.emptyEmailMessage,
4
+ invalidEmailMessage: navContent.footer.waitlistForm.invalidEmailMessage,
5
+ errorMessage: navContent.footer.waitlistForm.errorMessage,
6
+ submittingLabel: navContent.footer.waitlistForm.submittingLabel,
7
+ });
@@ -0,0 +1,24 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { WaitlistRequest, WaitlistSource } from '../waitlist/types.js';
3
+ import { type FooterWaitlistVariant } from './styles.js';
4
+ export interface FooterWaitlistProps {
5
+ /** Identifies which form on the site was submitted. */
6
+ source: WaitlistSource;
7
+ /** Performs the submission. Resolve for success, throw for failure. */
8
+ submit: (request: WaitlistRequest) => Promise<unknown>;
9
+ /** Namespaces stored attribution. One stable value per site. */
10
+ attributionKey: string;
11
+ variant?: FooterWaitlistVariant;
12
+ legalMeta?: ReactNode | undefined;
13
+ onWaitlistSuccess?: ((message: string) => void) | undefined;
14
+ onWaitlistError?: ((message: string) => void) | undefined;
15
+ /** Fires when submit is pressed, before validation. For analytics. */
16
+ onSubmitClick?: (() => void) | undefined;
17
+ }
18
+ /**
19
+ * Email capture card with the site's social row beneath it.
20
+ *
21
+ * Wordmark and social links come from `<BrandProvider>`, so two sites render
22
+ * this identically while showing entirely different artwork and destinations.
23
+ */
24
+ export declare function FooterWaitlist({ source, submit, attributionKey, variant, legalMeta, onWaitlistSuccess, onWaitlistError, onSubmitClick, }: FooterWaitlistProps): React.JSX.Element;
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { useBrand } from '../brand/index.js';
4
+ import { navigationContent } from '../navigationContent.js';
5
+ import { useCurrentLocale } from '../locale/hooks.js';
6
+ import { resetAutocompleteFillMarkersAfterInput } from '../waitlist/autocompleteFillMarkers.js';
7
+ import { useWaitlistForm } from '../waitlist/useWaitlistForm.js';
8
+ import { resolveWaitlistFormContent } from './helper.js';
9
+ import { WaitlistCard, WaitlistCardContent, WaitlistCardTitle, WaitlistCheckbox, WaitlistCheckboxLabel, WaitlistCheckboxRow, WaitlistContainer, WaitlistDisclaimer, WaitlistFieldGroup, WaitlistForm, WaitlistInput, WaitlistInputMeasure, WaitlistLowerContent, WaitlistSectionShell, WaitlistSocialBlock, WaitlistSocialIcon, WaitlistSocialLink, WaitlistSocialLinks, WaitlistStatusText, WaitlistSubmitButton, } from './styles.js';
10
+ // Below this the address is unreadable; the field scrolls instead of shrinking further.
11
+ const minWaitlistInputScale = 0.5;
12
+ /**
13
+ * Email capture card with the site's social row beneath it.
14
+ *
15
+ * Wordmark and social links come from `<BrandProvider>`, so two sites render
16
+ * this identically while showing entirely different artwork and destinations.
17
+ */
18
+ export function FooterWaitlist({ source, submit, attributionKey, variant = 'footer', legalMeta, onWaitlistSuccess, onWaitlistError, onSubmitClick, }) {
19
+ const locale = useCurrentLocale();
20
+ const brand = useBrand();
21
+ const navContent = navigationContent[locale];
22
+ const waitlistContent = resolveWaitlistFormContent(navContent);
23
+ const inputRef = useRef(null);
24
+ const inputMeasureRef = useRef(null);
25
+ const [waitlistInputScale, setWaitlistInputScale] = useState(1);
26
+ const waitlistMessages = {
27
+ ...waitlistContent,
28
+ emailPlaceholder: navContent.footer.emailPlaceholder,
29
+ submitLabel: navContent.footer.submitLabel,
30
+ };
31
+ const waitlistOptions = {
32
+ source,
33
+ submit,
34
+ attributionKey,
35
+ messages: waitlistMessages,
36
+ initialConsentMarketing: false,
37
+ };
38
+ if (onWaitlistSuccess) {
39
+ waitlistOptions.onSuccess = onWaitlistSuccess;
40
+ }
41
+ if (onWaitlistError) {
42
+ waitlistOptions.onError = onWaitlistError;
43
+ }
44
+ const { email, setEmail, consentMarketing, setConsentMarketing, emailError, isSubmitting, statusMessage, statusTone, onSubmit, } = useWaitlistForm(waitlistOptions);
45
+ const describedBy = statusMessage ? `${variant}-waitlist-status` : undefined;
46
+ const setWaitlistInputRef = useCallback((input) => {
47
+ inputRef.current = input;
48
+ if (input) {
49
+ resetAutocompleteFillMarkersAfterInput(input);
50
+ }
51
+ }, []);
52
+ const updateWaitlistInputScale = useCallback(() => {
53
+ const input = inputRef.current;
54
+ const inputMeasure = inputMeasureRef.current;
55
+ if (!email || !input || !inputMeasure) {
56
+ setWaitlistInputScale(1);
57
+ return;
58
+ }
59
+ const inputStyles = window.getComputedStyle(input);
60
+ const inputHorizontalPadding = Number.parseFloat(inputStyles.paddingLeft) + Number.parseFloat(inputStyles.paddingRight);
61
+ const availableInputWidth = input.clientWidth - inputHorizontalPadding;
62
+ const measuredTextWidth = inputMeasure.getBoundingClientRect().width;
63
+ if (availableInputWidth <= 0 || measuredTextWidth <= 0) {
64
+ setWaitlistInputScale(1);
65
+ return;
66
+ }
67
+ const nextScale = Math.min(1, Math.max(minWaitlistInputScale, availableInputWidth / measuredTextWidth));
68
+ setWaitlistInputScale((currentScale) => Math.abs(currentScale - nextScale) < 0.01 ? currentScale : nextScale);
69
+ }, [email]);
70
+ const handleWaitlistEmailChange = useCallback((event) => {
71
+ setEmail(event.currentTarget.value);
72
+ resetAutocompleteFillMarkersAfterInput(event.currentTarget);
73
+ }, [setEmail]);
74
+ useLayoutEffect(() => {
75
+ updateWaitlistInputScale();
76
+ }, [updateWaitlistInputScale]);
77
+ useEffect(() => {
78
+ window.addEventListener('resize', updateWaitlistInputScale);
79
+ return () => {
80
+ window.removeEventListener('resize', updateWaitlistInputScale);
81
+ };
82
+ }, [updateWaitlistInputScale]);
83
+ return (_jsx(WaitlistSectionShell, { "$variant": variant, children: _jsx(WaitlistContainer, { "$variant": variant, children: _jsx(WaitlistCard, { children: _jsxs(WaitlistCardContent, { "$variant": variant, children: [_jsx(WaitlistCardTitle, { "$variant": variant, children: navContent.footer.ctaTitle }), _jsxs(WaitlistForm, { "$variant": variant, noValidate: true, onSubmit: (event) => {
84
+ void onSubmit(event);
85
+ }, children: [_jsxs(WaitlistFieldGroup, { "$variant": variant, children: [_jsx(WaitlistInputMeasure, { ref: inputMeasureRef, "$variant": variant, "aria-hidden": "true", children: email }), _jsx(WaitlistInput, { ref: setWaitlistInputRef, "$variant": variant, "$fontScale": waitlistInputScale, "aria-describedby": describedBy, "aria-invalid": emailError ? true : undefined, "aria-label": waitlistMessages.emailPlaceholder, autoComplete: "email", placeholder: waitlistMessages.emailPlaceholder, type: "email", value: email, onChange: handleWaitlistEmailChange, onFocus: (event) => resetAutocompleteFillMarkersAfterInput(event.currentTarget) }), _jsx(WaitlistSubmitButton, { "$variant": variant, type: "submit", disabled: isSubmitting, onClick: onSubmitClick, children: isSubmitting ? waitlistMessages.submittingLabel : waitlistMessages.submitLabel })] }), statusMessage ? (_jsx(WaitlistStatusText, { "$tone": statusTone, id: `${variant}-waitlist-status`, "aria-live": "polite", role: "status", children: statusMessage })) : null, _jsx(WaitlistDisclaimer, { children: navContent.footer.consentNote })] }), _jsxs(WaitlistLowerContent, { "$variant": variant, children: [_jsxs(WaitlistCheckboxRow, { htmlFor: `${variant}-waitlist-marketing`, children: [_jsx(WaitlistCheckbox, { id: `${variant}-waitlist-marketing`, checked: consentMarketing, type: "checkbox", onChange: (event) => setConsentMarketing(event.target.checked) }), _jsx(WaitlistCheckboxLabel, { children: navContent.footer.consentLabel })] }), brand.socialLinks.length > 0 ? (_jsx(WaitlistSocialBlock, { "$variant": variant, "aria-label": navContent.footer.socialHeading, children: _jsx(WaitlistSocialLinks, { children: brand.socialLinks.map((socialLink) => (_jsx(WaitlistSocialLink, { href: socialLink.href, target: "_blank", rel: "noreferrer", "aria-label": socialLink.label, children: _jsx(WaitlistSocialIcon, { src: socialLink.iconSrc, alt: "", "aria-hidden": "true" }) }, socialLink.key))) }) })) : null, legalMeta] })] }) }) }) }));
86
+ }
@@ -0,0 +1,71 @@
1
+ export type FooterWaitlistVariant = 'standalone' | 'footer';
2
+ type StatusTone = 'default' | 'success' | 'error';
3
+ interface VariantProps {
4
+ $variant: FooterWaitlistVariant;
5
+ }
6
+ interface WaitlistInputProps extends VariantProps {
7
+ $fontScale: number;
8
+ }
9
+ export declare const WaitlistSectionShell: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLElement>, HTMLElement>, "style"> & {
10
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
11
+ }, VariantProps>> & string;
12
+ export declare const WaitlistContainer: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "style"> & {
13
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
14
+ }, VariantProps>> & string;
15
+ export declare const WaitlistCard: import("styled-components/dist/types").IStyledComponentBase<"web", Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLElement>, HTMLElement>, "style"> & {
16
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
17
+ }> & string;
18
+ export declare const WaitlistCardContent: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "style"> & {
19
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
20
+ }, VariantProps>> & string;
21
+ export declare const WaitlistCardTitle: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>, "style"> & {
22
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
23
+ }, VariantProps>> & string;
24
+ export declare const WaitlistForm: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>, "style"> & {
25
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
26
+ }, VariantProps>> & string;
27
+ export declare const WaitlistLowerContent: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "style"> & {
28
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
29
+ }, VariantProps>> & string;
30
+ export declare const WaitlistFieldGroup: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "style"> & {
31
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
32
+ }, VariantProps>> & string;
33
+ export declare const WaitlistInputMeasure: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "style"> & {
34
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
35
+ }, VariantProps>> & string;
36
+ export declare const WaitlistInput: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "style"> & {
37
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
38
+ }, WaitlistInputProps>> & string;
39
+ export declare const WaitlistSubmitButton: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, "style"> & {
40
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
41
+ }, VariantProps>> & string;
42
+ export declare const WaitlistDisclaimer: import("styled-components/dist/types").IStyledComponentBase<"web", Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>, "style"> & {
43
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
44
+ }> & string;
45
+ export declare const WaitlistCheckboxRow: import("styled-components/dist/types").IStyledComponentBase<"web", Omit<import("react").DetailedHTMLProps<import("react").LabelHTMLAttributes<HTMLLabelElement>, HTMLLabelElement>, "style"> & {
46
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
47
+ }> & string;
48
+ export declare const WaitlistCheckbox: import("styled-components/dist/types").IStyledComponentBase<"web", Omit<import("react").DetailedHTMLProps<import("react").InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "style"> & {
49
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
50
+ }> & string;
51
+ export declare const WaitlistCheckboxLabel: import("styled-components/dist/types").IStyledComponentBase<"web", Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "style"> & {
52
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
53
+ }> & string;
54
+ export declare const WaitlistStatusText: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>, "style"> & {
55
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
56
+ }, {
57
+ $tone: StatusTone;
58
+ }>> & string;
59
+ export declare const WaitlistSocialBlock: import("styled-components/dist/types").IStyledComponentBase<"web", import("styled-components/dist/types").Merged<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "style"> & {
60
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
61
+ }, VariantProps>> & string;
62
+ export declare const WaitlistSocialLinks: import("styled-components/dist/types").IStyledComponentBase<"web", Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "style"> & {
63
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
64
+ }> & string;
65
+ export declare const WaitlistSocialLink: import("styled-components/dist/types").IStyledComponentBase<"web", Omit<import("react").DetailedHTMLProps<import("react").AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>, "style"> & {
66
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
67
+ }> & string;
68
+ export declare const WaitlistSocialIcon: import("styled-components/dist/types").IStyledComponentBase<"web", Omit<import("react").DetailedHTMLProps<import("react").ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, "style"> & {
69
+ style?: import("react").CSSProperties | import("styled-components/dist/types").CSSPropertiesWithVars | undefined;
70
+ }> & string;
71
+ export {};