@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.
Files changed (182) hide show
  1. package/README.md +245 -0
  2. package/dist/cli/kitstart-size.d.ts +2 -0
  3. package/dist/cli/kitstart-size.js +11 -0
  4. package/dist/cli/size.d.ts +31 -0
  5. package/dist/cli/size.js +99 -0
  6. package/dist/core/accept.d.ts +49 -0
  7. package/dist/core/accept.js +78 -0
  8. package/dist/core/analytics.d.ts +43 -0
  9. package/dist/core/analytics.js +42 -0
  10. package/dist/core/antispam.d.ts +60 -0
  11. package/dist/core/antispam.js +90 -0
  12. package/dist/core/content.d.ts +82 -0
  13. package/dist/core/content.js +12 -0
  14. package/dist/core/lead.d.ts +88 -0
  15. package/dist/core/lead.js +42 -0
  16. package/dist/core/memo.d.ts +8 -0
  17. package/dist/core/memo.js +19 -0
  18. package/dist/core/place/index.d.ts +5 -0
  19. package/dist/core/place/index.js +5 -0
  20. package/dist/core/place/live.d.ts +30 -0
  21. package/dist/core/place/live.js +132 -0
  22. package/dist/core/place/publication.d.ts +29 -0
  23. package/dist/core/place/publication.js +42 -0
  24. package/dist/core/place/rating.d.ts +16 -0
  25. package/dist/core/place/rating.js +33 -0
  26. package/dist/core/place/types.d.ts +91 -0
  27. package/dist/core/place/types.js +27 -0
  28. package/dist/core/place/view.d.ts +56 -0
  29. package/dist/core/place/view.js +43 -0
  30. package/dist/core/routing.d.ts +121 -0
  31. package/dist/core/routing.js +198 -0
  32. package/dist/core/seo/ld.d.ts +58 -0
  33. package/dist/core/seo/ld.js +161 -0
  34. package/dist/core/seo/sitemap.d.ts +52 -0
  35. package/dist/core/seo/sitemap.js +60 -0
  36. package/dist/core/site.d.ts +118 -0
  37. package/dist/core/site.js +84 -0
  38. package/dist/core/status.d.ts +47 -0
  39. package/dist/core/status.js +62 -0
  40. package/dist/index.d.ts +19 -0
  41. package/dist/index.js +20 -0
  42. package/dist/next/config/build-env.d.ts +32 -0
  43. package/dist/next/config/build-env.js +101 -0
  44. package/dist/next/config/index.d.ts +30 -0
  45. package/dist/next/config/index.js +38 -0
  46. package/dist/next/index.d.ts +12 -0
  47. package/dist/next/index.js +12 -0
  48. package/dist/next/load.d.ts +24 -0
  49. package/dist/next/load.js +36 -0
  50. package/dist/next/metadata.d.ts +31 -0
  51. package/dist/next/metadata.js +66 -0
  52. package/dist/next/og-palette.d.ts +14 -0
  53. package/dist/next/og-palette.js +14 -0
  54. package/dist/next/og.d.ts +31 -0
  55. package/dist/next/og.js +42 -0
  56. package/dist/next/quote-route.d.ts +36 -0
  57. package/dist/next/quote-route.js +124 -0
  58. package/dist/next/routes.d.ts +15 -0
  59. package/dist/next/routes.js +50 -0
  60. package/dist/proxy/index.d.ts +31 -0
  61. package/dist/proxy/index.js +108 -0
  62. package/dist/react/AnalyticsBoundary.d.ts +14 -0
  63. package/dist/react/AnalyticsBoundary.js +55 -0
  64. package/dist/react/AreaChips.d.ts +8 -0
  65. package/dist/react/AreaChips.js +11 -0
  66. package/dist/react/CallBar.d.ts +29 -0
  67. package/dist/react/CallBar.js +7 -0
  68. package/dist/react/Coverage.d.ts +24 -0
  69. package/dist/react/Coverage.js +10 -0
  70. package/dist/react/Faq.d.ts +17 -0
  71. package/dist/react/Faq.js +5 -0
  72. package/dist/react/LangSwitch.d.ts +24 -0
  73. package/dist/react/LangSwitch.js +17 -0
  74. package/dist/react/MapFacade.d.ts +18 -0
  75. package/dist/react/MapFacade.js +8 -0
  76. package/dist/react/PlaceDirectory.d.ts +25 -0
  77. package/dist/react/PlaceDirectory.js +13 -0
  78. package/dist/react/QuoteFormShell.d.ts +34 -0
  79. package/dist/react/QuoteFormShell.js +10 -0
  80. package/dist/react/StatusScreen.d.ts +36 -0
  81. package/dist/react/StatusScreen.js +22 -0
  82. package/dist/react/index.d.ts +27 -0
  83. package/dist/react/index.js +29 -0
  84. package/dist/react/parts.d.ts +7 -0
  85. package/dist/react/parts.js +1 -0
  86. package/dist/server/boot.d.ts +11 -0
  87. package/dist/server/boot.js +20 -0
  88. package/dist/server/client-key.d.ts +22 -0
  89. package/dist/server/client-key.js +20 -0
  90. package/dist/server/env.d.ts +44 -0
  91. package/dist/server/env.js +73 -0
  92. package/dist/server/index.d.ts +15 -0
  93. package/dist/server/index.js +15 -0
  94. package/dist/server/lead-store-postgres.d.ts +14 -0
  95. package/dist/server/lead-store-postgres.js +23 -0
  96. package/dist/server/lead-store-sqlite.d.ts +21 -0
  97. package/dist/server/lead-store-sqlite.js +158 -0
  98. package/dist/server/lead-store.d.ts +20 -0
  99. package/dist/server/lead-store.js +43 -0
  100. package/dist/server/notify.d.ts +37 -0
  101. package/dist/server/notify.js +79 -0
  102. package/dist/server/place-source.d.ts +48 -0
  103. package/dist/server/place-source.js +107 -0
  104. package/dist/server/smtp.d.ts +38 -0
  105. package/dist/server/smtp.js +197 -0
  106. package/dist/testing/e2e.d.ts +52 -0
  107. package/dist/testing/e2e.js +57 -0
  108. package/dist/testing/fixtures.d.ts +11 -0
  109. package/dist/testing/fixtures.js +53 -0
  110. package/dist/testing/index.d.ts +12 -0
  111. package/dist/testing/index.js +12 -0
  112. package/dist/testing/landing-contract.d.ts +33 -0
  113. package/dist/testing/landing-contract.js +95 -0
  114. package/dist/testing/lead-store-contract.d.ts +13 -0
  115. package/dist/testing/lead-store-contract.js +55 -0
  116. package/dist/testing/served-files.d.ts +7 -0
  117. package/dist/testing/served-files.js +41 -0
  118. package/package.json +129 -0
  119. package/template/README.md +55 -0
  120. package/template/app/[locale]/[location]/layout.tsx +29 -0
  121. package/template/app/[locale]/[location]/page.tsx +18 -0
  122. package/template/app/[locale]/[location]/prices/page.tsx +26 -0
  123. package/template/app/[locale]/[location]/thanks/page.tsx +20 -0
  124. package/template/app/[locale]/error.tsx +14 -0
  125. package/template/app/[locale]/layout.tsx +28 -0
  126. package/template/app/[locale]/not-found.tsx +18 -0
  127. package/template/app/[locale]/page.tsx +40 -0
  128. package/template/app/[locale]/thanks/page.tsx +21 -0
  129. package/template/app/global-not-found.tsx +28 -0
  130. package/template/app/globals.css +18 -0
  131. package/template/app/health/route.ts +6 -0
  132. package/template/app/og/route.tsx +27 -0
  133. package/template/app/quote/route.ts +17 -0
  134. package/template/app/robots.ts +6 -0
  135. package/template/app/sitemap.ts +8 -0
  136. package/template/assets/brand.toml +68 -0
  137. package/template/assets/card.toml +8 -0
  138. package/template/assets/fonts/Inter-OFL.txt +93 -0
  139. package/template/assets/fonts/Inter-Regular.ttf +0 -0
  140. package/template/assets/fonts/Inter-Regular.woff2 +0 -0
  141. package/template/assets/mark.svg +1 -0
  142. package/template/deploy/config.nix +24 -0
  143. package/template/eslint.config.mjs +28 -0
  144. package/template/flake.nix +46 -0
  145. package/template/instrumentation.ts +13 -0
  146. package/template/next.config.ts +5 -0
  147. package/template/package.json +44 -0
  148. package/template/postcss.config.mjs +3 -0
  149. package/template/proxy.ts +11 -0
  150. package/template/src/entities/content/index.ts +12 -0
  151. package/template/src/entities/content/model/en.ts +47 -0
  152. package/template/src/entities/content/model/fr.ts +47 -0
  153. package/template/src/entities/content/model/types.ts +37 -0
  154. package/template/src/entities/place/api/load.ts +12 -0
  155. package/template/src/entities/place/index.ts +3 -0
  156. package/template/src/entities/place/server.ts +1 -0
  157. package/template/src/shared/config/env.ts +17 -0
  158. package/template/src/shared/config/i18n.ts +15 -0
  159. package/template/src/shared/config/lead.ts +15 -0
  160. package/template/src/shared/config/places.ts +20 -0
  161. package/template/src/shared/config/public.ts +11 -0
  162. package/template/src/shared/config/site.ts +39 -0
  163. package/template/src/shared/ui/fonts.ts +15 -0
  164. package/template/src/views/home/index.ts +1 -0
  165. package/template/src/views/home/ui/PlaceHome.tsx +76 -0
  166. package/template/src/views/not-found/index.ts +1 -0
  167. package/template/src/views/not-found/server.ts +1 -0
  168. package/template/src/views/not-found/ui/Gone.tsx +29 -0
  169. package/template/src/views/not-found/ui/NotFound.tsx +27 -0
  170. package/template/src/views/place/server.ts +13 -0
  171. package/template/src/views/server-error/index.ts +1 -0
  172. package/template/src/views/server-error/ui/ServerError.tsx +24 -0
  173. package/template/steiger.config.ts +26 -0
  174. package/template/tests/bundle_budget.txt +8 -0
  175. package/template/tests/contract.test.ts +14 -0
  176. package/template/tests/e2e/playwright.config.ts +26 -0
  177. package/template/tests/e2e/screenshot-section.css +5 -0
  178. package/template/tests/e2e/sections.spec.ts +12 -0
  179. package/template/tests/e2e/tsconfig.json +11 -0
  180. package/template/tests/empty.ts +1 -0
  181. package/template/tsconfig.json +23 -0
  182. package/template/vitest.config.ts +20 -0
