create-website-build-kit 0.1.0
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/README.md +54 -0
- package/index.mjs +149 -0
- package/package.json +42 -0
- package/template/.dev.vars.example +3 -0
- package/template/.github/workflows/gates.yml +58 -0
- package/template/.node-version +1 -0
- package/template/.pa11yci.json +24 -0
- package/template/BUILD-STATE.md +47 -0
- package/template/CLAUDE.md +153 -0
- package/template/astro.config.mjs +150 -0
- package/template/docs/analytics.md +86 -0
- package/template/docs/content.md +138 -0
- package/template/docs/handover.md +182 -0
- package/template/docs/handover.pdf +0 -0
- package/template/docs/runbook.md +661 -0
- package/template/docs/traps.md +903 -0
- package/template/gitignore +31 -0
- package/template/package-lock.json +8159 -0
- package/template/package.json +53 -0
- package/template/public/_headers +61 -0
- package/template/public/_redirects +39 -0
- package/template/public/site.webmanifest +13 -0
- package/template/scripts/a11y-evidence.mjs +258 -0
- package/template/scripts/check-console.mjs +125 -0
- package/template/scripts/check-env.mjs +99 -0
- package/template/scripts/check-reflow.mjs +148 -0
- package/template/scripts/check-sitemap.mjs +113 -0
- package/template/scripts/dns-snapshot.mjs +267 -0
- package/template/scripts/extract.mjs +317 -0
- package/template/scripts/indexnow.mjs +154 -0
- package/template/scripts/lastmod.mjs +147 -0
- package/template/scripts/lib/inventory.mjs +104 -0
- package/template/scripts/lib/preserved.mjs +42 -0
- package/template/scripts/lib/routes.mjs +92 -0
- package/template/scripts/md-to-pdf.mjs +335 -0
- package/template/scripts/og-cards.config.mjs +114 -0
- package/template/scripts/og-cards.mjs +487 -0
- package/template/scripts/optimize-media.mjs +380 -0
- package/template/scripts/recon.mjs +480 -0
- package/template/scripts/redirects.mjs +298 -0
- package/template/scripts/shots.mjs +447 -0
- package/template/scripts/staging-headers.mjs +102 -0
- package/template/scripts/tells.mjs +268 -0
- package/template/scripts/verify.mjs +1069 -0
- package/template/src/components/ContactForm.astro +405 -0
- package/template/src/components/CtaBand.astro +82 -0
- package/template/src/components/EnvBadge.astro +146 -0
- package/template/src/components/Footer.astro +210 -0
- package/template/src/components/Header.astro +530 -0
- package/template/src/components/Icon.astro +56 -0
- package/template/src/components/Img.astro +129 -0
- package/template/src/components/PageHero.astro +88 -0
- package/template/src/components/Seo.astro +119 -0
- package/template/src/components/StructuredData.astro +173 -0
- package/template/src/content/blog/.gitkeep +5 -0
- package/template/src/content/legal/.gitkeep +0 -0
- package/template/src/content.config.ts +81 -0
- package/template/src/data/areas.ts +31 -0
- package/template/src/data/business.ts +121 -0
- package/template/src/data/categories.ts +37 -0
- package/template/src/data/fonts.ts +25 -0
- package/template/src/data/image-manifest.json +1 -0
- package/template/src/data/lastmod.json +1 -0
- package/template/src/data/nav.ts +49 -0
- package/template/src/data/services.ts +39 -0
- package/template/src/data/site.ts +136 -0
- package/template/src/env.d.ts +28 -0
- package/template/src/layouts/Base.astro +223 -0
- package/template/src/lib/brevo.ts +96 -0
- package/template/src/lib/hast-media.mjs +55 -0
- package/template/src/lib/lastmod.mjs +47 -0
- package/template/src/lib/lead.ts +92 -0
- package/template/src/lib/legal-routes.mjs +31 -0
- package/template/src/lib/legal.ts +75 -0
- package/template/src/lib/posts.ts +64 -0
- package/template/src/lib/runtime.ts +33 -0
- package/template/src/pages/404.astro +51 -0
- package/template/src/pages/[slug].astro +111 -0
- package/template/src/pages/accessibility.astro +128 -0
- package/template/src/pages/api/contact.ts +191 -0
- package/template/src/pages/api/leads.csv.ts +82 -0
- package/template/src/pages/contact.astro +112 -0
- package/template/src/pages/index.astro +84 -0
- package/template/src/pages/robots.txt.ts +38 -0
- package/template/src/pages/rss.xml.ts +27 -0
- package/template/src/styles/global.css +463 -0
- package/template/src/styles/project.css +14 -0
- package/template/src/styles/prose.css +182 -0
- package/template/src/styles/tokens.css +218 -0
- package/template/tsconfig.json +5 -0
- package/template/wrangler.jsonc +63 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SERVICE_OPTIONS } from '../lib/lead';
|
|
3
|
+
import { business } from '../data/business';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Contact form.
|
|
7
|
+
*
|
|
8
|
+
* Here for its behaviour: server-side validation surfaced per field, a
|
|
9
|
+
* no-JavaScript path that still submits, a honeypot, a busy state that
|
|
10
|
+
* prevents double submission, and an outcome announced via role="status"
|
|
11
|
+
* without stealing focus.
|
|
12
|
+
*
|
|
13
|
+
* The styling is the STATES, not a look — hover, focus-visible, invalid,
|
|
14
|
+
* disabled, busy, succeeded. Restyle all of it; do not remove a state.
|
|
15
|
+
* The fields themselves are a project decision: ask for what you will act on,
|
|
16
|
+
* and nothing a regulator says you may not hold (compliance.md §10).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** Field names the server rejected, for the no-JavaScript path. */
|
|
21
|
+
invalidFields?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { invalidFields = [] } = Astro.props;
|
|
25
|
+
const invalid = (name: string) => (invalidFields.includes(name) ? '' : undefined);
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<!--
|
|
29
|
+
The trailing slash is load-bearing. `trailingSlash: 'always'` makes
|
|
30
|
+
/api/contact 308 to /api/contact/, and a redirected POST loses the request
|
|
31
|
+
body on the Workers runtime — the endpoint then looks broken while being
|
|
32
|
+
perfectly fine. Post to the canonical URL directly.
|
|
33
|
+
|
|
34
|
+
data-phone carries the number into the script, so a failure message can name
|
|
35
|
+
it. Never hard-code a phone number in a component: one left in an error
|
|
36
|
+
string is a previous client's number in this client's worst moment.
|
|
37
|
+
-->
|
|
38
|
+
<form
|
|
39
|
+
class="form"
|
|
40
|
+
method="post"
|
|
41
|
+
action="/api/contact/"
|
|
42
|
+
novalidate
|
|
43
|
+
data-contact-form
|
|
44
|
+
data-phone={business.phone.display}
|
|
45
|
+
>
|
|
46
|
+
<div class="form__grid">
|
|
47
|
+
<div class="field" data-invalid={invalid('name')}>
|
|
48
|
+
<label for="cf-name">Your name <span aria-hidden="true">*</span></label>
|
|
49
|
+
<input
|
|
50
|
+
id="cf-name"
|
|
51
|
+
name="name"
|
|
52
|
+
type="text"
|
|
53
|
+
autocomplete="name"
|
|
54
|
+
required
|
|
55
|
+
aria-describedby="cf-name-error"
|
|
56
|
+
/>
|
|
57
|
+
<p class="field__error" id="cf-name-error" data-error-for="name" hidden></p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="field" data-invalid={invalid('phone')}>
|
|
61
|
+
<label for="cf-phone">Phone <span aria-hidden="true">*</span></label>
|
|
62
|
+
<input
|
|
63
|
+
id="cf-phone"
|
|
64
|
+
name="phone"
|
|
65
|
+
type="tel"
|
|
66
|
+
autocomplete="tel"
|
|
67
|
+
inputmode="tel"
|
|
68
|
+
required
|
|
69
|
+
aria-describedby="cf-phone-error"
|
|
70
|
+
/>
|
|
71
|
+
<p class="field__error" id="cf-phone-error" data-error-for="phone" hidden></p>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div class="field field--full" data-invalid={invalid('email')}>
|
|
75
|
+
<label for="cf-email">Email <span aria-hidden="true">*</span></label>
|
|
76
|
+
<input
|
|
77
|
+
id="cf-email"
|
|
78
|
+
name="email"
|
|
79
|
+
type="email"
|
|
80
|
+
autocomplete="email"
|
|
81
|
+
required
|
|
82
|
+
aria-describedby="cf-email-error"
|
|
83
|
+
/>
|
|
84
|
+
<p class="field__error" id="cf-email-error" data-error-for="email" hidden></p>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
SERVICE_OPTIONS.length > 0 && (
|
|
89
|
+
<div class="field field--full">
|
|
90
|
+
<label for="cf-service">What do you need?</label>
|
|
91
|
+
<select id="cf-service" name="service">
|
|
92
|
+
<option value="">Select…</option>
|
|
93
|
+
{SERVICE_OPTIONS.map((option) => (
|
|
94
|
+
<option value={option}>{option}</option>
|
|
95
|
+
))}
|
|
96
|
+
</select>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
<div class="field field--full" data-invalid={invalid('message')}>
|
|
102
|
+
<label for="cf-message">Message</label>
|
|
103
|
+
<textarea id="cf-message" name="message" rows="4" aria-describedby="cf-message-error"
|
|
104
|
+
></textarea>
|
|
105
|
+
<p class="field__error" id="cf-message-error" data-error-for="message" hidden></p>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<!-- Honeypot. Hidden from sight and from assistive tech, and never focusable
|
|
110
|
+
via the keyboard, so no real person can fill it by accident. -->
|
|
111
|
+
<div class="form__trap" aria-hidden="true">
|
|
112
|
+
<label for="cf-company">Company</label>
|
|
113
|
+
<input id="cf-company" name="company" type="text" tabindex="-1" autocomplete="off" />
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<input type="hidden" name="page" data-page-field value="" />
|
|
117
|
+
|
|
118
|
+
<div class="form__foot">
|
|
119
|
+
<button class="btn form__submit" type="submit">
|
|
120
|
+
<span data-submit-label>Send</span>
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<!-- role="status" so the outcome is announced without stealing focus. -->
|
|
125
|
+
<p class="form__result" role="status" aria-live="polite" data-form-result hidden></p>
|
|
126
|
+
</form>
|
|
127
|
+
|
|
128
|
+
<style>
|
|
129
|
+
.form {
|
|
130
|
+
display: grid;
|
|
131
|
+
gap: var(--space-m);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.form__grid {
|
|
135
|
+
display: grid;
|
|
136
|
+
gap: var(--space-m);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@media (min-width: 34rem) {
|
|
140
|
+
.form__grid {
|
|
141
|
+
grid-template-columns: 1fr 1fr;
|
|
142
|
+
}
|
|
143
|
+
.field--full {
|
|
144
|
+
grid-column: 1 / -1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.field {
|
|
149
|
+
display: grid;
|
|
150
|
+
gap: var(--space-3xs);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.field label {
|
|
154
|
+
font-size: var(--step--1);
|
|
155
|
+
font-weight: var(--weight-semibold);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.field input,
|
|
159
|
+
.field textarea,
|
|
160
|
+
.field select {
|
|
161
|
+
inline-size: 100%;
|
|
162
|
+
/* 44px minimum tap target, from padding so it survives a wrapped label. */
|
|
163
|
+
min-block-size: 2.75rem;
|
|
164
|
+
padding: 0.65em 0.9em;
|
|
165
|
+
font-size: var(--step-0);
|
|
166
|
+
color: var(--text);
|
|
167
|
+
background: var(--surface);
|
|
168
|
+
border: 1.5px solid var(--border-strong);
|
|
169
|
+
border-radius: var(--radius-s);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.field textarea {
|
|
173
|
+
resize: vertical;
|
|
174
|
+
min-block-size: 7rem;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.field input:hover,
|
|
178
|
+
.field textarea:hover,
|
|
179
|
+
.field select:hover {
|
|
180
|
+
border-color: var(--text-muted);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* The outline is replaced, not removed — the ring is the visible focus
|
|
184
|
+
indicator and it must stay at least as obvious as the default. */
|
|
185
|
+
.field input:focus-visible,
|
|
186
|
+
.field textarea:focus-visible,
|
|
187
|
+
.field select:focus-visible {
|
|
188
|
+
outline: none;
|
|
189
|
+
border-color: var(--focus);
|
|
190
|
+
box-shadow: var(--ring);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* Colour is never the only cue: the error text is what carries the meaning,
|
|
194
|
+
and aria-invalid carries it to assistive tech. */
|
|
195
|
+
.field[data-invalid] input,
|
|
196
|
+
.field[data-invalid] textarea {
|
|
197
|
+
border-color: var(--danger);
|
|
198
|
+
background: var(--danger-bg);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.field__error {
|
|
202
|
+
font-size: var(--step--1);
|
|
203
|
+
color: var(--danger);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.field__error[hidden] {
|
|
207
|
+
display: none;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.form__trap {
|
|
211
|
+
position: absolute;
|
|
212
|
+
inline-size: 1px;
|
|
213
|
+
block-size: 1px;
|
|
214
|
+
overflow: hidden;
|
|
215
|
+
clip-path: inset(50%);
|
|
216
|
+
white-space: nowrap;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.form__foot {
|
|
220
|
+
display: flex;
|
|
221
|
+
flex-wrap: wrap;
|
|
222
|
+
align-items: center;
|
|
223
|
+
gap: var(--space-s) var(--space-m);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.form__result {
|
|
227
|
+
padding: var(--space-s) var(--space-m);
|
|
228
|
+
border: 1px solid var(--border);
|
|
229
|
+
border-radius: var(--radius-s);
|
|
230
|
+
font-size: var(--step--1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.form__result[hidden] {
|
|
234
|
+
display: none;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.form__result[data-tone='error'] {
|
|
238
|
+
border-color: var(--danger);
|
|
239
|
+
background: var(--danger-bg);
|
|
240
|
+
color: var(--danger);
|
|
241
|
+
}
|
|
242
|
+
</style>
|
|
243
|
+
|
|
244
|
+
<script>
|
|
245
|
+
/**
|
|
246
|
+
* Progressive enhancement, not a requirement.
|
|
247
|
+
*
|
|
248
|
+
* Without this script the form is a plain HTML POST to /api/contact — it
|
|
249
|
+
* still submits, still stores, still emails. The script only upgrades the
|
|
250
|
+
* response to inline field errors and an in-page confirmation.
|
|
251
|
+
*/
|
|
252
|
+
/**
|
|
253
|
+
* The single place a lead conversion is announced. One announcer, two pipes.
|
|
254
|
+
*
|
|
255
|
+
* ── WHY BOTH ─────────────────────────────────────────────────────────────
|
|
256
|
+
* `gtag` reaches GA4 directly; `dataLayer` reaches a Tag Manager container.
|
|
257
|
+
* A site may have either, both, or neither, and the two are independent —
|
|
258
|
+
* pushing to a dataLayer no container reads is inert, and so is a gtag call
|
|
259
|
+
* with no GA4 tag behind it.
|
|
260
|
+
*
|
|
261
|
+
* ⚠ NEVER ALSO SEND THIS CONVERSION FROM A TAG MANAGER TRIGGER. Two pipes
|
|
262
|
+
* for one event double-counts, and a doubled conversion looks like a good
|
|
263
|
+
* month rather than a bug. Nothing in a build will catch it — confirm in
|
|
264
|
+
* Realtime that one submission produces one event.
|
|
265
|
+
*/
|
|
266
|
+
function announceLead() {
|
|
267
|
+
const w = window as any;
|
|
268
|
+
if (typeof w.gtag === 'function') {
|
|
269
|
+
w.gtag('event', 'generate_lead', { form: 'contact' });
|
|
270
|
+
}
|
|
271
|
+
w.dataLayer = w.dataLayer || [];
|
|
272
|
+
w.dataLayer.push({ event: 'lead_submitted', form: 'contact' });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The no-JavaScript conversion.
|
|
277
|
+
*
|
|
278
|
+
* Without JavaScript the form posts natively, the API 303s to `?sent=1`, and
|
|
279
|
+
* THIS page load is the only signal a lead happened. With JavaScript the
|
|
280
|
+
* fetch path calls the announcer and never navigates, so the two paths are
|
|
281
|
+
* mutually exclusive and cannot both fire.
|
|
282
|
+
*
|
|
283
|
+
* ⚠ THE OBVIOUS IMPLEMENTATION IS WRONG. A Tag Manager trigger on "URL
|
|
284
|
+
* contains sent=1" catches almost nothing here, because the enhanced path
|
|
285
|
+
* intercepts the submit and never changes the URL. It fires correctly in
|
|
286
|
+
* testing — where you click through by hand — and then reports a handful of
|
|
287
|
+
* conversions a month while looking like it works.
|
|
288
|
+
*
|
|
289
|
+
* The parameter is stripped with replaceState immediately after, so a
|
|
290
|
+
* reload, a bookmark or a shared link cannot invent a second conversion.
|
|
291
|
+
*/
|
|
292
|
+
function announceNoScriptLead() {
|
|
293
|
+
const url = new URL(window.location.href);
|
|
294
|
+
if (!url.searchParams.has('sent')) return;
|
|
295
|
+
|
|
296
|
+
announceLead();
|
|
297
|
+
|
|
298
|
+
url.searchParams.delete('sent');
|
|
299
|
+
history.replaceState(null, '', url.pathname + url.search + url.hash);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function setupContactForm() {
|
|
303
|
+
const form = document.querySelector<HTMLFormElement>('[data-contact-form]');
|
|
304
|
+
if (!form) return;
|
|
305
|
+
|
|
306
|
+
const result = form.querySelector<HTMLElement>('[data-form-result]')!;
|
|
307
|
+
const submit = form.querySelector<HTMLButtonElement>('.form__submit')!;
|
|
308
|
+
const label = form.querySelector<HTMLElement>('[data-submit-label]')!;
|
|
309
|
+
const pageField = form.querySelector<HTMLInputElement>('[data-page-field]');
|
|
310
|
+
if (pageField) pageField.value = location.pathname + location.search;
|
|
311
|
+
|
|
312
|
+
// From the markup, which reads business.ts. Never a literal here.
|
|
313
|
+
const phone = form.dataset.phone ?? '';
|
|
314
|
+
const callUs = phone ? ` Please call us at ${phone}.` : ' Please call us instead.';
|
|
315
|
+
|
|
316
|
+
const clearErrors = () => {
|
|
317
|
+
form.querySelectorAll<HTMLElement>('[data-error-for]').forEach((node) => {
|
|
318
|
+
node.hidden = true;
|
|
319
|
+
node.textContent = '';
|
|
320
|
+
node.closest('.field')?.removeAttribute('data-invalid');
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const showErrors = (errors: Record<string, string>) => {
|
|
325
|
+
let first: HTMLElement | null = null;
|
|
326
|
+
for (const [field, message] of Object.entries(errors)) {
|
|
327
|
+
const node = form.querySelector<HTMLElement>(`[data-error-for="${field}"]`);
|
|
328
|
+
if (!node) continue;
|
|
329
|
+
node.textContent = message;
|
|
330
|
+
node.hidden = false;
|
|
331
|
+
const wrapper = node.closest('.field');
|
|
332
|
+
wrapper?.setAttribute('data-invalid', '');
|
|
333
|
+
const input = wrapper?.querySelector<HTMLElement>('input, textarea, select');
|
|
334
|
+
if (input) {
|
|
335
|
+
input.setAttribute('aria-invalid', 'true');
|
|
336
|
+
if (!first) first = input;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
first?.focus();
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const say = (message: string, tone: 'success' | 'error') => {
|
|
343
|
+
result.textContent = message;
|
|
344
|
+
result.dataset.tone = tone;
|
|
345
|
+
result.hidden = false;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
form.addEventListener('submit', async (event) => {
|
|
349
|
+
event.preventDefault();
|
|
350
|
+
clearErrors();
|
|
351
|
+
result.hidden = true;
|
|
352
|
+
|
|
353
|
+
submit.dataset.busy = '';
|
|
354
|
+
const original = label.textContent;
|
|
355
|
+
label.textContent = 'Sending…';
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const response = await fetch(form.action, {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
headers: { accept: 'application/json' },
|
|
361
|
+
body: new FormData(form),
|
|
362
|
+
});
|
|
363
|
+
const data = (await response.json().catch(() => ({}))) as {
|
|
364
|
+
ok?: boolean;
|
|
365
|
+
errors?: Record<string, string>;
|
|
366
|
+
error?: string;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
if (response.status === 422 && data.errors) {
|
|
370
|
+
showErrors(data.errors);
|
|
371
|
+
say('Please check the highlighted fields.', 'error');
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!response.ok || !data.ok) {
|
|
376
|
+
say(data.error ?? `Something went wrong.${callUs}`, 'error');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
form.reset();
|
|
381
|
+
say('Thank you — your message is in. We will come back to you shortly.', 'success');
|
|
382
|
+
announceLead();
|
|
383
|
+
} catch {
|
|
384
|
+
say(`We could not reach the server.${callUs}`, 'error');
|
|
385
|
+
} finally {
|
|
386
|
+
delete submit.dataset.busy;
|
|
387
|
+
label.textContent = original;
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Clear a field's error state as soon as the visitor starts fixing it.
|
|
392
|
+
form.addEventListener('input', (event) => {
|
|
393
|
+
const field = (event.target as HTMLElement).closest('.field');
|
|
394
|
+
if (!field?.hasAttribute('data-invalid')) return;
|
|
395
|
+
field.removeAttribute('data-invalid');
|
|
396
|
+
field.querySelector<HTMLElement>('[data-error-for]')!.hidden = true;
|
|
397
|
+
(event.target as HTMLElement).removeAttribute('aria-invalid');
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
document.addEventListener('astro:page-load', () => {
|
|
402
|
+
setupContactForm();
|
|
403
|
+
announceNoScriptLead();
|
|
404
|
+
});
|
|
405
|
+
</script>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { business } from '../data/business';
|
|
3
|
+
import { primaryAction } from '../data/nav';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The conversion block, repeated at the bottom of a page.
|
|
7
|
+
*
|
|
8
|
+
* Structure only — a heading, a line of copy, and the one action that counts
|
|
9
|
+
* as a win plus the phone. Where a page's conversion sits, and what it looks
|
|
10
|
+
* like, is the archetype's decision and the design direction's respectively;
|
|
11
|
+
* neither belongs in the template.
|
|
12
|
+
*
|
|
13
|
+
* Pass a real title and body per page. A generic call to action is the
|
|
14
|
+
* cheapest thing on the page to get specific.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
title?: string;
|
|
19
|
+
body?: string;
|
|
20
|
+
/** Extra classes for the section — the project's own treatment. */
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
title = `Talk to ${business.name}`,
|
|
26
|
+
body = business.tagline,
|
|
27
|
+
class: className = '',
|
|
28
|
+
} = Astro.props;
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
<section class:list={['section', className]} aria-labelledby="cta-title">
|
|
32
|
+
<div class="container cta__inner">
|
|
33
|
+
<div>
|
|
34
|
+
<h2 id="cta-title">{title}</h2>
|
|
35
|
+
<p class="cta__body">{body}</p>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="cta__actions">
|
|
38
|
+
<a class="btn" href={business.phone.href}>Call {business.phone.display}</a>
|
|
39
|
+
<a class="btn btn--ghost" href={primaryAction.href}>{primaryAction.label}</a>
|
|
40
|
+
<p class="cta__note">{business.hours.display}</p>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</section>
|
|
44
|
+
|
|
45
|
+
<style>
|
|
46
|
+
.cta__inner {
|
|
47
|
+
display: grid;
|
|
48
|
+
gap: var(--space-l);
|
|
49
|
+
align-items: start;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@media (min-width: 55rem) {
|
|
53
|
+
.cta__inner {
|
|
54
|
+
grid-template-columns: 1fr auto;
|
|
55
|
+
gap: var(--space-2xl);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.cta__body {
|
|
60
|
+
margin-block-start: var(--space-s);
|
|
61
|
+
color: var(--text-muted);
|
|
62
|
+
max-inline-size: var(--measure);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.cta__actions {
|
|
66
|
+
display: grid;
|
|
67
|
+
gap: var(--space-2xs);
|
|
68
|
+
justify-items: stretch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@media (min-width: 30rem) {
|
|
72
|
+
.cta__actions {
|
|
73
|
+
justify-items: start;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.cta__note {
|
|
78
|
+
margin-block-start: var(--space-2xs);
|
|
79
|
+
font-size: var(--step--1);
|
|
80
|
+
color: var(--text-muted);
|
|
81
|
+
}
|
|
82
|
+
</style>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A standing marker that this is not production.
|
|
4
|
+
*
|
|
5
|
+
* Renders on every non-production build and on none of production, from
|
|
6
|
+
* `site.indexable` — the one environment switch. There is no second flag and no
|
|
7
|
+
* way to leave it on by accident, because nothing turns it on by hand.
|
|
8
|
+
*
|
|
9
|
+
* ── IT VERIFIES, IT DOES NOT ASSERT ────────────────────────────────────────
|
|
10
|
+
* A badge that printed "STAGING · NOINDEX" from a build variable would tell you
|
|
11
|
+
* what the build *intended*. That is the thing already known. What is worth
|
|
12
|
+
* showing is what a crawler would actually find, so the script below reads the
|
|
13
|
+
* live DOM — the real `<meta name="robots">`, the real analytics tags — and
|
|
14
|
+
* says **NOT NOINDEX** in an alarm state when the two disagree.
|
|
15
|
+
*
|
|
16
|
+
* That is the case this exists for: a staging site that is quietly indexable
|
|
17
|
+
* competes with production for the client's own terms, and nothing on the page
|
|
18
|
+
* looks wrong.
|
|
19
|
+
*
|
|
20
|
+
* ── WHY IT CANNOT BE CLICKED ───────────────────────────────────────────────
|
|
21
|
+
* `pointer-events: none`. A fixed overlay that intercepts a click is a bug you
|
|
22
|
+
* find while demoing to a client. Hide it for a screenshot with `?nobadge=1`,
|
|
23
|
+
* which lasts the session — never a dismissal that persists, because a badge
|
|
24
|
+
* you dismissed on Tuesday is not there to warn you on Friday.
|
|
25
|
+
*/
|
|
26
|
+
import { site } from '../data/site';
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
!site.indexable && (
|
|
31
|
+
<div id="env-badge" data-env={site.env} hidden>
|
|
32
|
+
<span data-env-label>{site.env}</span>
|
|
33
|
+
<span data-env-state />
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
!site.indexable && (
|
|
40
|
+
<script is:inline>
|
|
41
|
+
(function () {
|
|
42
|
+
var el = document.getElementById('env-badge');
|
|
43
|
+
if (!el) return;
|
|
44
|
+
|
|
45
|
+
/* ?nobadge=1 for screenshots. sessionStorage, so it comes back — a
|
|
46
|
+
dismissal that outlives the session is a warning that is not there
|
|
47
|
+
the day it matters. */
|
|
48
|
+
try {
|
|
49
|
+
if (new URLSearchParams(location.search).has('nobadge')) {
|
|
50
|
+
sessionStorage.setItem('nobadge', '1');
|
|
51
|
+
}
|
|
52
|
+
if (sessionStorage.getItem('nobadge')) return;
|
|
53
|
+
} catch (e) {
|
|
54
|
+
/* private mode: show the badge rather than fail closed */
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
var robots = document.querySelector('meta[name="robots"]');
|
|
58
|
+
var noindex = !!robots && /noindex/i.test(robots.getAttribute('content') || '');
|
|
59
|
+
|
|
60
|
+
/* The needle is assembled rather than written out. A detector that
|
|
61
|
+
contains the literal it looks for shows up in every grep-based scan
|
|
62
|
+
for that literal — including this kit's own staging check, which
|
|
63
|
+
asserts a staging page carries ZERO analytics references. Spelling it
|
|
64
|
+
out here would have made the badge the thing that failed it. */
|
|
65
|
+
var tagHost = 'google' + 'tagmanager';
|
|
66
|
+
var analytics = !!(
|
|
67
|
+
document.querySelector('script[src*="' + tagHost + '"]') ||
|
|
68
|
+
window.dataLayer ||
|
|
69
|
+
window.gtag
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
var state = el.querySelector('[data-env-state]');
|
|
73
|
+
var problems = [];
|
|
74
|
+
if (!noindex) problems.push('NOT NOINDEX');
|
|
75
|
+
if (analytics) problems.push('ANALYTICS LIVE');
|
|
76
|
+
|
|
77
|
+
if (problems.length) {
|
|
78
|
+
el.setAttribute('data-alarm', '');
|
|
79
|
+
state.textContent = ' · ' + problems.join(' · ');
|
|
80
|
+
} else {
|
|
81
|
+
state.textContent = ' · noindex';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
el.hidden = false;
|
|
85
|
+
})();
|
|
86
|
+
</script>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
<style>
|
|
91
|
+
/*
|
|
92
|
+
* Structure and state, not a look — the same rule as everything else in the
|
|
93
|
+
* shared layer. It uses `--danger`, which is a semantic token every project
|
|
94
|
+
* defines, never a brand colour, so it stays legible before the ramp is set
|
|
95
|
+
* and does not become a design decision the template made for you.
|
|
96
|
+
*/
|
|
97
|
+
#env-badge {
|
|
98
|
+
position: fixed;
|
|
99
|
+
inset-block-end: 1rem;
|
|
100
|
+
inset-inline-start: 1rem;
|
|
101
|
+
z-index: 2147483647;
|
|
102
|
+
|
|
103
|
+
/* Never intercepts a click, a tap, a hover or a drag. */
|
|
104
|
+
pointer-events: none;
|
|
105
|
+
user-select: none;
|
|
106
|
+
|
|
107
|
+
padding: 0.4rem 0.9rem;
|
|
108
|
+
border-radius: 999px;
|
|
109
|
+
font: 600 var(--step--2, 0.72rem) / 1 var(--font-body);
|
|
110
|
+
letter-spacing: 0.08em;
|
|
111
|
+
text-transform: uppercase;
|
|
112
|
+
color: var(--white);
|
|
113
|
+
background: var(--danger);
|
|
114
|
+
box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.3));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Something disagrees with the environment — make it impossible to miss. */
|
|
118
|
+
/* Darker than --danger, derived from it rather than picked — no second
|
|
119
|
+
colour for a project to keep in step, and no hex in a component. */
|
|
120
|
+
#env-badge[data-alarm] {
|
|
121
|
+
background: color-mix(in srgb, var(--danger) 78%, black);
|
|
122
|
+
animation: env-badge-pulse 1.6s ease-in-out infinite;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@keyframes env-badge-pulse {
|
|
126
|
+
50% {
|
|
127
|
+
opacity: 0.55;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* The alarm still reads without motion; only the pulse goes. */
|
|
132
|
+
@media (prefers-reduced-motion: reduce) {
|
|
133
|
+
#env-badge[data-alarm] {
|
|
134
|
+
animation: none;
|
|
135
|
+
outline: 2px solid var(--white);
|
|
136
|
+
outline-offset: 2px;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* A PDF of a staging page should not carry it. */
|
|
141
|
+
@media print {
|
|
142
|
+
#env-badge {
|
|
143
|
+
display: none;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
</style>
|