@tokenoftrust/storefront-runner 4.7.2 → 4.7.4
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/apps/storefront/src/components/content/storefront/content/ContactForm.astro +206 -0
- package/apps/storefront/src/components/content/storefront/content/ProseSections.astro +4 -1
- package/apps/storefront/src/lib/content/prose.ts +40 -0
- package/package.json +1 -1
- package/packages/cli/src/visual-parity.mjs +2 -1
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +50 -22
- package/packages/public-runtime/src/integration/lifecycle-runtime.ts +5 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* <ContactForm> — the `contact_form` page section. Email and message are always
|
|
4
|
+
* shown and required; `fields` adds optional name/phone. Submissions go to the
|
|
5
|
+
* platform-owned JSON endpoint (contract: lib/engagement/contact/contract.ts), so
|
|
6
|
+
* the section carries copy only: no recipient, endpoint or script prop.
|
|
7
|
+
*
|
|
8
|
+
* The endpoint is JSON-only, so a plain HTML post cannot reach it. The form is
|
|
9
|
+
* therefore revealed by script, and visitors without JS see an explanatory note
|
|
10
|
+
* instead of a submit button that cannot work.
|
|
11
|
+
*
|
|
12
|
+
* The endpoint is api-base-scoped (`apiBasePath`, never `basePath`, which a
|
|
13
|
+
* preview/candidate pin overrides with a render prefix that has no `/api` mirror).
|
|
14
|
+
*/
|
|
15
|
+
import type { ProseContactFormSection } from "@/lib/content/prose";
|
|
16
|
+
import { withBase } from "@/lib/publication/shared/basePath";
|
|
17
|
+
import { CONTACT_LIMITS } from "@/lib/engagement/contact/contract";
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
section: ProseContactFormSection;
|
|
21
|
+
/** Distinguishes ids when several forms share a page. */
|
|
22
|
+
index?: number;
|
|
23
|
+
}
|
|
24
|
+
const { section, index = 0 } = Astro.props;
|
|
25
|
+
const { apiBasePath } = Astro.locals;
|
|
26
|
+
const endpoint = withBase(apiBasePath ?? "", "/api/contact");
|
|
27
|
+
const id = `contact-form-${index}`;
|
|
28
|
+
const fields = section.fields ?? [];
|
|
29
|
+
const messageLabel = section.messageLabel ?? "How can we help?";
|
|
30
|
+
const submitLabel = section.submitLabel ?? "Send message";
|
|
31
|
+
const successMessage = section.successMessage ?? "Thanks. Your message is on its way.";
|
|
32
|
+
---
|
|
33
|
+
<section
|
|
34
|
+
class="contact-form"
|
|
35
|
+
data-contact-form
|
|
36
|
+
data-endpoint={endpoint}
|
|
37
|
+
data-success={successMessage}
|
|
38
|
+
aria-labelledby={section.heading ? `${id}-heading` : undefined}
|
|
39
|
+
>
|
|
40
|
+
{section.heading && <h2 id={`${id}-heading`} class="contact-form__heading">{section.heading}</h2>}
|
|
41
|
+
<noscript>
|
|
42
|
+
<p class="contact-form__note">This contact form needs JavaScript. Enable it and reload, or use the contact details on this page.</p>
|
|
43
|
+
</noscript>
|
|
44
|
+
<form hidden novalidate data-contact-form-el>
|
|
45
|
+
{fields.includes("name") && (
|
|
46
|
+
<div class="contact-form__field">
|
|
47
|
+
<label for={`${id}-name`}>Name</label>
|
|
48
|
+
<input id={`${id}-name`} name="name" type="text" autocomplete="name" maxlength={CONTACT_LIMITS.name} />
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
<div class="contact-form__field">
|
|
52
|
+
<label for={`${id}-email`}>Email</label>
|
|
53
|
+
<input id={`${id}-email`} name="email" type="email" inputmode="email" autocomplete="email" required maxlength={CONTACT_LIMITS.email} />
|
|
54
|
+
</div>
|
|
55
|
+
{fields.includes("phone") && (
|
|
56
|
+
<div class="contact-form__field">
|
|
57
|
+
<label for={`${id}-phone`}>Phone</label>
|
|
58
|
+
<input id={`${id}-phone`} name="phone" type="tel" inputmode="tel" autocomplete="tel" maxlength={CONTACT_LIMITS.phone} />
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
<div class="contact-form__field">
|
|
62
|
+
<label for={`${id}-message`}>{messageLabel}</label>
|
|
63
|
+
<textarea id={`${id}-message`} name="message" rows="6" required maxlength={CONTACT_LIMITS.message}></textarea>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="contact-form__trap" aria-hidden="true">
|
|
66
|
+
<input name="company" type="text" tabindex="-1" autocomplete="off" />
|
|
67
|
+
</div>
|
|
68
|
+
<button type="submit" class="contact-form__submit">{submitLabel}</button>
|
|
69
|
+
</form>
|
|
70
|
+
<p class="contact-form__status" role="status" aria-live="polite" data-contact-form-status></p>
|
|
71
|
+
</section>
|
|
72
|
+
|
|
73
|
+
<style>
|
|
74
|
+
.contact-form { margin-top: 1.5rem; }
|
|
75
|
+
.contact-form__heading { font-family: var(--font-display, inherit); font-size: var(--heading-h2, 1.5rem); font-weight: 600; }
|
|
76
|
+
.contact-form__field { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 1rem; }
|
|
77
|
+
.contact-form__field label { font-weight: 600; }
|
|
78
|
+
.contact-form__field input,
|
|
79
|
+
.contact-form__field textarea {
|
|
80
|
+
width: 100%;
|
|
81
|
+
padding: 0.65rem 0.8rem;
|
|
82
|
+
border: 1px solid var(--color-border);
|
|
83
|
+
border-radius: var(--radius-md, 0.375rem);
|
|
84
|
+
background: var(--color-surface);
|
|
85
|
+
color: var(--color-text);
|
|
86
|
+
font: inherit;
|
|
87
|
+
}
|
|
88
|
+
.contact-form__field input[aria-invalid="true"],
|
|
89
|
+
.contact-form__field textarea[aria-invalid="true"] { border-color: var(--color-danger, currentColor); }
|
|
90
|
+
.contact-form__field input:focus-visible,
|
|
91
|
+
.contact-form__field textarea:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
|
92
|
+
.contact-form__trap { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
|
|
93
|
+
.contact-form__submit {
|
|
94
|
+
margin-top: 1.25rem;
|
|
95
|
+
min-height: var(--tap-target, 44px);
|
|
96
|
+
padding: 0.65rem 1.4rem;
|
|
97
|
+
border: 0;
|
|
98
|
+
border-radius: var(--radius-md, 0.375rem);
|
|
99
|
+
background: var(--color-accent);
|
|
100
|
+
color: var(--color-accent-contrast, var(--color-primary-contrast));
|
|
101
|
+
font: inherit;
|
|
102
|
+
font-weight: 700;
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
}
|
|
105
|
+
.contact-form__submit[disabled] { opacity: 0.6; cursor: progress; }
|
|
106
|
+
.contact-form__status { margin-top: 1rem; min-height: 1.5em; color: var(--color-text); }
|
|
107
|
+
.contact-form__status[data-state="error"] { color: var(--color-danger, var(--color-text)); font-weight: 600; }
|
|
108
|
+
.contact-form__note { color: var(--color-muted); }
|
|
109
|
+
</style>
|
|
110
|
+
|
|
111
|
+
<script>
|
|
112
|
+
import {
|
|
113
|
+
CONTACT_TOKEN_MAX_AGE_MS,
|
|
114
|
+
CONTACT_TOKEN_MIN_AGE_MS,
|
|
115
|
+
fetchContactToken,
|
|
116
|
+
submitContactMessage,
|
|
117
|
+
type ContactErrorCode,
|
|
118
|
+
type ContactField,
|
|
119
|
+
} from "@/lib/engagement/contact/contract";
|
|
120
|
+
|
|
121
|
+
const MESSAGES: Partial<Record<ContactErrorCode, string>> = {
|
|
122
|
+
rate_limited: "You have sent a few messages already. Wait a minute and try again.",
|
|
123
|
+
invalid_input: "Please check the highlighted field and try again.",
|
|
124
|
+
unavailable: "Messages can't be received right now. Your text is still here; try again shortly.",
|
|
125
|
+
store_failed: "We couldn't save your message. Your text is still here; try again.",
|
|
126
|
+
too_large: "That message is too long. Shorten it and try again.",
|
|
127
|
+
};
|
|
128
|
+
const GENERIC = "Something went wrong. Your text is still here; try again.";
|
|
129
|
+
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
130
|
+
|
|
131
|
+
function setup(root: HTMLElement) {
|
|
132
|
+
const form = root.querySelector<HTMLFormElement>("[data-contact-form-el]");
|
|
133
|
+
const status = root.querySelector<HTMLElement>("[data-contact-form-status]");
|
|
134
|
+
const endpoint = root.dataset.endpoint;
|
|
135
|
+
if (!form || !status || !endpoint) return;
|
|
136
|
+
// The wire contract appends /api/contact itself; hand it the api base.
|
|
137
|
+
const apiBase = endpoint.replace(/\/api\/contact$/, "");
|
|
138
|
+
const say = (text: string, state: "ok" | "error" | "") => {
|
|
139
|
+
status.textContent = text;
|
|
140
|
+
status.dataset.state = state;
|
|
141
|
+
};
|
|
142
|
+
let token: { value: string; at: number } | null = null;
|
|
143
|
+
const loadToken = async () => {
|
|
144
|
+
token = { value: await fetchContactToken(apiBase), at: Date.now() };
|
|
145
|
+
};
|
|
146
|
+
const ready = loadToken();
|
|
147
|
+
form.hidden = false;
|
|
148
|
+
ready.catch(() => say("Messages can't be received right now. Try again shortly.", "error"));
|
|
149
|
+
|
|
150
|
+
form.addEventListener("submit", async (e) => {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
const button = form.querySelector<HTMLButtonElement>("button[type=submit]");
|
|
153
|
+
const value = (name: string) =>
|
|
154
|
+
(form.elements.namedItem(name) as HTMLInputElement | null)?.value.trim() ?? "";
|
|
155
|
+
form.querySelectorAll("[aria-invalid]").forEach((el) => el.removeAttribute("aria-invalid"));
|
|
156
|
+
const flag = (field: ContactField) => {
|
|
157
|
+
const el = form.elements.namedItem(field) as HTMLElement | null;
|
|
158
|
+
el?.setAttribute("aria-invalid", "true");
|
|
159
|
+
el?.focus();
|
|
160
|
+
};
|
|
161
|
+
if (!value("email") || !/^\S+@\S+$/.test(value("email"))) {
|
|
162
|
+
flag("email");
|
|
163
|
+
return say("Enter a valid email address.", "error");
|
|
164
|
+
}
|
|
165
|
+
if (!value("message")) {
|
|
166
|
+
flag("message");
|
|
167
|
+
return say("Enter a message.", "error");
|
|
168
|
+
}
|
|
169
|
+
if (button) button.disabled = true;
|
|
170
|
+
say("Sending...", "");
|
|
171
|
+
try {
|
|
172
|
+
if (!token || Date.now() - token.at > CONTACT_TOKEN_MAX_AGE_MS - 60_000) await loadToken();
|
|
173
|
+
const age = Date.now() - token!.at;
|
|
174
|
+
if (age < CONTACT_TOKEN_MIN_AGE_MS) await wait(CONTACT_TOKEN_MIN_AGE_MS - age + 100);
|
|
175
|
+
const name = value("name");
|
|
176
|
+
const phone = value("phone");
|
|
177
|
+
const result = await submitContactMessage(apiBase, {
|
|
178
|
+
token: token!.value,
|
|
179
|
+
...(name ? { name } : {}),
|
|
180
|
+
email: value("email"),
|
|
181
|
+
...(phone ? { phone } : {}),
|
|
182
|
+
message: value("message"),
|
|
183
|
+
company: (form.elements.namedItem("company") as HTMLInputElement | null)?.value ?? "",
|
|
184
|
+
});
|
|
185
|
+
if (result.body.ok) {
|
|
186
|
+
form.reset();
|
|
187
|
+
token = null;
|
|
188
|
+
say(root.dataset.success ?? "Thanks. Your message is on its way.", "ok");
|
|
189
|
+
loadToken().catch(() => {});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const code = result.body.error;
|
|
193
|
+
if (code === "invalid_token" || code === "forbidden_origin") token = null;
|
|
194
|
+
if (result.body.field) flag(result.body.field);
|
|
195
|
+
say(MESSAGES[code] ?? GENERIC, "error");
|
|
196
|
+
if (!token) loadToken().catch(() => {});
|
|
197
|
+
} catch {
|
|
198
|
+
say(GENERIC, "error");
|
|
199
|
+
} finally {
|
|
200
|
+
if (button) button.disabled = false;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
document.querySelectorAll<HTMLElement>("[data-contact-form]").forEach(setup);
|
|
206
|
+
</script>
|
|
@@ -16,6 +16,7 @@ import type { ProseSection } from "@/lib/content/prose";
|
|
|
16
16
|
import { normalizeProse, withHeadingIds } from "@/lib/content/prose";
|
|
17
17
|
import RichText from "@/components/content/storefront/content/RichText.astro";
|
|
18
18
|
import Callout from "@/components/content/storefront/content/Callout.astro";
|
|
19
|
+
import ContactForm from "@/components/content/storefront/content/ContactForm.astro";
|
|
19
20
|
import NeedsReviewCallout from "@/components/content/storefront/content/NeedsReviewCallout.astro";
|
|
20
21
|
|
|
21
22
|
interface Props {
|
|
@@ -28,7 +29,7 @@ const rendered = withHeadingIds(normalizeProse(sections));
|
|
|
28
29
|
---
|
|
29
30
|
<div class="prose-sections max-w-[68ch] text-[var(--color-text)]" data-widget="prose-sections">
|
|
30
31
|
{
|
|
31
|
-
rendered.map((s) => {
|
|
32
|
+
rendered.map((s, i) => {
|
|
32
33
|
switch (s.kind) {
|
|
33
34
|
case "heading":
|
|
34
35
|
return (s.level ?? 2) === 3 ? (
|
|
@@ -109,6 +110,8 @@ const rendered = withHeadingIds(normalizeProse(sections));
|
|
|
109
110
|
)}
|
|
110
111
|
</address>
|
|
111
112
|
);
|
|
113
|
+
case "contact_form":
|
|
114
|
+
return <ContactForm section={s} index={i} />;
|
|
112
115
|
case "needs_review":
|
|
113
116
|
return includeNeedsReview ? (
|
|
114
117
|
<NeedsReviewCallout flag={{ reason: s.reason, severity: s.severity, owner: s.owner, href: s.href }} />
|
|
@@ -65,6 +65,20 @@ export interface ProseContactSection {
|
|
|
65
65
|
address?: string[];
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A working contact form. Email and message are always shown and required;
|
|
70
|
+
* `fields` adds the optional name/phone inputs. The destination, endpoint and
|
|
71
|
+
* script are platform-owned: this section carries copy only.
|
|
72
|
+
*/
|
|
73
|
+
export interface ProseContactFormSection {
|
|
74
|
+
kind: "contact_form";
|
|
75
|
+
heading?: string;
|
|
76
|
+
fields?: ("name" | "phone")[];
|
|
77
|
+
messageLabel?: string;
|
|
78
|
+
submitLabel?: string;
|
|
79
|
+
successMessage?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
/** A needs-review flag embedded in the flow (see needsReview.ts). */
|
|
69
83
|
export type ProseNeedsReviewSection = { kind: "needs_review" } & NeedsReview;
|
|
70
84
|
|
|
@@ -75,6 +89,7 @@ export type ProseSection =
|
|
|
75
89
|
| ProseDefinitionsSection
|
|
76
90
|
| ProseCalloutSection
|
|
77
91
|
| ProseContactSection
|
|
92
|
+
| ProseContactFormSection
|
|
78
93
|
| ProseNeedsReviewSection;
|
|
79
94
|
|
|
80
95
|
/** A full structured page (policy / about / compliance). */
|
|
@@ -129,6 +144,9 @@ export function normalizeProse(
|
|
|
129
144
|
case "contact":
|
|
130
145
|
if (s.email || s.phone || (s.address && s.address.length)) out.push(s);
|
|
131
146
|
break;
|
|
147
|
+
case "contact_form":
|
|
148
|
+
out.push(normalizeContactForm(s));
|
|
149
|
+
break;
|
|
132
150
|
case "needs_review":
|
|
133
151
|
if (s.reason.trim()) out.push(s);
|
|
134
152
|
break;
|
|
@@ -137,6 +155,28 @@ export function normalizeProse(
|
|
|
137
155
|
return out;
|
|
138
156
|
}
|
|
139
157
|
|
|
158
|
+
const copy = (v: unknown): string | undefined =>
|
|
159
|
+
typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
160
|
+
|
|
161
|
+
/** Rebuild from the known copy props only: unknown props (a recipient, an endpoint) never survive. */
|
|
162
|
+
function normalizeContactForm(s: ProseContactFormSection): ProseContactFormSection {
|
|
163
|
+
const raw: unknown = s.fields;
|
|
164
|
+
const fields = (["name", "phone"] as const).filter(
|
|
165
|
+
(f) => Array.isArray(raw) && raw.includes(f),
|
|
166
|
+
);
|
|
167
|
+
const out: ProseContactFormSection = { kind: "contact_form" };
|
|
168
|
+
const heading = copy(s.heading);
|
|
169
|
+
const messageLabel = copy(s.messageLabel);
|
|
170
|
+
const submitLabel = copy(s.submitLabel);
|
|
171
|
+
const successMessage = copy(s.successMessage);
|
|
172
|
+
if (heading) out.heading = heading;
|
|
173
|
+
if (fields.length) out.fields = [...fields];
|
|
174
|
+
if (messageLabel) out.messageLabel = messageLabel;
|
|
175
|
+
if (submitLabel) out.submitLabel = submitLabel;
|
|
176
|
+
if (successMessage) out.successMessage = successMessage;
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
140
180
|
/** Fill in a derived `id` for every heading that lacks one (for TOC anchoring). */
|
|
141
181
|
export function withHeadingIds(
|
|
142
182
|
sections: ReadonlyArray<ProseSection>,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.4",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -255,8 +255,9 @@ function pageShape(value, file) {
|
|
|
255
255
|
case "definitions": if (!Array.isArray(section.items) || !section.items.length || section.items.some((item) => !item || !string(item.term) || !rich(item.description))) findings.push(finding("parity-section-shape", at, "definitions require string terms and RichText descriptions", "supply valid definition items")); break;
|
|
256
256
|
case "callout": if (!rich(section.body) || !["info", "warning", "legal", "success"].includes(section.tone) || section.title !== undefined && !string(section.title)) findings.push(finding("parity-section-shape", at, "callout requires supported tone, RichText body, and optional string title", "supply a valid callout")); break;
|
|
257
257
|
case "contact": if (section.heading !== undefined && !string(section.heading) || section.email !== undefined && !string(section.email) || section.phone !== undefined && !string(section.phone) || section.address !== undefined && (!Array.isArray(section.address) || section.address.some((line) => !string(line))) || !string(section.email) && !string(section.phone) && !(Array.isArray(section.address) && section.address.length)) findings.push(finding("parity-section-shape", at, "contact requires string members and email, phone, or address", "supply valid contact details")); break;
|
|
258
|
+
case "contact_form": if (["heading", "messageLabel", "submitLabel", "successMessage"].some((key) => section[key] !== undefined && !string(section[key])) || section.fields !== undefined && (!Array.isArray(section.fields) || section.fields.some((field) => field !== "name" && field !== "phone"))) findings.push(finding("parity-section-shape", at, "contact_form requires optional string copy and fields limited to name or phone", "supply a valid contact_form section")); break;
|
|
258
259
|
case "needs_review": if (!string(section.reason) || section.severity !== undefined && !["blocker", "note"].includes(section.severity) || section.owner !== undefined && !string(section.owner) || section.href !== undefined && !string(section.href)) findings.push(finding("parity-section-shape", at, "needs_review requires reason and valid optional severity/owner/href", "supply a valid needs_review section")); break;
|
|
259
|
-
default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, or needs_review"));
|
|
260
|
+
default: findings.push(finding("parity-section-kind", `${at}/kind`, "section kind is not supported by the renderer", "use heading, paragraph, list, definitions, callout, contact, contact_form, or needs_review"));
|
|
260
261
|
}
|
|
261
262
|
});
|
|
262
263
|
const bodyHtml = typeof value.body_html === "string" && value.body_html.trim() !== "";
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* the renderer-published boot descriptor global, guarantees ONE lifecycle per document
|
|
6
6
|
* with a window-level singleton (so a nested layout that re-runs the boot island cannot
|
|
7
7
|
* double-init), wires same-document navigations to the reserved completed-navigation
|
|
8
|
-
* event, disposes on
|
|
8
|
+
* event, disposes on every `pagehide`, boots a fresh lifecycle when the back-forward
|
|
9
|
+
* cache restores the page, and is a guarded no-op off the browser.
|
|
9
10
|
*
|
|
10
11
|
* The entry module for each integration is the same-origin, exact-version asset the
|
|
11
12
|
* boot descriptor carries (`integration.entry.url`): the default loader dynamically
|
|
@@ -440,20 +441,38 @@ function deferFetchCapability(
|
|
|
440
441
|
})) as typeof fetch;
|
|
441
442
|
}
|
|
442
443
|
|
|
444
|
+
/** The window property marking that this window's event wiring is installed (once per window). */
|
|
445
|
+
const LIFECYCLE_WIRED_GLOBAL = "__TOT_INTEGRATION_LIFECYCLE_WIRED__";
|
|
446
|
+
|
|
443
447
|
/**
|
|
444
|
-
* Boot the lifecycle for `win
|
|
445
|
-
* (nested-layout safety).
|
|
448
|
+
* Boot the lifecycle for `win`. While a live lifecycle exists a second call returns it
|
|
449
|
+
* (nested-layout safety). A disposed one — the page was hidden into the back-forward
|
|
450
|
+
* cache — is replaced by a fresh lifecycle. Returns `null` when there is no boot
|
|
451
|
+
* descriptor to act on.
|
|
446
452
|
*/
|
|
447
453
|
export function bootIntegrationLifecycle(
|
|
448
454
|
win: LifecycleWindow,
|
|
449
455
|
options: BootLifecycleOptions = {},
|
|
450
456
|
): IntegrationLifecycle | null {
|
|
451
457
|
const existing = win[LIFECYCLE_SINGLETON_GLOBAL];
|
|
452
|
-
if (existing instanceof IntegrationLifecycle) return existing;
|
|
458
|
+
if (existing instanceof IntegrationLifecycle && !existing.isDisposed) return existing;
|
|
453
459
|
|
|
454
460
|
const descriptor = readDescriptor(win);
|
|
455
461
|
if (!descriptor) return null;
|
|
456
462
|
|
|
463
|
+
const lifecycle = createDocumentLifecycle(win, descriptor, options);
|
|
464
|
+
win[LIFECYCLE_SINGLETON_GLOBAL] = lifecycle;
|
|
465
|
+
wireWindow(win, options);
|
|
466
|
+
void lifecycle.init();
|
|
467
|
+
return lifecycle;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function createDocumentLifecycle(
|
|
471
|
+
win: LifecycleWindow,
|
|
472
|
+
descriptor: IntegrationBootDescriptor,
|
|
473
|
+
options: BootLifecycleOptions,
|
|
474
|
+
): IntegrationLifecycle {
|
|
475
|
+
|
|
457
476
|
const hostFetch = options.fetchCapability ??
|
|
458
477
|
(typeof win.fetch === "function" ? win.fetch.bind(win) : undefined);
|
|
459
478
|
const schedulePlatformAdapterStart =
|
|
@@ -508,7 +527,7 @@ export function bootIntegrationLifecycle(
|
|
|
508
527
|
// The managed-analytics bridge over the SAME broker the adapters subscribe to — the
|
|
509
528
|
// only seam that emits this document's governed page view. The lifecycle owns when it
|
|
510
529
|
// fires (after every adapter has subscribed, and re-attempted on a consent grant).
|
|
511
|
-
|
|
530
|
+
return new IntegrationLifecycle({
|
|
512
531
|
descriptor,
|
|
513
532
|
loadModule,
|
|
514
533
|
isEligible,
|
|
@@ -536,39 +555,48 @@ export function bootIntegrationLifecycle(
|
|
|
536
555
|
pageView: () => documentPageView(win, descriptor, options.now ?? Date.now),
|
|
537
556
|
},
|
|
538
557
|
});
|
|
539
|
-
|
|
558
|
+
}
|
|
540
559
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
return
|
|
560
|
+
/** The window's current live lifecycle, if any. */
|
|
561
|
+
function liveLifecycle(win: LifecycleWindow): IntegrationLifecycle | undefined {
|
|
562
|
+
const current = win[LIFECYCLE_SINGLETON_GLOBAL];
|
|
563
|
+
return current instanceof IntegrationLifecycle && !current.isDisposed ? current : undefined;
|
|
545
564
|
}
|
|
546
565
|
|
|
547
566
|
/**
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
* navigations
|
|
551
|
-
*
|
|
567
|
+
* Install this window's event wiring once; every listener acts on the CURRENT lifecycle.
|
|
568
|
+
*
|
|
569
|
+
* Same-document navigations — hash changes and history back/forward — emit the reserved
|
|
570
|
+
* completed-navigation event and never re-initialize integrations; a real document load
|
|
571
|
+
* re-inits by construction.
|
|
572
|
+
*
|
|
573
|
+
* Every `pagehide` disposes the lifecycle, so integrations clean up and abandon any
|
|
574
|
+
* deferred work (a held navigation never resumes after the shopper left). A
|
|
575
|
+
* `pageshow` from the back-forward cache boots a fresh lifecycle, so the restored page
|
|
576
|
+
* has live integrations again.
|
|
552
577
|
*/
|
|
553
|
-
function
|
|
578
|
+
function wireWindow(win: LifecycleWindow, options: BootLifecycleOptions): void {
|
|
579
|
+
if (win[LIFECYCLE_WIRED_GLOBAL] === true) return;
|
|
580
|
+
win[LIFECYCLE_WIRED_GLOBAL] = true;
|
|
554
581
|
let lastPath = win.location.pathname + win.location.hash;
|
|
555
582
|
win.addEventListener("hashchange", () => {
|
|
556
583
|
const to = win.location.pathname + win.location.hash;
|
|
557
|
-
|
|
584
|
+
liveLifecycle(win)?.notifyNavigationCompleted({ from: lastPath, to, kind: "hash" });
|
|
558
585
|
lastPath = to;
|
|
559
586
|
});
|
|
560
587
|
win.addEventListener("popstate", () => {
|
|
561
588
|
const to = win.location.pathname + win.location.hash;
|
|
562
589
|
const kind: NavigationKind = "back-forward";
|
|
563
|
-
|
|
590
|
+
liveLifecycle(win)?.notifyNavigationCompleted({ from: lastPath, to, kind });
|
|
564
591
|
lastPath = to;
|
|
565
592
|
});
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/** Dispose the lifecycle when the document is torn down, so integrations clean up once. */
|
|
569
|
-
function wireTeardown(win: LifecycleWindow, lifecycle: IntegrationLifecycle): void {
|
|
570
593
|
win.addEventListener("pagehide", () => {
|
|
571
|
-
|
|
594
|
+
const current = win[LIFECYCLE_SINGLETON_GLOBAL];
|
|
595
|
+
if (current instanceof IntegrationLifecycle) void current.dispose();
|
|
596
|
+
});
|
|
597
|
+
win.addEventListener("pageshow", (event) => {
|
|
598
|
+
if ((event as { persisted?: unknown } | null)?.persisted !== true) return;
|
|
599
|
+
bootIntegrationLifecycle(win, options);
|
|
572
600
|
});
|
|
573
601
|
}
|
|
574
602
|
|
|
@@ -224,6 +224,11 @@ export class IntegrationLifecycle {
|
|
|
224
224
|
return this.state === "initialized";
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/** True once dispose has started; a disposed lifecycle never runs again. */
|
|
228
|
+
get isDisposed(): boolean {
|
|
229
|
+
return this.isTerminal();
|
|
230
|
+
}
|
|
231
|
+
|
|
227
232
|
/**
|
|
228
233
|
* Initialize every integration EXACTLY ONCE per document, in the plan's resolved order.
|
|
229
234
|
* A second call — including one from a nested layout that re-runs the boot island — is a
|