@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,33 @@
|
|
|
1
|
+
import { type OwnerTodo, type Site } from "../core/site.js";
|
|
2
|
+
/**
|
|
3
|
+
* What every brand's site config must hold for the machinery to work — run
|
|
4
|
+
* from the brand's own tests:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* describeLandingContract(site, {
|
|
8
|
+
* globalsCss: readFileSync("app/globals.css", "utf8"),
|
|
9
|
+
* proxySource: readFileSync("proxy.ts", "utf8"),
|
|
10
|
+
* root: process.cwd(),
|
|
11
|
+
* text: TEXT,
|
|
12
|
+
* });
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export interface LandingContractOptions<L extends string> {
|
|
16
|
+
/** `app/globals.css`: Tailwind must be told to scan the package. */
|
|
17
|
+
globalsCss?: string;
|
|
18
|
+
/** `proxy.ts`: its literal matcher must be the package's. */
|
|
19
|
+
proxySource?: string;
|
|
20
|
+
/**
|
|
21
|
+
* The brand's directory: every file `app/` and `public/` serve outside
|
|
22
|
+
* `[locale]` must pass the proxy — be a `NON_PAGE_ROUTES` path or in
|
|
23
|
+
* `site.publicFiles` — or it answers 404; and every listed file must exist.
|
|
24
|
+
*/
|
|
25
|
+
root?: string;
|
|
26
|
+
/** The brand's copy, one per locale — every locale must have one. */
|
|
27
|
+
text?: Readonly<Partial<Record<L, unknown>>>;
|
|
28
|
+
/** Places that must be published once the owner's fields are in. */
|
|
29
|
+
mustPublish?: readonly string[];
|
|
30
|
+
/** The brand's open owner facts: a domain (launch) is refused while one that blocks launch is open. */
|
|
31
|
+
ownerTodo?: readonly OwnerTodo[];
|
|
32
|
+
}
|
|
33
|
+
export declare function describeLandingContract<L extends string, P extends string>(site: Site<L, P>, options?: LandingContractOptions<L>): void;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isPublished, publicationGaps } from "../core/place/publication.js";
|
|
3
|
+
import { createRouting, NON_PAGE_ROUTES, pointSuffixes, THANKS } from "../core/routing.js";
|
|
4
|
+
import { openLaunchBlockers } from "../core/site.js";
|
|
5
|
+
import { servedPaths } from "./served-files.js";
|
|
6
|
+
const SOURCE_LINE = /@source\s+["'][^"']*node_modules\/@evinvest\/kitstart\/dist["']/;
|
|
7
|
+
const MATCHER = "/((?!_next/).*)";
|
|
8
|
+
export function describeLandingContract(site, options = {}) {
|
|
9
|
+
describe(`landing contract: ${site.brand.id}`, () => {
|
|
10
|
+
it("has a home page and no page at the thank-you path", () => {
|
|
11
|
+
expect(site.pages.home).toBe("");
|
|
12
|
+
expect(pointSuffixes(site).filter(s => s === THANKS)).toHaveLength(1);
|
|
13
|
+
for (const key of site.pageKeys)
|
|
14
|
+
expect(site.pages[key], key).toMatch(/^(\/[a-z0-9-]+)*$/);
|
|
15
|
+
});
|
|
16
|
+
it("routes every page of every place to itself, in every locale", () => {
|
|
17
|
+
const routing = createRouting(site);
|
|
18
|
+
const host = (slug) => (site.topology.kind === "single" ? (site.brand.domain ?? "localhost") : `${slug}.${site.brand.domain ?? "localhost"}`);
|
|
19
|
+
for (const slug of site.placeSlugs) {
|
|
20
|
+
for (const locale of site.i18n.locales) {
|
|
21
|
+
for (const key of site.pageKeys) {
|
|
22
|
+
const decision = routing.decide({
|
|
23
|
+
host: host(slug),
|
|
24
|
+
pathname: site.i18n.localePath(locale, site.pages[key] || "/"),
|
|
25
|
+
query: new URLSearchParams(),
|
|
26
|
+
acceptLanguage: null,
|
|
27
|
+
cookieLang: null,
|
|
28
|
+
});
|
|
29
|
+
expect(decision, `${slug} ${locale} ${key}`).toEqual({ kind: "serve", pathname: `/${locale}/_${slug}${site.pages[key]}` });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
// On a single site the apex *is* the place, so a legacy path that is also
|
|
35
|
+
// a page suffix would move a live page away.
|
|
36
|
+
it("lets no legacy redirect shadow a live page", () => {
|
|
37
|
+
if (site.topology.kind !== "single")
|
|
38
|
+
return;
|
|
39
|
+
const suffixes = pointSuffixes(site);
|
|
40
|
+
for (const redirect of site.legacyRedirects ?? []) {
|
|
41
|
+
const moves = site.i18n.locales.some(locale => redirect.to(locale) !== null);
|
|
42
|
+
expect(moves && suffixes.includes(redirect.from), redirect.from).toBe(false);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
it("publishes nothing without a domain", () => {
|
|
46
|
+
if (site.brand.domain !== null)
|
|
47
|
+
return;
|
|
48
|
+
for (const place of site.places)
|
|
49
|
+
expect(isPublished(place, site.publication, site.brand), place.slug).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
if (options.mustPublish) {
|
|
52
|
+
it("publishes the places the owner has completed", () => {
|
|
53
|
+
for (const slug of options.mustPublish ?? []) {
|
|
54
|
+
const place = site.places.find(p => p.slug === slug);
|
|
55
|
+
expect(place, slug).toBeDefined();
|
|
56
|
+
if (place)
|
|
57
|
+
expect(publicationGaps(place, site.publication), slug).toEqual([]);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (options.ownerTodo) {
|
|
62
|
+
it("launches only with every blocking owner fact in", () => {
|
|
63
|
+
if (site.brand.domain === null)
|
|
64
|
+
return;
|
|
65
|
+
expect(openLaunchBlockers(options.ownerTodo ?? []).map(t => t.field)).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (options.text) {
|
|
69
|
+
it("has copy for every locale", () => {
|
|
70
|
+
for (const locale of site.i18n.locales)
|
|
71
|
+
expect(options.text?.[locale], locale).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (options.globalsCss !== undefined) {
|
|
75
|
+
it("lets Tailwind scan the package", () => {
|
|
76
|
+
expect(options.globalsCss).toMatch(SOURCE_LINE);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (options.root !== undefined) {
|
|
80
|
+
const root = options.root;
|
|
81
|
+
it("passes exactly the files it serves", () => {
|
|
82
|
+
const served = servedPaths(root);
|
|
83
|
+
const listed = site.publicFiles ?? [];
|
|
84
|
+
const passed = [...NON_PAGE_ROUTES, ...listed];
|
|
85
|
+
expect(served.filter(p => !passed.includes(p)), "served, but the proxy sends it to the 404: list it in site.publicFiles").toEqual([]);
|
|
86
|
+
expect(listed.filter(p => !served.includes(p)), "in site.publicFiles, but neither app/ nor public/ serves it").toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (options.proxySource !== undefined) {
|
|
90
|
+
it("spells the package's matcher in proxy.ts", () => {
|
|
91
|
+
expect(options.proxySource).toContain(`"${MATCHER}"`);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type LeadStore } from "../core/lead.js";
|
|
2
|
+
/**
|
|
3
|
+
* What every `LeadStore` adapter promises the funnel, whatever it keeps leads
|
|
4
|
+
* in. An adapter passes this suite before `LEADS_DB_URL` may select it.
|
|
5
|
+
*
|
|
6
|
+
* `open` gets a fresh, empty store; `reopen` (durable adapters) a second
|
|
7
|
+
* handle on the same data after the first was closed.
|
|
8
|
+
*/
|
|
9
|
+
export interface LeadStoreHarness {
|
|
10
|
+
open(): Promise<LeadStore>;
|
|
11
|
+
reopen?(): Promise<LeadStore>;
|
|
12
|
+
}
|
|
13
|
+
export declare function describeLeadStoreContract(name: string, harness: () => LeadStoreHarness): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { LEAD_SCHEMA_VERSION } from "../core/lead.js";
|
|
3
|
+
const lead = (over = {}) => ({
|
|
4
|
+
subject: "blocked_drain",
|
|
5
|
+
locality: "63130",
|
|
6
|
+
mobile: "06 12 34 56 78",
|
|
7
|
+
extras: {},
|
|
8
|
+
placeSlug: "royat",
|
|
9
|
+
spamVerdict: null,
|
|
10
|
+
...over,
|
|
11
|
+
});
|
|
12
|
+
export function describeLeadStoreContract(name, harness) {
|
|
13
|
+
describe(`LeadStore contract: ${name}`, () => {
|
|
14
|
+
it("answers each insert with a new, increasing id, and counts what it holds", async () => {
|
|
15
|
+
const store = await harness().open();
|
|
16
|
+
expect(await store.count()).toBe(0);
|
|
17
|
+
const first = await store.insert(lead());
|
|
18
|
+
const second = await store.insert(lead({ placeSlug: null, spamVerdict: "honeypot" }));
|
|
19
|
+
expect(second).toBeGreaterThan(first);
|
|
20
|
+
expect(await store.count()).toBe(2);
|
|
21
|
+
await store.close();
|
|
22
|
+
});
|
|
23
|
+
it("takes every shape the funnel produces: no place, a verdict, extras, a long value", async () => {
|
|
24
|
+
const store = await harness().open();
|
|
25
|
+
for (const variant of [
|
|
26
|
+
lead({ placeSlug: null }),
|
|
27
|
+
lead({ spamVerdict: "too-fast" }),
|
|
28
|
+
lead({ spamVerdict: "rate-limited", extras: { surface_m2: "40", notes: "3e étage, digicode 12B" } }),
|
|
29
|
+
lead({ subject: "x".repeat(200), locality: "Clermont-Ferrand — 63000" }),
|
|
30
|
+
]) {
|
|
31
|
+
expect(await store.insert(variant)).toBeGreaterThan(0);
|
|
32
|
+
}
|
|
33
|
+
expect(await store.count()).toBe(4);
|
|
34
|
+
await store.close();
|
|
35
|
+
});
|
|
36
|
+
it("stands at the current schema version and reports itself healthy", async () => {
|
|
37
|
+
const store = await harness().open();
|
|
38
|
+
expect(await store.schemaVersion()).toBe(LEAD_SCHEMA_VERSION);
|
|
39
|
+
await expect(store.health()).resolves.toBeUndefined();
|
|
40
|
+
await store.close();
|
|
41
|
+
});
|
|
42
|
+
it("keeps what it acknowledged across a close and a reopen", async () => {
|
|
43
|
+
const h = harness();
|
|
44
|
+
if (!h.reopen)
|
|
45
|
+
return;
|
|
46
|
+
const store = await h.open();
|
|
47
|
+
await store.insert(lead());
|
|
48
|
+
await store.close();
|
|
49
|
+
const again = await h.reopen();
|
|
50
|
+
expect(await again.count()).toBe(1);
|
|
51
|
+
expect(await again.schemaVersion()).toBe(LEAD_SCHEMA_VERSION);
|
|
52
|
+
await again.close();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every path a brand tree serves outside `[locale]`: `app/`'s route folders
|
|
3
|
+
* (`/og`), its root metadata files (`app/robots.ts` → `/robots.txt`,
|
|
4
|
+
* `app/icon.svg`) and everything under `public/`. A layout, a stylesheet or
|
|
5
|
+
* a route group serves nothing of its own here.
|
|
6
|
+
*/
|
|
7
|
+
export declare function servedPaths(root: string): string[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
/** Next's app-root metadata routes generated by code, served without an extension. */
|
|
4
|
+
const CODE_ROUTES = { robots: "/robots.txt", sitemap: "/sitemap.xml", manifest: "/manifest.webmanifest" };
|
|
5
|
+
const IMAGE_ROUTE = /^(icon|apple-icon|opengraph-image|twitter-image)\d*$/;
|
|
6
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs)$/;
|
|
7
|
+
const STATIC = /^(favicon\.ico|robots\.txt|sitemap\.xml|manifest\.(json|webmanifest)|(icon|apple-icon|opengraph-image|twitter-image)\d*\.(ico|jpe?g|png|gif|svg))$/;
|
|
8
|
+
/**
|
|
9
|
+
* Every path a brand tree serves outside `[locale]`: `app/`'s route folders
|
|
10
|
+
* (`/og`), its root metadata files (`app/robots.ts` → `/robots.txt`,
|
|
11
|
+
* `app/icon.svg`) and everything under `public/`. A layout, a stylesheet or
|
|
12
|
+
* a route group serves nothing of its own here.
|
|
13
|
+
*/
|
|
14
|
+
export function servedPaths(root) {
|
|
15
|
+
const served = [];
|
|
16
|
+
const app = join(root, "app");
|
|
17
|
+
for (const entry of existsSync(app) ? readdirSync(app, { withFileTypes: true }) : []) {
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
// `[locale]` is the pages; `(group)`, `_private` and `@slot` add no segment.
|
|
20
|
+
if (!/^[[(_@]/.test(entry.name))
|
|
21
|
+
served.push(`/${entry.name}`);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (STATIC.test(entry.name)) {
|
|
25
|
+
served.push(`/${entry.name}`);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (!CODE.test(entry.name))
|
|
29
|
+
continue;
|
|
30
|
+
const base = entry.name.replace(CODE, "");
|
|
31
|
+
const route = CODE_ROUTES[base] ?? (IMAGE_ROUTE.test(base) ? `/${base}` : undefined);
|
|
32
|
+
if (route)
|
|
33
|
+
served.push(route);
|
|
34
|
+
}
|
|
35
|
+
const pub = join(root, "public");
|
|
36
|
+
for (const entry of existsSync(pub) ? readdirSync(pub, { withFileTypes: true, recursive: true }) : []) {
|
|
37
|
+
if (entry.isFile())
|
|
38
|
+
served.push(`/${relative(pub, join(entry.parentPath, entry.name)).split(sep).join("/")}`);
|
|
39
|
+
}
|
|
40
|
+
return served.sort();
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@evinvest/kitstart",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at '.', more subpaths per runtime.",
|
|
5
|
+
"license": "BlueOak-1.0.0",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/EV-invest/lib.git",
|
|
12
|
+
"directory": "ts/kitstart"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./server": {
|
|
22
|
+
"types": "./dist/server/index.d.ts",
|
|
23
|
+
"import": "./dist/server/index.js",
|
|
24
|
+
"default": "./dist/server/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./proxy": {
|
|
27
|
+
"types": "./dist/proxy/index.d.ts",
|
|
28
|
+
"import": "./dist/proxy/index.js",
|
|
29
|
+
"default": "./dist/proxy/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./next": {
|
|
32
|
+
"types": "./dist/next/index.d.ts",
|
|
33
|
+
"import": "./dist/next/index.js",
|
|
34
|
+
"default": "./dist/next/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./next/config": {
|
|
37
|
+
"types": "./dist/next/config/index.d.ts",
|
|
38
|
+
"import": "./dist/next/config/index.js",
|
|
39
|
+
"default": "./dist/next/config/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./react": {
|
|
42
|
+
"types": "./dist/react/index.d.ts",
|
|
43
|
+
"import": "./dist/react/index.js",
|
|
44
|
+
"default": "./dist/react/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./testing": {
|
|
47
|
+
"types": "./dist/testing/index.d.ts",
|
|
48
|
+
"import": "./dist/testing/index.js",
|
|
49
|
+
"default": "./dist/testing/index.js"
|
|
50
|
+
},
|
|
51
|
+
"./testing/e2e": {
|
|
52
|
+
"types": "./dist/testing/e2e.d.ts",
|
|
53
|
+
"import": "./dist/testing/e2e.js",
|
|
54
|
+
"default": "./dist/testing/e2e.js"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"types": "./dist/index.d.ts",
|
|
58
|
+
"main": "./dist/index.js",
|
|
59
|
+
"bin": {
|
|
60
|
+
"kitstart-size": "./dist/cli/kitstart-size.js"
|
|
61
|
+
},
|
|
62
|
+
"files": [
|
|
63
|
+
"dist",
|
|
64
|
+
"template"
|
|
65
|
+
],
|
|
66
|
+
"sideEffects": false,
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=22.13"
|
|
69
|
+
},
|
|
70
|
+
"scripts": {
|
|
71
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/fully-specify.mjs dist",
|
|
72
|
+
"test": "vitest run",
|
|
73
|
+
"typecheck": "tsc --noEmit",
|
|
74
|
+
"lint": "eslint .",
|
|
75
|
+
"prepare": "npm run build",
|
|
76
|
+
"prepublishOnly": "node scripts/verify-pack.mjs",
|
|
77
|
+
"check:template": "node scripts/check-template.mjs"
|
|
78
|
+
},
|
|
79
|
+
"peerDependencies": {
|
|
80
|
+
"@evinvest/analytics": ">=0.3.0 <1",
|
|
81
|
+
"@evinvest/i18n": ">=0.9.0 <1",
|
|
82
|
+
"@evinvest/marketing": ">=0.3.0 <1",
|
|
83
|
+
"@evinvest/uikit": ">=0.20.0 <1",
|
|
84
|
+
"next": "^16",
|
|
85
|
+
"react": "^19",
|
|
86
|
+
"server-only": "*"
|
|
87
|
+
},
|
|
88
|
+
"peerDependenciesMeta": {
|
|
89
|
+
"@evinvest/uikit": {
|
|
90
|
+
"optional": true
|
|
91
|
+
},
|
|
92
|
+
"next": {
|
|
93
|
+
"optional": true
|
|
94
|
+
},
|
|
95
|
+
"react": {
|
|
96
|
+
"optional": true
|
|
97
|
+
},
|
|
98
|
+
"server-only": {
|
|
99
|
+
"optional": true
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"devDependencies": {
|
|
103
|
+
"@evinvest/analytics": "^0.3.0",
|
|
104
|
+
"@evinvest/i18n": "^0.9.0",
|
|
105
|
+
"@evinvest/marketing": "file:../marketing",
|
|
106
|
+
"@evinvest/uikit": "file:../uikit",
|
|
107
|
+
"@playwright/test": "^1.63.0",
|
|
108
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
109
|
+
"@testing-library/react": "^16.3.3",
|
|
110
|
+
"@types/node": "^22.10.0",
|
|
111
|
+
"@types/react": "^19.3.0",
|
|
112
|
+
"@types/react-dom": "^19.3.0",
|
|
113
|
+
"eslint": "^9.39.5",
|
|
114
|
+
"jsdom": "^25.0.1",
|
|
115
|
+
"next": "^16.3.6",
|
|
116
|
+
"react": "^19.3.0",
|
|
117
|
+
"react-dom": "^19.3.0",
|
|
118
|
+
"server-only": "^0.0.1",
|
|
119
|
+
"typescript": "^5.7.2",
|
|
120
|
+
"typescript-eslint": "^8.46.0",
|
|
121
|
+
"vitest": "^3.2.4"
|
|
122
|
+
},
|
|
123
|
+
"kitstartPack": [
|
|
124
|
+
"template/package.json",
|
|
125
|
+
"template/proxy.ts",
|
|
126
|
+
"template/app/[locale]/[location]/layout.tsx",
|
|
127
|
+
"dist/cli/kitstart-size.js"
|
|
128
|
+
]
|
|
129
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Brand landing — skeleton
|
|
2
|
+
|
|
3
|
+
A landing on `@evinvest/kitstart`: the machinery (routing, places, the lead
|
|
4
|
+
funnel, SEO) is the package's; the copy, the palette, the sections and the
|
|
5
|
+
config are yours. As shipped it is one service-area place on one site, before
|
|
6
|
+
launch — `noindex` everywhere until `assets/card.toml` gets a `site`.
|
|
7
|
+
|
|
8
|
+
## Twelve steps
|
|
9
|
+
|
|
10
|
+
1. Copy this directory into the new repo; `npm install`.
|
|
11
|
+
2. `flake.nix` calls the lib flake's `mkLanding` (the build, the image, the
|
|
12
|
+
smoke, the bundle-budget check); `nix develop`.
|
|
13
|
+
3. `assets/brand.toml` → `npm run palette` (writes `app/brand.css`; the OG card
|
|
14
|
+
takes `[colors.dark]` from the same file at build).
|
|
15
|
+
4. `assets/card.toml`: the email now; `phone` and `site` when the owner has them.
|
|
16
|
+
5. `assets/mark.svg`; the faces in `assets/fonts/` and `src/shared/ui/fonts.ts`
|
|
17
|
+
(`next/font/local`, literal paths); the OG route reads the `.ttf`.
|
|
18
|
+
6. `src/shared/config/site.ts`: brand facts, topology (`single`, or
|
|
19
|
+
`subdomains` for a network), pages, the publication gate, `OWNER_TODO`
|
|
20
|
+
(a domain is refused while a blocking one is open).
|
|
21
|
+
7. `src/shared/config/places.ts`: the places, storefront or service area.
|
|
22
|
+
8. `src/entities/content`: `Text extends CoreText`, one object per language,
|
|
23
|
+
checked with `satisfies`. Every word on a page comes from here.
|
|
24
|
+
9. `src/shared/config/lead.ts`: what the form asks (`subjects`, `wire`, `extras`).
|
|
25
|
+
10. `src/views/`: the brand's sections around the package's widgets.
|
|
26
|
+
11. Tests: `tests/contract.test.ts` runs the landing contract; add the copy's
|
|
27
|
+
own, the e2e sections (`tests/e2e`, baselines from CI on Linux) and set
|
|
28
|
+
`tests/bundle_budget.txt` from the first `npm run size`. `npm run lint` is
|
|
29
|
+
eslint (with the client-boundary rule) and steiger (FSD).
|
|
30
|
+
12. `deploy/config.nix` (`TRUSTED_PROXY`, the leads volume), gitops. Without a
|
|
31
|
+
domain the deploy is noindex.
|
|
32
|
+
|
|
33
|
+
## What must stay a literal
|
|
34
|
+
|
|
35
|
+
Next reads these statically, so they live in the route files, not the package:
|
|
36
|
+
`export const dynamic`, `revalidate`, `generateStaticParams`, `config.matcher`
|
|
37
|
+
in `proxy.ts`, and every `next/font/local` path. Everything else is a factory
|
|
38
|
+
call.
|
|
39
|
+
|
|
40
|
+
## The rules that keep pages cached
|
|
41
|
+
|
|
42
|
+
No page, layout or boundary reads the request (`headers()`, `cookies()`): the
|
|
43
|
+
link mode rides in the `[location]` param. A page that did would render per
|
|
44
|
+
request again. The quote route, the sitemap, robots and the 404 are dynamic
|
|
45
|
+
on purpose.
|
|
46
|
+
|
|
47
|
+
## The 404
|
|
48
|
+
|
|
49
|
+
Next 16 answers a `notFound()` with an empty shell it fills in after
|
|
50
|
+
hydration — blank without JavaScript. So the proxy sends every dead path it
|
|
51
|
+
can recognise to `app/global-not-found.tsx`, which renders the brand's screen
|
|
52
|
+
on the server with the right language and place. The `[locale]/not-found.tsx`
|
|
53
|
+
boundary is only for what the proxy cannot foresee (a place the live source
|
|
54
|
+
withdrew); it and `error.tsx` are client modules that read `BRAND_PUBLIC`,
|
|
55
|
+
never the site config.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { AnalyticsBoundary } from "@evinvest/kitstart/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { loadPlace } from "@/views/place/server";
|
|
4
|
+
import { serverEnv } from "@/shared/config/env";
|
|
5
|
+
import { site } from "@/shared/config/site";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Incremental static regeneration. Nothing is prerendered at build — in a
|
|
9
|
+
* sandbox with no network that would bake the fallback into the artefact —
|
|
10
|
+
* so the list is empty: the first request renders a page, the result is
|
|
11
|
+
* cached, and it re-renders in the background after the live data's TTL.
|
|
12
|
+
*/
|
|
13
|
+
export function generateStaticParams(): { locale: string; location: string }[] {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A literal: Next reads it statically. The place source's TTL. */
|
|
18
|
+
export const revalidate = 600;
|
|
19
|
+
|
|
20
|
+
export default async function PlaceLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string; location: string }> }) {
|
|
21
|
+
const { view } = await loadPlace(params);
|
|
22
|
+
const env = serverEnv();
|
|
23
|
+
// The key is read from the container when the page renders, never inlined.
|
|
24
|
+
return (
|
|
25
|
+
<AnalyticsBoundary target={{ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }} placeSlug={view.place.slug}>
|
|
26
|
+
{children}
|
|
27
|
+
</AnalyticsBoundary>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { placeMetadata } from "@evinvest/kitstart/next";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import { loadPlace } from "@/views/place/server";
|
|
4
|
+
import { site } from "@/shared/config/site";
|
|
5
|
+
import { PlaceHome } from "@/views/home";
|
|
6
|
+
|
|
7
|
+
type Props = { params: Promise<{ locale: string; location: string }> };
|
|
8
|
+
|
|
9
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
10
|
+
const { view, copy } = await loadPlace(params);
|
|
11
|
+
const page = copy.t.pages.home;
|
|
12
|
+
return placeMetadata(site, view, "home", { title: page.title(copy.f), description: page.description(copy.f) });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default async function PlaceHomePage({ params }: Props) {
|
|
16
|
+
const { view, copy, renderedAt } = await loadPlace(params);
|
|
17
|
+
return <PlaceHome view={view} copy={copy} renderedAt={renderedAt} />;
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { faqPageNode } from "@evinvest/kitstart";
|
|
2
|
+
import { placeMetadata } from "@evinvest/kitstart/next";
|
|
3
|
+
import { Faq, JsonLd, Section } from "@evinvest/kitstart/react";
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
import { loadPlace } from "@/views/place/server";
|
|
6
|
+
import { site } from "@/shared/config/site";
|
|
7
|
+
|
|
8
|
+
type Props = { params: Promise<{ locale: string; location: string }> };
|
|
9
|
+
|
|
10
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
11
|
+
const { view, copy } = await loadPlace(params);
|
|
12
|
+
const page = copy.t.pages.prices;
|
|
13
|
+
return placeMetadata(site, view, "prices", { title: page.title(copy.f), description: page.description(copy.f) });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default async function PricesPage({ params }: Props) {
|
|
17
|
+
const { copy } = await loadPlace(params);
|
|
18
|
+
return (
|
|
19
|
+
<main>
|
|
20
|
+
<JsonLd data={faqPageNode(copy.t.faqs)} />
|
|
21
|
+
<Section>
|
|
22
|
+
<Faq items={copy.t.faqs} />
|
|
23
|
+
</Section>
|
|
24
|
+
</main>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { statusTarget } from "@evinvest/kitstart";
|
|
2
|
+
import { statusMetadata } from "@evinvest/kitstart/next";
|
|
3
|
+
import { StatusScreen } from "@evinvest/kitstart/react";
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
import { loadPlace } from "@/views/place/server";
|
|
6
|
+
import { site } from "@/shared/config/site";
|
|
7
|
+
|
|
8
|
+
type Props = { params: Promise<{ locale: string; location: string }> };
|
|
9
|
+
|
|
10
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
11
|
+
const { copy } = await loadPlace(params);
|
|
12
|
+
return statusMetadata(site, copy.t.thanks.title);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The form's receipt, on the place's own links: not indexed, not a page view. */
|
|
16
|
+
export default async function PlaceThanks({ params }: Props) {
|
|
17
|
+
const { copy } = await loadPlace(params);
|
|
18
|
+
const target = statusTarget(site, await params, { thanks: true });
|
|
19
|
+
return <StatusScreen copy={copy} status={copy.t.thanks} target={target} locales={site.i18n.locales} brandName={site.brand.name} />;
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import dynamic from "next/dynamic";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The 500, loaded on demand: an error boundary ships with every page under
|
|
7
|
+
* it, so whatever it imports is paid by every visitor, error or not. It never
|
|
8
|
+
* imports the site config — see `BRAND_PUBLIC`.
|
|
9
|
+
*/
|
|
10
|
+
const Screen = dynamic(() => import("@/views/server-error").then(m => m.ServerError));
|
|
11
|
+
|
|
12
|
+
export default function ErrorBoundary() {
|
|
13
|
+
return <Screen />;
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import "../globals.css";
|
|
2
|
+
import { loadLocale, metadataBase } from "@evinvest/kitstart/next";
|
|
3
|
+
import type { Metadata, Viewport } from "next";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { site } from "@/shared/config/site";
|
|
6
|
+
import { text } from "@/shared/ui/fonts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The root layout lives under `[locale]` so `<html lang>` is the page's own
|
|
10
|
+
* language on the first byte. The brand scope and the polarity sit on
|
|
11
|
+
* `<html>`, so overlays portalled to `body` stay inside both.
|
|
12
|
+
*/
|
|
13
|
+
export const metadata: Metadata = {
|
|
14
|
+
metadataBase: metadataBase(site),
|
|
15
|
+
applicationName: site.brand.name,
|
|
16
|
+
formatDetection: { telephone: false },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const viewport: Viewport = { width: "device-width", initialScale: 1 };
|
|
20
|
+
|
|
21
|
+
export default async function RootLayout({ children, params }: { children: ReactNode; params: Promise<{ locale: string }> }) {
|
|
22
|
+
const locale = await loadLocale(site, params);
|
|
23
|
+
return (
|
|
24
|
+
<html lang={locale} data-brand={site.brand.id} className={`light ${text.variable}`}>
|
|
25
|
+
<body>{children}</body>
|
|
26
|
+
</html>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import dynamic from "next/dynamic";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The boundary for a `notFound()` the proxy could not foresee — a place the
|
|
7
|
+
* live source withdrew. Every dead path the proxy recognises goes to
|
|
8
|
+
* `app/global-not-found.tsx` instead: Next 16 answers a `notFound()` with an
|
|
9
|
+
* empty shell and renders this boundary only in the browser.
|
|
10
|
+
*
|
|
11
|
+
* Rendered into every page under the segment, so it must not read the request
|
|
12
|
+
* and should not weigh on pages that never 404: a lazily loaded client module.
|
|
13
|
+
*/
|
|
14
|
+
const Screen = dynamic(() => import("@/views/not-found").then(m => m.NotFound));
|
|
15
|
+
|
|
16
|
+
export default function NotFound() {
|
|
17
|
+
return <Screen />;
|
|
18
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { brandMetadata, loadLocale } from "@evinvest/kitstart/next";
|
|
2
|
+
import { PlaceDirectory } from "@evinvest/kitstart/react";
|
|
3
|
+
import { contactOf, placeUrl } from "@evinvest/kitstart";
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
import { TEXT } from "@/entities/content";
|
|
6
|
+
import { places } from "@/shared/config/env";
|
|
7
|
+
import { site } from "@/shared/config/site";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The apex of a `subdomains` site: a directory of its places. On a `single`
|
|
11
|
+
* site the proxy rewrites `/fr` to the place's own route and this never renders.
|
|
12
|
+
*/
|
|
13
|
+
export function generateStaticParams(): { locale: string }[] {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const revalidate = 600;
|
|
18
|
+
|
|
19
|
+
type Props = { params: Promise<{ locale: string }> };
|
|
20
|
+
|
|
21
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
22
|
+
const locale = await loadLocale(site, params);
|
|
23
|
+
const { title, description } = TEXT[locale].brandPage;
|
|
24
|
+
return brandMetadata(site, locale, { title, description });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default async function BrandPage({ params }: Props) {
|
|
28
|
+
const locale = await loadLocale(site, params);
|
|
29
|
+
return (
|
|
30
|
+
<main className="p-8">
|
|
31
|
+
<PlaceDirectory
|
|
32
|
+
places={await places.listPlaces(locale, "page")}
|
|
33
|
+
locale={locale}
|
|
34
|
+
hrefOf={p => placeUrl(site, p.slug, locale, "")}
|
|
35
|
+
phoneOf={p => contactOf(site, p).phone}
|
|
36
|
+
openLabel={TEXT[locale].brandPage.open}
|
|
37
|
+
/>
|
|
38
|
+
</main>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { statusTarget } from "@evinvest/kitstart";
|
|
2
|
+
import { loadLocale, statusMetadata } from "@evinvest/kitstart/next";
|
|
3
|
+
import { StatusScreen } from "@evinvest/kitstart/react";
|
|
4
|
+
import type { Metadata } from "next";
|
|
5
|
+
import { copyFor } from "@/entities/content";
|
|
6
|
+
import { site } from "@/shared/config/site";
|
|
7
|
+
|
|
8
|
+
/** Where a lead with no place lands. The lead is stored all the same. */
|
|
9
|
+
type Props = { params: Promise<{ locale: string }> };
|
|
10
|
+
|
|
11
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
12
|
+
const locale = await loadLocale(site, params);
|
|
13
|
+
return statusMetadata(site, copyFor(locale, { place: site.brand.name, phone: site.brand.phone }).t.thanks.title);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default async function BrandThanks({ params }: Props) {
|
|
17
|
+
const locale = await loadLocale(site, params);
|
|
18
|
+
const target = statusTarget(site, { locale }, { thanks: true });
|
|
19
|
+
const copy = copyFor(locale, { place: site.brand.name, phone: target.phone });
|
|
20
|
+
return <StatusScreen copy={copy} status={copy.t.thanks} target={target} locales={site.i18n.locales} brandName={site.brand.name} />;
|
|
21
|
+
}
|