@@ -0,0 +1,17 @@
1
+ import "server-only";
2
+ import { createPlaceSource, createServerEnv, leadNotifier, type LeadNotifier } from "@evinvest/kitstart/server";
3
+ import { site } from "./site";
4
+
5
+ /** Parsed once, lazily: `next build` imports this and must need no secrets. */
6
+ export const serverEnv = createServerEnv(site);
7
+
8
+ /** The live place data, when `LOCATIONS_API_URL` is set; baked otherwise. */
9
+ export const places = createPlaceSource(site, { baseUrl: () => serverEnv().locationsApiUrl });
10
+
11
+ let built: LeadNotifier | undefined;
12
+
13
+ /** Built at boot (`instrumentation.ts`), so SMTP with no sender fails startup. */
14
+ export function notifier(): LeadNotifier {
15
+ built ??= leadNotifier(site, serverEnv());
16
+ return built;
17
+ }
@@ -0,0 +1,15 @@
1
+ import { createLocaleRegistry } from "@evinvest/kitstart";
2
+
3
+ /**
4
+ * Every language carries a prefix: a new site launches with no legacy URLs to
5
+ * keep. The default is what a header-less crawler is sent to.
6
+ */
7
+ export const i18n = createLocaleRegistry({
8
+ locales: ["fr", "en"],
9
+ labels: { fr: "Français", en: "English" },
10
+ default: "fr",
11
+ prefixDefaultLocale: true,
12
+ hreflang: { fr: "fr-FR" },
13
+ });
14
+
15
+ export type Locale = (typeof i18n.locales)[number];
@@ -0,0 +1,15 @@
1
+ import type { LeadSchema } from "@evinvest/kitstart";
2
+
3
+ export const SUBJECTS = ["standard", "deep", "other"] as const;
4
+ export type Subject = (typeof SUBJECTS)[number];
5
+
6
+ /**
7
+ * What the quote form asks, and the one rule worth enforcing: a lead with no
8
+ * way to reach the customer is not a lead. The reason is for the log only.
9
+ */
10
+ export const LEAD: LeadSchema<Subject> = {
11
+ subjects: SUBJECTS,
12
+ wire: { subject: "subject", locality: "locality", mobile: "mobile" },
13
+ extras: [{ name: "surface_m2", max: 6 }],
14
+ validate: lead => (lead.mobile.replace(/\D/g, "").length < 10 ? "a mobile number" : null),
15
+ };
@@ -0,0 +1,20 @@
1
+ import type { Place } from "@evinvest/kitstart";
2
+ import type { Locale } from "./i18n";
3
+
4
+ /**
5
+ * The baked half of every place: what the site says when the live source is
6
+ * absent or unreachable. A service-area business has no address in its type;
7
+ * a storefront would be `{ kind: "storefront", address, geo, … }`.
8
+ */
9
+ export const PLACES: readonly Place<Locale>[] = [
10
+ {
11
+ slug: "paris",
12
+ gbpName: "Brand — Paris",
13
+ name: { fr: "Paris", en: "Paris" },
14
+ presence: { kind: "service-area" },
15
+ serviceArea: [{ kind: "localities", names: ["Paris"] }],
16
+ channels: { phone: null, whatsapp: null },
17
+ hours: [{ days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], opens: "08:00", closes: "19:00" }],
18
+ rating: null,
19
+ },
20
+ ];
@@ -0,0 +1,11 @@
1
+ import { cardFact } from "@evinvest/kitstart";
2
+
3
+ /**
4
+ * The brand facts a client component may know — the error boundary prints
5
+ * the name and the phone, and must not import the whole site config (and its
6
+ * places) to do it. Inlined at build from `assets/card.toml`.
7
+ */
8
+ export const BRAND_PUBLIC = {
9
+ name: "Brand",
10
+ phone: cardFact("SITE_CARD_PHONE", process.env.SITE_CARD_PHONE),
11
+ } as const;
@@ -0,0 +1,39 @@
1
+ import { cardFact, defineSite, SERVICE_AREA_GATE, type OwnerTodo } from "@evinvest/kitstart";
2
+ import { i18n } from "./i18n";
3
+ import { LEAD } from "./lead";
4
+ import { PLACES } from "./places";
5
+
6
+ /**
7
+ * The brand as the machinery sees it — routing, the lead funnel, schema.org,
8
+ * analytics and mail read their facts from this one object. Placeholders the
9
+ * owner has not supplied are listed in `OWNER_TODO`, once, rather than found
10
+ * on the page.
11
+ */
12
+ export const site = defineSite({
13
+ brand: {
14
+ id: "brand",
15
+ name: "Brand",
16
+ legalName: "Brand SAS",
17
+ email: cardFact("SITE_CARD_EMAIL", process.env.SITE_CARD_EMAIL),
18
+ phone: cardFact("SITE_CARD_PHONE", process.env.SITE_CARD_PHONE),
19
+ // `null` until the card has a `site`: noindex everywhere.
20
+ domain: cardFact("SITE_CARD_SITE", process.env.SITE_CARD_SITE),
21
+ businessType: "LocalBusiness",
22
+ },
23
+ i18n,
24
+ ogLocale: { fr: "fr_FR", en: "en_GB" },
25
+ // One place, served at the apex. A network of places is
26
+ // `{ kind: "subdomains", apex: "directory" }` — `<slug>.<domain>` each.
27
+ topology: { kind: "single", place: "paris" },
28
+ pages: { home: "", prices: "/prices" },
29
+ places: PLACES,
30
+ publication: SERVICE_AREA_GATE,
31
+ lead: LEAD,
32
+ });
33
+
34
+ export type PageKey = (typeof site.pageKeys)[number];
35
+
36
+ export const OWNER_TODO: readonly OwnerTodo[] = [
37
+ { field: "site.brand.legalName", why: "raison sociale as registered", blocksLaunch: true },
38
+ { field: "assets/card.toml phone", why: "the public number", blocksLaunch: false },
39
+ ];
@@ -0,0 +1,15 @@
1
+ import localFont from "next/font/local";
2
+
3
+ /**
4
+ * The brand's faces, served from the site itself: no request to a font CDN,
5
+ * and no layout shift once they load. Paths must be literals — Next reads
6
+ * this call statically. The shipped face is Inter, cut to the Latin range the
7
+ * copy uses; replace it with the brand's (and re-cut to the copy's glyphs).
8
+ */
9
+ export const text = localFont({
10
+ src: "../../../assets/fonts/Inter-Regular.woff2",
11
+ weight: "400",
12
+ style: "normal",
13
+ display: "swap",
14
+ variable: "--font-sans",
15
+ });
@@ -0,0 +1 @@
1
+ export { PlaceHome } from "./ui/PlaceHome";
@@ -0,0 +1,76 @@
1
+ import { contactOf, placeGraph, type PlaceView } from "@evinvest/kitstart";
2
+ import {
3
+ Button,
4
+ CallBar,
5
+ Coverage,
6
+ Display,
7
+ Faq,
8
+ Field,
9
+ FieldLabel,
10
+ Input,
11
+ JsonLd,
12
+ NativeSelect,
13
+ NativeSelectOption,
14
+ PHONE_INPUT_PROPS,
15
+ QuoteFormShell,
16
+ Section,
17
+ } from "@evinvest/kitstart/react";
18
+ import type { Copy } from "@/entities/content";
19
+ import type { Locale } from "@/shared/config/i18n";
20
+ import { LEAD } from "@/shared/config/lead";
21
+ import { site } from "@/shared/config/site";
22
+
23
+ /**
24
+ * A place's home page: the brand's own hero, the package's structural
25
+ * widgets, and the JSON-LD derived from the same copy. Replace the hero with
26
+ * the brand's sections; keep the form's field names on `site.lead.wire`.
27
+ */
28
+ export function PlaceHome({ view, copy, renderedAt }: { view: PlaceView<Locale>; copy: Copy; renderedAt: number }) {
29
+ const { t, f } = copy;
30
+ const contact = contactOf(site, view.place);
31
+ const graph = placeGraph(site, view, "home", { placeName: f.place, title: t.pages.home.title(f), description: t.pages.home.description(f) }, new Date(renderedAt));
32
+ return (
33
+ <>
34
+ <JsonLd data={graph} />
35
+ <main>
36
+ <Section>
37
+ <Display>{t.hero.title(f)}</Display>
38
+ <p className="mt-4 text-lg text-ink-soft">{t.hero.lede}</p>
39
+ </Section>
40
+ <Section surface="card" id="quote-band">
41
+ <QuoteFormShell placeSlug={view.place.slug} locale={view.locale} renderedAt={renderedAt} honeypotLabel={t.quoteForm.honeypotLabel} className="max-w-md">
42
+ <p className="font-display text-2xl font-bold text-ink">{t.quoteForm.title}</p>
43
+ <Field className="flex flex-col gap-2">
44
+ <FieldLabel>{t.quoteLabels.subject}</FieldLabel>
45
+ <NativeSelect name={LEAD.wire.subject} size="lg" defaultValue={LEAD.subjects[0]}>
46
+ {LEAD.subjects.map(s => (
47
+ <NativeSelectOption key={s} value={s}>
48
+ {t.subjects[s]}
49
+ </NativeSelectOption>
50
+ ))}
51
+ </NativeSelect>
52
+ </Field>
53
+ <Field className="flex flex-col gap-2">
54
+ <FieldLabel>{t.quoteLabels.locality}</FieldLabel>
55
+ <Input name={LEAD.wire.locality} size="lg" autoComplete="postal-code" required />
56
+ </Field>
57
+ <Field className="flex flex-col gap-2">
58
+ <FieldLabel>{t.quoteLabels.mobile}</FieldLabel>
59
+ <Input name={LEAD.wire.mobile} size="lg" {...PHONE_INPUT_PROPS} required />
60
+ </Field>
61
+ <Button type="submit" size="xl">
62
+ {t.quoteForm.submit}
63
+ </Button>
64
+ </QuoteFormShell>
65
+ </Section>
66
+ <Section id="areas">
67
+ <Coverage place={view.place} locale={view.locale} head={<Display>{t.coverageTitle}</Display>} />
68
+ </Section>
69
+ <Section>
70
+ <Faq items={t.faqs} />
71
+ </Section>
72
+ </main>
73
+ <CallBar id="callbar" label={t.contactLabel} copy={copy} phone={contact.phone} whatsapp={contact.whatsapp} quoteHref={view.href("#quote")} />
74
+ </>
75
+ );
76
+ }
@@ -0,0 +1 @@
1
+ export { NotFound } from "./ui/NotFound";
@@ -0,0 +1 @@
1
+ export { Gone } from "./ui/Gone";
@@ -0,0 +1,29 @@
1
+ import "server-only";
2
+ import { statusTarget } from "@evinvest/kitstart";
3
+ import { StatusScreen } from "@evinvest/kitstart/react";
4
+ import { copyFor } from "@/entities/content";
5
+ import type { Locale } from "@/shared/config/i18n";
6
+ import { site } from "@/shared/config/site";
7
+
8
+ /**
9
+ * The 404 for a dead path the proxy recognised, rendered on the server into
10
+ * the response: a visitor without JavaScript and a crawler get the offer and
11
+ * the right place's phone, in the path's language.
12
+ */
13
+ export function Gone({ locale, location }: { locale: Locale; location: string | null }) {
14
+ const target = statusTarget(site, { locale, location: location ?? undefined });
15
+ const copy = copyFor(target.locale, { place: target.place?.name[target.locale] ?? site.brand.name, phone: target.phone });
16
+ return (
17
+ <>
18
+ <title>{`${copy.t.notFound.title} · ${site.brand.name}`}</title>
19
+ <StatusScreen
20
+ copy={copy}
21
+ status={copy.t.notFound}
22
+ target={target}
23
+ locales={site.i18n.locales}
24
+ labels={site.i18n.labels}
25
+ brandName={site.brand.name}
26
+ />
27
+ </>
28
+ );
29
+ }
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { brandStatusTarget } from "@evinvest/kitstart";
4
+ import { StatusScreen } from "@evinvest/kitstart/react";
5
+ import { useParams } from "next/navigation";
6
+ import { copyFor } from "@/entities/content";
7
+ import { i18n } from "@/shared/config/i18n";
8
+ import { BRAND_PUBLIC } from "@/shared/config/public";
9
+
10
+ /**
11
+ * The `notFound()` boundary, for what the proxy could not foresee — a place
12
+ * the live source withdrew. It speaks for the brand: the site config (and its
13
+ * places) must not ride into every page's bundle. Every dead path the proxy
14
+ * does know is rendered on the server by `app/global-not-found.tsx` instead.
15
+ */
16
+ export function NotFound() {
17
+ const params = useParams<{ locale?: string }>();
18
+ const locale = i18n.isLocale(params?.locale) ? params.locale : i18n.defaultLocale;
19
+ const target = brandStatusTarget({ locales: i18n.locales, phone: BRAND_PUBLIC.phone }, locale);
20
+ const copy = copyFor(locale, { place: BRAND_PUBLIC.name, phone: BRAND_PUBLIC.phone });
21
+ return (
22
+ <>
23
+ <title>{`${copy.t.notFound.title} · ${BRAND_PUBLIC.name}`}</title>
24
+ <StatusScreen copy={copy} status={copy.t.notFound} target={target} locales={i18n.locales} labels={i18n.labels} brandName={BRAND_PUBLIC.name} />
25
+ </>
26
+ );
27
+ }
@@ -0,0 +1,13 @@
1
+ import "server-only";
2
+ import { contactOf, type PlaceView } from "@evinvest/kitstart";
3
+ import { copyFor, type Copy } from "@/entities/content";
4
+ import { loadPlaceView } from "@/entities/place/server";
5
+ import type { Locale } from "@/shared/config/i18n";
6
+ import { site } from "@/shared/config/site";
7
+
8
+ /** A place page's view and its words: the entity and the copy, composed here. */
9
+ export async function loadPlace(params: Promise<{ locale: string; location: string }>): Promise<{ view: PlaceView<Locale>; copy: Copy; renderedAt: number }> {
10
+ const view = await loadPlaceView(params);
11
+ // The time trap's stamp: when this render (under ISR, this cache fill) happened.
12
+ return { view, renderedAt: Date.now(), copy: copyFor(view.locale, { place: view.place.name[view.locale], phone: contactOf(site, view.place).phone }) };
13
+ }
@@ -0,0 +1 @@
1
+ export { ServerError } from "./ui/ServerError";
@@ -0,0 +1,24 @@
1
+ "use client";
2
+
3
+ import { brandStatusTarget } from "@evinvest/kitstart";
4
+ import { StatusScreen } from "@evinvest/kitstart/react";
5
+ import { useParams, usePathname } from "next/navigation";
6
+ import { copyFor } from "@/entities/content";
7
+ import { i18n } from "@/shared/config/i18n";
8
+ import { BRAND_PUBLIC } from "@/shared/config/public";
9
+
10
+ /**
11
+ * The 500. Client-only by Next's contract, and it imports `BRAND_PUBLIC`, not
12
+ * the site config: the name and the phone are all it prints, and the phone
13
+ * works whatever broke.
14
+ */
15
+ export function ServerError() {
16
+ const params = useParams<{ locale?: string }>();
17
+ const pathname = usePathname();
18
+ const locale = i18n.isLocale(params?.locale) ? params.locale : i18n.defaultLocale;
19
+ const target = brandStatusTarget({ locales: i18n.locales, phone: BRAND_PUBLIC.phone }, locale, { retry: pathname });
20
+ const copy = copyFor(locale, { place: BRAND_PUBLIC.name, phone: BRAND_PUBLIC.phone });
21
+ return (
22
+ <StatusScreen copy={copy} status={copy.t.serverError} target={target} locales={i18n.locales} labels={i18n.labels} brandName={BRAND_PUBLIC.name} />
23
+ );
24
+ }
@@ -0,0 +1,26 @@
1
+ import fsd from "@feature-sliced/steiger-plugin";
2
+ import { defineConfig } from "steiger";
3
+
4
+ // The FSD layers of the brand's `src/`. A slice exports through `index.ts`,
5
+ // and its server half through `server.ts`.
6
+ export default defineConfig([
7
+ ...fsd.configs.recommended,
8
+ {
9
+ // A skeleton has one page of each kind; a real brand's slices earn their
10
+ // keep. Turn this back on once there are a few.
11
+ rules: { "fsd/insignificant-slice": "off" },
12
+ },
13
+ {
14
+ // `shared/config` is read file by file on purpose: `public.ts` must reach a
15
+ // client boundary without `site.ts` (every place) riding along, which a
16
+ // barrel would not guarantee.
17
+ files: ["./src/shared/config/**"],
18
+ rules: { "fsd/public-api": "off" },
19
+ },
20
+ {
21
+ // The same reason for the sidestep rule, which cannot tell a layer apart:
22
+ // everything reaching `shared/config/<file>` would trip it. Slices above
23
+ // `shared` still export through `index.ts` / `server.ts`.
24
+ rules: { "fsd/no-public-api-sidestep": "off" },
25
+ },
26
+ ]);
@@ -0,0 +1,8 @@
1
+ # Maximum gzip bytes of first-load JavaScript on a place page
2
+ # (`/[locale]/[location]`), checked by `kitstart-size` after `next build`
3
+ # (and by `mkLanding`'s bundle-budget check on the Nix build).
4
+ #
5
+ # Measured on the untouched template: 152,780 B gz (Next 16.3.6, Turbopack),
6
+ # about 70 KB of it react-dom and 43 KB the app-router runtime. Set from the
7
+ # first measurement plus a few percent; raise it only in a commit that says why.
8
+ 158000
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { describeLandingContract } from "@evinvest/kitstart/testing";
3
+ import { TEXT } from "@/entities/content";
4
+ import { OWNER_TODO, site } from "@/shared/config/site";
5
+
6
+ // A domain in card.toml is the launch; `ownerTodo` refuses it while a
7
+ // blocking owner fact is open.
8
+ describeLandingContract(site, {
9
+ globalsCss: readFileSync("app/globals.css", "utf8"),
10
+ proxySource: readFileSync("proxy.ts", "utf8"),
11
+ root: process.cwd(),
12
+ text: TEXT,
13
+ ownerTodo: OWNER_TODO,
14
+ });
@@ -0,0 +1,26 @@
1
+ import { defineConfig, devices } from "@playwright/test";
2
+ import { BREAKPOINTS } from "@evinvest/kitstart/testing/e2e";
3
+
4
+ // The standalone server under test, never `next dev`. Baselines are Linux's
5
+ // (CI's); elsewhere the pixel comparison is skipped.
6
+ const PORT = Number(process.env["E2E_PORT"] ?? 59089);
7
+ const linux = process.platform === "linux";
8
+
9
+ export default defineConfig({
10
+ testDir: ".",
11
+ snapshotPathTemplate: "{testDir}/__screenshots__/{arg}{ext}",
12
+ ignoreSnapshots: !linux,
13
+ retries: 0,
14
+ expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: "disabled", stylePath: "./screenshot-section.css" } },
15
+ use: { baseURL: `http://localhost:${PORT}`, deviceScaleFactor: 1, colorScheme: "light", locale: "fr-FR" },
16
+ projects: BREAKPOINTS.map(b => ({ name: b.name, use: { ...devices["Desktop Chrome"], viewport: b.viewport } })),
17
+ webServer: {
18
+ command: "node .next/standalone/server.js",
19
+ cwd: "../..",
20
+ url: `http://localhost:${PORT}/health`,
21
+ reuseExistingServer: false,
22
+ // The prod server refuses to boot without these (instrumentation.ts); on
23
+ // loopback the one hop is the runner itself.
24
+ env: { PORT: String(PORT), HOSTNAME: "127.0.0.1", LEADS_DB_PATH: `/tmp/brand-e2e-${PORT}/leads.db`, TRUSTED_PROXY: "xff:1" },
25
+ },
26
+ });
@@ -0,0 +1,5 @@
1
+ /* The call bar rides the viewport, so it lands inside every tall section's
2
+ * crop at a scroll-dependent place; it is shot on its own instead. */
3
+ #callbar {
4
+ display: none !important;
5
+ }
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { defineSectionSuite } from "@evinvest/kitstart/testing/e2e";
3
+
4
+ // One visual baseline per section at both breakpoints; adding a section is one
5
+ // line. Each is reached by a `#` the site links to, never by scrolling. The
6
+ // runner is ours — the package only borrows it.
7
+ defineSectionSuite({ test, expect }, [
8
+ { name: "hero", url: "/fr", selector: "main > section >> nth=0" },
9
+ { name: "quote", url: "/fr#quote", selector: "#quote-band" },
10
+ { name: "areas", url: "/fr#areas", selector: "#areas" },
11
+ { name: "callbar", url: "/fr", selector: "#callbar", mobileOnly: true, bare: true },
12
+ ]);
@@ -0,0 +1,11 @@
1
+ {
2
+ // Its own project because `@playwright/test` is not an npm dependency: the
3
+ // flake supplies it with the pinned browsers, and `nix run .#test` links it
4
+ // into tests/e2e/node_modules before checking these files.
5
+ "extends": "../../tsconfig.json",
6
+ "compilerOptions": {
7
+ "plugins": []
8
+ },
9
+ "include": ["**/*.ts"],
10
+ "exclude": ["node_modules"]
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "ES2023"],
5
+ "allowJs": false,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "exactOptionalPropertyTypes": true,
10
+ "noEmit": true,
11
+ "esModuleInterop": true,
12
+ "module": "esnext",
13
+ "moduleResolution": "bundler",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "jsx": "react-jsx",
17
+ "incremental": false,
18
+ "plugins": [{ "name": "next" }],
19
+ "paths": { "@/*": ["./src/*"] }
20
+ },
21
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
22
+ "exclude": ["node_modules", "tests/e2e", "steiger.config.ts"]
23
+ }
@@ -0,0 +1,20 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { buildEnv } from "@evinvest/kitstart/next/config";
3
+ import { defineConfig } from "vitest/config";
4
+
5
+ export default defineConfig({
6
+ resolve: {
7
+ alias: {
8
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
9
+ // The marker throws outside a React Server bundle; tests call the
10
+ // server modules directly, which is the point.
11
+ "server-only": fileURLToPath(new URL("./tests/empty.ts", import.meta.url)),
12
+ },
13
+ },
14
+ test: {
15
+ environment: "node",
16
+ include: ["tests/**/*.test.ts"],
17
+ // The facts `next.config.ts` inlines, so the site reads the card here too.
18
+ env: buildEnv(fileURLToPath(new URL(".", import.meta.url))),
19
+ },
20
+ });