@tokenoftrust/storefront-runner 4.7.2 → 4.7.3

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.
@@ -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.2",
3
+ "version": "4.7.3",
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() !== "";