@wildo-ai/saas-website 1.1.5 → 1.1.7
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 +9 -0
- package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
- package/dist/esm/astro/blog-post-bridge.js +10 -5
- 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 +25 -6
- package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.js +10 -6
- 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 +9 -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 +18 -9
- 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.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 +11 -0
- 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 +15 -4
- 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 +14 -2
- 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 +34 -4
- 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.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentBanner.js +11 -10
- package/dist/esm/core/consent/WebsiteConsentBanner.js.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentContext.d.ts.map +1 -1
- package/dist/esm/core/consent/WebsiteConsentGate.d.ts.map +1 -1
- package/dist/esm/core/consent/useWebsiteConsent.d.ts.map +1 -1
- package/dist/esm/core/consent/website-consent-storage.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -1
- package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +25 -5
- 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/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.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 +15 -5
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +39 -24
- 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.map +1 -1
- package/dist/esm/core/layouts/WebsitePageLayout.js +10 -7
- 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 +30 -65
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +30 -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.map +1 -1
- 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 +9 -8
- 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-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 +5 -5
- package/src/__tests__/bundle-isolation.test.ts +19 -22
- package/src/astro/__tests__/blog-post-bridge.test.tsx +18 -2
- package/src/astro/__tests__/bridge-runtime.test.tsx +21 -3
- package/src/astro/__tests__/label-pack-loader.test.ts +7 -4
- package/src/astro/blog-post-bridge.tsx +19 -5
- package/src/astro/bridge-runtime.tsx +35 -11
- package/src/astro/label-pack-loader.ts +8 -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 +25 -14
- package/src/config/load-website-pricing-catalog.ts +52 -0
- package/src/config/load-website-site-context.ts +145 -0
- package/src/config-loader.ts +2 -0
- package/src/core/__tests__/contexts.test.tsx +20 -1
- package/src/core/__tests__/website-consent.test.tsx +2 -1
- 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/inbound-contact-form.schema.ts +16 -4
- package/src/core/anonymous-session/website-anonymous-session-client.ts +39 -4
- package/src/core/anonymous-session/website-submission-challenge.ts +99 -0
- package/src/core/consent/WebsiteConsentBanner.tsx +11 -10
- package/src/core/contexts/WebsiteRuntimeContext.tsx +25 -5
- 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/provider-component-registry.website.ts +115 -0
- package/src/core/external-providers/useWebsiteProviderComponent.ts +76 -0
- package/src/core/external-providers/useWebsiteProviderScripts.ts +56 -37
- 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 +11 -6
- package/src/index.ts +31 -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 +12 -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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1735 — the contact form answers the application's submission challenge.
|
|
3
|
+
*
|
|
4
|
+
* The client is a double (it records what the form sent and answers like the engine); the challenge helper, the
|
|
5
|
+
* proof-of-work solver and the refusal detection are the real ones.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
10
|
+
import '@testing-library/jest-dom';
|
|
11
|
+
import {
|
|
12
|
+
ProofOfWorkAlgorithm,
|
|
13
|
+
SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH,
|
|
14
|
+
SubmissionChallengeAction,
|
|
15
|
+
SubmissionChallengeRefusalCode,
|
|
16
|
+
SubmissionChallengeResponseKind,
|
|
17
|
+
WildoHeaderKeys,
|
|
18
|
+
hashProofOfWorkCandidate,
|
|
19
|
+
type ProofOfWorkChallenge,
|
|
20
|
+
} from '@wildo-ai/saas-models/public-runtime';
|
|
21
|
+
|
|
22
|
+
import { InboundContactForm, type InboundContactFormLabels } from '../InboundContactForm';
|
|
23
|
+
import { WebsiteAnonymousRequestError, type WebsiteAnonymousSessionClient } from '../website-anonymous-session-client';
|
|
24
|
+
import type { WebsiteSubmissionChallengeSource } from '../website-submission-challenge';
|
|
25
|
+
|
|
26
|
+
afterEach(() => { cleanup(); });
|
|
27
|
+
|
|
28
|
+
/** Every label, as the section passes them (#1986: all are required). */
|
|
29
|
+
const LABELS: InboundContactFormLabels = {
|
|
30
|
+
emailLabel: 'email-label',
|
|
31
|
+
firstNameLabel: 'first-name-label',
|
|
32
|
+
lastNameLabel: 'last-name-label',
|
|
33
|
+
phoneLabel: 'phone-label',
|
|
34
|
+
marketingConsentLabel: 'marketing-consent-label',
|
|
35
|
+
submitLabel: 'submit-label',
|
|
36
|
+
submittingLabel: 'submitting-label',
|
|
37
|
+
successMessage: 'success-label',
|
|
38
|
+
errorMessage: 'error-label',
|
|
39
|
+
challengeRefusedMessage: 'refused-label',
|
|
40
|
+
invalidEmailMessage: 'invalid-email-label',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
async function smallChallenge(): Promise<ProofOfWorkChallenge> {
|
|
44
|
+
const salt = `${'a'.repeat(24)}?expires=9999999999`;
|
|
45
|
+
return { algorithm: ProofOfWorkAlgorithm.SHA_256, salt, challenge: await hashProofOfWorkCandidate(salt, 7), maxNumber: 20, signature: 'b'.repeat(64) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function refusal(code: SubmissionChallengeRefusalCode): WebsiteAnonymousRequestError {
|
|
49
|
+
return new WebsiteAnonymousRequestError(400, JSON.stringify({ error: { type: 'VALIDATION', code } }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clientDouble(answers: Array<'ok' | WebsiteAnonymousRequestError>) {
|
|
53
|
+
const posts: Array<Record<string, string> | undefined> = [];
|
|
54
|
+
const gets: string[] = [];
|
|
55
|
+
const client = {
|
|
56
|
+
postAnonymous: vi.fn(async (_path: string, _body: unknown, headers?: Record<string, string>) => {
|
|
57
|
+
posts.push(headers);
|
|
58
|
+
const next = answers.shift() ?? 'ok';
|
|
59
|
+
if (next !== 'ok') throw next;
|
|
60
|
+
return {};
|
|
61
|
+
}),
|
|
62
|
+
getPublic: vi.fn(async (path: string) => {
|
|
63
|
+
gets.push(path);
|
|
64
|
+
return smallChallenge();
|
|
65
|
+
}),
|
|
66
|
+
} as unknown as WebsiteAnonymousSessionClient;
|
|
67
|
+
return { client, posts, gets };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function submit(): void {
|
|
71
|
+
fireEvent.change(screen.getByRole('textbox', { name: /email/i }), { target: { value: 'visitor@example.test' } });
|
|
72
|
+
fireEvent.click(screen.getByRole('button', { name: LABELS.submitLabel }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const answerIn = (headers: Record<string, string> | undefined) => JSON.parse(headers?.[WildoHeaderKeys.SUBMISSION_CHALLENGE_RESPONSE] ?? 'null');
|
|
76
|
+
|
|
77
|
+
describe('InboundContactForm — the submission challenge (#1735)', () => {
|
|
78
|
+
it('sends no answer on a site that declares no captcha', async () => {
|
|
79
|
+
const { client, posts, gets } = clientDouble(['ok']);
|
|
80
|
+
render(<InboundContactForm client={client} challengeSource={{ captchaDeclared: false }} labels={LABELS} />);
|
|
81
|
+
submit();
|
|
82
|
+
await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
|
|
83
|
+
expect(posts).toEqual([undefined]);
|
|
84
|
+
expect(gets).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('sends the RUNNING vendor\'s token for this form\'s action', async () => {
|
|
88
|
+
const { client, posts } = clientDouble(['ok']);
|
|
89
|
+
const execute = vi.fn(async () => 'vendor-token');
|
|
90
|
+
const source: WebsiteSubmissionChallengeSource = { captchaDeclared: true, runningCaptcha: { providerRef: 'recaptcha', sink: { execute } } };
|
|
91
|
+
render(<InboundContactForm client={client} challengeSource={source} labels={LABELS} />);
|
|
92
|
+
submit();
|
|
93
|
+
await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
|
|
94
|
+
expect(execute).toHaveBeenCalledWith(SubmissionChallengeAction.INBOUND_CONTACT);
|
|
95
|
+
expect(answerIn(posts[0])).toEqual({ kind: SubmissionChallengeResponseKind.PROVIDER_TOKEN, providerRef: 'recaptcha', token: 'vendor-token' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('solves the proof of work when the captcha is declared but not running (no consent)', async () => {
|
|
99
|
+
const { client, posts, gets } = clientDouble(['ok']);
|
|
100
|
+
render(<InboundContactForm client={client} challengeSource={{ captchaDeclared: true }} labels={LABELS} />);
|
|
101
|
+
submit();
|
|
102
|
+
await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
|
|
103
|
+
expect(gets).toEqual([SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH]);
|
|
104
|
+
expect(answerIn(posts[0])).toMatchObject({ kind: SubmissionChallengeResponseKind.PROOF_OF_WORK, number: 7 });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('answers a refused vendor token ONCE more with the proof of work', async () => {
|
|
108
|
+
const { client, posts } = clientDouble([refusal(SubmissionChallengeRefusalCode.REFUSED), 'ok']);
|
|
109
|
+
const source: WebsiteSubmissionChallengeSource = { captchaDeclared: true, runningCaptcha: { providerRef: 'recaptcha', sink: { execute: async () => 'low-score' } } };
|
|
110
|
+
render(<InboundContactForm client={client} challengeSource={source} labels={LABELS} />);
|
|
111
|
+
submit();
|
|
112
|
+
await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
|
|
113
|
+
expect(posts.map((headers) => answerIn(headers).kind)).toEqual([SubmissionChallengeResponseKind.PROVIDER_TOKEN, SubmissionChallengeResponseKind.PROOF_OF_WORK]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('answers a site that did not know the backend protects the form (REQUIRED) with the proof of work', async () => {
|
|
117
|
+
const { client, posts } = clientDouble([refusal(SubmissionChallengeRefusalCode.REQUIRED), 'ok']);
|
|
118
|
+
render(<InboundContactForm client={client} challengeSource={{ captchaDeclared: false }} labels={LABELS} />);
|
|
119
|
+
submit();
|
|
120
|
+
await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument());
|
|
121
|
+
expect(posts[0]).toBeUndefined();
|
|
122
|
+
expect(answerIn(posts[1])).toMatchObject({ kind: SubmissionChallengeResponseKind.PROOF_OF_WORK });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('shows the challenge refusal, in its own words, when the proof of work is refused too', async () => {
|
|
126
|
+
const { client } = clientDouble([refusal(SubmissionChallengeRefusalCode.REFUSED), refusal(SubmissionChallengeRefusalCode.REFUSED)]);
|
|
127
|
+
render(<InboundContactForm client={client} challengeSource={{ captchaDeclared: true }} labels={LABELS} />);
|
|
128
|
+
submit();
|
|
129
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('refused-label');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('does not retry any other failure', async () => {
|
|
133
|
+
const { client, posts } = clientDouble([new WebsiteAnonymousRequestError(500, '{}')]);
|
|
134
|
+
render(<InboundContactForm client={client} challengeSource={{ captchaDeclared: false }} labels={LABELS} />);
|
|
135
|
+
submit();
|
|
136
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('error-label');
|
|
137
|
+
expect(posts).toHaveLength(1);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -23,18 +23,30 @@ import { z } from "zod";
|
|
|
23
23
|
|
|
24
24
|
import { ConsentPurpose } from "@wildo-ai/saas-models/public-runtime";
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Length ceilings of the optional text fields. Exported so the form puts the SAME numbers on its inputs as
|
|
28
|
+
* `maxLength`: the browser then stops a visitor at the ceiling, and a length failure is not something the form ever
|
|
29
|
+
* has to word (#1986).
|
|
30
|
+
*/
|
|
31
|
+
export const INBOUND_CONTACT_NAME_MAX_LENGTH = 200;
|
|
32
|
+
export const INBOUND_CONTACT_PHONE_MAX_LENGTH = 64;
|
|
33
|
+
|
|
26
34
|
/**
|
|
27
35
|
* The wire payload POSTed to `/api/v1/inbound-contacts`. `privacy` matches the
|
|
28
36
|
* resource's `InboundContactPrivacySchema` shape (a nested consent object) so the
|
|
29
37
|
* backend accepts it verbatim.
|
|
38
|
+
*
|
|
39
|
+
* No issue carries a message (#1986). A visitor reads the application's own words, which the form takes from its
|
|
40
|
+
* section's label pack (`InboundContactFormLabels.invalidEmailMessage`); a message written here would be English in
|
|
41
|
+
* every locale, and nobody authoring the pack could find it.
|
|
30
42
|
*/
|
|
31
43
|
export const InboundContactFormSchema = z.object({
|
|
32
44
|
// Zod 4: top-level `z.email()` (not the deprecated `z.string().email()`), matching
|
|
33
45
|
// the resource schema. An empty string fails it, so it also covers "required".
|
|
34
|
-
email: z.email(
|
|
35
|
-
firstName: z.string().trim().max(
|
|
36
|
-
lastName: z.string().trim().max(
|
|
37
|
-
phone: z.string().trim().max(
|
|
46
|
+
email: z.email(),
|
|
47
|
+
firstName: z.string().trim().max(INBOUND_CONTACT_NAME_MAX_LENGTH).optional(),
|
|
48
|
+
lastName: z.string().trim().max(INBOUND_CONTACT_NAME_MAX_LENGTH).optional(),
|
|
49
|
+
phone: z.string().trim().max(INBOUND_CONTACT_PHONE_MAX_LENGTH).optional(),
|
|
38
50
|
/**
|
|
39
51
|
* OPTIONAL marketing opt-in — maps into the resource's `privacy.consentedPurposes` set.
|
|
40
52
|
* It is the only privacy field on this form, and it gates nothing: sending the form is the
|
|
@@ -58,6 +58,19 @@ export class WebsiteAnonymousRequestError extends Error {
|
|
|
58
58
|
super(`Anonymous request failed with status ${status}`);
|
|
59
59
|
this.name = 'WebsiteAnonymousRequestError';
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The refusal's stable `error.code` — what a client branches on (the engine hoists an error's `context.code` there) —
|
|
64
|
+
* or `undefined` when the body is not the engine's error envelope or carries no code.
|
|
65
|
+
*/
|
|
66
|
+
get errorCode(): string | undefined {
|
|
67
|
+
try {
|
|
68
|
+
const code = (JSON.parse(this.bodyText) as { error?: { code?: unknown } }).error?.code;
|
|
69
|
+
return typeof code === 'string' ? code : undefined;
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
export class WebsiteAnonymousSessionClient {
|
|
@@ -108,8 +121,24 @@ export class WebsiteAnonymousSessionClient {
|
|
|
108
121
|
* service-name headers, captures any returned session token, and returns the parsed
|
|
109
122
|
* JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.
|
|
110
123
|
*/
|
|
111
|
-
async postAnonymous<TResponse = unknown>(path: string, body: unknown): Promise<TResponse> {
|
|
112
|
-
return this.requestAnonymous<TResponse>('POST', path, body);
|
|
124
|
+
async postAnonymous<TResponse = unknown>(path: string, body: unknown, extraHeaders?: Readonly<Record<string, string>>): Promise<TResponse> {
|
|
125
|
+
return this.requestAnonymous<TResponse>('POST', path, body, extraHeaders);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* GET a PUBLIC endpoint — one that needs no session (a proof-of-work challenge, #1735). Starts no anonymous session
|
|
130
|
+
* and sends no session header or cookie: asking for a challenge must not mint an identity for the visitor.
|
|
131
|
+
*/
|
|
132
|
+
async getPublic<TResponse = unknown>(path: string): Promise<TResponse> {
|
|
133
|
+
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
134
|
+
method: 'GET',
|
|
135
|
+
headers: { [WildoHeaderKeys.FRONTEND_SERVICE_NAME]: this.frontendServiceName },
|
|
136
|
+
credentials: 'omit',
|
|
137
|
+
});
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new WebsiteAnonymousRequestError(response.status, await safeReadText(response));
|
|
140
|
+
}
|
|
141
|
+
return (await response.json()) as TResponse;
|
|
113
142
|
}
|
|
114
143
|
|
|
115
144
|
/**
|
|
@@ -122,12 +151,18 @@ export class WebsiteAnonymousSessionClient {
|
|
|
122
151
|
* the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside
|
|
123
152
|
* `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.
|
|
124
153
|
*/
|
|
125
|
-
async requestAnonymous<TResponse = unknown>(
|
|
154
|
+
async requestAnonymous<TResponse = unknown>(
|
|
155
|
+
method: string,
|
|
156
|
+
path: string,
|
|
157
|
+
body: unknown,
|
|
158
|
+
/** Request-specific headers, such as a public form's challenge answer (#1735). Never overrides the session headers. */
|
|
159
|
+
extraHeaders?: Readonly<Record<string, string>>,
|
|
160
|
+
): Promise<TResponse> {
|
|
126
161
|
this.ensureSessionStarted();
|
|
127
162
|
|
|
128
163
|
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
129
164
|
method,
|
|
130
|
-
headers: this.buildHeaders(),
|
|
165
|
+
headers: { ...extraHeaders, ...this.buildHeaders() },
|
|
131
166
|
body: JSON.stringify(body),
|
|
132
167
|
// Carry the parent-domain `__wildo_anon_session` cookie to the API so the
|
|
133
168
|
// session is continuous across the apex/app/api origins.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The answer a public website form sends with its submission when the application protects it with a captcha (#1735), to
|
|
3
|
+
* any operation that declares a submission challenge (#1985).
|
|
4
|
+
*
|
|
5
|
+
* Two answers, one header (`x-submission-challenge-response`), chosen in the visitor's browser:
|
|
6
|
+
*
|
|
7
|
+
* - the consented captcha vendor's TOKEN, when its browser part is running on the page (it starts only once the
|
|
8
|
+
* visitor grants the purpose the vendor rests on — Google reCAPTCHA rests on ANALYTICS);
|
|
9
|
+
* - otherwise the engine's PROOF OF WORK: a challenge fetched from the backend and solved here. Consent-exempt —
|
|
10
|
+
* nothing is stored in the browser and nothing reaches a third party — so a visitor who declined still submits.
|
|
11
|
+
*
|
|
12
|
+
* The backend requires one of the two whenever it declares a captcha provider, and refuses a submission with neither.
|
|
13
|
+
* A site that declares none sends nothing. A refused vendor token is retried ONCE with the proof of work (a low score or
|
|
14
|
+
* an unreachable vendor must not lock a real person out); see {@link isSubmissionChallengeRefusal}.
|
|
15
|
+
*/
|
|
16
|
+
import { useContext, useMemo } from 'react';
|
|
17
|
+
import {
|
|
18
|
+
BUILTIN_PROVIDER_CAPABILITY,
|
|
19
|
+
SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH,
|
|
20
|
+
SubmissionChallengeRefusalCode,
|
|
21
|
+
SubmissionChallengeResponseKind,
|
|
22
|
+
encodeSubmissionChallengeResponse,
|
|
23
|
+
isFrontendCaptchaSink,
|
|
24
|
+
solveProofOfWorkChallenge,
|
|
25
|
+
type FrontendCaptchaSink,
|
|
26
|
+
type ProofOfWorkChallenge,
|
|
27
|
+
} from '@wildo-ai/saas-models/public-runtime';
|
|
28
|
+
|
|
29
|
+
import { WebsiteRuntimeContext } from '../contexts/WebsiteRuntimeContext';
|
|
30
|
+
import { useWebsiteProviderSdkActivations } from '../external-providers/website-provider-sdk-activations';
|
|
31
|
+
import { WebsiteAnonymousRequestError, type WebsiteAnonymousSessionClient } from './website-anonymous-session-client';
|
|
32
|
+
|
|
33
|
+
/** @wildo_source:part:start saas.website.submission-challenge facet:layer:frontend facet:audience:app-developer */
|
|
34
|
+
/** What a form knows about the page's captcha when it submits. */
|
|
35
|
+
export interface WebsiteSubmissionChallengeSource {
|
|
36
|
+
/** This website declares a captcha provider, so its public forms answer a challenge. */
|
|
37
|
+
readonly captchaDeclared: boolean;
|
|
38
|
+
/** The captcha vendor RUNNING on the page — present only once the visitor consented and it started. */
|
|
39
|
+
readonly runningCaptcha?: { readonly providerRef: string; readonly sink: FrontendCaptchaSink };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The challenge source of the current page: its declared captcha provider and, if started, its running client. */
|
|
43
|
+
export function useWebsiteSubmissionChallengeSource(): WebsiteSubmissionChallengeSource {
|
|
44
|
+
// Read without `useWebsiteRuntime`'s throw: a form rendered outside a page (a test, a storybook) simply has no captcha.
|
|
45
|
+
const runtime = useContext(WebsiteRuntimeContext);
|
|
46
|
+
const running = useWebsiteProviderSdkActivations();
|
|
47
|
+
const captchaDeclared = runtime?.frontendProviderRegistry?.findProviderByProviderCapability(BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA) !== undefined;
|
|
48
|
+
return useMemo(() => {
|
|
49
|
+
const captcha = running.find((entry) => entry.providerCapabilities.includes(BUILTIN_PROVIDER_CAPABILITY.FRONTEND_CAPTCHA)
|
|
50
|
+
&& isFrontendCaptchaSink(entry.activation.handle));
|
|
51
|
+
return {
|
|
52
|
+
captchaDeclared,
|
|
53
|
+
...(captcha === undefined ? {} : { runningCaptcha: { providerRef: captcha.providerRef, sink: captcha.activation.handle as FrontendCaptchaSink } }),
|
|
54
|
+
};
|
|
55
|
+
}, [captchaDeclared, running]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The encoded answer to send, or `undefined` when this site asks for none. `proofOfWorkOnly` skips the vendor (the
|
|
60
|
+
* retry after a refused token). Throws only when the proof-of-work challenge cannot be fetched or solved; the caller
|
|
61
|
+
* then shows its ordinary failure and does not submit unanswered (`InboundContactForm` shows its `errorMessage`).
|
|
62
|
+
*/
|
|
63
|
+
export async function obtainSubmissionChallengeAnswer(params: {
|
|
64
|
+
readonly source: WebsiteSubmissionChallengeSource;
|
|
65
|
+
/**
|
|
66
|
+
* The action the operation the form posts to DECLARES in its `submissionChallenge` (#1985): the engine's
|
|
67
|
+
* `SubmissionChallengeAction.INBOUND_CONTACT` for `/inbound-contacts`, your own action for your own operation. A token
|
|
68
|
+
* minted for any other action is refused.
|
|
69
|
+
*/
|
|
70
|
+
readonly action: string;
|
|
71
|
+
readonly client: Pick<WebsiteAnonymousSessionClient, 'getPublic'>;
|
|
72
|
+
readonly proofOfWorkOnly?: boolean;
|
|
73
|
+
readonly signal?: AbortSignal;
|
|
74
|
+
}): Promise<string | undefined> {
|
|
75
|
+
const { source, action, client, proofOfWorkOnly = false, signal } = params;
|
|
76
|
+
if (!proofOfWorkOnly && source.runningCaptcha !== undefined) {
|
|
77
|
+
const token = await source.runningCaptcha.sink.execute(action);
|
|
78
|
+
if (token !== undefined) {
|
|
79
|
+
return encodeSubmissionChallengeResponse({
|
|
80
|
+
kind: SubmissionChallengeResponseKind.PROVIDER_TOKEN,
|
|
81
|
+
providerRef: source.runningCaptcha.providerRef,
|
|
82
|
+
token,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!source.captchaDeclared && !proofOfWorkOnly) return undefined;
|
|
87
|
+
const challenge = await client.getPublic<ProofOfWorkChallenge>(SUBMISSION_CHALLENGE_PROOF_OF_WORK_PATH);
|
|
88
|
+
const answer = await solveProofOfWorkChallenge(challenge, { signal });
|
|
89
|
+
if (answer === undefined) throw new Error('The proof-of-work challenge could not be solved.');
|
|
90
|
+
return encodeSubmissionChallengeResponse(answer);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Whether a failed submission was refused at its challenge — the one refusal a form answers again, with the proof of work. */
|
|
94
|
+
export function isSubmissionChallengeRefusal(error: unknown): boolean {
|
|
95
|
+
if (!(error instanceof WebsiteAnonymousRequestError)) return false;
|
|
96
|
+
const code = error.errorCode;
|
|
97
|
+
return code === SubmissionChallengeRefusalCode.REQUIRED || code === SubmissionChallengeRefusalCode.REFUSED;
|
|
98
|
+
}
|
|
99
|
+
/** @wildo_source:part:end saas.website.submission-challenge */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useId, useState, type ReactNode } from 'react';
|
|
2
2
|
import type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
|
|
3
3
|
|
|
4
|
+
import { websiteDesignTokenCssVar } from '../../schemas/design-tokens/website-design-tokens.shared.schemas';
|
|
4
5
|
import { useWebsiteLabelByKey } from '../hooks/useWebsiteLabel';
|
|
5
6
|
import { WebsiteConsentStatus } from './WebsiteConsentContext';
|
|
6
7
|
import { useWebsiteConsent } from './useWebsiteConsent';
|
|
@@ -49,7 +50,7 @@ const ROW_STYLE: React.CSSProperties = {
|
|
|
49
50
|
columnGap: '0.625rem',
|
|
50
51
|
alignItems: 'center',
|
|
51
52
|
padding: '0.5rem 0.75rem',
|
|
52
|
-
borderBottom:
|
|
53
|
+
borderBottom: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`,
|
|
53
54
|
};
|
|
54
55
|
|
|
55
56
|
const ROW_DESCRIPTION_STYLE: React.CSSProperties = { margin: 0, gridColumn: 2, fontSize: '0.75rem', opacity: 0.8 };
|
|
@@ -79,10 +80,10 @@ function PurposeChoice({ purpose, checked, onChange }: { purpose: ConsentPurpose
|
|
|
79
80
|
|
|
80
81
|
const BUTTON_STYLE: React.CSSProperties = {
|
|
81
82
|
padding: '0.5rem 1rem',
|
|
82
|
-
borderRadius: '
|
|
83
|
-
border:
|
|
84
|
-
background: '
|
|
85
|
-
color: '
|
|
83
|
+
borderRadius: websiteDesignTokenCssVar('radii', 'md', '0.375rem'),
|
|
84
|
+
border: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`,
|
|
85
|
+
background: websiteDesignTokenCssVar('colors', 'background', '#fff'),
|
|
86
|
+
color: websiteDesignTokenCssVar('colors', 'foreground', '#000'),
|
|
86
87
|
cursor: 'pointer',
|
|
87
88
|
};
|
|
88
89
|
|
|
@@ -141,10 +142,10 @@ export function WebsiteConsentBanner(): ReactNode {
|
|
|
141
142
|
display: 'flex',
|
|
142
143
|
flexDirection: 'column',
|
|
143
144
|
gap: '0.625rem',
|
|
144
|
-
background: '
|
|
145
|
-
color: '
|
|
146
|
-
border:
|
|
147
|
-
borderRadius: '
|
|
145
|
+
background: websiteDesignTokenCssVar('colors', 'surface', '#fff'),
|
|
146
|
+
color: websiteDesignTokenCssVar('colors', 'surfaceForeground', '#000'),
|
|
147
|
+
border: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`,
|
|
148
|
+
borderRadius: websiteDesignTokenCssVar('radii', 'lg', '0.5rem'),
|
|
148
149
|
}}
|
|
149
150
|
>
|
|
150
151
|
<h2 id={headingId} style={{ margin: 0, fontSize: '1.125rem' }}>{title}</h2>
|
|
@@ -154,7 +155,7 @@ export function WebsiteConsentBanner(): ReactNode {
|
|
|
154
155
|
nothing — the same vacuous pin the app tier already had to repair. */}
|
|
155
156
|
<div
|
|
156
157
|
data-website-consent-rows=""
|
|
157
|
-
style={{ minHeight: 0, flex: 1, overflowY: 'auto', border:
|
|
158
|
+
style={{ minHeight: 0, flex: 1, overflowY: 'auto', border: `1px solid ${websiteDesignTokenCssVar('colors', 'border', '#ccc')}`, borderRadius: websiteDesignTokenCssVar('radii', 'md', '0.375rem') }}
|
|
158
159
|
>
|
|
159
160
|
{/*
|
|
160
161
|
* The always-active row, first, carrying a CHECKED and DISABLED box. It is not a purpose the
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import React, { createContext, type ComponentType, type ReactNode } from 'react';
|
|
2
2
|
|
|
3
|
-
import type { AvailableLanguage } from '@wildo-ai/saas-models/public-runtime';
|
|
3
|
+
import type { AvailableLanguage, PublicPricingCatalog } from '@wildo-ai/saas-models/public-runtime';
|
|
4
4
|
|
|
5
5
|
import type { WebsiteDesignTokens } from '../../schemas/design-tokens/website-design-tokens.shared.schemas';
|
|
6
6
|
import type { FrontendWebsiteProviderRegistry } from '../external-providers/frontend-provider-registry.website';
|
|
7
|
+
import type { WebsiteProviderComponentRegistry } from '../external-providers/provider-component-registry.website';
|
|
7
8
|
import type { WebsiteLabelKey } from '../../schemas/label-keys/website-label-key.schemas';
|
|
8
9
|
import type { WebsiteNavigationConfig } from '../../schemas/manifests/website-root-config.shared.schemas';
|
|
9
10
|
import type { LabelPack } from '../../schemas/validators/label-pack.validator';
|
|
@@ -12,10 +13,10 @@ import type { LabelPack } from '../../schemas/validators/label-pack.validator';
|
|
|
12
13
|
* @wildo_source:part:start saas.website.runtime.context facet:layer:core facet:family:website
|
|
13
14
|
*
|
|
14
15
|
* `WebsiteRuntimeContext` — site-wide runtime state for the marketing
|
|
15
|
-
* surface. Created **once per page render** by the host adapter
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `<WebsiteRuntimeContextProvider value={…}>` around the page tree
|
|
16
|
+
* surface. Created **once per page render** by the host adapter: the
|
|
17
|
+
* page's `.astro` frontmatter loads the site context and the resolved label
|
|
18
|
+
* pack, and its bridge island (`createWebsitePageBridgeIsland`) calls
|
|
19
|
+
* `<WebsiteRuntimeContextProvider value={…}>` around the page tree.
|
|
19
20
|
*
|
|
20
21
|
* **Why "runtime" and not "config"**: the values here are the
|
|
21
22
|
* locale-resolved subset of `WebsiteRootConfig` — only the active
|
|
@@ -152,6 +153,14 @@ export interface WebsiteRuntimeContextValue {
|
|
|
152
153
|
* catalog for analytics / captcha / browser-monitoring slices.
|
|
153
154
|
*/
|
|
154
155
|
frontendProviderRegistry?: FrontendWebsiteProviderRegistry;
|
|
156
|
+
/**
|
|
157
|
+
* Components the live providers contribute, keyed `capability:providerRef` (#1703) — the same
|
|
158
|
+
* registry the application builds, from the same `components` declaration. Built by the page bridge
|
|
159
|
+
* once per island from `frontendProviderRegistry`; read through `useWebsiteProviderComponent` and
|
|
160
|
+
* rendered by `WebsiteProviderComponent`. Optional for the same reason as the registry above: a
|
|
161
|
+
* provider-free site, or a test runtime, has none, and every slot then renders nothing.
|
|
162
|
+
*/
|
|
163
|
+
providerComponentRegistry?: WebsiteProviderComponentRegistry;
|
|
155
164
|
/**
|
|
156
165
|
* Replaces the framework's visitor consent panel (#534). Reads `useWebsiteConsent()`; see
|
|
157
166
|
* `WebsiteConsentBanner` for the three properties a replacement must keep. Absent uses the default.
|
|
@@ -173,6 +182,17 @@ export interface WebsiteRuntimeContextValue {
|
|
|
173
182
|
* silently failing to submit.
|
|
174
183
|
*/
|
|
175
184
|
apiOrigin?: string;
|
|
185
|
+
/**
|
|
186
|
+
* The application's public pricing catalogue (#1835), derived at build time from the billing
|
|
187
|
+
* catalogue the backend sells from — so a pricing section states the prices the application
|
|
188
|
+
* actually charges instead of a hand copy of them. Node-loaded
|
|
189
|
+
* (`loadWebsitePricingCatalog` on `/config-loader`) and carried as an island prop for the
|
|
190
|
+
* reason `appOrigin` above is. Read it with `useWebsitePricingCatalog()`.
|
|
191
|
+
*
|
|
192
|
+
* Optional because only pricing pages need it; a section that reads it on a page that did not
|
|
193
|
+
* pass it fails loudly rather than rendering no plans.
|
|
194
|
+
*/
|
|
195
|
+
pricingCatalog?: PublicPricingCatalog;
|
|
176
196
|
}
|
|
177
197
|
|
|
178
198
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useContext } from 'react';
|
|
2
2
|
|
|
3
|
+
import type { PublicPricingCatalog } from '@wildo-ai/saas-models/public-runtime';
|
|
4
|
+
|
|
3
5
|
import {
|
|
4
6
|
WebsiteRuntimeContext,
|
|
5
7
|
type WebsiteRuntimeContextValue,
|
|
@@ -25,10 +27,39 @@ export function useWebsiteRuntime(): WebsiteRuntimeContextValue {
|
|
|
25
27
|
if (value === null) {
|
|
26
28
|
throw new Error(
|
|
27
29
|
'[useWebsiteRuntime] must be called inside <WebsiteRuntimeContextProvider>. ' +
|
|
28
|
-
'In Astro pages this is provided by
|
|
29
|
-
'
|
|
30
|
+
'In Astro pages this is provided by the page\'s bridge island (`createWebsitePageBridgeIsland` ' +
|
|
31
|
+
'from @wildo-ai/saas-website/astro-island) — make sure the page renders its bridge instead of ' +
|
|
32
|
+
'rendering React components directly.',
|
|
30
33
|
);
|
|
31
34
|
}
|
|
32
35
|
return value;
|
|
33
36
|
}
|
|
34
37
|
/** @wildo_source:part:end saas.website.use-runtime.hook */
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @wildo_source:part:start saas.website.use-pricing-catalog.hook facet:layer:core facet:family:website facet:audience:app-developer
|
|
41
|
+
*
|
|
42
|
+
* `useWebsitePricingCatalog()` — the application's public pricing catalogue, for a pricing section.
|
|
43
|
+
*
|
|
44
|
+
* The plans, their prices, which one is recommended, how much of each price may be stated, and which
|
|
45
|
+
* are announced but not yet on sale — all derived at build time from the billing catalogue the
|
|
46
|
+
* backend sells from. A pricing section renders from THIS and never keeps its own copy of a price:
|
|
47
|
+
* a copy is a price that will disagree with the one the application charges.
|
|
48
|
+
*
|
|
49
|
+
* Throws when the page did not pass the catalogue to its bridge island: a pricing section with no
|
|
50
|
+
* plans would silently state that the application sells nothing. The page's `.astro` frontmatter
|
|
51
|
+
* loads it with `loadWebsitePricingCatalog()` from `@wildo-ai/saas-website/config-loader` and passes
|
|
52
|
+
* it as the island's `pricingCatalog` prop.
|
|
53
|
+
*/
|
|
54
|
+
export function useWebsitePricingCatalog(): PublicPricingCatalog {
|
|
55
|
+
const { pricingCatalog } = useWebsiteRuntime();
|
|
56
|
+
if (pricingCatalog === undefined) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
'[useWebsitePricingCatalog] this page did not pass a pricing catalogue to its bridge island. In the page\'s '
|
|
59
|
+
+ '`.astro` frontmatter, load it with `await loadWebsitePricingCatalog()` (the @wildo-ai/saas-website/config-loader '
|
|
60
|
+
+ 'subpath) and pass `pricingCatalog={pricingCatalog}` to the island.',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return pricingCatalog;
|
|
64
|
+
}
|
|
65
|
+
/** @wildo_source:part:end saas.website.use-pricing-catalog.hook */
|