@wildo-ai/saas-website 1.1.4 → 1.1.5
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 +14 -20
- package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -1
- package/dist/esm/astro/blog-post-bridge.js +4 -2
- 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 +30 -20
- package/dist/esm/astro/bridge-runtime.d.ts.map +1 -1
- package/dist/esm/astro/bridge-runtime.js +13 -3
- 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.map +1 -1
- package/dist/esm/astro/label-pack-loader.js +5 -0
- 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.map +1 -1
- 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.map +1 -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.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.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.map +1 -1
- package/dist/esm/config/index.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.d.ts.map +1 -1
- package/dist/esm/config/load-website-config.js +2 -1
- package/dist/esm/config/load-website-config.js.map +1 -1
- 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.map +1 -1
- package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -1
- package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +3 -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 +13 -9
- 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 +11 -0
- 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 +14 -1
- package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -1
- 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 +128 -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 +8 -1
- 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.map +1 -1
- package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -1
- 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/useWebsiteProviderScripts.d.ts +33 -10
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.d.ts.map +1 -1
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js +64 -16
- package/dist/esm/core/external-providers/useWebsiteProviderScripts.js.map +1 -1
- package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -1
- package/dist/esm/core/factories/define-website-section.d.ts.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 +32 -4
- 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 +6 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +6 -0
- 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.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/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -1
- package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.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.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/astro/__tests__/blog-post-bridge.test.tsx +41 -0
- package/src/astro/__tests__/bridge-runtime.test.tsx +25 -0
- package/src/astro/__tests__/label-pack-loader.test.ts +26 -1
- package/src/astro/blog-post-bridge.tsx +18 -22
- package/src/astro/bridge-runtime.tsx +45 -23
- package/src/astro/label-pack-loader.ts +4 -0
- package/src/config/load-website-config.ts +2 -1
- package/src/config/wildo-website-config.schemas.ts +10 -5
- package/src/core/__tests__/WebsitePageLayout.test.tsx +1 -1
- package/src/core/__tests__/website-consent.test.tsx +311 -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 +14 -9
- package/src/core/anonymous-session/website-anonymous-session-client.ts +15 -1
- package/src/core/consent/WebsiteConsentBanner.tsx +222 -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 +8 -1
- package/src/core/external-providers/frontend-provider-registry.website.ts +1 -2
- package/src/core/external-providers/useWebsiteProviderScripts.ts +72 -17
- package/src/core/layouts/WebsitePageLayout.tsx +66 -3
- package/src/index.ts +7 -0
- package/src/schemas/label-keys/__tests__/scaffolded-consent-label-pack.parity.test.ts +71 -0
- package/src/schemas/label-keys/website-consent-label-keys.schemas.ts +78 -0
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inbound-contact-form.schema.js","sourceRoot":"","sources":["
|
|
1
|
+
{"version":3,"file":"inbound-contact-form.schema.js","sourceRoot":"","sources":["../../../../../src/core/anonymous-session/inbound-contact-form.schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAEtE;;;;GAIG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/C,mFAAmF;IACnF,+EAA+E;IAC/E,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,6BAA6B,CAAC;IAC7C,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE;IAChD,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE;IAC/C,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE;IAC3C;;;;;OAKG;IACH,gBAAgB,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC;CAC7C,CAAC,CAAC;AAIH;;;;GAIG;AACH,MAAM,UAAU,6BAA6B,CAAC,MAAgC;IAC5E,OAAO;QACL,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE;QACxC,GAAG,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5D,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzD,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAChD,OAAO,EAAE;YACP,6FAA6F;YAC7F,0FAA0F;YAC1F,4FAA4F;YAC5F,iBAAiB,EAAE,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE;YAC5E,gBAAgB,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;SAC3C;KACF,CAAC;AACJ,CAAC","sourcesContent":["/**\n * Inbound-contact form input schema (website-local, bundle-safe).\n *\n * Deliberately a SEPARATE plain-Zod schema, NOT `InboundContactSchema` from\n * `@wildo-ai/saas-models`: that schema is decorator-bound (`.isPrimaryKey()`,\n * `.excludeFromCreate()`, `.isBackendOnly()`, …), so importing it would boot the\n * decorator framework into the browser bundle — forbidden by bundle discipline. The\n * website only needs to validate the handful of client-authored CREATE fields; the\n * backend is the authority on the full contract (it re-validates + injects the\n * server-set fields: `_id`, `anonymousUserId`, `userId`, `emailUserMatch`, timestamps).\n *\n * Mirrors the non-excluded, non-backend-only CREATE inputs of `InboundContact`\n * (`email` required; `firstName` / `lastName` / `phone` optional), plus ONE privacy\n * signal: an OPTIONAL marketing opt-in (mapped to the `privacy.consentedPurposes` set).\n * There is no second, required agreement field: a governed notice, contract or\n * consent interaction must use the separate exact-release receipt contract, because this\n * DTO cannot manufacture legal evidence from a checkbox and a client timestamp.\n * `relatedUserId` is intentionally absent — it is `.excludeFromCreate()` on the\n * resource (sales/admin-only, never client-authored).\n */\n\nimport { z } from \"zod\";\n\nimport { ConsentPurpose } from \"@wildo-ai/saas-models/public-runtime\";\n\n/**\n * The wire payload POSTed to `/api/v1/inbound-contacts`. `privacy` matches the\n * resource's `InboundContactPrivacySchema` shape (a nested consent object) so the\n * backend accepts it verbatim.\n */\nexport const InboundContactFormSchema = z.object({\n // Zod 4: top-level `z.email()` (not the deprecated `z.string().email()`), matching\n // the resource schema. An empty string fails it, so it also covers \"required\".\n email: z.email(\"Enter a valid email address\"),\n firstName: z.string().trim().max(200).optional(),\n lastName: z.string().trim().max(200).optional(),\n phone: z.string().trim().max(64).optional(),\n /**\n * OPTIONAL marketing opt-in — maps into the resource's `privacy.consentedPurposes` set.\n * It is the only privacy field on this form, and it gates nothing: sending the form is the\n * essential act and never depends on optional consent, so an unchecked box still submits,\n * granting nothing.\n */\n marketingConsent: z.boolean().default(false),\n});\n\nexport type InboundContactFormValues = z.infer<typeof InboundContactFormSchema>;\n\n/**\n * Build the exact JSON body the backend CREATE expects from validated form values.\n * Keeps the `privacy` nesting + the consent purpose mapping in ONE place so the form\n * component stays declarative.\n */\nexport function buildInboundContactCreateBody(values: InboundContactFormValues): Record<string, unknown> {\n return {\n email: values.email.trim().toLowerCase(),\n ...(values.firstName ? { firstName: values.firstName } : {}),\n ...(values.lastName ? { lastName: values.lastName } : {}),\n ...(values.phone ? { phone: values.phone } : {}),\n privacy: {\n // The visitor SAW the opt-in and submitted, so this is a decision either way (#534): the set\n // they granted — MARKETING or nothing — plus a CLAIM that they decided now. The backend's\n // CREATE prefix replaces the instant with its own clock; a browser clock is never recorded.\n consentedPurposes: values.marketingConsent ? [ConsentPurpose.MARKETING] : [],\n consentDecidedAt: new Date().toISOString(),\n },\n };\n}\n"]}
|
|
@@ -72,6 +72,17 @@ export declare class WebsiteAnonymousSessionClient {
|
|
|
72
72
|
* JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.
|
|
73
73
|
*/
|
|
74
74
|
postAnonymous<TResponse = unknown>(path: string, body: unknown): Promise<TResponse>;
|
|
75
|
+
/**
|
|
76
|
+
* Send `body` to an anonymous endpoint with an explicit METHOD.
|
|
77
|
+
*
|
|
78
|
+
* `postAnonymous` above is the POST case and delegates here. This exists because not every anonymous
|
|
79
|
+
* door is a POST and the method is not guessable from the path: the visitor consent door declares
|
|
80
|
+
* `resourceOperationLike: UPDATE_MANY`, which the engine maps to PUT, so a POST to it answers 404 —
|
|
81
|
+
* and this client's one silent caller swallowed exactly that. A caller reads the door's method from
|
|
82
|
+
* the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside
|
|
83
|
+
* `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.
|
|
84
|
+
*/
|
|
85
|
+
requestAnonymous<TResponse = unknown>(method: string, path: string, body: unknown): Promise<TResponse>;
|
|
75
86
|
private ensureSessionStarted;
|
|
76
87
|
private buildHeaders;
|
|
77
88
|
private captureSessionToken;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"website-anonymous-session-client.d.ts","sourceRoot":"","sources":["
|
|
1
|
+
{"version":3,"file":"website-anonymous-session-client.d.ts","sourceRoot":"","sources":["../../../../../src/core/anonymous-session/website-anonymous-session-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAeH,MAAM,WAAW,oCAAoC;IACnD,mFAAmF;IACnF,SAAS,EAAE,MAAM,CAAC;IAClB,sFAAsF;IACtF,mBAAmB,EAAE,MAAM,CAAC;CAC7B;AAED,iEAAiE;AACjE,qBAAa,4BAA6B,SAAQ,KAAK;aAEnC,MAAM,EAAE,MAAM;aACd,QAAQ,EAAE,MAAM;gBADhB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM;CAKnC;AAED,qBAAa,6BAA6B;IACxC,OAAO,CAAC,eAAe,CAAuB;IAC9C,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;gBAEjC,OAAO,EAAE,oCAAoC;IAMzD,8FAA8F;IAC9F,kBAAkB,IAAI,MAAM,GAAG,IAAI;IAInC;;;;;;;;;;;;OAYG;IACH,yBAAyB,IAAI,MAAM;IAMnC,6EAA6E;IAC7E,eAAe,IAAI,OAAO;IAI1B;;;;;;OAMG;IACG,aAAa,CAAC,SAAS,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,SAAS,CAAC;IAIzF;;;;;;;;;OASG;IACG,gBAAgB,CAAC,SAAS,GAAG,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,SAAS,CAAC;IAoB5G,OAAO,CAAC,oBAAoB;IAQ5B,OAAO,CAAC,YAAY;IAcpB,OAAO,CAAC,mBAAmB;IAS3B,OAAO,CAAC,kBAAkB;IAY1B,OAAO,CAAC,OAAO;CAYhB"}
|
|
@@ -94,9 +94,22 @@ export class WebsiteAnonymousSessionClient {
|
|
|
94
94
|
* JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.
|
|
95
95
|
*/
|
|
96
96
|
async postAnonymous(path, body) {
|
|
97
|
+
return this.requestAnonymous('POST', path, body);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Send `body` to an anonymous endpoint with an explicit METHOD.
|
|
101
|
+
*
|
|
102
|
+
* `postAnonymous` above is the POST case and delegates here. This exists because not every anonymous
|
|
103
|
+
* door is a POST and the method is not guessable from the path: the visitor consent door declares
|
|
104
|
+
* `resourceOperationLike: UPDATE_MANY`, which the engine maps to PUT, so a POST to it answers 404 —
|
|
105
|
+
* and this client's one silent caller swallowed exactly that. A caller reads the door's method from
|
|
106
|
+
* the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside
|
|
107
|
+
* `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.
|
|
108
|
+
*/
|
|
109
|
+
async requestAnonymous(method, path, body) {
|
|
97
110
|
this.ensureSessionStarted();
|
|
98
111
|
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
99
|
-
method
|
|
112
|
+
method,
|
|
100
113
|
headers: this.buildHeaders(),
|
|
101
114
|
body: JSON.stringify(body),
|
|
102
115
|
// Carry the parent-domain `__wildo_anon_session` cookie to the API so the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"website-anonymous-session-client.js","sourceRoot":"","sources":["../../../../../src/core/anonymous-session/website-anonymous-session-client.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * WebsiteAnonymousSessionClient\n *\n * A self-contained, browser-only anonymous-session HTTP client for the marketing\n * website. It MIRRORS the SaaS app's `AnonymousSessionContext` protocol WITHOUT\n * importing `@wildo-ai/saas-frontend-lib` — the website ships into a browser bundle\n * that must not pull the decorator framework / heavy frontend graph (bundle\n * discipline). It uses ONLY browser globals (`fetch` / `crypto` / `localStorage`) and\n * the bundle-safe `@wildo-ai/saas-models/public-runtime` atoms. No axios (axios is the\n * SSR tier), no React — a React island context can wrap this class.\n *\n * Protocol (W7.3a):\n * 1. The first anonymous request sends `x-anonymous-session-init: <clientUuid>` — the\n * backend mints an `AnonymousUser` (uuidRef = clientUuid) and returns a signed JWT\n * in the `x-anonymous-session-token` RESPONSE header.\n * 2. Subsequent requests send `x-anonymous-session: <jwt>`.\n * Every request also sends `X-Frontend-Service-Name` (required by the anon backend\n * to accept the request and resolve the frontend identity).\n *\n * CORS (critical): the website is a DIFFERENT origin from the backend API, so the\n * `x-anonymous-session-token` response header is readable by `fetch` ONLY if the\n * backend lists it in `Access-Control-Expose-Headers` (non-safelisted response headers\n * are hidden from cross-origin `fetch`). Without that, the captured token is `null`,\n * the session never advances past init, and every request re-mints a session.\n *\n * Cross-origin continuity: the anonymous session created here (uuidRef) is what the\n * SaaS app transposes on signup/login. It is carried across the apex↔app boundary by\n * the backend-set parent-domain `__wildo_anon_session` cookie — hence `credentials:\n * 'include'`, so that cookie rides same-site requests to the API.\n */\n\nimport { WildoHeaderKeys, MAIN_API_BASE_PATH } from '@wildo-ai/saas-models/public-runtime';\n\n/**\n * localStorage keys. The website is self-contained (mirror-don't-import), so it pins\n * the stable literal values rather than importing the decorator-adjacent shared\n * contract. Cross-origin continuity flows through the backend cookie, NOT this\n * localStorage, so these keys need not match the app's byte-for-byte.\n */\nconst STORAGE_KEYS = {\n anonymousUserId: 'anonymousUserId',\n anonymousSessionToken: 'anonymousSessionToken',\n} as const;\n\nexport interface WebsiteAnonymousSessionClientOptions {\n /** Backend API origin WITHOUT a trailing slash, e.g. `https://api.example.com`. */\n apiOrigin: string;\n /** `X-Frontend-Service-Name` value identifying this marketing site to the backend. */\n frontendServiceName: string;\n}\n\n/** Thrown when an anonymous request returns a non-2xx status. */\nexport class WebsiteAnonymousRequestError extends Error {\n constructor(\n public readonly status: number,\n public readonly bodyText: string,\n ) {\n super(`Anonymous request failed with status ${status}`);\n this.name = 'WebsiteAnonymousRequestError';\n }\n}\n\nexport class WebsiteAnonymousSessionClient {\n private anonymousUserId: string | null = null;\n private sessionToken: string | null = null;\n private readonly apiBaseUrl: string;\n private readonly frontendServiceName: string;\n\n constructor(options: WebsiteAnonymousSessionClientOptions) {\n this.apiBaseUrl = `${options.apiOrigin.replace(/\\/+$/, '')}${MAIN_API_BASE_PATH}`;\n this.frontendServiceName = options.frontendServiceName;\n this.restoreFromStorage();\n }\n\n /** The anonymous session's relational identity (client uuidRef), or `null` if not started. */\n getAnonymousUserId(): string | null {\n return this.anonymousUserId;\n }\n\n /**\n * Return the anonymous session's relational identity (client uuidRef),\n * STARTING the session first if it has not begun yet. Unlike\n * `getAnonymousUserId()` (which may return `null`), this always yields\n * a stable uuidRef.\n *\n * Used by NESTED anonymous routes that must embed the id in the URL\n * BEFORE the first request — e.g. draft-notes POSTs to\n * `/user-anonymous-users/<uuidRef>/draft-notes`. The id generated here\n * is the SAME one the next `postAnonymous(...)` sends as\n * `x-anonymous-session-init`, so the URL scope and the minted session\n * refer to one anonymous user (no split identity).\n */\n getOrStartAnonymousUserId(): string {\n this.ensureSessionStarted();\n // ensureSessionStarted() guarantees anonymousUserId is non-null.\n return this.anonymousUserId as string;\n }\n\n /** `true` once an init request has succeeded and a session token is held. */\n isSessionActive(): boolean {\n return this.anonymousUserId !== null && this.sessionToken !== null;\n }\n\n /**\n * POST `body` to an anonymous endpoint. `path` is relative to `/api/v1`\n * (e.g. `/inbound-contacts`, or a nested `/anonymous-users/<uuidRef>/draft-notes`).\n * Lazily starts the session on the first call, attaches the anon-session +\n * service-name headers, captures any returned session token, and returns the parsed\n * JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.\n */\n async postAnonymous<TResponse = unknown>(path: string, body: unknown): Promise<TResponse> {\n this.ensureSessionStarted();\n\n const response = await fetch(`${this.apiBaseUrl}${path}`, {\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(body),\n // Carry the parent-domain `__wildo_anon_session` cookie to the API so the\n // session is continuous across the apex/app/api origins.\n credentials: 'include',\n });\n\n this.captureSessionToken(response);\n\n if (!response.ok) {\n throw new WebsiteAnonymousRequestError(response.status, await safeReadText(response));\n }\n return (await response.json()) as TResponse;\n }\n\n private ensureSessionStarted(): void {\n if (!this.anonymousUserId) {\n this.anonymousUserId = crypto.randomUUID();\n this.sessionToken = null;\n this.persist();\n }\n }\n\n private buildHeaders(): Record<string, string> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n [WildoHeaderKeys.FRONTEND_SERVICE_NAME]: this.frontendServiceName,\n };\n // Once we hold the JWT, restore the session; before that, the raw UUID inits it.\n if (this.sessionToken) {\n headers[WildoHeaderKeys.ANONYMOUS_SESSION] = this.sessionToken;\n } else if (this.anonymousUserId) {\n headers[WildoHeaderKeys.ANONYMOUS_SESSION_INIT] = this.anonymousUserId;\n }\n return headers;\n }\n\n private captureSessionToken(response: Response): void {\n // Requires the backend to CORS-expose this header (see the class-level CORS note).\n const token = response.headers.get(WildoHeaderKeys.ANONYMOUS_SESSION_TOKEN);\n if (token && token !== this.sessionToken) {\n this.sessionToken = token;\n this.persist();\n }\n }\n\n private restoreFromStorage(): void {\n try {\n const userId = localStorage.getItem(STORAGE_KEYS.anonymousUserId);\n const token = localStorage.getItem(STORAGE_KEYS.anonymousSessionToken);\n if (userId) this.anonymousUserId = userId;\n if (token) this.sessionToken = token;\n } catch {\n // localStorage unavailable (SSR build / privacy mode) — the session stays\n // in-memory for this page load only.\n }\n }\n\n private persist(): void {\n try {\n if (this.anonymousUserId) {\n localStorage.setItem(STORAGE_KEYS.anonymousUserId, this.anonymousUserId);\n }\n if (this.sessionToken) {\n localStorage.setItem(STORAGE_KEYS.anonymousSessionToken, this.sessionToken);\n }\n } catch {\n // Best-effort persistence; a failure only means the session won't survive reload.\n }\n }\n}\n\nasync function safeReadText(response: Response): Promise<string> {\n try {\n return await response.text();\n } catch {\n return '';\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"website-anonymous-session-client.js","sourceRoot":"","sources":["../../../../../src/core/anonymous-session/website-anonymous-session-client.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * WebsiteAnonymousSessionClient\n *\n * A self-contained, browser-only anonymous-session HTTP client for the marketing\n * website. It MIRRORS the SaaS app's `AnonymousSessionContext` protocol WITHOUT\n * importing `@wildo-ai/saas-frontend-lib` — the website ships into a browser bundle\n * that must not pull the decorator framework / heavy frontend graph (bundle\n * discipline). It uses ONLY browser globals (`fetch` / `crypto` / `localStorage`) and\n * the bundle-safe `@wildo-ai/saas-models/public-runtime` atoms. No axios (axios is the\n * SSR tier), no React — a React island context can wrap this class.\n *\n * Protocol (W7.3a):\n * 1. The first anonymous request sends `x-anonymous-session-init: <clientUuid>` — the\n * backend mints an `AnonymousUser` (uuidRef = clientUuid) and returns a signed JWT\n * in the `x-anonymous-session-token` RESPONSE header.\n * 2. Subsequent requests send `x-anonymous-session: <jwt>`.\n * Every request also sends `X-Frontend-Service-Name` (required by the anon backend\n * to accept the request and resolve the frontend identity).\n *\n * CORS (critical): the website is a DIFFERENT origin from the backend API, so the\n * `x-anonymous-session-token` response header is readable by `fetch` ONLY if the\n * backend lists it in `Access-Control-Expose-Headers` (non-safelisted response headers\n * are hidden from cross-origin `fetch`). Without that, the captured token is `null`,\n * the session never advances past init, and every request re-mints a session.\n *\n * Cross-origin continuity: the anonymous session created here (uuidRef) is what the\n * SaaS app transposes on signup/login. It is carried across the apex↔app boundary by\n * the backend-set parent-domain `__wildo_anon_session` cookie — hence `credentials:\n * 'include'`, so that cookie rides same-site requests to the API.\n */\n\nimport { WildoHeaderKeys, MAIN_API_BASE_PATH } from '@wildo-ai/saas-models/public-runtime';\n\n/**\n * localStorage keys. The website is self-contained (mirror-don't-import), so it pins\n * the stable literal values rather than importing the decorator-adjacent shared\n * contract. Cross-origin continuity flows through the backend cookie, NOT this\n * localStorage, so these keys need not match the app's byte-for-byte.\n */\nconst STORAGE_KEYS = {\n anonymousUserId: 'anonymousUserId',\n anonymousSessionToken: 'anonymousSessionToken',\n} as const;\n\nexport interface WebsiteAnonymousSessionClientOptions {\n /** Backend API origin WITHOUT a trailing slash, e.g. `https://api.example.com`. */\n apiOrigin: string;\n /** `X-Frontend-Service-Name` value identifying this marketing site to the backend. */\n frontendServiceName: string;\n}\n\n/** Thrown when an anonymous request returns a non-2xx status. */\nexport class WebsiteAnonymousRequestError extends Error {\n constructor(\n public readonly status: number,\n public readonly bodyText: string,\n ) {\n super(`Anonymous request failed with status ${status}`);\n this.name = 'WebsiteAnonymousRequestError';\n }\n}\n\nexport class WebsiteAnonymousSessionClient {\n private anonymousUserId: string | null = null;\n private sessionToken: string | null = null;\n private readonly apiBaseUrl: string;\n private readonly frontendServiceName: string;\n\n constructor(options: WebsiteAnonymousSessionClientOptions) {\n this.apiBaseUrl = `${options.apiOrigin.replace(/\\/+$/, '')}${MAIN_API_BASE_PATH}`;\n this.frontendServiceName = options.frontendServiceName;\n this.restoreFromStorage();\n }\n\n /** The anonymous session's relational identity (client uuidRef), or `null` if not started. */\n getAnonymousUserId(): string | null {\n return this.anonymousUserId;\n }\n\n /**\n * Return the anonymous session's relational identity (client uuidRef),\n * STARTING the session first if it has not begun yet. Unlike\n * `getAnonymousUserId()` (which may return `null`), this always yields\n * a stable uuidRef.\n *\n * Used by NESTED anonymous routes that must embed the id in the URL\n * BEFORE the first request — e.g. draft-notes POSTs to\n * `/user-anonymous-users/<uuidRef>/draft-notes`. The id generated here\n * is the SAME one the next `postAnonymous(...)` sends as\n * `x-anonymous-session-init`, so the URL scope and the minted session\n * refer to one anonymous user (no split identity).\n */\n getOrStartAnonymousUserId(): string {\n this.ensureSessionStarted();\n // ensureSessionStarted() guarantees anonymousUserId is non-null.\n return this.anonymousUserId as string;\n }\n\n /** `true` once an init request has succeeded and a session token is held. */\n isSessionActive(): boolean {\n return this.anonymousUserId !== null && this.sessionToken !== null;\n }\n\n /**\n * POST `body` to an anonymous endpoint. `path` is relative to `/api/v1`\n * (e.g. `/inbound-contacts`, or a nested `/anonymous-users/<uuidRef>/draft-notes`).\n * Lazily starts the session on the first call, attaches the anon-session +\n * service-name headers, captures any returned session token, and returns the parsed\n * JSON response. Throws `WebsiteAnonymousRequestError` on a non-2xx status.\n */\n async postAnonymous<TResponse = unknown>(path: string, body: unknown): Promise<TResponse> {\n return this.requestAnonymous<TResponse>('POST', path, body);\n }\n\n /**\n * Send `body` to an anonymous endpoint with an explicit METHOD.\n *\n * `postAnonymous` above is the POST case and delegates here. This exists because not every anonymous\n * door is a POST and the method is not guessable from the path: the visitor consent door declares\n * `resourceOperationLike: UPDATE_MANY`, which the engine maps to PUT, so a POST to it answers 404 —\n * and this client's one silent caller swallowed exactly that. A caller reads the door's method from\n * the same place it reads the path (`ANONYMOUS_CONSENT_RECORD_METHOD` beside\n * `ANONYMOUS_CONSENT_RECORD_PATH`) rather than inferring it from a method name.\n */\n async requestAnonymous<TResponse = unknown>(method: string, path: string, body: unknown): Promise<TResponse> {\n this.ensureSessionStarted();\n\n const response = await fetch(`${this.apiBaseUrl}${path}`, {\n method,\n headers: this.buildHeaders(),\n body: JSON.stringify(body),\n // Carry the parent-domain `__wildo_anon_session` cookie to the API so the\n // session is continuous across the apex/app/api origins.\n credentials: 'include',\n });\n\n this.captureSessionToken(response);\n\n if (!response.ok) {\n throw new WebsiteAnonymousRequestError(response.status, await safeReadText(response));\n }\n return (await response.json()) as TResponse;\n }\n\n private ensureSessionStarted(): void {\n if (!this.anonymousUserId) {\n this.anonymousUserId = crypto.randomUUID();\n this.sessionToken = null;\n this.persist();\n }\n }\n\n private buildHeaders(): Record<string, string> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n [WildoHeaderKeys.FRONTEND_SERVICE_NAME]: this.frontendServiceName,\n };\n // Once we hold the JWT, restore the session; before that, the raw UUID inits it.\n if (this.sessionToken) {\n headers[WildoHeaderKeys.ANONYMOUS_SESSION] = this.sessionToken;\n } else if (this.anonymousUserId) {\n headers[WildoHeaderKeys.ANONYMOUS_SESSION_INIT] = this.anonymousUserId;\n }\n return headers;\n }\n\n private captureSessionToken(response: Response): void {\n // Requires the backend to CORS-expose this header (see the class-level CORS note).\n const token = response.headers.get(WildoHeaderKeys.ANONYMOUS_SESSION_TOKEN);\n if (token && token !== this.sessionToken) {\n this.sessionToken = token;\n this.persist();\n }\n }\n\n private restoreFromStorage(): void {\n try {\n const userId = localStorage.getItem(STORAGE_KEYS.anonymousUserId);\n const token = localStorage.getItem(STORAGE_KEYS.anonymousSessionToken);\n if (userId) this.anonymousUserId = userId;\n if (token) this.sessionToken = token;\n } catch {\n // localStorage unavailable (SSR build / privacy mode) — the session stays\n // in-memory for this page load only.\n }\n }\n\n private persist(): void {\n try {\n if (this.anonymousUserId) {\n localStorage.setItem(STORAGE_KEYS.anonymousUserId, this.anonymousUserId);\n }\n if (this.sessionToken) {\n localStorage.setItem(STORAGE_KEYS.anonymousSessionToken, this.sessionToken);\n }\n } catch {\n // Best-effort persistence; a failure only means the session won't survive reload.\n }\n }\n}\n\nasync function safeReadText(response: Response): Promise<string> {\n try {\n return await response.text();\n } catch {\n return '';\n }\n}\n"]}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export declare function WebsiteConsentBanner(): ReactNode;
|
|
3
|
+
/** The reopen affordance: always reachable once a visitor decided, so every choice can be changed. */
|
|
4
|
+
export declare function WebsiteConsentSettingsButton(): ReactNode;
|
|
5
|
+
/** @wildo_source:part:end saas.website.consent.banner */
|
|
6
|
+
//# sourceMappingURL=WebsiteConsentBanner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WebsiteConsentBanner.d.ts","sourceRoot":"","sources":["../../../../../src/core/consent/WebsiteConsentBanner.tsx"],"names":[],"mappings":"AAAA,OAAc,EAA8B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAwF1E,wBAAgB,oBAAoB,IAAI,SAAS,CAyHhD;AAED,sGAAsG;AACtG,wBAAgB,4BAA4B,IAAI,SAAS,CAQxD;AACD,yDAAyD"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useState } from 'react';
|
|
3
|
+
import { useWebsiteLabelByKey } from '../hooks/useWebsiteLabel.js';
|
|
4
|
+
import { WebsiteConsentStatus } from './WebsiteConsentContext.js';
|
|
5
|
+
import { useWebsiteConsent } from './useWebsiteConsent.js';
|
|
6
|
+
import { WebsiteConsentLabelSlot, websiteConsentLabelKey, websiteConsentPurposeDescriptionLabelKey, websiteConsentPurposeLabelKey, } from '../../schemas/label-keys/website-consent-label-keys.schemas.js';
|
|
7
|
+
/**
|
|
8
|
+
* @wildo_source:part:start saas.website.consent.banner facet:layer:core facet:family:website
|
|
9
|
+
*
|
|
10
|
+
* `WebsiteConsentBanner` — the framework's default visitor consent panel (#534).
|
|
11
|
+
*
|
|
12
|
+
* Rendered by `WebsitePageLayout` when the site runs a consent-based provider and the visitor has not
|
|
13
|
+
* decided, or re-opened the panel. A site replaces it by passing `consentBannerComponent` to its bridge
|
|
14
|
+
* island; a replacement reads `useWebsiteConsent()` exactly as this one does and must keep its three
|
|
15
|
+
* properties:
|
|
16
|
+
*
|
|
17
|
+
* 1. **No box is pre-ticked.** A pre-ticked box is not a choice.
|
|
18
|
+
* 2. **Declining is as easy as accepting**, and is RECORDED — so the panel does not return on every page.
|
|
19
|
+
* 3. **Only purposes in use are offered** (`purposesInUse`), each named by its label.
|
|
20
|
+
*
|
|
21
|
+
* Styled only through the site's `--website-*` design tokens, like the skip link, so it needs no
|
|
22
|
+
* stylesheet and cannot be shadowed by a utility class.
|
|
23
|
+
*/
|
|
24
|
+
function useWebsiteConsentSlotLabel(slot) {
|
|
25
|
+
return useWebsiteLabelByKey(websiteConsentLabelKey(slot));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Every row has the SAME shape: the control, then the name on its line, then the explanation beneath
|
|
29
|
+
* the NAME rather than beneath the control — a two-column grid, so the second line starts where the
|
|
30
|
+
* first line's words do and the row stays scannable.
|
|
31
|
+
*
|
|
32
|
+
* That includes the row that cannot be changed, which carries a real checkbox, CHECKED and DISABLED,
|
|
33
|
+
* rather than a text status. A person scanning the panel reads DOWN the column of controls, and a row
|
|
34
|
+
* whose control is missing does not read as "cannot be changed" — it reads as a row that was
|
|
35
|
+
* forgotten. A checked, disabled box says it in the vocabulary the rest of the list already uses, and
|
|
36
|
+
* a screen reader announces "checked, unavailable" without any extra words.
|
|
37
|
+
*/
|
|
38
|
+
const ROW_STYLE = {
|
|
39
|
+
display: 'grid',
|
|
40
|
+
gridTemplateColumns: 'auto 1fr',
|
|
41
|
+
columnGap: '0.625rem',
|
|
42
|
+
alignItems: 'center',
|
|
43
|
+
padding: '0.5rem 0.75rem',
|
|
44
|
+
borderBottom: '1px solid var(--website-colors-border, #ccc)',
|
|
45
|
+
};
|
|
46
|
+
const ROW_DESCRIPTION_STYLE = { margin: 0, gridColumn: 2, fontSize: '0.75rem', opacity: 0.8 };
|
|
47
|
+
/** Slightly larger than a default box: this is the one control the whole panel exists to offer. */
|
|
48
|
+
const ROW_CONTROL_STYLE = { width: '1.15rem', height: '1.15rem' };
|
|
49
|
+
/**
|
|
50
|
+
* One purpose: its NAME, what agreeing to it MEANS for this visitor, and the control.
|
|
51
|
+
*
|
|
52
|
+
* The explanation is the half that was missing. A tick box labelled "Analytics" is a category, not
|
|
53
|
+
* something a visitor can weigh, and consent is only valid if it is informed — which is why every
|
|
54
|
+
* specialized consent platform pairs the two and why the label key is derived from the vocabulary,
|
|
55
|
+
* so a purpose can never arrive with a name and no explanation.
|
|
56
|
+
*/
|
|
57
|
+
function PurposeChoice({ purpose, checked, onChange }) {
|
|
58
|
+
const label = useWebsiteLabelByKey(websiteConsentPurposeLabelKey(purpose));
|
|
59
|
+
const description = useWebsiteLabelByKey(websiteConsentPurposeDescriptionLabelKey(purpose));
|
|
60
|
+
return (_jsxs("label", { "data-website-consent-purpose": purpose, style: { ...ROW_STYLE, cursor: 'pointer' }, children: [_jsx("input", { type: "checkbox", checked: checked, style: ROW_CONTROL_STYLE, onChange: (event) => onChange(event.target.checked) }), _jsx("span", { style: { fontWeight: 600 }, children: label }), _jsx("span", { style: ROW_DESCRIPTION_STYLE, children: description })] }));
|
|
61
|
+
}
|
|
62
|
+
const BUTTON_STYLE = {
|
|
63
|
+
padding: '0.5rem 1rem',
|
|
64
|
+
borderRadius: 'var(--website-radii-md, 0.375rem)',
|
|
65
|
+
border: '1px solid var(--website-colors-border, #ccc)',
|
|
66
|
+
background: 'var(--website-colors-background, #fff)',
|
|
67
|
+
color: 'var(--website-colors-foreground, #000)',
|
|
68
|
+
cursor: 'pointer',
|
|
69
|
+
};
|
|
70
|
+
export function WebsiteConsentBanner() {
|
|
71
|
+
const consent = useWebsiteConsent();
|
|
72
|
+
const headingId = useId();
|
|
73
|
+
const [selected, setSelected] = useState(consent.grantedPurposes);
|
|
74
|
+
// Re-seed from the stored decision each time the panel is re-opened, so it shows what is in force.
|
|
75
|
+
useEffect(() => setSelected(consent.grantedPurposes), [consent.grantedPurposes, consent.isSettingsOpen]);
|
|
76
|
+
const title = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.TITLE);
|
|
77
|
+
const description = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.DESCRIPTION);
|
|
78
|
+
const acceptAll = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ACCEPT_ALL);
|
|
79
|
+
const declineAll = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.DECLINE_ALL);
|
|
80
|
+
const save = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.SAVE);
|
|
81
|
+
const essentialTitle = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ESSENTIAL_TITLE);
|
|
82
|
+
const essentialDescription = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ESSENTIAL_DESCRIPTION);
|
|
83
|
+
const offered = consent.purposesInUse;
|
|
84
|
+
// Purposes the panel does not offer keep their stored grant; offered ones are granted exactly when ticked.
|
|
85
|
+
const choose = (chosen) => consent.decide([...consent.grantedPurposes.filter((purpose) => !offered.includes(purpose)), ...chosen]);
|
|
86
|
+
return (_jsxs("section", { role: "region", "aria-labelledby": headingId, "data-website-consent-banner": consent.status === WebsiteConsentStatus.UNDECIDED ? 'undecided' : 'settings', style: {
|
|
87
|
+
/*
|
|
88
|
+
* BOTTOM-RIGHT CORNER, and the three properties together are what make that work on a phone
|
|
89
|
+
* too. `left` and `right` both bound the box to the viewport with a gutter; `maxWidth` caps it
|
|
90
|
+
* on a wide screen; `marginLeft: auto` pushes the remaining slack to the left so the box hugs
|
|
91
|
+
* the right edge. On a narrow screen the gutters bind before the cap does and it simply spans
|
|
92
|
+
* the width — a corner popup that overflowed a phone would be worse than a bar.
|
|
93
|
+
*
|
|
94
|
+
* Inline styles, not a media query: this ships into a static marketing page with no stylesheet
|
|
95
|
+
* of its own, which is the same constraint that keeps the rest of this file inline.
|
|
96
|
+
*/
|
|
97
|
+
position: 'fixed',
|
|
98
|
+
left: '1rem',
|
|
99
|
+
right: '1rem',
|
|
100
|
+
bottom: '1rem',
|
|
101
|
+
zIndex: 9000,
|
|
102
|
+
maxWidth: '30rem',
|
|
103
|
+
marginLeft: 'auto',
|
|
104
|
+
/*
|
|
105
|
+
* The panel never exceeds the viewport, and its ACTIONS are never what gets cut off: the rows
|
|
106
|
+
* scroll inside `maxHeight`, the buttons sit outside that box. A panel that grows with its
|
|
107
|
+
* content pushes accept and reject off the bottom of a short screen — and a refusal that
|
|
108
|
+
* cannot be reached is a refusal that was not offered, whatever the markup says. `dvh` rather
|
|
109
|
+
* than `vh` because a mobile browser's toolbars make `vh` taller than the visible page.
|
|
110
|
+
*/
|
|
111
|
+
maxHeight: '85dvh',
|
|
112
|
+
padding: '0.875rem 1rem',
|
|
113
|
+
display: 'flex',
|
|
114
|
+
flexDirection: 'column',
|
|
115
|
+
gap: '0.625rem',
|
|
116
|
+
background: 'var(--website-colors-surface, #fff)',
|
|
117
|
+
color: 'var(--website-colors-surface-foreground, #000)',
|
|
118
|
+
border: '1px solid var(--website-colors-border, #ccc)',
|
|
119
|
+
borderRadius: 'var(--website-radii-lg, 0.5rem)',
|
|
120
|
+
}, children: [_jsx("h2", { id: headingId, style: { margin: 0, fontSize: '1.125rem' }, children: title }), _jsx("p", { style: { margin: 0 }, children: description }), _jsxs("div", { "data-website-consent-rows": "", style: { minHeight: 0, flex: 1, overflowY: 'auto', border: '1px solid var(--website-colors-border, #ccc)', borderRadius: 'var(--website-radii-md, 0.375rem)' }, children: [_jsxs("div", { "data-website-consent-essential": "", style: ROW_STYLE, children: [_jsx("input", { type: "checkbox", checked: true, disabled: true, "aria-label": essentialTitle, style: ROW_CONTROL_STYLE }), _jsx("span", { style: { fontWeight: 600 }, children: essentialTitle }), _jsx("span", { style: ROW_DESCRIPTION_STYLE, children: essentialDescription })] }), offered.map((purpose) => (_jsx(PurposeChoice, { purpose: purpose, checked: selected.includes(purpose), onChange: (checked) => setSelected((current) => (checked ? [...current.filter((entry) => entry !== purpose), purpose] : current.filter((entry) => entry !== purpose))) }, purpose)))] }), _jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: '0.5rem', justifyContent: 'flex-end' }, children: [_jsx("button", { type: "button", style: BUTTON_STYLE, onClick: () => choose([]), children: declineAll }), offered.length > 1 && (_jsx("button", { type: "button", style: BUTTON_STYLE, onClick: () => choose(selected.filter((purpose) => offered.includes(purpose))), children: save })), _jsx("button", { type: "button", style: BUTTON_STYLE, onClick: () => choose(offered), children: acceptAll })] })] }));
|
|
121
|
+
}
|
|
122
|
+
/** The reopen affordance: always reachable once a visitor decided, so every choice can be changed. */
|
|
123
|
+
export function WebsiteConsentSettingsButton() {
|
|
124
|
+
const consent = useWebsiteConsent();
|
|
125
|
+
const manage = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.MANAGE);
|
|
126
|
+
return (_jsx("button", { type: "button", "data-website-consent-manage": "", style: BUTTON_STYLE, onClick: consent.openSettings, children: manage }));
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=WebsiteConsentBanner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WebsiteConsentBanner.js","sourceRoot":"","sources":["../../../../../src/core/consent/WebsiteConsentBanner.tsx"],"names":[],"mappings":"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","sourcesContent":["import React, { useEffect, useId, useState, type ReactNode } from 'react';\nimport type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';\n\nimport { useWebsiteLabelByKey } from '../hooks/useWebsiteLabel';\nimport { WebsiteConsentStatus } from './WebsiteConsentContext';\nimport { useWebsiteConsent } from './useWebsiteConsent';\nimport {\n WebsiteConsentLabelSlot,\n websiteConsentLabelKey,\n websiteConsentPurposeDescriptionLabelKey,\n websiteConsentPurposeLabelKey,\n} from '../../schemas/label-keys/website-consent-label-keys.schemas';\n\n/**\n * @wildo_source:part:start saas.website.consent.banner facet:layer:core facet:family:website\n *\n * `WebsiteConsentBanner` — the framework's default visitor consent panel (#534).\n *\n * Rendered by `WebsitePageLayout` when the site runs a consent-based provider and the visitor has not\n * decided, or re-opened the panel. A site replaces it by passing `consentBannerComponent` to its bridge\n * island; a replacement reads `useWebsiteConsent()` exactly as this one does and must keep its three\n * properties:\n *\n * 1. **No box is pre-ticked.** A pre-ticked box is not a choice.\n * 2. **Declining is as easy as accepting**, and is RECORDED — so the panel does not return on every page.\n * 3. **Only purposes in use are offered** (`purposesInUse`), each named by its label.\n *\n * Styled only through the site's `--website-*` design tokens, like the skip link, so it needs no\n * stylesheet and cannot be shadowed by a utility class.\n */\nfunction useWebsiteConsentSlotLabel(slot: WebsiteConsentLabelSlot): string {\n return useWebsiteLabelByKey(websiteConsentLabelKey(slot));\n}\n\n/**\n * Every row has the SAME shape: the control, then the name on its line, then the explanation beneath\n * the NAME rather than beneath the control — a two-column grid, so the second line starts where the\n * first line's words do and the row stays scannable.\n *\n * That includes the row that cannot be changed, which carries a real checkbox, CHECKED and DISABLED,\n * rather than a text status. A person scanning the panel reads DOWN the column of controls, and a row\n * whose control is missing does not read as \"cannot be changed\" — it reads as a row that was\n * forgotten. A checked, disabled box says it in the vocabulary the rest of the list already uses, and\n * a screen reader announces \"checked, unavailable\" without any extra words.\n */\nconst ROW_STYLE: React.CSSProperties = {\n display: 'grid',\n gridTemplateColumns: 'auto 1fr',\n columnGap: '0.625rem',\n alignItems: 'center',\n padding: '0.5rem 0.75rem',\n borderBottom: '1px solid var(--website-colors-border, #ccc)',\n};\n\nconst ROW_DESCRIPTION_STYLE: React.CSSProperties = { margin: 0, gridColumn: 2, fontSize: '0.75rem', opacity: 0.8 };\n\n/** Slightly larger than a default box: this is the one control the whole panel exists to offer. */\nconst ROW_CONTROL_STYLE: React.CSSProperties = { width: '1.15rem', height: '1.15rem' };\n\n/**\n * One purpose: its NAME, what agreeing to it MEANS for this visitor, and the control.\n *\n * The explanation is the half that was missing. A tick box labelled \"Analytics\" is a category, not\n * something a visitor can weigh, and consent is only valid if it is informed — which is why every\n * specialized consent platform pairs the two and why the label key is derived from the vocabulary,\n * so a purpose can never arrive with a name and no explanation.\n */\nfunction PurposeChoice({ purpose, checked, onChange }: { purpose: ConsentPurpose; checked: boolean; onChange: (checked: boolean) => void }): ReactNode {\n const label = useWebsiteLabelByKey(websiteConsentPurposeLabelKey(purpose));\n const description = useWebsiteLabelByKey(websiteConsentPurposeDescriptionLabelKey(purpose));\n return (\n <label data-website-consent-purpose={purpose} style={{ ...ROW_STYLE, cursor: 'pointer' }}>\n <input type=\"checkbox\" checked={checked} style={ROW_CONTROL_STYLE} onChange={(event) => onChange(event.target.checked)} />\n <span style={{ fontWeight: 600 }}>{label}</span>\n <span style={ROW_DESCRIPTION_STYLE}>{description}</span>\n </label>\n );\n}\n\nconst BUTTON_STYLE: React.CSSProperties = {\n padding: '0.5rem 1rem',\n borderRadius: 'var(--website-radii-md, 0.375rem)',\n border: '1px solid var(--website-colors-border, #ccc)',\n background: 'var(--website-colors-background, #fff)',\n color: 'var(--website-colors-foreground, #000)',\n cursor: 'pointer',\n};\n\nexport function WebsiteConsentBanner(): ReactNode {\n const consent = useWebsiteConsent();\n const headingId = useId();\n const [selected, setSelected] = useState<readonly ConsentPurpose[]>(consent.grantedPurposes);\n // Re-seed from the stored decision each time the panel is re-opened, so it shows what is in force.\n useEffect(() => setSelected(consent.grantedPurposes), [consent.grantedPurposes, consent.isSettingsOpen]);\n\n const title = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.TITLE);\n const description = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.DESCRIPTION);\n const acceptAll = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ACCEPT_ALL);\n const declineAll = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.DECLINE_ALL);\n const save = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.SAVE);\n const essentialTitle = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ESSENTIAL_TITLE);\n const essentialDescription = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.ESSENTIAL_DESCRIPTION);\n\n const offered = consent.purposesInUse;\n // Purposes the panel does not offer keep their stored grant; offered ones are granted exactly when ticked.\n const choose = (chosen: readonly ConsentPurpose[]): void =>\n consent.decide([...consent.grantedPurposes.filter((purpose) => !offered.includes(purpose)), ...chosen]);\n\n return (\n <section\n role=\"region\"\n aria-labelledby={headingId}\n data-website-consent-banner={consent.status === WebsiteConsentStatus.UNDECIDED ? 'undecided' : 'settings'}\n style={{\n /*\n * BOTTOM-RIGHT CORNER, and the three properties together are what make that work on a phone\n * too. `left` and `right` both bound the box to the viewport with a gutter; `maxWidth` caps it\n * on a wide screen; `marginLeft: auto` pushes the remaining slack to the left so the box hugs\n * the right edge. On a narrow screen the gutters bind before the cap does and it simply spans\n * the width — a corner popup that overflowed a phone would be worse than a bar.\n *\n * Inline styles, not a media query: this ships into a static marketing page with no stylesheet\n * of its own, which is the same constraint that keeps the rest of this file inline.\n */\n position: 'fixed',\n left: '1rem',\n right: '1rem',\n bottom: '1rem',\n zIndex: 9000,\n maxWidth: '30rem',\n marginLeft: 'auto',\n /*\n * The panel never exceeds the viewport, and its ACTIONS are never what gets cut off: the rows\n * scroll inside `maxHeight`, the buttons sit outside that box. A panel that grows with its\n * content pushes accept and reject off the bottom of a short screen — and a refusal that\n * cannot be reached is a refusal that was not offered, whatever the markup says. `dvh` rather\n * than `vh` because a mobile browser's toolbars make `vh` taller than the visible page.\n */\n maxHeight: '85dvh',\n padding: '0.875rem 1rem',\n display: 'flex',\n flexDirection: 'column',\n gap: '0.625rem',\n background: 'var(--website-colors-surface, #fff)',\n color: 'var(--website-colors-surface-foreground, #000)',\n border: '1px solid var(--website-colors-border, #ccc)',\n borderRadius: 'var(--website-radii-lg, 0.5rem)',\n }}\n >\n <h2 id={headingId} style={{ margin: 0, fontSize: '1.125rem' }}>{title}</h2>\n <p style={{ margin: 0 }}>{description}</p>\n {/* `data-website-consent-rows` is the scroll box's own hook. A test anchoring on a style\n substring instead matched the ROW (whose grid template also contains \"auto\") and asserted\n nothing — the same vacuous pin the app tier already had to repair. */}\n <div\n data-website-consent-rows=\"\"\n style={{ minHeight: 0, flex: 1, overflowY: 'auto', border: '1px solid var(--website-colors-border, #ccc)', borderRadius: 'var(--website-radii-md, 0.375rem)' }}\n >\n {/*\n * The always-active row, first, carrying a CHECKED and DISABLED box. It is not a purpose the\n * visitor decides — which is exactly why it is stated: a panel listing only the optional\n * purposes leaves them unable to see what is happening on the site at all, which is the\n * question they opened it to answer.\n *\n * It carried a text status instead until 2026-09-20, on the argument that a disabled control\n * invites a click that cannot work. The reversal: a visitor scans DOWN the column of boxes,\n * and a gap there does not read as \"cannot be changed\", it reads as a row that was\n * forgotten. A disabled box says it in the vocabulary the rest of the list already uses.\n */}\n <div data-website-consent-essential=\"\" style={ROW_STYLE}>\n {/*\n * `aria-label`, because this row is a `div` rather than a `<label>` — nothing else names\n * the control, and a checkbox with no accessible name is announced as an unlabelled\n * checkbox. Its own note claimed AT would announce \"checked, unavailable\"; it announced\n * no words at all.\n *\n * `readOnly` is gone: it is inert on a checkbox per HTML, and leaving it invites a later\n * reader to delete `disabled` believing the lock survives.\n */}\n <input type=\"checkbox\" checked disabled aria-label={essentialTitle} style={ROW_CONTROL_STYLE} />\n <span style={{ fontWeight: 600 }}>{essentialTitle}</span>\n <span style={ROW_DESCRIPTION_STYLE}>{essentialDescription}</span>\n </div>\n {offered.map((purpose) => (\n <PurposeChoice\n key={purpose}\n purpose={purpose}\n checked={selected.includes(purpose)}\n onChange={(checked) => setSelected((current) => (checked ? [...current.filter((entry) => entry !== purpose), purpose] : current.filter((entry) => entry !== purpose)))}\n />\n ))}\n </div>\n <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', justifyContent: 'flex-end' }}>\n <button type=\"button\" style={BUTTON_STYLE} onClick={() => choose([])}>{declineAll}</button>\n {/*\n * SAVE appears only when a MIXED choice exists to save — two or more purposes in use.\n *\n * With one purpose the button is unanswerable: \"decline all\" and \"accept all\" already cover\n * the whole space, so a third action beside them describes nothing a visitor can picture and\n * reads as a question rather than an answer. Derived from the offered set, so a site that\n * adds a second purpose gets the control with no edit here.\n */}\n {offered.length > 1 && (\n <button type=\"button\" style={BUTTON_STYLE} onClick={() => choose(selected.filter((purpose) => offered.includes(purpose)))}>{save}</button>\n )}\n <button type=\"button\" style={BUTTON_STYLE} onClick={() => choose(offered)}>{acceptAll}</button>\n </div>\n </section>\n );\n}\n\n/** The reopen affordance: always reachable once a visitor decided, so every choice can be changed. */\nexport function WebsiteConsentSettingsButton(): ReactNode {\n const consent = useWebsiteConsent();\n const manage = useWebsiteConsentSlotLabel(WebsiteConsentLabelSlot.MANAGE);\n return (\n <button type=\"button\" data-website-consent-manage=\"\" style={BUTTON_STYLE} onClick={consent.openSettings}>\n {manage}\n </button>\n );\n}\n/** @wildo_source:part:end saas.website.consent.banner */\n"]}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React, { type ReactNode } from 'react';
|
|
2
|
+
import type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
|
|
3
|
+
/**
|
|
4
|
+
* @wildo_source:part:start saas.website.consent.context facet:layer:core facet:family:website
|
|
5
|
+
*
|
|
6
|
+
* `WebsiteConsentContext` — a website visitor's consent state (#534), provided once per page by
|
|
7
|
+
* `WebsitePageLayout`.
|
|
8
|
+
*
|
|
9
|
+
* Consent is a SET of `ConsentPurpose`s, each granted or not on its own. Nothing optional runs until its
|
|
10
|
+
* purpose is granted: the layout's provider SDKs and scripts read `grantedPurposes`, and so does
|
|
11
|
+
* `<WebsiteConsentGate purpose={…}>`.
|
|
12
|
+
*
|
|
13
|
+
* `purposesInUse` is DERIVED from the live providers (`consentPurposesInUseByProviders`), so the panel asks
|
|
14
|
+
* about exactly the purposes this site processes and nothing else. A site running no consent-based
|
|
15
|
+
* provider shows no panel at all.
|
|
16
|
+
*/
|
|
17
|
+
export declare enum WebsiteConsentStatus {
|
|
18
|
+
/** The visitor has not decided — or their decision cannot be read. Nothing optional runs. */
|
|
19
|
+
UNDECIDED = "undecided",
|
|
20
|
+
/** The visitor decided; `grantedPurposes` is their answer, possibly empty. */
|
|
21
|
+
DECIDED = "decided"
|
|
22
|
+
}
|
|
23
|
+
export interface WebsiteConsentContextValue {
|
|
24
|
+
status: WebsiteConsentStatus;
|
|
25
|
+
/**
|
|
26
|
+
* `false` during the server render and the first client render, before storage has been read. A
|
|
27
|
+
* consent panel must not render while this is false: it would flash for a visitor who already decided,
|
|
28
|
+
* and differ from the static HTML. Optional behaviour stays off either way.
|
|
29
|
+
*/
|
|
30
|
+
isResolved: boolean;
|
|
31
|
+
/** What the visitor granted. Empty while UNDECIDED. */
|
|
32
|
+
grantedPurposes: readonly ConsentPurpose[];
|
|
33
|
+
/** When the visitor decided, ISO-8601; absent while UNDECIDED. */
|
|
34
|
+
decidedAt?: string;
|
|
35
|
+
/** The purposes this site's live providers rest on — what a consent panel offers. */
|
|
36
|
+
purposesInUse: readonly ConsentPurpose[];
|
|
37
|
+
/** Whether the visitor re-opened the panel to change a decision already made. */
|
|
38
|
+
isSettingsOpen: boolean;
|
|
39
|
+
/** Whether `purpose` is granted. */
|
|
40
|
+
hasGranted: (purpose: ConsentPurpose) => boolean;
|
|
41
|
+
/** Record the WHOLE set the visitor grants — a purpose left out is not granted. Closes the panel. */
|
|
42
|
+
decide: (grantedPurposes: readonly ConsentPurpose[]) => void;
|
|
43
|
+
/** Re-open the panel so the visitor can change their decision. */
|
|
44
|
+
openSettings: () => void;
|
|
45
|
+
/** Close a re-opened panel without changing anything. */
|
|
46
|
+
closeSettings: () => void;
|
|
47
|
+
}
|
|
48
|
+
export declare const WebsiteConsentContext: React.Context<WebsiteConsentContextValue | null>;
|
|
49
|
+
/**
|
|
50
|
+
* The narrow slice of `WebsiteAnonymousSessionClient` this provider needs, declared structurally
|
|
51
|
+
* rather than imported (#1258).
|
|
52
|
+
*
|
|
53
|
+
* Structural because the provider does not want the client's construction concerns — an `apiOrigin`
|
|
54
|
+
* and the application's own `frontendServiceName`, which only the application knows — and because it
|
|
55
|
+
* makes the durable write trivially substitutable in a test.
|
|
56
|
+
*/
|
|
57
|
+
export interface WebsiteConsentRecorder {
|
|
58
|
+
/**
|
|
59
|
+
* NOT generic, deliberately, though `WebsiteAnonymousSessionClient.requestAnonymous` is: this
|
|
60
|
+
* provider ignores the response entirely, so a type parameter here would be a promise to the caller
|
|
61
|
+
* that nothing keeps. The client's generic method satisfies this shape structurally.
|
|
62
|
+
*
|
|
63
|
+
* It takes the METHOD rather than being named after one. The consent door is a PUT — its operation
|
|
64
|
+
* borrows `UPDATE_MANY` — and this interface previously named `postAnonymous`, so every decision a
|
|
65
|
+
* visitor made 404'd, silently, by this provider's own (correct) design of never telling a visitor
|
|
66
|
+
* their privacy choice failed. A door's method is part of its address, so it is passed like one.
|
|
67
|
+
*/
|
|
68
|
+
requestAnonymous(method: string, path: string, body: unknown): Promise<unknown>;
|
|
69
|
+
}
|
|
70
|
+
export interface WebsiteConsentProviderProps {
|
|
71
|
+
purposesInUse: readonly ConsentPurpose[];
|
|
72
|
+
/**
|
|
73
|
+
* OPTIONAL. When present, a decision is ALSO recorded server-side against the visitor's anonymous
|
|
74
|
+
* session, which is what lets it follow them into the account they go on to create (#1258).
|
|
75
|
+
*
|
|
76
|
+
* Absent is a first-class answer and is exactly today's behaviour: the decision lives in this
|
|
77
|
+
* browser and nowhere else. A site with no anonymous-session wiring keeps working, gates the same
|
|
78
|
+
* way, and simply carries nothing forward.
|
|
79
|
+
*/
|
|
80
|
+
recorder?: WebsiteConsentRecorder;
|
|
81
|
+
children: ReactNode;
|
|
82
|
+
}
|
|
83
|
+
export declare function WebsiteConsentProvider({ purposesInUse, recorder, children }: WebsiteConsentProviderProps): ReactNode;
|
|
84
|
+
/** @wildo_source:part:end saas.website.consent.context */
|
|
85
|
+
//# sourceMappingURL=WebsiteConsentContext.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WebsiteConsentContext.d.ts","sourceRoot":"","sources":["../../../../../src/core/consent/WebsiteConsentContext.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAA4D,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAExG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAS3E;;;;;;;;;;;;;GAaG;AACH,oBAAY,oBAAoB;IAC9B,6FAA6F;IAC7F,SAAS,cAAc;IACvB,8EAA8E;IAC9E,OAAO,YAAY;CACpB;AAED,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,oBAAoB,CAAC;IAC7B;;;;OAIG;IACH,UAAU,EAAE,OAAO,CAAC;IACpB,uDAAuD;IACvD,eAAe,EAAE,SAAS,cAAc,EAAE,CAAC;IAC3C,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qFAAqF;IACrF,aAAa,EAAE,SAAS,cAAc,EAAE,CAAC;IACzC,iFAAiF;IACjF,cAAc,EAAE,OAAO,CAAC;IACxB,oCAAoC;IACpC,UAAU,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC;IACjD,qGAAqG;IACrG,MAAM,EAAE,CAAC,eAAe,EAAE,SAAS,cAAc,EAAE,KAAK,IAAI,CAAC;IAC7D,kEAAkE;IAClE,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,yDAAyD;IACzD,aAAa,EAAE,MAAM,IAAI,CAAC;CAC3B;AAED,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,OAAO,CAAC,0BAA0B,GAAG,IAAI,CAA0D,CAAC;AAE9I;;;;;;;GAOG;AACH,MAAM,WAAW,sBAAsB;IACrC;;;;;;;;;OASG;IACH,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACjF;AAED,MAAM,WAAW,2BAA2B;IAC1C,aAAa,EAAE,SAAS,cAAc,EAAE,CAAC;IACzC;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,sBAAsB,CAAC;IAClC,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,wBAAgB,sBAAsB,CAAC,EAAE,aAAa,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,2BAA2B,GAAG,SAAS,CAkEpH;AACD,0DAA0D"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { ANONYMOUS_CONSENT_RECORD_METHOD, ANONYMOUS_CONSENT_RECORD_PATH } from '@wildo-ai/saas-models/public-runtime';
|
|
4
|
+
import { readWebsiteVisitorConsent, subscribeWebsiteVisitorConsent, writeWebsiteVisitorConsent, } from './website-consent-storage.js';
|
|
5
|
+
/**
|
|
6
|
+
* @wildo_source:part:start saas.website.consent.context facet:layer:core facet:family:website
|
|
7
|
+
*
|
|
8
|
+
* `WebsiteConsentContext` — a website visitor's consent state (#534), provided once per page by
|
|
9
|
+
* `WebsitePageLayout`.
|
|
10
|
+
*
|
|
11
|
+
* Consent is a SET of `ConsentPurpose`s, each granted or not on its own. Nothing optional runs until its
|
|
12
|
+
* purpose is granted: the layout's provider SDKs and scripts read `grantedPurposes`, and so does
|
|
13
|
+
* `<WebsiteConsentGate purpose={…}>`.
|
|
14
|
+
*
|
|
15
|
+
* `purposesInUse` is DERIVED from the live providers (`consentPurposesInUseByProviders`), so the panel asks
|
|
16
|
+
* about exactly the purposes this site processes and nothing else. A site running no consent-based
|
|
17
|
+
* provider shows no panel at all.
|
|
18
|
+
*/
|
|
19
|
+
export var WebsiteConsentStatus;
|
|
20
|
+
(function (WebsiteConsentStatus) {
|
|
21
|
+
/** The visitor has not decided — or their decision cannot be read. Nothing optional runs. */
|
|
22
|
+
WebsiteConsentStatus["UNDECIDED"] = "undecided";
|
|
23
|
+
/** The visitor decided; `grantedPurposes` is their answer, possibly empty. */
|
|
24
|
+
WebsiteConsentStatus["DECIDED"] = "decided";
|
|
25
|
+
})(WebsiteConsentStatus || (WebsiteConsentStatus = {}));
|
|
26
|
+
export const WebsiteConsentContext = createContext(null);
|
|
27
|
+
export function WebsiteConsentProvider({ purposesInUse, recorder, children }) {
|
|
28
|
+
const [record, setRecord] = useState(null);
|
|
29
|
+
const [isResolved, setIsResolved] = useState(false);
|
|
30
|
+
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
31
|
+
// Read after mount, never during render: the static HTML was produced with no storage at all.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setRecord(readWebsiteVisitorConsent());
|
|
34
|
+
setIsResolved(true);
|
|
35
|
+
return subscribeWebsiteVisitorConsent(() => setRecord(readWebsiteVisitorConsent()));
|
|
36
|
+
}, []);
|
|
37
|
+
const decide = useCallback((grantedPurposes) => {
|
|
38
|
+
/*
|
|
39
|
+
* Local storage FIRST, and it is what gates this page (D6). The site is statically built, so a
|
|
40
|
+
* refusal that only held while an API answered would not hold at all — and the visitor's panel
|
|
41
|
+
* must close on their click, not on a round trip.
|
|
42
|
+
*/
|
|
43
|
+
setRecord(writeWebsiteVisitorConsent(grantedPurposes));
|
|
44
|
+
setIsSettingsOpen(false);
|
|
45
|
+
/*
|
|
46
|
+
* Then the DURABLE copy, best-effort. This is the one that survives the browser and is carried
|
|
47
|
+
* onto the account at signup; its failure changes nothing the visitor can see, so it is never
|
|
48
|
+
* awaited and never surfaced.
|
|
49
|
+
*
|
|
50
|
+
* `purposesInUse` is sent as the OFFERED set — what this panel actually put in front of them.
|
|
51
|
+
* Without it the application could not tell a declined purpose from one this site never asked
|
|
52
|
+
* about, and would record a decision the visitor never made
|
|
53
|
+
* (`AnonymousUserPrivacySchema.offeredPurposes`).
|
|
54
|
+
*/
|
|
55
|
+
if (recorder !== undefined && purposesInUse.length > 0) {
|
|
56
|
+
void recorder
|
|
57
|
+
.requestAnonymous(ANONYMOUS_CONSENT_RECORD_METHOD, ANONYMOUS_CONSENT_RECORD_PATH, {
|
|
58
|
+
grantedPurposes: [...grantedPurposes],
|
|
59
|
+
offeredPurposes: [...purposesInUse],
|
|
60
|
+
})
|
|
61
|
+
.catch(() => {
|
|
62
|
+
/*
|
|
63
|
+
* Deliberately silent. The decision HELD — it is in this browser and gating this page. All
|
|
64
|
+
* that is lost is the carry, and telling a visitor their privacy choice failed when it did
|
|
65
|
+
* not would be worse than losing it.
|
|
66
|
+
*/
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}, [recorder, purposesInUse]);
|
|
70
|
+
const openSettings = useCallback(() => setIsSettingsOpen(true), []);
|
|
71
|
+
const closeSettings = useCallback(() => setIsSettingsOpen(false), []);
|
|
72
|
+
const value = useMemo(() => {
|
|
73
|
+
const grantedPurposes = record?.grantedPurposes ?? [];
|
|
74
|
+
return {
|
|
75
|
+
status: record === null ? WebsiteConsentStatus.UNDECIDED : WebsiteConsentStatus.DECIDED,
|
|
76
|
+
isResolved,
|
|
77
|
+
grantedPurposes,
|
|
78
|
+
...(record !== null ? { decidedAt: record.decidedAt } : {}),
|
|
79
|
+
purposesInUse,
|
|
80
|
+
isSettingsOpen,
|
|
81
|
+
hasGranted: (purpose) => grantedPurposes.includes(purpose),
|
|
82
|
+
decide,
|
|
83
|
+
openSettings,
|
|
84
|
+
closeSettings,
|
|
85
|
+
};
|
|
86
|
+
}, [record, isResolved, purposesInUse, isSettingsOpen, decide, openSettings, closeSettings]);
|
|
87
|
+
return _jsx(WebsiteConsentContext.Provider, { value: value, children: children });
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=WebsiteConsentContext.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WebsiteConsentContext.js","sourceRoot":"","sources":["../../../../../src/core/consent/WebsiteConsentContext.tsx"],"names":[],"mappings":"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","sourcesContent":["import React, { createContext, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';\nimport { ANONYMOUS_CONSENT_RECORD_METHOD, ANONYMOUS_CONSENT_RECORD_PATH } from '@wildo-ai/saas-models/public-runtime';\nimport type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';\n\nimport {\n readWebsiteVisitorConsent,\n subscribeWebsiteVisitorConsent,\n writeWebsiteVisitorConsent,\n type WebsiteVisitorConsentRecord,\n} from './website-consent-storage';\n\n/**\n * @wildo_source:part:start saas.website.consent.context facet:layer:core facet:family:website\n *\n * `WebsiteConsentContext` — a website visitor's consent state (#534), provided once per page by\n * `WebsitePageLayout`.\n *\n * Consent is a SET of `ConsentPurpose`s, each granted or not on its own. Nothing optional runs until its\n * purpose is granted: the layout's provider SDKs and scripts read `grantedPurposes`, and so does\n * `<WebsiteConsentGate purpose={…}>`.\n *\n * `purposesInUse` is DERIVED from the live providers (`consentPurposesInUseByProviders`), so the panel asks\n * about exactly the purposes this site processes and nothing else. A site running no consent-based\n * provider shows no panel at all.\n */\nexport enum WebsiteConsentStatus {\n /** The visitor has not decided — or their decision cannot be read. Nothing optional runs. */\n UNDECIDED = 'undecided',\n /** The visitor decided; `grantedPurposes` is their answer, possibly empty. */\n DECIDED = 'decided',\n}\n\nexport interface WebsiteConsentContextValue {\n status: WebsiteConsentStatus;\n /**\n * `false` during the server render and the first client render, before storage has been read. A\n * consent panel must not render while this is false: it would flash for a visitor who already decided,\n * and differ from the static HTML. Optional behaviour stays off either way.\n */\n isResolved: boolean;\n /** What the visitor granted. Empty while UNDECIDED. */\n grantedPurposes: readonly ConsentPurpose[];\n /** When the visitor decided, ISO-8601; absent while UNDECIDED. */\n decidedAt?: string;\n /** The purposes this site's live providers rest on — what a consent panel offers. */\n purposesInUse: readonly ConsentPurpose[];\n /** Whether the visitor re-opened the panel to change a decision already made. */\n isSettingsOpen: boolean;\n /** Whether `purpose` is granted. */\n hasGranted: (purpose: ConsentPurpose) => boolean;\n /** Record the WHOLE set the visitor grants — a purpose left out is not granted. Closes the panel. */\n decide: (grantedPurposes: readonly ConsentPurpose[]) => void;\n /** Re-open the panel so the visitor can change their decision. */\n openSettings: () => void;\n /** Close a re-opened panel without changing anything. */\n closeSettings: () => void;\n}\n\nexport const WebsiteConsentContext: React.Context<WebsiteConsentContextValue | null> = createContext<WebsiteConsentContextValue | null>(null);\n\n/**\n * The narrow slice of `WebsiteAnonymousSessionClient` this provider needs, declared structurally\n * rather than imported (#1258).\n *\n * Structural because the provider does not want the client's construction concerns — an `apiOrigin`\n * and the application's own `frontendServiceName`, which only the application knows — and because it\n * makes the durable write trivially substitutable in a test.\n */\nexport interface WebsiteConsentRecorder {\n /**\n * NOT generic, deliberately, though `WebsiteAnonymousSessionClient.requestAnonymous` is: this\n * provider ignores the response entirely, so a type parameter here would be a promise to the caller\n * that nothing keeps. The client's generic method satisfies this shape structurally.\n *\n * It takes the METHOD rather than being named after one. The consent door is a PUT — its operation\n * borrows `UPDATE_MANY` — and this interface previously named `postAnonymous`, so every decision a\n * visitor made 404'd, silently, by this provider's own (correct) design of never telling a visitor\n * their privacy choice failed. A door's method is part of its address, so it is passed like one.\n */\n requestAnonymous(method: string, path: string, body: unknown): Promise<unknown>;\n}\n\nexport interface WebsiteConsentProviderProps {\n purposesInUse: readonly ConsentPurpose[];\n /**\n * OPTIONAL. When present, a decision is ALSO recorded server-side against the visitor's anonymous\n * session, which is what lets it follow them into the account they go on to create (#1258).\n *\n * Absent is a first-class answer and is exactly today's behaviour: the decision lives in this\n * browser and nowhere else. A site with no anonymous-session wiring keeps working, gates the same\n * way, and simply carries nothing forward.\n */\n recorder?: WebsiteConsentRecorder;\n children: ReactNode;\n}\n\nexport function WebsiteConsentProvider({ purposesInUse, recorder, children }: WebsiteConsentProviderProps): ReactNode {\n const [record, setRecord] = useState<WebsiteVisitorConsentRecord | null>(null);\n const [isResolved, setIsResolved] = useState(false);\n const [isSettingsOpen, setIsSettingsOpen] = useState(false);\n\n // Read after mount, never during render: the static HTML was produced with no storage at all.\n useEffect(() => {\n setRecord(readWebsiteVisitorConsent());\n setIsResolved(true);\n return subscribeWebsiteVisitorConsent(() => setRecord(readWebsiteVisitorConsent()));\n }, []);\n\n const decide = useCallback((grantedPurposes: readonly ConsentPurpose[]): void => {\n /*\n * Local storage FIRST, and it is what gates this page (D6). The site is statically built, so a\n * refusal that only held while an API answered would not hold at all — and the visitor's panel\n * must close on their click, not on a round trip.\n */\n setRecord(writeWebsiteVisitorConsent(grantedPurposes));\n setIsSettingsOpen(false);\n\n /*\n * Then the DURABLE copy, best-effort. This is the one that survives the browser and is carried\n * onto the account at signup; its failure changes nothing the visitor can see, so it is never\n * awaited and never surfaced.\n *\n * `purposesInUse` is sent as the OFFERED set — what this panel actually put in front of them.\n * Without it the application could not tell a declined purpose from one this site never asked\n * about, and would record a decision the visitor never made\n * (`AnonymousUserPrivacySchema.offeredPurposes`).\n */\n if (recorder !== undefined && purposesInUse.length > 0) {\n void recorder\n .requestAnonymous(ANONYMOUS_CONSENT_RECORD_METHOD, ANONYMOUS_CONSENT_RECORD_PATH, {\n grantedPurposes: [...grantedPurposes],\n offeredPurposes: [...purposesInUse],\n })\n .catch(() => {\n /*\n * Deliberately silent. The decision HELD — it is in this browser and gating this page. All\n * that is lost is the carry, and telling a visitor their privacy choice failed when it did\n * not would be worse than losing it.\n */\n });\n }\n }, [recorder, purposesInUse]);\n const openSettings = useCallback((): void => setIsSettingsOpen(true), []);\n const closeSettings = useCallback((): void => setIsSettingsOpen(false), []);\n\n const value = useMemo<WebsiteConsentContextValue>(() => {\n const grantedPurposes = record?.grantedPurposes ?? [];\n return {\n status: record === null ? WebsiteConsentStatus.UNDECIDED : WebsiteConsentStatus.DECIDED,\n isResolved,\n grantedPurposes,\n ...(record !== null ? { decidedAt: record.decidedAt } : {}),\n purposesInUse,\n isSettingsOpen,\n hasGranted: (purpose) => grantedPurposes.includes(purpose),\n decide,\n openSettings,\n closeSettings,\n };\n }, [record, isResolved, purposesInUse, isSettingsOpen, decide, openSettings, closeSettings]);\n\n return <WebsiteConsentContext.Provider value={value}>{children}</WebsiteConsentContext.Provider>;\n}\n/** @wildo_source:part:end saas.website.consent.context */\n"]}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';
|
|
3
|
+
/**
|
|
4
|
+
* @wildo_source:part:start saas.website.consent.gate facet:layer:core facet:family:website
|
|
5
|
+
*
|
|
6
|
+
* `<WebsiteConsentGate>` — renders its children only when the visitor's consent allows it (#534).
|
|
7
|
+
*
|
|
8
|
+
* - **No `purpose`** — the content is ESSENTIAL and always renders. This is the right wrapper for a
|
|
9
|
+
* contact form: submitting a form the visitor chose to fill in never depends on optional consent, and the
|
|
10
|
+
* form's own opt-ins (marketing) are recorded with the submission, not here.
|
|
11
|
+
* - **`purpose`** — the content rests on that purpose (an embedded video that tracks, a chat widget, a
|
|
12
|
+
* marketing pixel). It renders only once the visitor granted it; until then `fallback` renders — a
|
|
13
|
+
* placeholder that says what is withheld and offers `useWebsiteConsent().openSettings()`.
|
|
14
|
+
*
|
|
15
|
+
* It renders `fallback` during the server render and before the stored decision is read, so a static page
|
|
16
|
+
* never ships consent-based content in its HTML.
|
|
17
|
+
*/
|
|
18
|
+
export interface WebsiteConsentGateProps {
|
|
19
|
+
/** The purpose the children rest on. Omit for essential content. */
|
|
20
|
+
purpose?: ConsentPurpose;
|
|
21
|
+
/** Rendered in place of consent-based children until the purpose is granted. Defaults to nothing. */
|
|
22
|
+
fallback?: ReactNode;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
export declare function WebsiteConsentGate({ purpose, fallback, children }: WebsiteConsentGateProps): ReactNode;
|
|
26
|
+
/** @wildo_source:part:end saas.website.consent.gate */
|
|
27
|
+
//# sourceMappingURL=WebsiteConsentGate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WebsiteConsentGate.d.ts","sourceRoot":"","sources":["../../../../../src/core/consent/WebsiteConsentGate.tsx"],"names":[],"mappings":"AAAA,OAAc,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sCAAsC,CAAC;AAI3E;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,uBAAuB;IACtC,oEAAoE;IACpE,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,qGAAqG;IACrG,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,wBAAgB,kBAAkB,CAAC,EAAE,OAAO,EAAE,QAAe,EAAE,QAAQ,EAAE,EAAE,uBAAuB,GAAG,SAAS,CAI7G;AACD,uDAAuD"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useWebsiteConsent } from './useWebsiteConsent.js';
|
|
3
|
+
export function WebsiteConsentGate({ purpose, fallback = null, children }) {
|
|
4
|
+
const consent = useWebsiteConsent();
|
|
5
|
+
if (purpose === undefined)
|
|
6
|
+
return _jsx(_Fragment, { children: children });
|
|
7
|
+
return _jsx(_Fragment, { children: consent.isResolved && consent.hasGranted(purpose) ? children : fallback });
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=WebsiteConsentGate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WebsiteConsentGate.js","sourceRoot":"","sources":["../../../../../src/core/consent/WebsiteConsentGate.tsx"],"names":[],"mappings":";AAGA,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAyBxD,MAAM,UAAU,kBAAkB,CAAC,EAAE,OAAO,EAAE,QAAQ,GAAG,IAAI,EAAE,QAAQ,EAA2B;IAChG,MAAM,OAAO,GAAG,iBAAiB,EAAE,CAAC;IACpC,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,4BAAG,QAAQ,GAAI,CAAC;IAClD,OAAO,4BAAG,OAAO,CAAC,UAAU,IAAI,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,GAAI,CAAC;AACxF,CAAC","sourcesContent":["import React, { type ReactNode } from 'react';\nimport type { ConsentPurpose } from '@wildo-ai/saas-models/public-runtime';\n\nimport { useWebsiteConsent } from './useWebsiteConsent';\n\n/**\n * @wildo_source:part:start saas.website.consent.gate facet:layer:core facet:family:website\n *\n * `<WebsiteConsentGate>` — renders its children only when the visitor's consent allows it (#534).\n *\n * - **No `purpose`** — the content is ESSENTIAL and always renders. This is the right wrapper for a\n * contact form: submitting a form the visitor chose to fill in never depends on optional consent, and the\n * form's own opt-ins (marketing) are recorded with the submission, not here.\n * - **`purpose`** — the content rests on that purpose (an embedded video that tracks, a chat widget, a\n * marketing pixel). It renders only once the visitor granted it; until then `fallback` renders — a\n * placeholder that says what is withheld and offers `useWebsiteConsent().openSettings()`.\n *\n * It renders `fallback` during the server render and before the stored decision is read, so a static page\n * never ships consent-based content in its HTML.\n */\nexport interface WebsiteConsentGateProps {\n /** The purpose the children rest on. Omit for essential content. */\n purpose?: ConsentPurpose;\n /** Rendered in place of consent-based children until the purpose is granted. Defaults to nothing. */\n fallback?: ReactNode;\n children: ReactNode;\n}\n\nexport function WebsiteConsentGate({ purpose, fallback = null, children }: WebsiteConsentGateProps): ReactNode {\n const consent = useWebsiteConsent();\n if (purpose === undefined) return <>{children}</>;\n return <>{consent.isResolved && consent.hasGranted(purpose) ? children : fallback}</>;\n}\n/** @wildo_source:part:end saas.website.consent.gate */\n"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type WebsiteConsentContextValue } from './WebsiteConsentContext';
|
|
2
|
+
/**
|
|
3
|
+
* The visitor's consent state (#534). Read this — never storage, never a cookie — to decide whether
|
|
4
|
+
* optional behaviour may run.
|
|
5
|
+
*
|
|
6
|
+
* Throws outside `WebsitePageLayout`, which provides it: a consumer that silently read "nothing granted"
|
|
7
|
+
* would look correct and hide a page mounted outside the layout.
|
|
8
|
+
*/
|
|
9
|
+
export declare function useWebsiteConsent(): WebsiteConsentContextValue;
|
|
10
|
+
//# sourceMappingURL=useWebsiteConsent.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useWebsiteConsent.d.ts","sourceRoot":"","sources":["../../../../../src/core/consent/useWebsiteConsent.ts"],"names":[],"mappings":"AAEA,OAAO,EAAyB,KAAK,0BAA0B,EAAE,MAAM,yBAAyB,CAAC;AAEjG;;;;;;GAMG;AACH,wBAAgB,iBAAiB,IAAI,0BAA0B,CAM9D"}
|