@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.
- 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
|
@@ -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.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() !== "";
|