@cronos-labs/ui 0.2.1 → 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/dist/fetch.js ADDED
@@ -0,0 +1,44 @@
1
+ export class HttpError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.name = 'HttpError';
6
+ this.status = status;
7
+ }
8
+ }
9
+ /**
10
+ * Performs a JSON request with timeout support.
11
+ * @param input Fetch URL or Request object.
12
+ * @param init Optional fetch init.
13
+ * @param timeoutMs Timeout in milliseconds.
14
+ * @returns Parsed JSON payload.
15
+ * @throws {DOMException} When request is aborted by timeout.
16
+ * @throws {HttpError} When response status is not 2xx.
17
+ */
18
+ export const fetchJson = async (input, init, timeoutMs = 10_000) => {
19
+ const controller = new AbortController();
20
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
21
+ // Sending Content-Type on a bodyless request makes a cross-origin GET
22
+ // preflighted, so it is only declared when there is a body to describe.
23
+ const hasBody = init?.body !== undefined && init.body !== null;
24
+ const headers = hasBody
25
+ ? {
26
+ 'Content-Type': 'application/json',
27
+ ...(init?.headers ?? {}),
28
+ }
29
+ : init?.headers;
30
+ try {
31
+ const response = await fetch(input, {
32
+ ...init,
33
+ signal: controller.signal,
34
+ ...(headers !== undefined ? { headers } : {}),
35
+ });
36
+ if (!response.ok) {
37
+ throw new HttpError(response.status, `Request failed with status ${response.status}`);
38
+ }
39
+ return (await response.json());
40
+ }
41
+ finally {
42
+ clearTimeout(timeout);
43
+ }
44
+ };
package/dist/index.d.ts CHANGED
@@ -19,3 +19,16 @@ export { SectionAnchorNav, type SectionAnchorNavItem } from './SectionAnchorNav/
19
19
  export { scrollToSectionHeading } from './scrollToSectionHeading.js';
20
20
  export { PillButton, FilterPillRow } from './FilterPillGroup/index.js';
21
21
  export { SearchField, type SearchFieldProps } from './SearchField/index.js';
22
+ export { Toast, type ToastTone, type ToastIcons } from './Toast/index.js';
23
+ export { fetchJson, HttpError } from './fetch.js';
24
+ export { isOneTrustEventTarget, openCookiePreferences } from './onetrust.js';
25
+ export { BrandProvider, useBrand, useOptionalBrand } from './brand/index.js';
26
+ export type { BrandAssets, BrandSocialLink } from './brand/index.js';
27
+ export { FooterWaitlist, type FooterWaitlistProps } from './FooterWaitlist/index.js';
28
+ export { useWaitlistForm } from './waitlist/useWaitlistForm.js';
29
+ export type { UseWaitlistFormOptions, WaitlistStatusTone } from './waitlist/useWaitlistForm.js';
30
+ export type { WaitlistRequest, WaitlistResponse, WaitlistSource, WaitlistFormMessages, } from './waitlist/types.js';
31
+ export { resetAutocompleteFillMarkersAfterInput } from './waitlist/autocompleteFillMarkers.js';
32
+ export { clearWaitlistAttribution, getWaitlistBrowserContext } from './waitlist/attribution.js';
33
+ export { cardSurface, cardHoverSurface, wellSurface } from './surfaces.js';
34
+ export { Button, type ButtonProps, type ButtonVariant } from './Button/index.js';
package/dist/index.js CHANGED
@@ -16,3 +16,13 @@ export { SectionAnchorNav } from './SectionAnchorNav/index.js';
16
16
  export { scrollToSectionHeading } from './scrollToSectionHeading.js';
17
17
  export { PillButton, FilterPillRow } from './FilterPillGroup/index.js';
18
18
  export { SearchField } from './SearchField/index.js';
