@wildo-ai/saas-website 1.1.4 → 1.1.6
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/esm/astro/blog-post-bridge.d.ts +23 -20
- package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
- package/dist/esm/astro/blog-post-bridge.js +13 -6
- package/dist/esm/astro/blog-post-bridge.js.map +1 -1
- package/dist/esm/astro/blog-post-head.renderer.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.d.ts +55 -26
- package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.js +21 -7
- package/dist/esm/astro/bridge-runtime.js.map +1 -1
- package/dist/esm/astro/collect-expected-label-keys.d.ts.map +1 -1
- package/dist/esm/astro/headers-renderer.d.ts.map +1 -1
- package/dist/esm/astro/i18n-routing.helper.d.ts.map +1 -1
- package/dist/esm/astro/label-pack-loader.d.ts +4 -4
- package/dist/esm/astro/label-pack-loader.d.ts.map +1 -1
- package/dist/esm/astro/label-pack-loader.js +14 -4
- package/dist/esm/astro/label-pack-loader.js.map +1 -1
- package/dist/esm/astro/llms-txt-server.d.ts.map +1 -1
- package/dist/esm/astro/pick-labels-for-page.d.ts.map +1 -1
- package/dist/esm/astro/robots-renderer.d.ts +9 -8
- package/dist/esm/astro/robots-renderer.d.ts.map +1 -1
- package/dist/esm/astro/robots-renderer.js +15 -15
- package/dist/esm/astro/robots-renderer.js.map +1 -1
- package/dist/esm/astro/sitemap-coverage.d.ts.map +1 -1
- package/dist/esm/astro/structured-data.renderer.d.ts.map +1 -1
- package/dist/esm/astro/website-page-head.renderer.d.ts +1 -1
- package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -1
- package/dist/esm/astro/website-page-head.renderer.js +2 -1
- package/dist/esm/astro/website-page-head.renderer.js.map +1 -1
- package/dist/esm/astro/website-page-runtime.helper.d.ts.map +1 -1
- package/dist/esm/astro/website-site-context.d.ts +5 -2
- package/dist/esm/astro/website-site-context.d.ts.map +1 -1
- package/dist/esm/astro/website-site-context.js.map +1 -1
- package/dist/esm/astro-island.d.ts.map +1 -1
- package/dist/esm/astro.d.ts.map +1 -1
- package/dist/esm/companion-exports.d.ts +8 -15
- package/dist/esm/companion-exports.d.ts.map +1 -1
- package/dist/esm/companion-exports.js +8 -15
- package/dist/esm/companion-exports.js.map +1 -1
- package/dist/esm/components/low-level/WebsiteInternalButton.d.ts.map +1 -1
- package/dist/esm/components/low-level/WebsiteInternalLink.d.ts.map +1 -1
- package/dist/esm/config/define-website-config.d.ts +3 -2
- package/dist/esm/config/define-website-config.d.ts.map +1 -1
- package/dist/esm/config/define-website-config.js +10 -10
- package/dist/esm/config/define-website-config.js.map +1 -1
- package/dist/esm/config/index.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.d.ts +6 -0
- package/dist/esm/config/load-website-config.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.js +20 -10
- package/dist/esm/config/load-website-config.js.map +1 -1
- package/dist/esm/config/load-website-pricing-catalog.d.ts +13 -0
- package/dist/esm/config/load-website-pricing-catalog.d.ts.map +1 -0
- package/dist/esm/config/load-website-pricing-catalog.js +34 -0
- package/dist/esm/config/load-website-pricing-catalog.js.map +1 -0
- package/dist/esm/config/load-website-site-context.d.ts +53 -0
- package/dist/esm/config/load-website-site-context.d.ts.map +1 -0
- package/dist/esm/config/load-website-site-context.js +98 -0
- package/dist/esm/config/load-website-site-context.js.map +1 -0
- package/dist/esm/config/wildo-website-config.schemas.d.ts +10 -5
- package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -1
- package/dist/esm/config/wildo-website-config.schemas.js +10 -5
- package/dist/esm/config/wildo-website-config.schemas.js.map +1 -1
- package/dist/esm/config-loader.d.ts +2 -0
- package/dist/esm/config-loader.d.ts.map +1 -1
- package/dist/esm/config-loader.js +2 -0
- package/dist/esm/config-loader.js.map +1 -1
- package/dist/esm/core/anonymous-session/InboundContactForm.d.ts +26 -6
- package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/InboundContactForm.js +51 -33
- package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +14 -3
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +28 -13
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +24 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +46 -3
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
- package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts +36 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.d.ts.map +1 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.js +69 -0
- package/dist/esm/core/anonymous-session/website-submission-challenge.js.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentBanner.d.ts +6 -0
- package/dist/esm/core/consent/WebsiteConsentBanner.d.ts.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentBanner.js +129 -0
- package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentContext.d.ts +85 -0
- package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentContext.js +89 -0
- package/dist/esm/core/consent/WebsiteConsentContext.js.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentGate.d.ts +27 -0
- package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -0
- package/dist/esm/core/consent/WebsiteConsentGate.js +9 -0
- package/dist/esm/core/consent/WebsiteConsentGate.js.map +1 -0
- package/dist/esm/core/consent/useWebsiteConsent.d.ts +10 -0
- package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -0
- package/dist/esm/core/consent/useWebsiteConsent.js +17 -0
- package/dist/esm/core/consent/useWebsiteConsent.js.map +1 -0
- package/dist/esm/core/consent/website-consent-storage.d.ts +22 -0
- package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -0
- package/dist/esm/core/consent/website-consent-storage.js +102 -0
- package/dist/esm/core/consent/website-consent-storage.js.map +1 -0
- package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsitePageContext.js.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +33 -6
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -1
- package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsiteSectionContext.js.map +1 -1
- package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsiteRuntime.d.ts +18 -0
- package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -1
- package/dist/esm/core/contexts/useWebsiteRuntime.js +28 -2
- package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -1
- package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
- package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts +57 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.d.ts.map +1 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.js +56 -0
- package/dist/esm/core/external-providers/WebsiteProviderComponent.js.map +1 -0
- package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts +1 -2
- package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -1
- package/dist/esm/core/external-providers/frontend-provider-registry.website.js.map +1 -1
- package/dist/esm/core/external-providers/provider-component-registry.website.d.ts +63 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.d.ts.map +1 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.js +68 -0
- package/dist/esm/core/external-providers/provider-component-registry.website.js.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts +36 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.d.ts.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.js +59 -0
- package/dist/esm/core/external-providers/useWebsiteProviderComponent.js.map +1 -0
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts +43 -10
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +92 -29
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
- package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts +11 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.d.ts.map +1 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.js +14 -0
- package/dist/esm/core/external-providers/website-provider-sdk-activations.js.map +1 -0
- package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
- package/dist/esm/core/factories/define-website-section.d.ts +3 -3
- package/dist/esm/core/factories/define-website-section.d.ts.map +1 -1
- package/dist/esm/core/factories/define-website-section.js +3 -3
- package/dist/esm/core/factories/define-website-section.js.map +1 -1
- package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -1
- package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.d.ts +21 -1
- package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.js +39 -8
- package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -1
- package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -1
- package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -1
- package/dist/esm/index.d.ts +36 -65
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +36 -65
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/mdx/BlogPost.d.ts.map +1 -1
- package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -1
- package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -1
- package/dist/esm/mdx/index.d.ts.map +1 -1
- package/dist/esm/mdx.d.ts.map +1 -1
- package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts +35 -6
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js +39 -5
- package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts +61 -0
- package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.d.ts.map +1 -0
- package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js +73 -0
- package/dist/esm/schemas/label-keys/website-consent-label-keys.schemas.js.map +1 -0
- package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts +32 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.d.ts.map +1 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js +36 -0
- package/dist/esm/schemas/label-keys/website-provider-component-label-keys.schemas.js.map +1 -0
- package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +6 -5
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js +8 -7
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-category.shared.js.map +1 -1
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +14 -7
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +14 -7
- package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts +57 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.d.ts.map +1 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js +76 -0
- package/dist/esm/schemas/structured-data/pricing-offer-structured-data.shared.js.map +1 -0
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts +23 -0
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -1
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js +20 -2
- package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -1
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts +7 -5
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -1
- package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +6 -29
- package/src/__tests__/bundle-isolation.test.ts +19 -22
- package/src/astro/__tests__/blog-post-bridge.test.tsx +58 -1
- package/src/astro/__tests__/bridge-runtime.test.tsx +44 -1
- package/src/astro/__tests__/label-pack-loader.test.ts +29 -1
- package/src/astro/blog-post-bridge.tsx +36 -26
- package/src/astro/bridge-runtime.tsx +79 -33
- package/src/astro/label-pack-loader.ts +12 -4
- package/src/astro/robots-renderer.ts +15 -15
- package/src/astro/website-page-head.renderer.ts +2 -2
- package/src/astro/website-site-context.ts +5 -2
- package/src/companion-exports.ts +8 -15
- package/src/config/__tests__/website-build-origins.test.ts +52 -0
- package/src/config/define-website-config.ts +10 -10
- package/src/config/load-website-config.ts +27 -15
- package/src/config/load-website-pricing-catalog.ts +52 -0
- package/src/config/load-website-site-context.ts +145 -0
- package/src/config/wildo-website-config.schemas.ts +10 -5
- package/src/config-loader.ts +2 -0
- package/src/core/__tests__/WebsitePageLayout.test.tsx +1 -1
- package/src/core/__tests__/contexts.test.tsx +20 -1
- package/src/core/__tests__/website-consent.test.tsx +312 -0
- package/src/core/anonymous-session/InboundContactForm.tsx +100 -45
- package/src/core/anonymous-session/__tests__/InboundContactForm.labels.test.tsx +108 -0
- package/src/core/anonymous-session/__tests__/InboundContactForm.submission-challenge.test.tsx +139 -0
- package/src/core/anonymous-session/__tests__/inbound-contact-form.schema.test.ts +11 -4
- package/src/core/anonymous-session/inbound-contact-form.schema.ts +30 -13
- package/src/core/anonymous-session/website-anonymous-session-client.ts +52 -3
- package/src/core/anonymous-session/website-submission-challenge.ts +99 -0
- package/src/core/consent/WebsiteConsentBanner.tsx +223 -0
- package/src/core/consent/WebsiteConsentContext.tsx +164 -0
- package/src/core/consent/WebsiteConsentGate.tsx +34 -0
- package/src/core/consent/useWebsiteConsent.ts +18 -0
- package/src/core/consent/website-consent-storage.ts +116 -0
- package/src/core/contexts/WebsiteRuntimeContext.tsx +33 -6
- package/src/core/contexts/useWebsiteRuntime.ts +33 -2
- package/src/core/external-providers/WebsiteProviderComponent.tsx +153 -0
- package/src/core/external-providers/__tests__/frontend-provider-registry.website.test.ts +4 -0
- package/src/core/external-providers/__tests__/website-provider-component.test.tsx +238 -0
- package/src/core/external-providers/__tests__/website-provider-sdks.test.tsx +72 -0
- package/src/core/external-providers/frontend-provider-registry.website.ts +1 -2
- package/src/core/external-providers/provider-component-registry.website.ts +115 -0
- package/src/core/external-providers/useWebsiteProviderComponent.ts +76 -0
- package/src/core/external-providers/useWebsiteProviderScripts.ts +114 -40
- package/src/core/external-providers/website-provider-sdk-activations.ts +23 -0
- package/src/core/factories/define-website-section.ts +3 -3
- package/src/core/layouts/WebsitePageLayout.tsx +75 -7
- package/src/index.ts +38 -65
- package/src/schemas/__tests__/design-token-css-property.test.ts +72 -0
- package/src/schemas/__tests__/pricing-offer-structured-data.test.ts +122 -0
- package/src/schemas/__tests__/structured-data-reconciliation.test.ts +35 -0
- package/src/schemas/design-tokens/website-design-tokens.shared.schemas.ts +45 -5
- package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +83 -0
- package/src/schemas/label-keys/website-consent-label-keys.schemas.ts +78 -0
- package/src/schemas/label-keys/website-provider-component-label-keys.schemas.ts +40 -0
- package/src/schemas/manifests/website-root-config.shared.schemas.ts +10 -9
- package/src/schemas/sections/website-section-definition.shared.schemas.ts +14 -7
- package/src/schemas/structured-data/pricing-offer-structured-data.shared.ts +144 -0
- package/src/schemas/structured-data/structured-data-reconciliation.shared.ts +46 -1
- package/src/schemas/structured-data/website-structured-data.shared.schemas.ts +7 -5
- package/dist/esm/core/external-providers/provider-scripts.website.d.ts +0 -68
- package/dist/esm/core/external-providers/provider-scripts.website.d.ts.map +0 -1
- package/dist/esm/core/external-providers/provider-scripts.website.js +0 -99
- package/dist/esm/core/external-providers/provider-scripts.website.js.map +0 -1
- package/src/core/external-providers/__tests__/provider-scripts.website.test.ts +0 -146
- package/src/core/external-providers/provider-scripts.website.ts +0 -126
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import '@testing-library/jest-dom';
|
|
5
|
+
import {
|
|
6
|
+
ANONYMOUS_CONSENT_RECORD_METHOD,
|
|
7
|
+
ANONYMOUS_CONSENT_RECORD_PATH,
|
|
8
|
+
AvailableLanguage,
|
|
9
|
+
BUILTIN_PROVIDER_CAPABILITY,
|
|
10
|
+
ConsentPurpose,
|
|
11
|
+
defineProviderMetadata,
|
|
12
|
+
ProviderOriginKind,
|
|
13
|
+
ProviderTier,
|
|
14
|
+
WEBSITE_VISITOR_CONSENT_STORAGE_KEY,
|
|
15
|
+
} from '@wildo-ai/saas-models/public-runtime';
|
|
16
|
+
|
|
17
|
+
import { WebsitePageLayout } from '../layouts/WebsitePageLayout';
|
|
18
|
+
import { WebsiteRuntimeContextProvider, type WebsiteRuntimeContextValue } from '../contexts/WebsiteRuntimeContext';
|
|
19
|
+
import { WebsiteConsentGate } from '../consent/WebsiteConsentGate';
|
|
20
|
+
import { readWebsiteVisitorConsent, writeWebsiteVisitorConsent } from '../consent/website-consent-storage';
|
|
21
|
+
import { WebsiteLabelKeySchema } from '../../schemas/label-keys/website-label-key.schemas';
|
|
22
|
+
import {
|
|
23
|
+
WEBSITE_CONSENT_LABEL_KEYS,
|
|
24
|
+
WebsiteConsentLabelSlot,
|
|
25
|
+
websiteConsentLabelKey,
|
|
26
|
+
websiteConsentPurposeDescriptionLabelKey,
|
|
27
|
+
websiteConsentPurposeLabelKey,
|
|
28
|
+
} from '../../schemas/label-keys/website-consent-label-keys.schemas';
|
|
29
|
+
import type { FrontendWebsiteProviderRegistry } from '../external-providers/frontend-provider-registry.website';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The visitor tier of the consent gate (#534), through the real layout.
|
|
33
|
+
*
|
|
34
|
+
* What this pins is the property the whole feature exists for: an analytics SDK the site declares does
|
|
35
|
+
* NOT start until the visitor grants ANALYTICS, starts when they do, and is STOPPED when they withdraw —
|
|
36
|
+
* while a provider resting on no consent (error monitoring) starts regardless and is never restarted.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const SKIP_KEY = WebsiteLabelKeySchema.parse('website.chrome.layout.skip-to-main-content');
|
|
40
|
+
// Every consent key resolves to itself, so buttons are found by the key they render.
|
|
41
|
+
const LABEL_PACK = Object.fromEntries([SKIP_KEY, ...WEBSITE_CONSENT_LABEL_KEYS].map((key) => [key as unknown as string, key as unknown as string]));
|
|
42
|
+
const label = (slot: WebsiteConsentLabelSlot): string => websiteConsentLabelKey(slot) as unknown as string;
|
|
43
|
+
|
|
44
|
+
function registryOf(modules: Record<string, unknown>[]): FrontendWebsiteProviderRegistry {
|
|
45
|
+
const map = new Map(modules.map((module) => [(module.metadata as { ref: string }).ref, module]));
|
|
46
|
+
return {
|
|
47
|
+
modules: map,
|
|
48
|
+
getProviderByRef: (ref: string) => map.get(ref),
|
|
49
|
+
getProvidersByProviderCapability: () => [],
|
|
50
|
+
findProviderByProviderCapability: () => undefined,
|
|
51
|
+
} as unknown as FrontendWebsiteProviderRegistry;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function provider(ref: string, capability: string, activate: ReturnType<typeof vi.fn>): Record<string, unknown> {
|
|
55
|
+
return {
|
|
56
|
+
metadata: defineProviderMetadata({ ref, packageName: '@test/providers', tier: ProviderTier.EXTENSION, origin: { kind: ProviderOriginKind.APPLICATION } }),
|
|
57
|
+
providerCapabilities: [capability],
|
|
58
|
+
protocols: ['FRONTEND_SDK'],
|
|
59
|
+
// Declared stoppable: a consent-based SDK must be, or it never starts (#1704).
|
|
60
|
+
sdk: { descriptor: { packageName: `@vendor/${ref}` }, stoppable: true, activate },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderSite(registry: FrontendWebsiteProviderRegistry | undefined, children: React.ReactNode = <p>page</p>, consentRecorder?: { requestAnonymous: (method: string, path: string, body: unknown) => Promise<unknown> }): void {
|
|
65
|
+
const runtime: WebsiteRuntimeContextValue = {
|
|
66
|
+
locale: AvailableLanguage.EN,
|
|
67
|
+
defaultLocale: AvailableLanguage.EN,
|
|
68
|
+
labelPack: LABEL_PACK,
|
|
69
|
+
designTokens: {
|
|
70
|
+
colors: {
|
|
71
|
+
background: '#fff', foreground: '#000', primary: '#000', primaryForeground: '#fff', accent: '#000', accentForeground: '#fff',
|
|
72
|
+
surface: '#fff', surfaceForeground: '#000', border: '#ccc', muted: '#eee', mutedForeground: '#666',
|
|
73
|
+
},
|
|
74
|
+
radii: { none: '0', sm: '2px', md: '4px', lg: '8px', xl: '12px', full: '9999px' },
|
|
75
|
+
},
|
|
76
|
+
navigation: { headerComponent: () => null, footerComponent: () => null },
|
|
77
|
+
skipToMainContentLabelKey: SKIP_KEY,
|
|
78
|
+
...(registry ? { frontendProviderRegistry: registry } : {}),
|
|
79
|
+
};
|
|
80
|
+
render(
|
|
81
|
+
<WebsiteRuntimeContextProvider value={runtime}>
|
|
82
|
+
<WebsitePageLayout pageRef="landing" {...(consentRecorder ? { consentRecorder } : {})}>{children}</WebsitePageLayout>
|
|
83
|
+
</WebsiteRuntimeContextProvider>,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
beforeEach(() => window.localStorage.clear());
|
|
88
|
+
afterEach(cleanup);
|
|
89
|
+
|
|
90
|
+
describe('website consent storage', () => {
|
|
91
|
+
it('reads nothing when nothing, or nothing valid, is stored', () => {
|
|
92
|
+
expect(readWebsiteVisitorConsent()).toBeNull();
|
|
93
|
+
window.localStorage.setItem(WEBSITE_VISITOR_CONSENT_STORAGE_KEY, '{not json');
|
|
94
|
+
expect(readWebsiteVisitorConsent()).toBeNull();
|
|
95
|
+
window.localStorage.setItem(WEBSITE_VISITOR_CONSENT_STORAGE_KEY, JSON.stringify({ schemaVersion: 2, grantedPurposes: ['analytics'], decidedAt: new Date().toISOString() }));
|
|
96
|
+
expect(readWebsiteVisitorConsent()).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('round-trips a decision as a normalized SET, dropping purposes the vocabulary no longer has', () => {
|
|
100
|
+
const decidedAt = new Date('2026-09-13T08:00:00.000Z');
|
|
101
|
+
writeWebsiteVisitorConsent([ConsentPurpose.MARKETING, ConsentPurpose.ANALYTICS], decidedAt);
|
|
102
|
+
expect(readWebsiteVisitorConsent()).toEqual({ grantedPurposes: [ConsentPurpose.ANALYTICS, ConsentPurpose.MARKETING], decidedAt: decidedAt.toISOString() });
|
|
103
|
+
|
|
104
|
+
window.localStorage.setItem(WEBSITE_VISITOR_CONSENT_STORAGE_KEY, JSON.stringify({ schemaVersion: 1, grantedPurposes: ['analytics', 'retired'], decidedAt: decidedAt.toISOString() }));
|
|
105
|
+
expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('keeps an EMPTY decision distinct from no decision — declining is a recorded answer', () => {
|
|
109
|
+
writeWebsiteVisitorConsent([]);
|
|
110
|
+
expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('still returns the decision when storage refuses the write', () => {
|
|
114
|
+
const refusing = { getItem: () => null, setItem: () => { throw new Error('quota'); } } as unknown as Storage;
|
|
115
|
+
expect(writeWebsiteVisitorConsent([ConsentPurpose.ANALYTICS], new Date(), refusing).grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('WebsitePageLayout enforces visitor consent on provider SDKs', () => {
|
|
120
|
+
it('starts error monitoring at once, withholds analytics until ACCEPT, and stops analytics on withdrawal', async () => {
|
|
121
|
+
const analyticsDeactivate = vi.fn();
|
|
122
|
+
const analyticsActivate = vi.fn().mockResolvedValue({ handle: 'posthog', deactivate: analyticsDeactivate });
|
|
123
|
+
const monitoringActivate = vi.fn().mockResolvedValue({ handle: 'sentry' });
|
|
124
|
+
renderSite(registryOf([
|
|
125
|
+
provider('posthog', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS, analyticsActivate),
|
|
126
|
+
provider('sentry', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_ERROR_MONITORING, monitoringActivate),
|
|
127
|
+
]));
|
|
128
|
+
|
|
129
|
+
await waitFor(() => expect(monitoringActivate).toHaveBeenCalledTimes(1));
|
|
130
|
+
// The panel asks, and nothing optional has started.
|
|
131
|
+
expect(await screen.findByRole('region', { name: label(WebsiteConsentLabelSlot.TITLE) })).toBeInTheDocument();
|
|
132
|
+
// The OPTIONAL box, reached through the row's own `data-website-consent-purpose` hook — the
|
|
133
|
+
// always-active row carries a checkbox too (checked, disabled), so an unqualified role query
|
|
134
|
+
// would now match two, and the accessible name is the row's title AND its explanation.
|
|
135
|
+
expect(document.querySelector<HTMLInputElement>('[data-website-consent-purpose="analytics"] input')).not.toBeChecked();
|
|
136
|
+
expect(analyticsActivate).not.toHaveBeenCalled();
|
|
137
|
+
|
|
138
|
+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: label(WebsiteConsentLabelSlot.ACCEPT_ALL) })); });
|
|
139
|
+
await waitFor(() => expect(analyticsActivate).toHaveBeenCalledTimes(1));
|
|
140
|
+
expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
141
|
+
expect(screen.queryByRole('region', { name: label(WebsiteConsentLabelSlot.TITLE) })).toBeNull();
|
|
142
|
+
|
|
143
|
+
// Withdraw through the always-reachable reopen button.
|
|
144
|
+
fireEvent.click(screen.getByRole('button', { name: label(WebsiteConsentLabelSlot.MANAGE) }));
|
|
145
|
+
await act(async () => { fireEvent.click(screen.getByRole('button', { name: label(WebsiteConsentLabelSlot.DECLINE_ALL) })); });
|
|
146
|
+
await waitFor(() => expect(analyticsDeactivate).toHaveBeenCalledTimes(1));
|
|
147
|
+
expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([]);
|
|
148
|
+
// Error monitoring was never restarted by any of it.
|
|
149
|
+
expect(monitoringActivate).toHaveBeenCalledTimes(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('honours a stored refusal: no panel, no analytics, and a way to change it', async () => {
|
|
153
|
+
writeWebsiteVisitorConsent([]);
|
|
154
|
+
const analyticsActivate = vi.fn().mockResolvedValue({ handle: 'posthog' });
|
|
155
|
+
renderSite(registryOf([provider('posthog', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS, analyticsActivate)]));
|
|
156
|
+
|
|
157
|
+
expect(await screen.findByRole('button', { name: label(WebsiteConsentLabelSlot.MANAGE) })).toBeInTheDocument();
|
|
158
|
+
expect(screen.queryByRole('region', { name: label(WebsiteConsentLabelSlot.TITLE) })).toBeNull();
|
|
159
|
+
expect(analyticsActivate).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('shows no consent surface at all on a site whose providers rest on no consent', async () => {
|
|
163
|
+
const monitoringActivate = vi.fn().mockResolvedValue({ handle: 'sentry' });
|
|
164
|
+
renderSite(registryOf([provider('sentry', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_ERROR_MONITORING, monitoringActivate)]));
|
|
165
|
+
await waitFor(() => expect(monitoringActivate).toHaveBeenCalledTimes(1));
|
|
166
|
+
expect(screen.queryByRole('region')).toBeNull();
|
|
167
|
+
expect(screen.queryByRole('button', { name: label(WebsiteConsentLabelSlot.MANAGE) })).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* #1258 — what a visitor can actually SEE in the panel.
|
|
173
|
+
*
|
|
174
|
+
* The banner showed a tick box labelled "Analytics" and nothing else. That is a category, not
|
|
175
|
+
* something a visitor can weigh, and consent is only valid if it is INFORMED — so the panel was
|
|
176
|
+
* asking for a decision it had not given anybody the means to make. A marketing-site consent panel
|
|
177
|
+
* is also the surface with the tightest constraints anywhere in the product: it must state what runs
|
|
178
|
+
* regardless, explain each choice, and never push its own refusal below the fold.
|
|
179
|
+
*/
|
|
180
|
+
describe('the visitor panel shows the WHOLE picture, not only the tick boxes', () => {
|
|
181
|
+
const analytics = () => provider('posthog', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS, vi.fn().mockResolvedValue({ handle: 'posthog' }));
|
|
182
|
+
|
|
183
|
+
it('explains each purpose, states what is always active, and keeps the actions out of the scroll box', async () => {
|
|
184
|
+
renderSite(registryOf([analytics()]), <p>page</p>);
|
|
185
|
+
|
|
186
|
+
// Each offered purpose carries its explanation, not only its name.
|
|
187
|
+
expect(await screen.findByText(websiteConsentPurposeLabelKey(ConsentPurpose.ANALYTICS) as unknown as string)).toBeInTheDocument();
|
|
188
|
+
expect(screen.getByText(websiteConsentPurposeDescriptionLabelKey(ConsentPurpose.ANALYTICS) as unknown as string)).toBeInTheDocument();
|
|
189
|
+
|
|
190
|
+
/*
|
|
191
|
+
* The always-active row is STATED, and carries a real control — checked and disabled. A row with
|
|
192
|
+
* no control does not read as "cannot be changed": a visitor scans DOWN the column of boxes, and
|
|
193
|
+
* a gap there reads as a row that was forgotten. The disabled assertion is also what holds the
|
|
194
|
+
* line that this is not a purpose — the moment the box is operable, the panel is offering a
|
|
195
|
+
* choice about processing that has none.
|
|
196
|
+
*/
|
|
197
|
+
expect(screen.getByText(label(WebsiteConsentLabelSlot.ESSENTIAL_TITLE))).toBeInTheDocument();
|
|
198
|
+
const boxes = screen.getAllByRole('checkbox');
|
|
199
|
+
// The COUNT first: a destructure of the first two cannot see a third row appearing, and the order
|
|
200
|
+
// it depends on would be unstated. One always-active row, one purpose in use.
|
|
201
|
+
expect(boxes).toHaveLength(2);
|
|
202
|
+
const [essentialBox, analyticsBox] = boxes;
|
|
203
|
+
expect(essentialBox).toBeChecked();
|
|
204
|
+
expect(essentialBox).toBeDisabled();
|
|
205
|
+
expect(analyticsBox).not.toBeChecked();
|
|
206
|
+
expect(analyticsBox).toBeEnabled();
|
|
207
|
+
|
|
208
|
+
/*
|
|
209
|
+
* And no SAVE: with one purpose in use, decline-all and accept-all already cover the whole space,
|
|
210
|
+
* so a third button could only repeat them — which is exactly the confusion it caused.
|
|
211
|
+
*/
|
|
212
|
+
expect(screen.queryByRole('button', { name: label(WebsiteConsentLabelSlot.SAVE) })).toBeNull();
|
|
213
|
+
|
|
214
|
+
/*
|
|
215
|
+
* The rows scroll; the panel does not grow. A panel that grows with its content pushes accept and
|
|
216
|
+
* reject off the bottom of a short screen, and a refusal that cannot be reached is a refusal that
|
|
217
|
+
* was not offered. Asserting the ACCEPT button is outside the scroll box is what catches a
|
|
218
|
+
* "simplification" that puts the whole panel in one scroller.
|
|
219
|
+
*/
|
|
220
|
+
const panel = screen.getByRole('region');
|
|
221
|
+
expect(panel.style.maxHeight).toBe('85dvh');
|
|
222
|
+
/*
|
|
223
|
+
* Anchored on the scroll box's OWN hook. `closest('div[style*="auto"]')` matched the essential
|
|
224
|
+
* ROW — its grid template is `auto 1fr` — so it resolved to a box that is not the scroller and
|
|
225
|
+
* both following assertions passed for free, including with the scroller deleted.
|
|
226
|
+
*/
|
|
227
|
+
const scrollBox = document.querySelector('[data-website-consent-rows]');
|
|
228
|
+
expect(scrollBox, 'the purpose rows must sit in their own scroll box').not.toBeNull();
|
|
229
|
+
// …and it must actually SCROLL. Containment alone holds for a plain div, so deleting the
|
|
230
|
+
// overflow — the change that puts the rows' height back onto the panel — would pass without this.
|
|
231
|
+
expect((scrollBox as HTMLElement).style.overflowY).toBe('auto');
|
|
232
|
+
expect(scrollBox!.contains(screen.getByText(label(WebsiteConsentLabelSlot.ESSENTIAL_TITLE))), 'the rows must be INSIDE it').toBe(true);
|
|
233
|
+
expect(scrollBox!.contains(screen.getByRole('button', { name: label(WebsiteConsentLabelSlot.ACCEPT_ALL) })), 'the actions must not').toBe(false);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('WebsiteConsentGate', () => {
|
|
238
|
+
it('always renders essential content, and consent-based content only once its purpose is granted', async () => {
|
|
239
|
+
renderSite(undefined, (
|
|
240
|
+
<>
|
|
241
|
+
<WebsiteConsentGate><form aria-label="contact" /></WebsiteConsentGate>
|
|
242
|
+
<WebsiteConsentGate purpose={ConsentPurpose.MARKETING} fallback={<p>withheld</p>}><p>pixel</p></WebsiteConsentGate>
|
|
243
|
+
</>
|
|
244
|
+
));
|
|
245
|
+
expect(screen.getByRole('form', { name: 'contact' })).toBeInTheDocument();
|
|
246
|
+
expect(await screen.findByText('withheld')).toBeInTheDocument();
|
|
247
|
+
expect(screen.queryByText('pixel')).toBeNull();
|
|
248
|
+
|
|
249
|
+
await act(async () => { writeWebsiteVisitorConsent([ConsentPurpose.MARKETING]); });
|
|
250
|
+
expect(await screen.findByText('pixel')).toBeInTheDocument();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* #1258 Step 1c — the visitor's decision reaching the server, so it can follow them to an account.
|
|
256
|
+
*
|
|
257
|
+
* What these pin is the pair of properties the carry depends on: the decision is recorded with the
|
|
258
|
+
* set that was actually OFFERED (without which the application cannot tell a decline from a
|
|
259
|
+
* never-asked), and the local decision is never hostage to that request succeeding.
|
|
260
|
+
*/
|
|
261
|
+
describe('a visitor decision is recorded server-side when the site wires a recorder', () => {
|
|
262
|
+
const analytics = () => provider('posthog', BUILTIN_PROVIDER_CAPABILITY.FRONTEND_PRODUCT_ANALYTICS, vi.fn().mockResolvedValue({ handle: 'posthog' }));
|
|
263
|
+
|
|
264
|
+
it('posts the granted set AND the offered set to the anonymous consent route', async () => {
|
|
265
|
+
const requestAnonymous = vi.fn().mockResolvedValue({});
|
|
266
|
+
renderSite(registryOf([analytics()]), <p>page</p>, { requestAnonymous });
|
|
267
|
+
|
|
268
|
+
await act(async () => { fireEvent.click(await screen.findByRole('button', { name: label(WebsiteConsentLabelSlot.ACCEPT_ALL) })); });
|
|
269
|
+
|
|
270
|
+
await waitFor(() => expect(requestAnonymous).toHaveBeenCalledTimes(1));
|
|
271
|
+
const [method, path, body] = requestAnonymous.mock.calls[0] as [string, string, { grantedPurposes: string[]; offeredPurposes: string[] }];
|
|
272
|
+
// PUT, not POST. The door's operation borrows UPDATE_MANY; sending POST answered 404, and this
|
|
273
|
+
// provider's failure path is deliberately silent, so nothing surfaced it (#1258 Step 1d).
|
|
274
|
+
expect(method).toBe(ANONYMOUS_CONSENT_RECORD_METHOD);
|
|
275
|
+
expect(path).toBe(ANONYMOUS_CONSENT_RECORD_PATH);
|
|
276
|
+
expect(body.grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
277
|
+
// The OFFERED set is what this panel actually showed — derived from the site's live providers,
|
|
278
|
+
// which is why it can differ from what the application would ask.
|
|
279
|
+
expect(body.offeredPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('records a DECLINE too — an empty grant is a decision and must carry as one', async () => {
|
|
283
|
+
const requestAnonymous = vi.fn().mockResolvedValue({});
|
|
284
|
+
renderSite(registryOf([analytics()]), <p>page</p>, { requestAnonymous });
|
|
285
|
+
|
|
286
|
+
await act(async () => { fireEvent.click(await screen.findByRole('button', { name: label(WebsiteConsentLabelSlot.DECLINE_ALL) })); });
|
|
287
|
+
|
|
288
|
+
await waitFor(() => expect(requestAnonymous).toHaveBeenCalledTimes(1));
|
|
289
|
+
const [, , body] = requestAnonymous.mock.calls[0] as [string, string, { grantedPurposes: string[]; offeredPurposes: string[] }];
|
|
290
|
+
expect(body.grantedPurposes).toEqual([]);
|
|
291
|
+
expect(body.offeredPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('holds the decision locally even when the server write fails, and tells the visitor nothing', async () => {
|
|
295
|
+
const requestAnonymous = vi.fn().mockRejectedValue(new Error('offline'));
|
|
296
|
+
renderSite(registryOf([analytics()]), <p>page</p>, { requestAnonymous });
|
|
297
|
+
|
|
298
|
+
await act(async () => { fireEvent.click(await screen.findByRole('button', { name: label(WebsiteConsentLabelSlot.ACCEPT_ALL) })); });
|
|
299
|
+
|
|
300
|
+
// The decision HELD: stored, panel closed. Losing the carry must never look like losing the choice.
|
|
301
|
+
await waitFor(() => expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]));
|
|
302
|
+
expect(screen.queryByRole('region', { name: label(WebsiteConsentLabelSlot.TITLE) })).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('posts nothing at all when the site wires no recorder — the pre-#1258 behaviour, unchanged', async () => {
|
|
306
|
+
renderSite(registryOf([analytics()]));
|
|
307
|
+
|
|
308
|
+
await act(async () => { fireEvent.click(await screen.findByRole('button', { name: label(WebsiteConsentLabelSlot.ACCEPT_ALL) })); });
|
|
309
|
+
|
|
310
|
+
expect(readWebsiteVisitorConsent()?.grantedPurposes).toEqual([ConsentPurpose.ANALYTICS]);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -8,15 +8,41 @@
|
|
|
8
8
|
* the anonymous session (init header) and the backend stamps the lead's `anonymousUserId`
|
|
9
9
|
* so it can later transpose to the visitor's account on signup/login.
|
|
10
10
|
*
|
|
11
|
-
* Labels are injected
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Labels are injected rather than read from the website runtime context, so the component stays framework-light and
|
|
12
|
+
* unit-testable; the section wrapper passes the strings it resolved through `useWebsiteLabel`. EVERY label is required
|
|
13
|
+
* and the form carries no words of its own (#1986): a fallback would be English in every locale and would hide the
|
|
14
|
+
* missing label, while a required prop makes an omission a type error in the section, and a key the pack does not
|
|
15
|
+
* carry reaches the page as the key itself — which is what `useWebsiteLabel` returns, so somebody authors it.
|
|
16
|
+
*
|
|
17
|
+
* Submission challenge (#1735): when the application protects the form with a captcha, each submission carries an
|
|
18
|
+
* answer — the consented vendor's token when its browser part runs on the page, otherwise the engine's proof of work,
|
|
19
|
+
* which needs no consent (see `website-submission-challenge.ts`). Nothing for the visitor to click or read: both are
|
|
20
|
+
* invisible, so the form stays operable by keyboard and screen reader exactly as before. A vendor token the backend
|
|
21
|
+
* refuses is retried once with the proof of work; a refusal that survives that shows `challengeRefusedMessage`.
|
|
14
22
|
*/
|
|
15
23
|
|
|
16
|
-
import React, { useCallback,
|
|
17
|
-
import {
|
|
24
|
+
import React, { useCallback, useState } from "react";
|
|
25
|
+
import { SubmissionChallengeAction, WildoHeaderKeys } from "@wildo-ai/saas-models/public-runtime";
|
|
26
|
+
import {
|
|
27
|
+
INBOUND_CONTACT_NAME_MAX_LENGTH,
|
|
28
|
+
INBOUND_CONTACT_PHONE_MAX_LENGTH,
|
|
29
|
+
InboundContactFormSchema,
|
|
30
|
+
buildInboundContactCreateBody,
|
|
31
|
+
type InboundContactFormValues,
|
|
32
|
+
} from "./inbound-contact-form.schema";
|
|
18
33
|
import type { WebsiteAnonymousSessionClient } from "./website-anonymous-session-client";
|
|
34
|
+
import {
|
|
35
|
+
isSubmissionChallengeRefusal,
|
|
36
|
+
obtainSubmissionChallengeAnswer,
|
|
37
|
+
useWebsiteSubmissionChallengeSource,
|
|
38
|
+
type WebsiteSubmissionChallengeSource,
|
|
39
|
+
} from "./website-submission-challenge";
|
|
19
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Every word the form shows. All required (#1986): the section resolves each through `useWebsiteLabel` and lists its
|
|
43
|
+
* key in `expectedLabelKeys`, so a missing label is a type error in the section or a visible key on the page, never a
|
|
44
|
+
* silent English substitute.
|
|
45
|
+
*/
|
|
20
46
|
export interface InboundContactFormLabels {
|
|
21
47
|
emailLabel: string;
|
|
22
48
|
firstNameLabel: string;
|
|
@@ -28,29 +54,23 @@ export interface InboundContactFormLabels {
|
|
|
28
54
|
submittingLabel: string;
|
|
29
55
|
successMessage: string;
|
|
30
56
|
errorMessage: string;
|
|
57
|
+
/** The anti-spam check did not pass, even with the proof of work (#1735). */
|
|
58
|
+
challengeRefusedMessage: string;
|
|
59
|
+
/** Shown beside the email field when what was typed is not an email address (#1986). */
|
|
60
|
+
invalidEmailMessage: string;
|
|
31
61
|
}
|
|
32
62
|
|
|
33
|
-
const DEFAULT_LABELS: InboundContactFormLabels = {
|
|
34
|
-
emailLabel: "Email",
|
|
35
|
-
firstNameLabel: "First name",
|
|
36
|
-
lastNameLabel: "Last name",
|
|
37
|
-
phoneLabel: "Phone",
|
|
38
|
-
marketingConsentLabel: "Keep me updated about news and offers",
|
|
39
|
-
submitLabel: "Get in touch",
|
|
40
|
-
submittingLabel: "Sending…",
|
|
41
|
-
successMessage: "Thanks — we'll be in touch shortly.",
|
|
42
|
-
errorMessage: "Something went wrong. Please try again.",
|
|
43
|
-
};
|
|
44
|
-
|
|
45
63
|
type SubmitState = { status: "idle" } | { status: "submitting" } | { status: "success" } | { status: "error"; message: string };
|
|
46
64
|
|
|
47
65
|
export interface InboundContactFormProps {
|
|
48
66
|
/** The anonymous-session client (constructed by the section wiring from site config). */
|
|
49
67
|
client: WebsiteAnonymousSessionClient;
|
|
50
|
-
/**
|
|
51
|
-
labels
|
|
68
|
+
/** The section's i18n-resolved labels — every one of them (#1986). */
|
|
69
|
+
labels: InboundContactFormLabels;
|
|
52
70
|
/** Optional callback fired after a successful submission (e.g. analytics). */
|
|
53
71
|
onSubmitted?: () => void;
|
|
72
|
+
/** The page's captcha, for a form rendered outside a page layout (tests). Defaults to the page's own. */
|
|
73
|
+
challengeSource?: WebsiteSubmissionChallengeSource;
|
|
54
74
|
}
|
|
55
75
|
|
|
56
76
|
const EMPTY_VALUES: InboundContactFormValues = {
|
|
@@ -61,10 +81,11 @@ const EMPTY_VALUES: InboundContactFormValues = {
|
|
|
61
81
|
marketingConsent: false,
|
|
62
82
|
};
|
|
63
83
|
|
|
64
|
-
export function InboundContactForm({ client, labels, onSubmitted }: InboundContactFormProps): React.ReactElement {
|
|
65
|
-
const
|
|
84
|
+
export function InboundContactForm({ client, labels, onSubmitted, challengeSource }: InboundContactFormProps): React.ReactElement {
|
|
85
|
+
const pageChallengeSource = useWebsiteSubmissionChallengeSource();
|
|
86
|
+
const source = challengeSource ?? pageChallengeSource;
|
|
66
87
|
const [values, setValues] = useState<InboundContactFormValues>(EMPTY_VALUES);
|
|
67
|
-
const [
|
|
88
|
+
const [emailInvalid, setEmailInvalid] = useState(false);
|
|
68
89
|
const [submit, setSubmit] = useState<SubmitState>({ status: "idle" });
|
|
69
90
|
|
|
70
91
|
const setField = useCallback(<K extends keyof InboundContactFormValues>(key: K, value: InboundContactFormValues[K]) => {
|
|
@@ -74,38 +95,54 @@ export function InboundContactForm({ client, labels, onSubmitted }: InboundConta
|
|
|
74
95
|
const handleSubmit = useCallback(
|
|
75
96
|
async (event: React.FormEvent<HTMLFormElement>) => {
|
|
76
97
|
event.preventDefault();
|
|
77
|
-
|
|
98
|
+
setEmailInvalid(false);
|
|
78
99
|
|
|
79
100
|
const parsed = InboundContactFormSchema.safeParse(values);
|
|
80
101
|
if (!parsed.success) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
102
|
+
// The schema's own issue messages are never shown: they are English and nobody authors them (#1986). The email
|
|
103
|
+
// is the one field a visitor can get wrong, and it has its label. The other fields' only rule is a length the
|
|
104
|
+
// inputs already enforce with `maxLength`, so reaching a failure there means the values did not come from
|
|
105
|
+
// typing, and the form answers with its generic error rather than inventing a sentence.
|
|
106
|
+
const emailFailed = parsed.error.issues.some((issue) => issue.path[0] === "email");
|
|
107
|
+
setEmailInvalid(emailFailed);
|
|
108
|
+
if (!emailFailed) setSubmit({ status: "error", message: labels.errorMessage });
|
|
87
109
|
return;
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
setSubmit({ status: "submitting" });
|
|
113
|
+
const body = buildInboundContactCreateBody(parsed.data);
|
|
114
|
+
const send = async (proofOfWorkOnly: boolean): Promise<void> => {
|
|
115
|
+
const answer = await obtainSubmissionChallengeAnswer({ source, action: SubmissionChallengeAction.INBOUND_CONTACT, client, proofOfWorkOnly });
|
|
116
|
+
await client.postAnonymous("/inbound-contacts", body, answer === undefined ? undefined : { [WildoHeaderKeys.SUBMISSION_CHALLENGE_RESPONSE]: answer });
|
|
117
|
+
};
|
|
91
118
|
try {
|
|
92
|
-
|
|
119
|
+
try {
|
|
120
|
+
await send(false);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// A refused challenge is answered ONCE more, with the proof of work: a vendor that scored a real person low,
|
|
123
|
+
// could not be reached, or a site that did not know the backend protects the form must not lock anyone out.
|
|
124
|
+
if (!isSubmissionChallengeRefusal(error)) throw error;
|
|
125
|
+
await send(true);
|
|
126
|
+
}
|
|
93
127
|
setSubmit({ status: "success" });
|
|
94
128
|
setValues(EMPTY_VALUES);
|
|
95
129
|
onSubmitted?.();
|
|
96
|
-
} catch {
|
|
130
|
+
} catch (error) {
|
|
97
131
|
// The backend-only `emailUserMatch` triage signal is never returned here, so a
|
|
98
132
|
// failure exposes no account-existence information — just a generic retry.
|
|
99
|
-
setSubmit({
|
|
133
|
+
setSubmit({
|
|
134
|
+
status: "error",
|
|
135
|
+
message: isSubmissionChallengeRefusal(error) ? labels.challengeRefusedMessage : labels.errorMessage,
|
|
136
|
+
});
|
|
100
137
|
}
|
|
101
138
|
},
|
|
102
|
-
[client, values, onSubmitted,
|
|
139
|
+
[client, values, onSubmitted, source, labels.errorMessage, labels.challengeRefusedMessage],
|
|
103
140
|
);
|
|
104
141
|
|
|
105
142
|
if (submit.status === "success") {
|
|
106
143
|
return (
|
|
107
144
|
<div role="status" aria-live="polite">
|
|
108
|
-
{
|
|
145
|
+
{labels.successMessage}
|
|
109
146
|
</div>
|
|
110
147
|
);
|
|
111
148
|
}
|
|
@@ -115,42 +152,60 @@ export function InboundContactForm({ client, labels, onSubmitted }: InboundConta
|
|
|
115
152
|
return (
|
|
116
153
|
<form onSubmit={handleSubmit} noValidate>
|
|
117
154
|
<label>
|
|
118
|
-
{
|
|
155
|
+
{labels.emailLabel}
|
|
119
156
|
<input
|
|
120
157
|
type="email"
|
|
121
158
|
name="email"
|
|
122
159
|
required
|
|
123
160
|
value={values.email}
|
|
124
161
|
onChange={(e) => setField("email", e.target.value)}
|
|
125
|
-
aria-invalid={
|
|
162
|
+
aria-invalid={emailInvalid ? true : undefined}
|
|
126
163
|
/>
|
|
127
|
-
{
|
|
164
|
+
{emailInvalid ? <span role="alert">{labels.invalidEmailMessage}</span> : null}
|
|
128
165
|
</label>
|
|
129
166
|
|
|
130
167
|
<label>
|
|
131
|
-
{
|
|
132
|
-
<input
|
|
168
|
+
{labels.firstNameLabel}
|
|
169
|
+
<input
|
|
170
|
+
type="text"
|
|
171
|
+
name="firstName"
|
|
172
|
+
maxLength={INBOUND_CONTACT_NAME_MAX_LENGTH}
|
|
173
|
+
value={values.firstName ?? ""}
|
|
174
|
+
onChange={(e) => setField("firstName", e.target.value)}
|
|
175
|
+
/>
|
|
133
176
|
</label>
|
|
134
177
|
|
|
135
178
|
<label>
|
|
136
|
-
{
|
|
137
|
-
<input
|
|
179
|
+
{labels.lastNameLabel}
|
|
180
|
+
<input
|
|
181
|
+
type="text"
|
|
182
|
+
name="lastName"
|
|
183
|
+
maxLength={INBOUND_CONTACT_NAME_MAX_LENGTH}
|
|
184
|
+
value={values.lastName ?? ""}
|
|
185
|
+
onChange={(e) => setField("lastName", e.target.value)}
|
|
186
|
+
/>
|
|
138
187
|
</label>
|
|
139
188
|
|
|
140
189
|
<label>
|
|
141
|
-
{
|
|
142
|
-
<input
|
|
190
|
+
{labels.phoneLabel}
|
|
191
|
+
<input
|
|
192
|
+
type="tel"
|
|
193
|
+
name="phone"
|
|
194
|
+
maxLength={INBOUND_CONTACT_PHONE_MAX_LENGTH}
|
|
195
|
+
value={values.phone ?? ""}
|
|
196
|
+
onChange={(e) => setField("phone", e.target.value)}
|
|
197
|
+
/>
|
|
143
198
|
</label>
|
|
144
199
|
|
|
145
200
|
<label>
|
|
146
201
|
<input type="checkbox" name="marketingConsent" checked={values.marketingConsent} onChange={(e) => setField("marketingConsent", e.target.checked)} />
|
|
147
|
-
{
|
|
202
|
+
{labels.marketingConsentLabel}
|
|
148
203
|
</label>
|
|
149
204
|
|
|
150
205
|
{submit.status === "error" ? <div role="alert">{submit.message}</div> : null}
|
|
151
206
|
|
|
152
207
|
<button type="submit" disabled={isSubmitting}>
|
|
153
|
-
{isSubmitting ?
|
|
208
|
+
{isSubmitting ? labels.submittingLabel : labels.submitLabel}
|
|
154
209
|
</button>
|
|
155
210
|
</form>
|
|
156
211
|
);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1986 — every word the contact form shows is one its section passed.
|
|
3
|
+
*
|
|
4
|
+
* The form used to merge an English `DEFAULT_LABELS` under optional props, and showed the schema's own English issue
|
|
5
|
+
* message beside the email field. Both reached every locale and neither could be found by whoever authors the label
|
|
6
|
+
* pack. These pins walk the rendered text, so a literal put back into the markup, the schema or a fallback reddens them.
|
|
7
|
+
*/
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
11
|
+
import '@testing-library/jest-dom';
|
|
12
|
+
|
|
13
|
+
import { InboundContactForm, type InboundContactFormLabels } from '../InboundContactForm';
|
|
14
|
+
import { INBOUND_CONTACT_NAME_MAX_LENGTH, INBOUND_CONTACT_PHONE_MAX_LENGTH } from '../inbound-contact-form.schema';
|
|
15
|
+
import type { WebsiteAnonymousSessionClient } from '../website-anonymous-session-client';
|
|
16
|
+
|
|
17
|
+
afterEach(() => { cleanup(); });
|
|
18
|
+
|
|
19
|
+
const LABELS: InboundContactFormLabels = {
|
|
20
|
+
emailLabel: 'email-label',
|
|
21
|
+
firstNameLabel: 'first-name-label',
|
|
22
|
+
lastNameLabel: 'last-name-label',
|
|
23
|
+
phoneLabel: 'phone-label',
|
|
24
|
+
marketingConsentLabel: 'marketing-consent-label',
|
|
25
|
+
submitLabel: 'submit-label',
|
|
26
|
+
submittingLabel: 'submitting-label',
|
|
27
|
+
successMessage: 'success-label',
|
|
28
|
+
errorMessage: 'error-label',
|
|
29
|
+
challengeRefusedMessage: 'refused-label',
|
|
30
|
+
invalidEmailMessage: 'invalid-email-label',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const AUTHORED = new Set<string>(Object.values(LABELS));
|
|
34
|
+
|
|
35
|
+
/** Every non-blank text node under `root`, trimmed. */
|
|
36
|
+
function renderedWords(root: HTMLElement): string[] {
|
|
37
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
38
|
+
const words: string[] = [];
|
|
39
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
40
|
+
const text = node.textContent?.trim();
|
|
41
|
+
if (text) words.push(text);
|
|
42
|
+
}
|
|
43
|
+
return words;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clientDouble() {
|
|
47
|
+
const postAnonymous = vi.fn(async () => ({}));
|
|
48
|
+
return { client: { postAnonymous, getPublic: vi.fn() } as unknown as WebsiteAnonymousSessionClient, postAnonymous };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderForm() {
|
|
52
|
+
const double = clientDouble();
|
|
53
|
+
const view = render(<InboundContactForm client={double.client} challengeSource={{ captchaDeclared: false }} labels={LABELS} />);
|
|
54
|
+
return { ...double, container: view.container };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('InboundContactForm — its words are the section\'s (#1986)', () => {
|
|
58
|
+
it('shows no word of its own in the idle form', () => {
|
|
59
|
+
const { container } = renderForm();
|
|
60
|
+
const words = renderedWords(container);
|
|
61
|
+
expect(words.length).toBeGreaterThan(0);
|
|
62
|
+
expect(words.filter((word) => !AUTHORED.has(word))).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('words an invalid email with the section\'s label, never the schema\'s message', async () => {
|
|
66
|
+
const { container, postAnonymous } = renderForm();
|
|
67
|
+
fireEvent.change(screen.getByRole('textbox', { name: LABELS.emailLabel }), { target: { value: 'not-an-email' } });
|
|
68
|
+
fireEvent.click(screen.getByRole('button', { name: LABELS.submitLabel }));
|
|
69
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(LABELS.invalidEmailMessage);
|
|
70
|
+
expect(screen.getByRole('textbox', { name: new RegExp(LABELS.emailLabel) })).toHaveAttribute('aria-invalid', 'true');
|
|
71
|
+
expect(renderedWords(container).filter((word) => !AUTHORED.has(word))).toEqual([]);
|
|
72
|
+
expect(postAnonymous).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('answers a length failure no typed input can produce with the generic error, not a schema sentence', async () => {
|
|
76
|
+
const { container, postAnonymous } = renderForm();
|
|
77
|
+
fireEvent.change(screen.getByRole('textbox', { name: new RegExp(LABELS.emailLabel) }), { target: { value: 'visitor@example.test' } });
|
|
78
|
+
// `fireEvent.change` sets the value directly, which is exactly the path `maxLength` cannot stop.
|
|
79
|
+
fireEvent.change(screen.getByRole('textbox', { name: LABELS.firstNameLabel }), { target: { value: 'x'.repeat(INBOUND_CONTACT_NAME_MAX_LENGTH + 1) } });
|
|
80
|
+
fireEvent.click(screen.getByRole('button', { name: LABELS.submitLabel }));
|
|
81
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(LABELS.errorMessage);
|
|
82
|
+
expect(renderedWords(container).filter((word) => !AUTHORED.has(word) && !/^x+$/.test(word))).toEqual([]);
|
|
83
|
+
expect(postAnonymous).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('shows only the section\'s words once sent', async () => {
|
|
87
|
+
const { container } = renderForm();
|
|
88
|
+
fireEvent.change(screen.getByRole('textbox', { name: LABELS.emailLabel }), { target: { value: 'visitor@example.test' } });
|
|
89
|
+
fireEvent.click(screen.getByRole('button', { name: LABELS.submitLabel }));
|
|
90
|
+
await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
|
|
91
|
+
expect(renderedWords(container)).toEqual([LABELS.successMessage]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('stops typing at the schema\'s own length ceilings', () => {
|
|
95
|
+
renderForm();
|
|
96
|
+
expect(screen.getByRole('textbox', { name: LABELS.firstNameLabel })).toHaveAttribute('maxLength', String(INBOUND_CONTACT_NAME_MAX_LENGTH));
|
|
97
|
+
expect(screen.getByRole('textbox', { name: LABELS.lastNameLabel })).toHaveAttribute('maxLength', String(INBOUND_CONTACT_NAME_MAX_LENGTH));
|
|
98
|
+
expect(screen.getByRole('textbox', { name: LABELS.phoneLabel })).toHaveAttribute('maxLength', String(INBOUND_CONTACT_PHONE_MAX_LENGTH));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('refuses, at compile time, a section that omits a label', () => {
|
|
102
|
+
const { client } = clientDouble();
|
|
103
|
+
const { invalidEmailMessage: _omitted, ...incomplete } = LABELS;
|
|
104
|
+
// @ts-expect-error — every label is required (#1986): an omission is the section's type error, not English.
|
|
105
|
+
const element = <InboundContactForm client={client} labels={incomplete} />;
|
|
106
|
+
expect(element).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
});
|