@evinvest/kitstart 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 +245 -0
- package/dist/cli/kitstart-size.d.ts +2 -0
- package/dist/cli/kitstart-size.js +11 -0
- package/dist/cli/size.d.ts +31 -0
- package/dist/cli/size.js +99 -0
- package/dist/core/accept.d.ts +49 -0
- package/dist/core/accept.js +78 -0
- package/dist/core/analytics.d.ts +43 -0
- package/dist/core/analytics.js +42 -0
- package/dist/core/antispam.d.ts +60 -0
- package/dist/core/antispam.js +90 -0
- package/dist/core/content.d.ts +82 -0
- package/dist/core/content.js +12 -0
- package/dist/core/lead.d.ts +88 -0
- package/dist/core/lead.js +42 -0
- package/dist/core/memo.d.ts +8 -0
- package/dist/core/memo.js +19 -0
- package/dist/core/place/index.d.ts +5 -0
- package/dist/core/place/index.js +5 -0
- package/dist/core/place/live.d.ts +30 -0
- package/dist/core/place/live.js +132 -0
- package/dist/core/place/publication.d.ts +29 -0
- package/dist/core/place/publication.js +42 -0
- package/dist/core/place/rating.d.ts +16 -0
- package/dist/core/place/rating.js +33 -0
- package/dist/core/place/types.d.ts +91 -0
- package/dist/core/place/types.js +27 -0
- package/dist/core/place/view.d.ts +56 -0
- package/dist/core/place/view.js +43 -0
- package/dist/core/routing.d.ts +121 -0
- package/dist/core/routing.js +198 -0
- package/dist/core/seo/ld.d.ts +58 -0
- package/dist/core/seo/ld.js +161 -0
- package/dist/core/seo/sitemap.d.ts +52 -0
- package/dist/core/seo/sitemap.js +60 -0
- package/dist/core/site.d.ts +118 -0
- package/dist/core/site.js +84 -0
- package/dist/core/status.d.ts +47 -0
- package/dist/core/status.js +62 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +20 -0
- package/dist/next/config/build-env.d.ts +32 -0
- package/dist/next/config/build-env.js +101 -0
- package/dist/next/config/index.d.ts +30 -0
- package/dist/next/config/index.js +38 -0
- package/dist/next/index.d.ts +12 -0
- package/dist/next/index.js +12 -0
- package/dist/next/load.d.ts +24 -0
- package/dist/next/load.js +36 -0
- package/dist/next/metadata.d.ts +31 -0
- package/dist/next/metadata.js +66 -0
- package/dist/next/og-palette.d.ts +14 -0
- package/dist/next/og-palette.js +14 -0
- package/dist/next/og.d.ts +31 -0
- package/dist/next/og.js +42 -0
- package/dist/next/quote-route.d.ts +36 -0
- package/dist/next/quote-route.js +124 -0
- package/dist/next/routes.d.ts +15 -0
- package/dist/next/routes.js +50 -0
- package/dist/proxy/index.d.ts +31 -0
- package/dist/proxy/index.js +108 -0
- package/dist/react/AnalyticsBoundary.d.ts +14 -0
- package/dist/react/AnalyticsBoundary.js +55 -0
- package/dist/react/AreaChips.d.ts +8 -0
- package/dist/react/AreaChips.js +11 -0
- package/dist/react/CallBar.d.ts +29 -0
- package/dist/react/CallBar.js +7 -0
- package/dist/react/Coverage.d.ts +24 -0
- package/dist/react/Coverage.js +10 -0
- package/dist/react/Faq.d.ts +17 -0
- package/dist/react/Faq.js +5 -0
- package/dist/react/LangSwitch.d.ts +24 -0
- package/dist/react/LangSwitch.js +17 -0
- package/dist/react/MapFacade.d.ts +18 -0
- package/dist/react/MapFacade.js +8 -0
- package/dist/react/PlaceDirectory.d.ts +25 -0
- package/dist/react/PlaceDirectory.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +34 -0
- package/dist/react/QuoteFormShell.js +10 -0
- package/dist/react/StatusScreen.d.ts +36 -0
- package/dist/react/StatusScreen.js +22 -0
- package/dist/react/index.d.ts +27 -0
- package/dist/react/index.js +29 -0
- package/dist/react/parts.d.ts +7 -0
- package/dist/react/parts.js +1 -0
- package/dist/server/boot.d.ts +11 -0
- package/dist/server/boot.js +20 -0
- package/dist/server/client-key.d.ts +22 -0
- package/dist/server/client-key.js +20 -0
- package/dist/server/env.d.ts +44 -0
- package/dist/server/env.js +73 -0
- package/dist/server/index.d.ts +15 -0
- package/dist/server/index.js +15 -0
- package/dist/server/lead-store-postgres.d.ts +14 -0
- package/dist/server/lead-store-postgres.js +23 -0
- package/dist/server/lead-store-sqlite.d.ts +21 -0
- package/dist/server/lead-store-sqlite.js +158 -0
- package/dist/server/lead-store.d.ts +20 -0
- package/dist/server/lead-store.js +43 -0
- package/dist/server/notify.d.ts +37 -0
- package/dist/server/notify.js +79 -0
- package/dist/server/place-source.d.ts +48 -0
- package/dist/server/place-source.js +107 -0
- package/dist/server/smtp.d.ts +38 -0
- package/dist/server/smtp.js +197 -0
- package/dist/testing/e2e.d.ts +52 -0
- package/dist/testing/e2e.js +57 -0
- package/dist/testing/fixtures.d.ts +11 -0
- package/dist/testing/fixtures.js +53 -0
- package/dist/testing/index.d.ts +12 -0
- package/dist/testing/index.js +12 -0
- package/dist/testing/landing-contract.d.ts +33 -0
- package/dist/testing/landing-contract.js +95 -0
- package/dist/testing/lead-store-contract.d.ts +13 -0
- package/dist/testing/lead-store-contract.js +55 -0
- package/dist/testing/served-files.d.ts +7 -0
- package/dist/testing/served-files.js +41 -0
- package/package.json +129 -0
- package/template/README.md +55 -0
- package/template/app/[locale]/[location]/layout.tsx +29 -0
- package/template/app/[locale]/[location]/page.tsx +18 -0
- package/template/app/[locale]/[location]/prices/page.tsx +26 -0
- package/template/app/[locale]/[location]/thanks/page.tsx +20 -0
- package/template/app/[locale]/error.tsx +14 -0
- package/template/app/[locale]/layout.tsx +28 -0
- package/template/app/[locale]/not-found.tsx +18 -0
- package/template/app/[locale]/page.tsx +40 -0
- package/template/app/[locale]/thanks/page.tsx +21 -0
- package/template/app/global-not-found.tsx +28 -0
- package/template/app/globals.css +18 -0
- package/template/app/health/route.ts +6 -0
- package/template/app/og/route.tsx +27 -0
- package/template/app/quote/route.ts +17 -0
- package/template/app/robots.ts +6 -0
- package/template/app/sitemap.ts +8 -0
- package/template/assets/brand.toml +68 -0
- package/template/assets/card.toml +8 -0
- package/template/assets/fonts/Inter-OFL.txt +93 -0
- package/template/assets/fonts/Inter-Regular.ttf +0 -0
- package/template/assets/fonts/Inter-Regular.woff2 +0 -0
- package/template/assets/mark.svg +1 -0
- package/template/deploy/config.nix +24 -0
- package/template/eslint.config.mjs +28 -0
- package/template/flake.nix +46 -0
- package/template/instrumentation.ts +13 -0
- package/template/next.config.ts +5 -0
- package/template/package.json +44 -0
- package/template/postcss.config.mjs +3 -0
- package/template/proxy.ts +11 -0
- package/template/src/entities/content/index.ts +12 -0
- package/template/src/entities/content/model/en.ts +47 -0
- package/template/src/entities/content/model/fr.ts +47 -0
- package/template/src/entities/content/model/types.ts +37 -0
- package/template/src/entities/place/api/load.ts +12 -0
- package/template/src/entities/place/index.ts +3 -0
- package/template/src/entities/place/server.ts +1 -0
- package/template/src/shared/config/env.ts +17 -0
- package/template/src/shared/config/i18n.ts +15 -0
- package/template/src/shared/config/lead.ts +15 -0
- package/template/src/shared/config/places.ts +20 -0
- package/template/src/shared/config/public.ts +11 -0
- package/template/src/shared/config/site.ts +39 -0
- package/template/src/shared/ui/fonts.ts +15 -0
- package/template/src/views/home/index.ts +1 -0
- package/template/src/views/home/ui/PlaceHome.tsx +76 -0
- package/template/src/views/not-found/index.ts +1 -0
- package/template/src/views/not-found/server.ts +1 -0
- package/template/src/views/not-found/ui/Gone.tsx +29 -0
- package/template/src/views/not-found/ui/NotFound.tsx +27 -0
- package/template/src/views/place/server.ts +13 -0
- package/template/src/views/server-error/index.ts +1 -0
- package/template/src/views/server-error/ui/ServerError.tsx +24 -0
- package/template/steiger.config.ts +26 -0
- package/template/tests/bundle_budget.txt +8 -0
- package/template/tests/contract.test.ts +14 -0
- package/template/tests/e2e/playwright.config.ts +26 -0
- package/template/tests/e2e/screenshot-section.css +5 -0
- package/template/tests/e2e/sections.spec.ts +12 -0
- package/template/tests/e2e/tsconfig.json +11 -0
- package/template/tests/empty.ts +1 -0
- package/template/tsconfig.json +23 -0
- package/template/vitest.config.ts +20 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { headers } from "next/headers.js";
|
|
2
|
+
import { createRouting } from "../core/routing.js";
|
|
3
|
+
import { robotsFor, sitemapFor } from "../core/seo/sitemap.js";
|
|
4
|
+
/**
|
|
5
|
+
* `app/sitemap.ts`, `app/robots.ts` and `app/health/route.ts` in three lines
|
|
6
|
+
* each. The two metadata routes read the `Host` header — a sitemap may only
|
|
7
|
+
* list URLs on its own host — so the brand's file says
|
|
8
|
+
* `export const dynamic = "force-dynamic"` itself (Next reads it statically).
|
|
9
|
+
* They are cheap; the pages are what is cached.
|
|
10
|
+
*/
|
|
11
|
+
async function host() {
|
|
12
|
+
return (await headers()).get("host") ?? "";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Strict: with a live source configured, an unreachable one throws, and the
|
|
16
|
+
* crawler keeps its last copy — an empty or truncated sitemap would tell it
|
|
17
|
+
* the place is gone.
|
|
18
|
+
*/
|
|
19
|
+
export function sitemapRoute(site, source) {
|
|
20
|
+
const routing = createRouting(site);
|
|
21
|
+
return async () => {
|
|
22
|
+
const h = await host();
|
|
23
|
+
// A host's sitemap lists at most its own place, so that is the one fetch;
|
|
24
|
+
// the apex of a subdomains site lists only the brand page and asks nothing.
|
|
25
|
+
const slug = routing.hostSlug(h);
|
|
26
|
+
if (slug === null)
|
|
27
|
+
return sitemapFor(site, h, []);
|
|
28
|
+
const place = await source.getPlaceStrict(slug, site.i18n.defaultLocale);
|
|
29
|
+
return sitemapFor(site, h, place ? [place] : []);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function robotsRoute(site) {
|
|
33
|
+
return async () => robotsFor(site, await host());
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The readiness probe: the process answers. `check` is for a brand that wants
|
|
37
|
+
* more (the lead store's `health()`); a failing check answers 503.
|
|
38
|
+
*/
|
|
39
|
+
export function healthRoute(check) {
|
|
40
|
+
return async () => {
|
|
41
|
+
try {
|
|
42
|
+
await check?.();
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.error("health: check failed", error);
|
|
46
|
+
return new Response("unavailable", { status: 503, headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
|
47
|
+
}
|
|
48
|
+
return new Response("ok", { headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/proxy` — the routing decision applied to a live request.
|
|
3
|
+
* Edge-safe: it reads only the core and `next/server`.
|
|
4
|
+
*
|
|
5
|
+
* It hands a page nothing but the path it rewrites to. A page that read a
|
|
6
|
+
* request header would render per request, and every page here is cached
|
|
7
|
+
* (ISR); the link mode rides in the route param instead (`/fr/_royat`). The
|
|
8
|
+
* one header it sets goes to the 404 route (`GONE_HEADER`), which is not a
|
|
9
|
+
* page of any segment. A client-sent copy is stripped from every request but
|
|
10
|
+
* the 404 route's own second pass (see `ownGoneHeader`).
|
|
11
|
+
*
|
|
12
|
+
* The brand's `proxy.ts` keeps its `config.matcher` a literal — Next reads it
|
|
13
|
+
* statically — and takes the handler from here:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* export const proxy = createProxy(site);
|
|
17
|
+
* export const config = { matcher: ["/((?!_next/).*)"] };
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
import { NextResponse, type NextRequest } from "next/server.js";
|
|
21
|
+
import type { Site } from "../core/site.js";
|
|
22
|
+
/**
|
|
23
|
+
* The matcher the brand's `proxy.ts` spells out: everything but the build
|
|
24
|
+
* output. Paths with an extension come in too — let past, `/wp-login.php`
|
|
25
|
+
* would be a cached bare 404 — and `decide`, which owns the list of what is
|
|
26
|
+
* not a page, passes the real files. Exported so a contract test can compare
|
|
27
|
+
* the brand's literal against it.
|
|
28
|
+
*/
|
|
29
|
+
export declare const PROXY_MATCHER = "/((?!_next/).*)";
|
|
30
|
+
export declare function createProxy<L extends string, P extends string>(site: Site<L, P>): (request: NextRequest) => NextResponse;
|
|
31
|
+
export { GONE_HEADER, LANG_COOKIE, LANG_COOKIE_MAX_AGE } from "../core/routing.js";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@evinvest/kitstart/proxy` — the routing decision applied to a live request.
|
|
3
|
+
* Edge-safe: it reads only the core and `next/server`.
|
|
4
|
+
*
|
|
5
|
+
* It hands a page nothing but the path it rewrites to. A page that read a
|
|
6
|
+
* request header would render per request, and every page here is cached
|
|
7
|
+
* (ISR); the link mode rides in the route param instead (`/fr/_royat`). The
|
|
8
|
+
* one header it sets goes to the 404 route (`GONE_HEADER`), which is not a
|
|
9
|
+
* page of any segment. A client-sent copy is stripped from every request but
|
|
10
|
+
* the 404 route's own second pass (see `ownGoneHeader`).
|
|
11
|
+
*
|
|
12
|
+
* The brand's `proxy.ts` keeps its `config.matcher` a literal — Next reads it
|
|
13
|
+
* statically — and takes the handler from here:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* export const proxy = createProxy(site);
|
|
17
|
+
* export const config = { matcher: ["/((?!_next/).*)"] };
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
// `next/server.js`, not `next/server`: `next` has no `exports` map, so plain
|
|
21
|
+
// Node ESM resolves only the file name. Every bundler accepts both.
|
|
22
|
+
import { NextResponse } from "next/server.js";
|
|
23
|
+
import { createRouting, GONE_HEADER, goneHeader, gonePath, LANG_COOKIE, LANG_COOKIE_MAX_AGE, parseGoneHeader, parsePlaceParam } from "../core/routing.js";
|
|
24
|
+
/** The bare URL's answer depends on both; a shared cache must key on them. */
|
|
25
|
+
const VARY = "Accept-Language, Cookie";
|
|
26
|
+
/** A redirect chosen for this visitor: no cache may keep it for the next. */
|
|
27
|
+
const PER_VISITOR = "private, no-store";
|
|
28
|
+
/**
|
|
29
|
+
* The matcher the brand's `proxy.ts` spells out: everything but the build
|
|
30
|
+
* output. Paths with an extension come in too — let past, `/wp-login.php`
|
|
31
|
+
* would be a cached bare 404 — and `decide`, which owns the list of what is
|
|
32
|
+
* not a page, passes the real files. Exported so a contract test can compare
|
|
33
|
+
* the brand's literal against it.
|
|
34
|
+
*/
|
|
35
|
+
export const PROXY_MATCHER = "/((?!_next/).*)";
|
|
36
|
+
export function createProxy(site) {
|
|
37
|
+
const routing = createRouting(site);
|
|
38
|
+
/**
|
|
39
|
+
* Next renders a rewrite target by running this proxy again on it, so the
|
|
40
|
+
* 404 route arrives here carrying the header the first pass set. It is kept
|
|
41
|
+
* there, and only there, when it names that path's language and a real
|
|
42
|
+
* place: forged by hand, it can at most pick another real place's phone.
|
|
43
|
+
*/
|
|
44
|
+
const ownGoneHeader = (request) => {
|
|
45
|
+
const path = /^\/([^/]+)\/404\/404$/.exec(request.nextUrl.pathname);
|
|
46
|
+
if (!path || gonePath(path[1] ?? "") !== request.nextUrl.pathname)
|
|
47
|
+
return false;
|
|
48
|
+
const gone = parseGoneHeader(request.headers.get(GONE_HEADER));
|
|
49
|
+
if (gone.locale !== path[1])
|
|
50
|
+
return false;
|
|
51
|
+
return gone.location === undefined || site.placeSlugs.includes(parsePlaceParam(gone.location).slug);
|
|
52
|
+
};
|
|
53
|
+
/** Onward, minus a client-sent gone header; untouched (no override) when there is none. */
|
|
54
|
+
const onward = (request, rewrite) => {
|
|
55
|
+
if (!request.headers.has(GONE_HEADER) || ownGoneHeader(request))
|
|
56
|
+
return rewrite ? NextResponse.rewrite(rewrite) : NextResponse.next();
|
|
57
|
+
const headers = new Headers(request.headers);
|
|
58
|
+
headers.delete(GONE_HEADER);
|
|
59
|
+
return rewrite ? NextResponse.rewrite(rewrite, { request: { headers } }) : NextResponse.next({ request: { headers } });
|
|
60
|
+
};
|
|
61
|
+
return function proxy(request) {
|
|
62
|
+
const url = request.nextUrl;
|
|
63
|
+
const decision = routing.decide({
|
|
64
|
+
host: request.headers.get("host") ?? url.host,
|
|
65
|
+
pathname: url.pathname,
|
|
66
|
+
query: url.searchParams,
|
|
67
|
+
acceptLanguage: request.headers.get("accept-language"),
|
|
68
|
+
cookieLang: request.cookies.get(LANG_COOKIE)?.value ?? null,
|
|
69
|
+
});
|
|
70
|
+
switch (decision.kind) {
|
|
71
|
+
case "pass":
|
|
72
|
+
return onward(request, null);
|
|
73
|
+
case "moved":
|
|
74
|
+
return NextResponse.redirect(new URL(decision.location, url), 301);
|
|
75
|
+
case "negotiate": {
|
|
76
|
+
const response = NextResponse.redirect(new URL(decision.location, url), 302);
|
|
77
|
+
response.headers.set("Vary", VARY);
|
|
78
|
+
response.headers.set("Cache-Control", PER_VISITOR);
|
|
79
|
+
return response;
|
|
80
|
+
}
|
|
81
|
+
case "choose": {
|
|
82
|
+
const response = NextResponse.redirect(new URL(decision.location, url), 303);
|
|
83
|
+
response.headers.set("Cache-Control", PER_VISITOR);
|
|
84
|
+
response.cookies.set(LANG_COOKIE, decision.locale, {
|
|
85
|
+
path: "/",
|
|
86
|
+
maxAge: LANG_COOKIE_MAX_AGE,
|
|
87
|
+
sameSite: "lax",
|
|
88
|
+
httpOnly: true,
|
|
89
|
+
secure: true,
|
|
90
|
+
});
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
case "serve":
|
|
94
|
+
// No `Vary`: every page's language is in its path, so a prefixed page
|
|
95
|
+
// is the same bytes for every visitor (and Next overwrites `Vary` on
|
|
96
|
+
// rendered pages anyway).
|
|
97
|
+
return onward(request, decision.pathname === url.pathname ? null : new URL(`${decision.pathname}${url.search}`, url));
|
|
98
|
+
case "gone": {
|
|
99
|
+
// To a path no route matches, which Next answers 404 from
|
|
100
|
+
// `app/global-not-found.tsx` — the one reader of the header.
|
|
101
|
+
const headers = new Headers(request.headers);
|
|
102
|
+
headers.set(GONE_HEADER, goneHeader(decision.locale, decision.location));
|
|
103
|
+
return NextResponse.rewrite(new URL(gonePath(decision.locale), url), { request: { headers } });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export { GONE_HEADER, LANG_COOKIE, LANG_COOKIE_MAX_AGE } from "../core/routing.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type AnalyticsTarget } from "../core/analytics.js";
|
|
3
|
+
/**
|
|
4
|
+
* The only client island analytics needs. Everything it wraps stays server
|
|
5
|
+
* rendered; this adds one delegated listener (capture phase, never cancelling
|
|
6
|
+
* the navigation) and a page-view beacon. With no key it sends nothing, and it
|
|
7
|
+
* writes no cookie. `target` is plain data from the server layout — the key
|
|
8
|
+
* is read from the container when the page renders, never inlined.
|
|
9
|
+
*/
|
|
10
|
+
export declare function AnalyticsBoundary({ target, placeSlug, children }: {
|
|
11
|
+
target: AnalyticsTarget;
|
|
12
|
+
placeSlug: string | null;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { ContactLinkTracker } from "@evinvest/marketing/tracker";
|
|
4
|
+
import { usePathname } from "next/navigation.js";
|
|
5
|
+
import { useEffect, useMemo } from "react";
|
|
6
|
+
import { analyticsSink, countsAsPageView, EVENTS } from "../core/analytics.js";
|
|
7
|
+
/** `data-intent` on a link or button marks a conversion intent that is not a contact link. */
|
|
8
|
+
const INTENT_ATTR = "data-intent";
|
|
9
|
+
const INTENTS = ["form_open", "booking"];
|
|
10
|
+
function source() {
|
|
11
|
+
const utm = new URLSearchParams(window.location.search).get("utm_source");
|
|
12
|
+
if (utm)
|
|
13
|
+
return utm.slice(0, 64);
|
|
14
|
+
if (!document.referrer)
|
|
15
|
+
return "direct";
|
|
16
|
+
try {
|
|
17
|
+
const host = new URL(document.referrer).hostname;
|
|
18
|
+
return host === window.location.hostname ? "internal" : host;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The only client island analytics needs. Everything it wraps stays server
|
|
26
|
+
* rendered; this adds one delegated listener (capture phase, never cancelling
|
|
27
|
+
* the navigation) and a page-view beacon. With no key it sends nothing, and it
|
|
28
|
+
* writes no cookie. `target` is plain data from the server layout — the key
|
|
29
|
+
* is read from the container when the page renders, never inlined.
|
|
30
|
+
*/
|
|
31
|
+
export function AnalyticsBoundary({ target, placeSlug, children }) {
|
|
32
|
+
// On the target's values: a server layout hands a fresh object every render.
|
|
33
|
+
const { key, host, brandId } = target;
|
|
34
|
+
const sink = useMemo(() => analyticsSink({ key, host, brandId }, placeSlug), [key, host, brandId, placeSlug]);
|
|
35
|
+
const pathname = usePathname();
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!countsAsPageView(pathname))
|
|
38
|
+
return;
|
|
39
|
+
sink.capture(EVENTS.pageView, {
|
|
40
|
+
source: source(),
|
|
41
|
+
device: window.matchMedia("(max-width: 767px)").matches ? "mobile" : "desktop",
|
|
42
|
+
});
|
|
43
|
+
}, [sink, pathname]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const onClick = (event) => {
|
|
46
|
+
const el = event.target instanceof Element ? event.target.closest(`[${INTENT_ATTR}]`) : null;
|
|
47
|
+
const channel = el?.getAttribute(INTENT_ATTR);
|
|
48
|
+
if (channel && INTENTS.some(i => i === channel))
|
|
49
|
+
sink.capture(EVENTS.intent, { channel }, { transport: "beacon" });
|
|
50
|
+
};
|
|
51
|
+
document.addEventListener("click", onClick, { capture: true });
|
|
52
|
+
return () => document.removeEventListener("click", onClick, { capture: true });
|
|
53
|
+
}, [sink]);
|
|
54
|
+
return (_jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel }) => sink.capture(EVENTS.intent, { channel }, { transport: "beacon" }), children: children }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the van goes, as chips. A visitor's first question is whether you
|
|
3
|
+
* come to them at all; a service-area business has no map to show instead.
|
|
4
|
+
*/
|
|
5
|
+
export declare function AreaChips({ areas, className }: {
|
|
6
|
+
areas: readonly string[];
|
|
7
|
+
className?: string;
|
|
8
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@evinvest/uikit";
|
|
3
|
+
/**
|
|
4
|
+
* Where the van goes, as chips. A visitor's first question is whether you
|
|
5
|
+
* come to them at all; a service-area business has no map to show instead.
|
|
6
|
+
*/
|
|
7
|
+
export function AreaChips({ areas, className }) {
|
|
8
|
+
if (areas.length === 0)
|
|
9
|
+
return null;
|
|
10
|
+
return (_jsx("ul", { className: cn("flex flex-wrap gap-2 md:gap-2.5", className), children: areas.map(area => (_jsx("li", { className: "rounded-full border border-border bg-muted px-4 py-2 text-sm font-medium text-ink-mid", children: area }, area))) }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CallBarText, CopySlice } from "../core/content.js";
|
|
2
|
+
import type { PartClassNames } from "./parts.js";
|
|
3
|
+
export type CallBarPart = "call" | "whatsapp" | "quote";
|
|
4
|
+
/**
|
|
5
|
+
* Pinned to the bottom, mobile only. The header scrolls away with the hero,
|
|
6
|
+
* so without this there is no contact channel on a phone between the hero and
|
|
7
|
+
* the closing band. `sticky`, not `fixed`: it is the last thing in the flow,
|
|
8
|
+
* so it reserves its own height instead of the page guessing it back.
|
|
9
|
+
*
|
|
10
|
+
* The form and WhatsApp lead; the phone keeps a square of its own. A channel
|
|
11
|
+
* the place does not have is left out, never rendered dead.
|
|
12
|
+
*/
|
|
13
|
+
export interface CallBarProps<L extends string, F> {
|
|
14
|
+
copy: CopySlice<L, CallBarText<F>, F>;
|
|
15
|
+
phone: string | null;
|
|
16
|
+
whatsapp: string | null;
|
|
17
|
+
/** Where the form is: `view.href("#quote")`. */
|
|
18
|
+
quoteHref: string;
|
|
19
|
+
/** The bar's accessible name: "Contact". */
|
|
20
|
+
label: string;
|
|
21
|
+
/** For a page that anchors or shoots the bar (`#callbar` in e2e). */
|
|
22
|
+
id?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
/** The brand's CTA face (type, weight), applied to every button. */
|
|
25
|
+
buttonClassName?: string;
|
|
26
|
+
/** Per button, after `buttonClassName`: the geometry of each channel. */
|
|
27
|
+
classNames?: PartClassNames<CallBarPart>;
|
|
28
|
+
}
|
|
29
|
+
export declare function CallBar<L extends string, F>({ copy, phone, whatsapp, quoteHref, label, id, className, buttonClassName, classNames: c }: CallBarProps<L, F>): import("react").JSX.Element;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { telHref, whatsappHref } from "@evinvest/marketing";
|
|
3
|
+
import { Button, cn } from "@evinvest/uikit";
|
|
4
|
+
export function CallBar({ copy, phone, whatsapp, quoteHref, label, id, className, buttonClassName, classNames: c }) {
|
|
5
|
+
const { t, f } = copy;
|
|
6
|
+
return (_jsxs("nav", { id: id, "aria-label": label, className: cn("sticky bottom-0 z-30 flex gap-2 border-t border-border bg-background px-3 py-2.5 shadow-overlay md:hidden", className), children: [phone && (_jsx(Button, { href: telHref(phone), size: "xl", variant: "outline", "aria-label": t.callLabel(f), className: cn("shrink-0 px-4", buttonClassName, c?.call), children: _jsx("span", { "aria-hidden": "true", children: "\u260E" }) })), whatsapp && (_jsx(Button, { href: whatsappHref(whatsapp, t.whatsappMessage(f)), size: "xl", variant: "outline", className: cn("flex-1 px-3", buttonClassName, c?.whatsapp), children: t.whatsappShort })), _jsx(Button, { href: quoteHref, size: "xl", "data-intent": "form_open", className: cn("flex-1 px-3", buttonClassName, c?.quote), children: t.ctaShort })] }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type Place } from "../core/place/types.js";
|
|
3
|
+
import type { PartClassNames } from "./parts.js";
|
|
4
|
+
export type CoveragePart = "chips" | "landmark" | "map";
|
|
5
|
+
/**
|
|
6
|
+
* Where a place works: its communes as chips, and — for a storefront only —
|
|
7
|
+
* its landmark and a map behind a click. A service-area business has no
|
|
8
|
+
* address in its type, so it gets neither, by construction. The band around
|
|
9
|
+
* it (a `Section`, a heading) is the brand's.
|
|
10
|
+
*/
|
|
11
|
+
export interface CoverageProps<L extends string> {
|
|
12
|
+
place: Place<L>;
|
|
13
|
+
locale: L;
|
|
14
|
+
/** The map button's words; the map is left out when absent. */
|
|
15
|
+
map?: {
|
|
16
|
+
title: string;
|
|
17
|
+
show: string;
|
|
18
|
+
};
|
|
19
|
+
/** Anything the brand puts above the chips: its section head. */
|
|
20
|
+
head?: ReactNode;
|
|
21
|
+
className?: string;
|
|
22
|
+
classNames?: PartClassNames<CoveragePart>;
|
|
23
|
+
}
|
|
24
|
+
export declare function Coverage<L extends string>({ place, locale, map, head, className, classNames: c }: CoverageProps<L>): import("react").JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@evinvest/uikit";
|
|
3
|
+
import { servedLocalities, storefrontOf } from "../core/place/types.js";
|
|
4
|
+
import { AreaChips } from "./AreaChips.js";
|
|
5
|
+
import { MapFacade } from "./MapFacade.js";
|
|
6
|
+
export function Coverage({ place, locale, map, head, className, classNames: c }) {
|
|
7
|
+
const front = storefrontOf(place);
|
|
8
|
+
const address = front && `${front.address.street}, ${front.address.postalCode} ${front.address.locality}`;
|
|
9
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-6 md:gap-8", className), children: [head, _jsx(AreaChips, { areas: servedLocalities(place), ...(c?.chips ? { className: c.chips } : {}) }), front?.landmark && _jsx("p", { className: cn("text-sm text-ink-soft", c?.landmark), children: front.landmark[locale] }), address && map && _jsx(MapFacade, { query: `${place.gbpName}, ${address}`, title: map.title, show: map.show, address: address, ...(c?.map ? { className: c.map } : {}) })] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { QuestionAnswer } from "../core/seo/ld.js";
|
|
3
|
+
import type { PartClassNames } from "./parts.js";
|
|
4
|
+
export type FaqPart = "list" | "item" | "summary" | "question" | "icon" | "answer";
|
|
5
|
+
/**
|
|
6
|
+
* `<details>`, not an accordion component: it opens without hydration, is
|
|
7
|
+
* keyboard- and screen-reader-correct for free, and the FAQPage JSON-LD
|
|
8
|
+
* (`faqPageNode`) reads the same strings.
|
|
9
|
+
*/
|
|
10
|
+
export interface FaqProps {
|
|
11
|
+
items: readonly QuestionAnswer[];
|
|
12
|
+
head?: ReactNode;
|
|
13
|
+
id?: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
classNames?: PartClassNames<FaqPart>;
|
|
16
|
+
}
|
|
17
|
+
export declare function Faq({ items, head, id, className, classNames: c }: FaqProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@evinvest/uikit";
|
|
3
|
+
export function Faq({ items, head, id = "faq", className, classNames: c }) {
|
|
4
|
+
return (_jsxs("div", { id: id, className: cn("flex flex-col gap-6 md:gap-11", className), children: [head, _jsx("div", { className: cn("overflow-hidden rounded-xl border border-border bg-background", c?.list), children: items.map(item => (_jsxs("details", { className: cn("group border-b border-border last:border-b-0", c?.item), children: [_jsxs("summary", { className: cn("flex cursor-pointer list-none items-start gap-4 px-5 py-5 md:px-8 md:py-6 [&::-webkit-details-marker]:hidden", c?.summary), children: [_jsx("span", { className: cn("flex-1 font-display text-lg font-bold leading-snug text-ink", c?.question), children: item.q }), _jsx("span", { className: cn("text-lg text-primary-ink transition-transform group-open:rotate-45", c?.icon), "aria-hidden": "true", children: "+" })] }), _jsx("p", { className: cn("px-5 pb-5 text-base leading-relaxed text-ink-soft md:px-8 md:pb-6", c?.answer), children: item.a })] }, item.q))) })] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FR · EN`, each linking to the same page in that language. The `?lang=` is
|
|
3
|
+
* what mints the cookie in the proxy, so the choice survives the next bare
|
|
4
|
+
* visit and the negotiator stops fighting the visitor — with no JavaScript.
|
|
5
|
+
* A screen reader hears each language's own name, not its code.
|
|
6
|
+
*/
|
|
7
|
+
export interface LangSwitchProps<L extends string> {
|
|
8
|
+
current: L;
|
|
9
|
+
/** Switcher order: the registry's `locales`. */
|
|
10
|
+
locales: readonly L[];
|
|
11
|
+
/** The page's URL in each language, before `?lang=` is added; may carry a query or `#`. */
|
|
12
|
+
hrefs: Readonly<Record<L, string>>;
|
|
13
|
+
/** Each language's name in itself — the registry's `labels`. Codes when absent. */
|
|
14
|
+
labels?: Readonly<Record<L, string>>;
|
|
15
|
+
/** The navigation's accessible name. */
|
|
16
|
+
label?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `href` with `lang=<locale>` set, keeping its query and fragment — and its
|
|
21
|
+
* origin when it has one: a place's page in path mode links to another host.
|
|
22
|
+
*/
|
|
23
|
+
export declare function withLang(href: string, locale: string): string;
|
|
24
|
+
export declare function LangSwitch<L extends string>({ current, locales, hrefs, labels, label, className }: LangSwitchProps<L>): import("react").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@evinvest/uikit";
|
|
3
|
+
/**
|
|
4
|
+
* `href` with `lang=<locale>` set, keeping its query and fragment — and its
|
|
5
|
+
* origin when it has one: a place's page in path mode links to another host.
|
|
6
|
+
*/
|
|
7
|
+
export function withLang(href, locale) {
|
|
8
|
+
const url = new URL(href, "https://x.invalid");
|
|
9
|
+
url.searchParams.set("lang", locale);
|
|
10
|
+
const rest = `${url.pathname}${url.search}${url.hash}`;
|
|
11
|
+
if (href.startsWith("//"))
|
|
12
|
+
return `//${url.host}${rest}`;
|
|
13
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(href) ? `${url.origin}${rest}` : rest;
|
|
14
|
+
}
|
|
15
|
+
export function LangSwitch({ current, locales, hrefs, labels, label = "Language", className }) {
|
|
16
|
+
return (_jsx("nav", { "aria-label": label, className: cn("flex items-center gap-1.5", className), children: locales.map((locale, i) => (_jsxs("span", { className: "flex items-center gap-1.5", children: [i > 0 && (_jsx("span", { "aria-hidden": "true", className: "opacity-40", children: "\u00B7" })), _jsx("a", { href: withLang(hrefs[locale], locale), hrefLang: locale, lang: locale, "aria-label": labels?.[locale], "aria-current": locale === current ? "true" : undefined, className: locale === current ? "font-semibold" : "opacity-60 hover:opacity-100", children: locale.toUpperCase() })] }, locale))) }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google's map behind a click. Until the visitor asks, the page holds a button
|
|
3
|
+
* and nothing else — no Google request, no cookie — so a cookieless page stays
|
|
4
|
+
* cookieless and the first load carries no map. The keyless `output=embed`
|
|
5
|
+
* URL needs no API key in the build.
|
|
6
|
+
*/
|
|
7
|
+
export interface MapFacadeProps {
|
|
8
|
+
/** What Google searches for: the place's GBP name and address. */
|
|
9
|
+
query: string;
|
|
10
|
+
/** The iframe's accessible title. */
|
|
11
|
+
title: string;
|
|
12
|
+
/** The button's call to action. */
|
|
13
|
+
show: string;
|
|
14
|
+
/** The address, printed under the call to action. */
|
|
15
|
+
address: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function MapFacade({ query, title, show, address, className }: MapFacadeProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ClickToLoad } from "@evinvest/marketing/click-to-load";
|
|
4
|
+
import { cn } from "@evinvest/uikit";
|
|
5
|
+
export function MapFacade({ query, title, show, address, className }) {
|
|
6
|
+
const src = `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`;
|
|
7
|
+
return (_jsx(ClickToLoad, { className: cn("relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted md:aspect-[21/9]", className), placeholder: load => (_jsxs("button", { type: "button", onClick: load, className: "flex size-full flex-col items-center justify-center gap-2 px-6 text-center hover:bg-hover", children: [_jsx("span", { className: "font-display text-lg font-bold text-ink", children: show }), _jsx("span", { className: "text-sm text-ink-soft", children: address })] })), children: _jsx("iframe", { title: title, src: src, loading: "lazy", referrerPolicy: "no-referrer-when-downgrade", className: "absolute inset-0 size-full border-0" }) }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type Place } from "../core/place/types.js";
|
|
3
|
+
import type { PartClassNames } from "./parts.js";
|
|
4
|
+
export type PlaceDirectoryPart = "list" | "card" | "name" | "address" | "phone" | "link";
|
|
5
|
+
/**
|
|
6
|
+
* The apex's one job on a `subdomains` site: send the visitor to their place.
|
|
7
|
+
* Each card links to the place's canonical home and carries its phone, so the
|
|
8
|
+
* emergency visitor need not click through at all. A service-area place shows
|
|
9
|
+
* no address.
|
|
10
|
+
*/
|
|
11
|
+
export interface PlaceDirectoryProps<L extends string> {
|
|
12
|
+
places: readonly Place<L>[];
|
|
13
|
+
locale: L;
|
|
14
|
+
/** Each place's canonical home: `placeUrl(site, slug, locale, "")`. */
|
|
15
|
+
hrefOf: (place: Place<L>) => string;
|
|
16
|
+
/** Each place's phone: `contactOf(site, place).phone`. */
|
|
17
|
+
phoneOf: (place: Place<L>) => string | null;
|
|
18
|
+
/** The link's label: "Open the page". */
|
|
19
|
+
openLabel: string;
|
|
20
|
+
head?: ReactNode;
|
|
21
|
+
id?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
classNames?: PartClassNames<PlaceDirectoryPart>;
|
|
24
|
+
}
|
|
25
|
+
export declare function PlaceDirectory<L extends string>(props: PlaceDirectoryProps<L>): import("react").JSX.Element;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@evinvest/uikit";
|
|
3
|
+
import { telHref } from "@evinvest/marketing";
|
|
4
|
+
import { storefrontOf } from "../core/place/types.js";
|
|
5
|
+
export function PlaceDirectory(props) {
|
|
6
|
+
const { places, head, id = "points", className } = props;
|
|
7
|
+
return (_jsxs("div", { id: id, className: cn("flex flex-col gap-7 md:gap-10", className), children: [head, _jsx("ul", { className: cn("grid gap-4 sm:grid-cols-2 lg:grid-cols-3", props.classNames?.list), children: places.map(place => (_jsx(PlaceCard, { place: place, ...props }, place.slug))) })] }));
|
|
8
|
+
}
|
|
9
|
+
function PlaceCard({ place, locale, hrefOf, phoneOf, openLabel, classNames: c }) {
|
|
10
|
+
const phone = phoneOf(place);
|
|
11
|
+
const front = storefrontOf(place);
|
|
12
|
+
return (_jsxs("li", { className: cn("flex flex-col gap-3 rounded-xl border border-border bg-card p-6 md:p-7", c?.card), children: [_jsx("p", { className: cn("font-display text-xl font-bold text-ink", c?.name), children: place.name[locale] }), front && (_jsxs("address", { className: cn("text-sm not-italic leading-relaxed text-ink-soft", c?.address), children: [front.address.street, _jsx("br", {}), front.address.postalCode, " ", front.address.locality] })), phone && (_jsx("a", { href: telHref(phone), className: cn("font-display text-lg font-bold text-primary-ink", c?.phone), children: phone })), _jsxs("a", { href: hrefOf(place), className: cn("mt-auto text-sm font-medium text-ink underline-offset-4 hover:underline", c?.link), children: [openLabel, " \u2192"] })] }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The quote form's frame: a plain `<form method="post" action="/quote">`
|
|
4
|
+
* answered with a 303, so it works before any JavaScript arrives — which is
|
|
5
|
+
* when the visitor standing in water submits it. The shell owns what the
|
|
6
|
+
* funnel reads (the hidden place, locale, form id and render stamp, and the
|
|
7
|
+
* honeypot); the visible fields and the submit button are the brand's
|
|
8
|
+
* `children`, named after `site.lead.wire`.
|
|
9
|
+
*/
|
|
10
|
+
export interface QuoteFormShellProps {
|
|
11
|
+
/** The place the lead is for, or `null` on the brand's own pages. */
|
|
12
|
+
placeSlug: string | null;
|
|
13
|
+
locale: string;
|
|
14
|
+
/**
|
|
15
|
+
* When the page rendered, ms since the epoch: the time trap's stamp. Under
|
|
16
|
+
* ISR that is when the cache was filled, not when the visitor arrived — so
|
|
17
|
+
* the stamp only ever marks a lead `too-fast`, it never withholds one.
|
|
18
|
+
*/
|
|
19
|
+
renderedAt: number;
|
|
20
|
+
/** The honeypot's label — read only by a bot filling every field. */
|
|
21
|
+
honeypotLabel: string;
|
|
22
|
+
formId?: string;
|
|
23
|
+
action?: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
/** The mobile field's attributes: the numeric keypad and the phone's autofill. */
|
|
29
|
+
export declare const PHONE_INPUT_PROPS: {
|
|
30
|
+
readonly type: "tel";
|
|
31
|
+
readonly inputMode: "tel";
|
|
32
|
+
readonly autoComplete: "tel";
|
|
33
|
+
};
|
|
34
|
+
export declare function QuoteFormShell(props: QuoteFormShellProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@evinvest/uikit";
|
|
3
|
+
import { FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD } from "../core/accept.js";
|
|
4
|
+
import { HONEYPOT_FIELD, RENDERED_AT_FIELD } from "../core/antispam.js";
|
|
5
|
+
/** The mobile field's attributes: the numeric keypad and the phone's autofill. */
|
|
6
|
+
export const PHONE_INPUT_PROPS = { type: "tel", inputMode: "tel", autoComplete: "tel" };
|
|
7
|
+
export function QuoteFormShell(props) {
|
|
8
|
+
const { placeSlug, locale, renderedAt, honeypotLabel, formId = "quote", action = "/quote", id = "quote", className, children } = props;
|
|
9
|
+
return (_jsxs("form", { id: id, method: "post", action: action, className: cn("relative flex w-full flex-col gap-5", className), children: [placeSlug && _jsx("input", { type: "hidden", name: LOCATION_FIELD, value: placeSlug }), _jsx("input", { type: "hidden", name: LOCALE_FIELD, value: locale }), _jsx("input", { type: "hidden", name: FORM_ID_FIELD, value: formId }), _jsx("input", { type: "hidden", name: RENDERED_AT_FIELD, value: String(renderedAt) }), children, _jsx("div", { "aria-hidden": "true", style: { position: "absolute", left: "-10000px", width: 1, height: 1, overflow: "hidden" }, children: _jsxs("label", { children: [honeypotLabel, _jsx("input", { type: "text", name: HONEYPOT_FIELD, tabIndex: -1, autoComplete: "off", defaultValue: "" })] }) })] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { CopySlice, StatusCopy, StatusScreenText } from "../core/content.js";
|
|
3
|
+
import type { StatusTarget } from "../core/status.js";
|
|
4
|
+
import type { PartClassNames } from "./parts.js";
|
|
5
|
+
export type StatusScreenPart = "header" | "logo" | "phone" | "main" | "eyebrow" | "code" | "headline" | "body" | "actions" | "primaryButton" | "secondaryButton" | "strip" | "footer";
|
|
6
|
+
/**
|
|
7
|
+
* The 404, the 500 and the post-submit confirmation: one screen over a
|
|
8
|
+
* `StatusCopy`, differing only in eyebrow, numeral, headline and buttons. A
|
|
9
|
+
* visitor who hit a 404 still gets the offer — the strip is the hero's terms,
|
|
10
|
+
* compressed — and the phone is always one tap away.
|
|
11
|
+
*
|
|
12
|
+
* Renders on either side: the error boundary is a client component, so this
|
|
13
|
+
* takes the brand's name and marks as props, never the site config.
|
|
14
|
+
*
|
|
15
|
+
* It sets its own `text-ink`: shown on a polarity other than the page's (a
|
|
16
|
+
* dark screen in a light site), anything uncoloured inside — the outline
|
|
17
|
+
* button's label — would inherit the `<body>`'s ink and vanish at 1:1.
|
|
18
|
+
*/
|
|
19
|
+
export interface StatusScreenProps<L extends string, F> {
|
|
20
|
+
copy: CopySlice<L, StatusScreenText<F>, F>;
|
|
21
|
+
status: StatusCopy<F>;
|
|
22
|
+
target: Pick<StatusTarget<L>, "phone" | "home" | "retry" | "langHrefs">;
|
|
23
|
+
locales: readonly L[];
|
|
24
|
+
/** Each language's name in itself, for the switch. */
|
|
25
|
+
labels?: Readonly<Record<L, string>>;
|
|
26
|
+
/** The accessible name of the home link around `logo`. */
|
|
27
|
+
brandName: string;
|
|
28
|
+
/** The brand's lock-up, in the header. */
|
|
29
|
+
logo?: ReactNode;
|
|
30
|
+
/** The brand's mark, above the eyebrow. */
|
|
31
|
+
mark?: ReactNode;
|
|
32
|
+
className?: string;
|
|
33
|
+
buttonClassName?: string;
|
|
34
|
+
classNames?: PartClassNames<StatusScreenPart>;
|
|
35
|
+
}
|
|
36
|
+
export declare function StatusScreen<L extends string, F>(props: StatusScreenProps<L, F>): import("react").JSX.Element;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { telHref } from "@evinvest/marketing";
|
|
3
|
+
import { Button, Check, cn } from "@evinvest/uikit";
|
|
4
|
+
import { LangSwitch } from "./LangSwitch.js";
|
|
5
|
+
export function StatusScreen(props) {
|
|
6
|
+
const { copy, status, target, locales, labels, brandName, logo, mark, className, buttonClassName, classNames: c } = props;
|
|
7
|
+
const { t, f } = copy;
|
|
8
|
+
const action = (a) => a === "call"
|
|
9
|
+
? target.phone
|
|
10
|
+
? { href: telHref(target.phone), label: t.callLabel(f) }
|
|
11
|
+
: null
|
|
12
|
+
: a === "home"
|
|
13
|
+
? { href: target.home, label: t.backHome }
|
|
14
|
+
: { href: target.retry, label: t.tryAgain };
|
|
15
|
+
// A brand with no phone falls back to "home" rather than a dead button.
|
|
16
|
+
const primary = action(status.primary) ?? action("home");
|
|
17
|
+
const secondary = action(status.secondary);
|
|
18
|
+
return (_jsxs("div", { className: cn("relative flex min-h-screen flex-col bg-background text-ink", className), children: [_jsxs("header", { className: cn("relative flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-5 py-3 md:flex-nowrap md:gap-5 md:px-12 md:py-5", c?.header), children: [_jsx("a", { href: target.home, "aria-label": brandName, className: cn("shrink-0 text-ink", c?.logo), children: logo ?? _jsx("span", { className: "font-display text-lg font-bold md:text-xl", children: brandName }) }), _jsx("div", { className: "flex-1" }), _jsx(LangSwitch, { current: copy.locale, locales: locales, hrefs: target.langHrefs, ...(labels ? { labels } : {}), className: "order-last w-full text-sm font-medium text-ink-soft md:order-none md:w-auto" }), target.phone && (_jsx("a", { href: telHref(target.phone), className: cn("whitespace-nowrap font-display text-base font-bold text-primary-ink md:text-xl", c?.phone), children: target.phone }))] }), _jsxs("main", { className: cn("relative flex flex-1 flex-col items-center gap-5 px-5 py-12 text-center md:gap-6 md:py-24", c?.main), children: [mark, _jsx("p", { className: cn("text-xs font-medium tracking-widest text-primary-ink", c?.eyebrow), children: status.eyebrow }), _jsx("p", { className: cn("font-display text-8xl font-bold leading-none tracking-tight text-ink md:text-9xl", c?.code), children: status.code }), _jsxs("h1", { className: cn("font-display text-2xl font-bold leading-snug text-ink md:text-4xl", c?.headline), children: [status.headline[0], _jsx("span", { className: "text-primary-ink", children: status.headline[1] })] }), _jsx("p", { className: cn("max-w-2xl text-base leading-relaxed text-ink-soft md:text-lg", c?.body), children: status.body(f) }), _jsxs("div", { className: cn("flex flex-col gap-3.5 sm:flex-row", c?.actions), children: [primary && (_jsx(Button, { href: primary.href, size: "xl", className: cn(buttonClassName, c?.primaryButton), children: primary.label })), secondary && secondary.href !== primary?.href && (
|
|
19
|
+
// The kit's outline border is `--border`, 14 % ink: 1.4:1 on the
|
|
20
|
+
// dark polarity. Half ink clears 3:1 for a control on either one.
|
|
21
|
+
_jsx(Button, { href: secondary.href, size: "xl", variant: "outline", className: cn("border-ink/50", buttonClassName, c?.secondaryButton), children: secondary.label }))] }), _jsx("ul", { className: cn("flex flex-col items-center gap-3 text-sm sm:flex-row sm:gap-6", c?.strip), children: t.statusStrip.map(term => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx(Check, {}), _jsx("span", { className: "font-medium text-ink-soft", children: term })] }, term))) })] }), _jsx("footer", { className: cn("relative border-t border-border px-5 py-6 text-xs uppercase tracking-wider text-ink-soft md:px-12 md:py-7", c?.footer), children: _jsx("p", { children: t.facts(f).join(" · ") }) })] }));
|
|
22
|
+
}
|