19
+ export { Toast } from './Toast/index.js';
20
+ export { fetchJson, HttpError } from './fetch.js';
21
+ export { isOneTrustEventTarget, openCookiePreferences } from './onetrust.js';
22
+ export { BrandProvider, useBrand, useOptionalBrand } from './brand/index.js';
23
+ export { FooterWaitlist } from './FooterWaitlist/index.js';
24
+ export { useWaitlistForm } from './waitlist/useWaitlistForm.js';
25
+ export { resetAutocompleteFillMarkersAfterInput } from './waitlist/autocompleteFillMarkers.js';
26
+ export { clearWaitlistAttribution, getWaitlistBrowserContext } from './waitlist/attribution.js';
27
+ export { cardSurface, cardHoverSurface, wellSurface } from './surfaces.js';
28
+ export { Button } from './Button/index.js';
@@ -0,0 +1,11 @@
1
+ interface OneTrustApi {
2
+ ToggleInfoDisplay?: () => void;
3
+ }
4
+ declare global {
5
+ interface Window {
6
+ OneTrust?: OneTrustApi;
7
+ }
8
+ }
9
+ export declare const openCookiePreferences: () => void;
10
+ export declare const isOneTrustEventTarget: (target: EventTarget | null) => boolean;
11
+ export {};
@@ -0,0 +1,17 @@
1
+ const oneTrustConsentSelector = [
2
+ '#onetrust-consent-sdk',
3
+ '#onetrust-banner-sdk',
4
+ '#ot-sdk-cookie-policy',
5
+ '#ot-pc-content',
6
+ '#ot-pc-lst',
7
+ '.ot-sdk-container',
8
+ '.ot-sdk-modal',
9
+ '.ot-sdk-row',
10
+ ].join(',');
11
+ export const openCookiePreferences = () => {
12
+ window.OneTrust?.ToggleInfoDisplay?.();
13
+ };
14
+ export const isOneTrustEventTarget = (target) => {
15
+ const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
16
+ return Boolean(element?.closest(oneTrustConsentSelector));
17
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The shared card surface for every Cronos property.
3
+ *
4
+ * A card separates from the page by a background-lightness step alone — no
5
+ * border, no drop shadow. Four of the five card definitions that existed
6
+ * across the apps before this was centralised already looked exactly like
7
+ * this; the one that added a hairline border was the outlier, and it made
8
+ * Launch's token cards visibly different from Network's project cards while
9
+ * a comment in each claimed they matched.
10
+ *
11
+ * Compose it rather than restyling it:
12
+ *
13
+ * ```ts
14
+ * const TokenCard = styled(Link)`
15
+ * ${cardSurface}
16
+ * padding: ${({ theme }) => theme.sizes.lg};
17
+ * `;
18
+ * ```
19
+ */
20
+ export declare const cardSurface: import("styled-components").RuleSet<object>;
21
+ /**
22
+ * Hover treatment for a card that is itself a link or button.
23
+ *
24
+ * Lift plus a background step, matching the surface's no-border rule — callers
25
+ * add their own `transition` and reduced-motion guard alongside, since the
26
+ * properties worth animating differ per card.
27
+ */
28
+ export declare const cardHoverSurface: import("styled-components").RuleSet<object>;
29
+ /**
30
+ * A recessed well inside a card — inputs, tracks, read-only values.
31
+ *
32
+ * Darkens whatever card it sits on rather than introducing another hex, so the
33
+ * ramp stays page < well < card at every nesting depth.
34
+ */
35
+ export declare const wellSurface: import("styled-components").RuleSet<object>;
@@ -0,0 +1,45 @@
1
+ import { css } from 'styled-components';
2
+ /**
3
+ * The shared card surface for every Cronos property.
4
+ *
5
+ * A card separates from the page by a background-lightness step alone — no
6
+ * border, no drop shadow. Four of the five card definitions that existed
7
+ * across the apps before this was centralised already looked exactly like
8
+ * this; the one that added a hairline border was the outlier, and it made
9
+ * Launch's token cards visibly different from Network's project cards while
10
+ * a comment in each claimed they matched.
11
+ *
12
+ * Compose it rather than restyling it:
13
+ *
14
+ * ```ts
15
+ * const TokenCard = styled(Link)`
16
+ * ${cardSurface}
17
+ * padding: ${({ theme }) => theme.sizes.lg};
18
+ * `;
19
+ * ```
20
+ */
21
+ export const cardSurface = css `
22
+ border-radius: ${({ theme }) => theme.borderRadius.cardLg};
23
+ background: ${({ theme }) => theme.colors.surfaceDarkElevated};
24
+ `;
25
+ /**
26
+ * Hover treatment for a card that is itself a link or button.
27
+ *
28
+ * Lift plus a background step, matching the surface's no-border rule — callers
29
+ * add their own `transition` and reduced-motion guard alongside, since the
30
+ * properties worth animating differ per card.
31
+ */
32
+ export const cardHoverSurface = css `
33
+ transform: translateY(-2px);
34
+ background: ${({ theme }) => theme.colors.surfaceDarkHover};
35
+ `;
36
+ /**
37
+ * A recessed well inside a card — inputs, tracks, read-only values.
38
+ *
39
+ * Darkens whatever card it sits on rather than introducing another hex, so the
40
+ * ramp stays page < well < card at every nesting depth.
41
+ */
42
+ export const wellSurface = css `
43
+ border-radius: ${({ theme }) => theme.borderRadius.card};
44
+ background: ${({ theme }) => theme.colors.surfaceDark};
45
+ `;
@@ -0,0 +1,9 @@
1
+ export interface WaitlistBrowserContext {
2
+ documentReferrer?: string;
3
+ landingPageUrl?: string;
4
+ referrerCode?: string;
5
+ }
6
+ export declare const buildWaitlistReferrerStorageKey: (projectUid: string) => string;
7
+ export declare const syncWaitlistReferrerCode: (projectUid: string) => string | undefined;
8
+ export declare const clearWaitlistAttribution: (projectUid: string) => void;
9
+ export declare const getWaitlistBrowserContext: (projectUid: string) => WaitlistBrowserContext;
@@ -0,0 +1,79 @@
1
+ const WAITLIST_REFERRER_STORAGE_KEY_PREFIX = 'prefinery';
2
+ const waitlistTrackingQueryKeys = [
3
+ 'r',
4
+ 'utm_source',
5
+ 'utm_medium',
6
+ 'utm_campaign',
7
+ 'utm_term',
8
+ 'utm_content',
9
+ ];
10
+ const parseOptionalString = (value) => {
11
+ if (typeof value !== 'string') {
12
+ return undefined;
13
+ }
14
+ const trimmedValue = value.trim();
15
+ return trimmedValue || undefined;
16
+ };
17
+ const getDocumentReferrer = () => {
18
+ return parseOptionalString(document.referrer);
19
+ };
20
+ const getLandingPageUrlFromLocation = () => {
21
+ const searchParams = new URLSearchParams(window.location.search);
22
+ const hasTrackingParams = waitlistTrackingQueryKeys.some((key) => searchParams.has(key));
23
+ return hasTrackingParams ? window.location.href : undefined;
24
+ };
25
+ const getReferralCodeFromLocation = () => {
26
+ const searchParams = new URLSearchParams(window.location.search);
27
+ return parseOptionalString(searchParams.get('r'));
28
+ };
29
+ export const buildWaitlistReferrerStorageKey = (projectUid) => {
30
+ return `${WAITLIST_REFERRER_STORAGE_KEY_PREFIX}.${projectUid}.referrer_code`;
31
+ };
32
+ const buildWaitlistLandingPageStorageKey = (projectUid) => {
33
+ return `${WAITLIST_REFERRER_STORAGE_KEY_PREFIX}.${projectUid}.landing_page_url`;
34
+ };
35
+ const getStoredWaitlistReferrerCode = (projectUid) => {
36
+ return parseOptionalString(window.localStorage.getItem(buildWaitlistReferrerStorageKey(projectUid)));
37
+ };
38
+ const getStoredWaitlistLandingPageUrl = (projectUid) => {
39
+ return parseOptionalString(window.localStorage.getItem(buildWaitlistLandingPageStorageKey(projectUid)));
40
+ };
41
+ export const syncWaitlistReferrerCode = (projectUid) => {
42
+ const locationReferrerCode = getReferralCodeFromLocation();
43
+ if (locationReferrerCode) {
44
+ window.localStorage.setItem(buildWaitlistReferrerStorageKey(projectUid), locationReferrerCode);
45
+ return locationReferrerCode;
46
+ }
47
+ return getStoredWaitlistReferrerCode(projectUid);
48
+ };
49
+ // Preserve the first tracked landing URL so UTM/referral attribution survives navigation before
50
+ // the visitor submits the waitlist form.
51
+ const syncWaitlistLandingPageUrl = (projectUid) => {
52
+ const locationLandingPageUrl = getLandingPageUrlFromLocation();
53
+ if (locationLandingPageUrl) {
54
+ window.localStorage.setItem(buildWaitlistLandingPageStorageKey(projectUid), locationLandingPageUrl);
55
+ return locationLandingPageUrl;
56
+ }
57
+ return getStoredWaitlistLandingPageUrl(projectUid);
58
+ };
59
+ // Clear attribution after a successful signup so a future browser signup does not
60
+ // inherit stale campaign or referral data.
61
+ export const clearWaitlistAttribution = (projectUid) => {
62
+ const storageKeys = [
63
+ buildWaitlistLandingPageStorageKey(projectUid),
64
+ buildWaitlistReferrerStorageKey(projectUid),
65
+ ];
66
+ storageKeys.forEach((storageKey) => {
67
+ window.localStorage.removeItem(storageKey);
68
+ });
69
+ };
70
+ export const getWaitlistBrowserContext = (projectUid) => {
71
+ const documentReferrer = getDocumentReferrer();
72
+ const landingPageUrl = syncWaitlistLandingPageUrl(projectUid);
73
+ const referrerCode = syncWaitlistReferrerCode(projectUid);
74
+ return {
75
+ ...(documentReferrer ? { documentReferrer } : {}),
76
+ ...(landingPageUrl ? { landingPageUrl } : {}),
77
+ ...(referrerCode ? { referrerCode } : {}),
78
+ };
79
+ };
@@ -0,0 +1,3 @@
1
+ export declare const isAutocompleteFillMarkerAttribute: (attributeName: string) => boolean;
2
+ export declare const resetAutocompleteFillMarkers: (input: HTMLInputElement) => void;
3
+ export declare const resetAutocompleteFillMarkersAfterInput: (input: HTMLInputElement) => void;
@@ -0,0 +1,17 @@
1
+ export const isAutocompleteFillMarkerAttribute = (attributeName) => attributeName.startsWith('data-') && attributeName.includes('filled');
2
+ export const resetAutocompleteFillMarkers = (input) => {
3
+ // Some autocomplete extensions mark filled fields with data attributes and style them with
4
+ // extension CSS. Remove those markers so the waitlist pill keeps its own background treatment.
5
+ Array.from(input.attributes).forEach((attribute) => {
6
+ if (isAutocompleteFillMarkerAttribute(attribute.name)) {
7
+ input.removeAttribute(attribute.name);
8
+ }
9
+ });
10
+ };
11
+ export const resetAutocompleteFillMarkersAfterInput = (input) => {
12
+ resetAutocompleteFillMarkers(input);
13
+ // Dropdown tools can add filled-state markers just after dispatching the input event.
14
+ window.requestAnimationFrame(() => {
15
+ resetAutocompleteFillMarkers(input);
16
+ });
17
+ };
@@ -0,0 +1,29 @@
1
+ import type { AppLocale } from '../locale/types.js';
2
+ /**
3
+ * Where a submission came from, e.g. `'home-hero'` or `'launch-footer'`.
4
+ *
5
+ * Deliberately a plain string: each site renders a different set of forms, so
6
+ * the closed union lives in the app that knows its own pages, not here.
7
+ */
8
+ export type WaitlistSource = string;
9
+ export interface WaitlistRequest {
10
+ email: string;
11
+ consentMarketing: boolean;
12
+ source: WaitlistSource;
13
+ locale: AppLocale;
14
+ documentReferrer?: string;
15
+ landingPageUrl?: string;
16
+ referrerCode?: string;
17
+ }
18
+ export interface WaitlistResponse {
19
+ success: boolean;
20
+ message: string;
21
+ }
22
+ /** Copy the form shows for each outcome. Every string is caller-supplied so it can be localized. */
23
+ export interface WaitlistFormMessages {
24
+ successMessage: string;
25
+ emptyEmailMessage: string;
26
+ invalidEmailMessage: string;
27
+ errorMessage: string;
28
+ submittingLabel: string;
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import type { WaitlistFormMessages, WaitlistRequest, WaitlistSource } from './types.js';
2
+ export type WaitlistStatusTone = 'default' | 'success' | 'error';
3
+ interface UseWaitlistFormResult {
4
+ email: string;
5
+ setEmail: (value: string) => void;
6
+ consentMarketing: boolean;
7
+ setConsentMarketing: (value: boolean) => void;
8
+ emailError: string;
9
+ isSubmitting: boolean;
10
+ statusMessage: string;
11
+ statusTone: WaitlistStatusTone;
12
+ onSubmit: (event: React.FormEvent<HTMLFormElement>) => Promise<void>;
13
+ }
14
+ export interface UseWaitlistFormOptions {
15
+ /** Identifies which form on the site was submitted. */
16
+ source: WaitlistSource;
17
+ /**
18
+ * Performs the submission. Resolve for success, throw or reject for failure.
19
+ *
20
+ * Injected rather than built in, so each site keeps its own transport — a
21
+ * Redux thunk, a bare `fetch`, a third-party SDK — without this package
22
+ * taking a dependency on any of them.
23
+ */
24
+ submit: (request: WaitlistRequest) => Promise<unknown>;
25
+ /**
26
+ * Namespaces the stored attribution keys. Use one stable value per site so
27
+ * two Cronos properties on the same origin never read each other's state.
28
+ */
29
+ attributionKey: string;
30
+ messages?: WaitlistFormMessages;
31
+ initialConsentMarketing?: boolean;
32
+ /** Called instead of showing inline status text — e.g. to raise a toast. */
33
+ onSuccess?: (message: string) => void;
34
+ onError?: (message: string) => void;
35
+ }
36
+ /**
37
+ * Email capture with validation, attribution and submission status.
38
+ *
39
+ * Owns only form state. It never knows how a submission travels, which is what
40
+ * lets the same form back a Redux-driven app and a plain one.
41
+ */
42
+ export declare function useWaitlistForm({ source, submit, attributionKey, messages, initialConsentMarketing, onSuccess, onError, }: UseWaitlistFormOptions): UseWaitlistFormResult;
43
+ export {};
@@ -0,0 +1,115 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { useCurrentLocale } from '../locale/hooks.js';
3
+ import { clearWaitlistAttribution, getWaitlistBrowserContext } from './attribution.js';
4
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
5
+ const defaultMessages = {
6
+ submittingLabel: 'Submitting...',
7
+ successMessage: 'You are on the waitlist.',
8
+ emptyEmailMessage: 'Please enter your email address.',
9
+ invalidEmailMessage: 'Please enter a valid email address.',
10
+ errorMessage: 'Unable to submit right now. Please try again.',
11
+ };
12
+ /**
13
+ * Email capture with validation, attribution and submission status.
14
+ *
15
+ * Owns only form state. It never knows how a submission travels, which is what
16
+ * lets the same form back a Redux-driven app and a plain one.
17
+ */
18
+ export function useWaitlistForm({ source, submit, attributionKey, messages = defaultMessages, initialConsentMarketing = false, onSuccess, onError, }) {
19
+ const locale = useCurrentLocale();
20
+ const [email, setEmail] = useState('');
21
+ const [consentMarketing, setConsentMarketing] = useState(initialConsentMarketing);
22
+ const [validationMessage, setValidationMessage] = useState('');
23
+ const [validationField, setValidationField] = useState(null);
24
+ const [submissionMessage, setSubmissionMessage] = useState('');
25
+ const [submissionTone, setSubmissionTone] = useState('default');
26
+ const [isSubmitting, setIsSubmitting] = useState(false);
27
+ // Capture referral parameters on first paint: they live in the landing URL,
28
+ // which a later navigation would have already replaced.
29
+ useEffect(() => {
30
+ getWaitlistBrowserContext(attributionKey);
31
+ }, [attributionKey]);
32
+ const clearFeedback = useCallback(() => {
33
+ setValidationMessage('');
34
+ setValidationField(null);
35
+ setSubmissionMessage('');
36
+ setSubmissionTone('default');
37
+ }, []);
38
+ const updateEmail = useCallback((value) => {
39
+ setEmail(value);
40
+ clearFeedback();
41
+ }, [clearFeedback]);
42
+ const updateConsentMarketing = useCallback((value) => {
43
+ setConsentMarketing(value);
44
+ clearFeedback();
45
+ }, [clearFeedback]);
46
+ const onSubmit = useCallback(async (event) => {
47
+ event.preventDefault();
48
+ const normalizedEmail = email.trim();
49
+ if (!normalizedEmail) {
50
+ setValidationField('email');
51
+ setValidationMessage(messages.emptyEmailMessage);
52
+ return;
53
+ }
54
+ if (!EMAIL_PATTERN.test(normalizedEmail)) {
55
+ setValidationField('email');
56
+ setValidationMessage(messages.invalidEmailMessage);
57
+ return;
58
+ }
59
+ clearFeedback();
60
+ setIsSubmitting(true);
61
+ const payload = {
62
+ email: normalizedEmail,
63
+ consentMarketing,
64
+ source,
65
+ locale,
66
+ ...getWaitlistBrowserContext(attributionKey),
67
+ };
68
+ try {
69
+ await submit(payload);
70
+ clearWaitlistAttribution(attributionKey);
71
+ if (onSuccess) {
72
+ onSuccess(messages.successMessage);
73
+ }
74
+ else {
75
+ setSubmissionMessage(messages.successMessage);
76
+ setSubmissionTone('success');
77
+ }
78
+ }
79
+ catch {
80
+ if (onError) {
81
+ onError(messages.errorMessage);
82
+ }
83
+ else {
84
+ setSubmissionMessage(messages.errorMessage);
85
+ setSubmissionTone('error');
86
+ }
87
+ }
88
+ finally {
89
+ setIsSubmitting(false);
90
+ }
91
+ }, [
92
+ attributionKey,
93
+ clearFeedback,
94
+ consentMarketing,
95
+ email,
96
+ locale,
97
+ messages,
98
+ onError,
99
+ onSuccess,
100
+ source,
101
+ submit,
102
+ ]);
103
+ const statusTone = validationMessage ? 'error' : submissionTone;
104
+ return {
105
+ email,
106
+ setEmail: updateEmail,
107
+ consentMarketing,
108
+ setConsentMarketing: updateConsentMarketing,
109
+ emailError: validationField === 'email' ? validationMessage : '',
110
+ isSubmitting,
111
+ statusMessage: validationMessage || submissionMessage,
112
+ statusTone,
113
+ onSubmit,
114
+ };
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cronos-labs/ui",
3
- "version": "0.2.1",
3
+ "version": "0.5.0",
4
4
  "description": "Shared Header, Footer, Seo, locale and theme primitives for Cronos web properties.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",