@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,28 @@
1
+ import "./globals.css";
2
+ import { GONE_HEADER, parseGoneHeader } from "@evinvest/kitstart";
3
+ import { headers } from "next/headers";
4
+ import { site } from "@/shared/config/site";
5
+ import { text } from "@/shared/ui/fonts";
6
+ import { Gone } from "@/views/not-found/server";
7
+
8
+ /**
9
+ * Every path no route matches — among them each dead path the proxy sends
10
+ * here (`gone`) — answered 404 with the brand's screen rendered on the
11
+ * server, so a visitor without JavaScript still gets the offer and the phone.
12
+ * A document of its own: the root layout lives under `[locale]`.
13
+ *
14
+ * It reads the proxy's header, which only this route does: it is not a
15
+ * boundary inside the cached pages, so they stay static. The proxy strips a
16
+ * client-sent copy. No `robots` here: Next already emits `noindex` for a 404.
17
+ */
18
+ export default async function GlobalNotFound() {
19
+ const gone = parseGoneHeader((await headers()).get(GONE_HEADER));
20
+ const locale = site.i18n.isLocale(gone.locale) ? gone.locale : site.i18n.defaultLocale;
21
+ return (
22
+ <html lang={locale} data-brand={site.brand.id} className={`light ${text.variable}`}>
23
+ <body>
24
+ <Gone locale={locale} location={gone.location ?? null} />
25
+ </body>
26
+ </html>
27
+ );
28
+ }
@@ -0,0 +1,18 @@
1
+ @import "tailwindcss";
2
+ @import "@evinvest/uikit/styles/tokens.css";
3
+ @import "./brand.css";
4
+
5
+ /* Tailwind v4 does not scan node_modules: name the packages whose markup it
6
+ must see. The landing contract test checks the kitstart line. */
7
+ @source "../node_modules/@evinvest/uikit/dist";
8
+ @source "../node_modules/@evinvest/kitstart/dist";
9
+
10
+ html {
11
+ scroll-behavior: smooth;
12
+ }
13
+
14
+ @media (prefers-reduced-motion: reduce) {
15
+ html {
16
+ scroll-behavior: auto;
17
+ }
18
+ }
@@ -0,0 +1,6 @@
1
+ import { healthRoute } from "@evinvest/kitstart/next";
2
+
3
+ /** The container's readiness probe. */
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export const GET = healthRoute();
@@ -0,0 +1,27 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { ogPalette, ogRoute } from "@evinvest/kitstart/next";
4
+ import { TEXT } from "@/entities/content";
5
+ import { site } from "@/shared/config/site";
6
+
7
+ /** Drawn once per (place, language, page) per process; baked data only. */
8
+ export const dynamic = "force-dynamic";
9
+
10
+ /** `[colors.dark]` of assets/brand.toml, inlined at build — not a second copy here. */
11
+ const c = ogPalette();
12
+
13
+ export const GET = ogRoute(site, {
14
+ // `withLanding({ ogFiles })` traces the file into the standalone output.
15
+ fonts: async () => [{ name: "Inter", data: await readFile(join(process.cwd(), "assets/fonts/Inter-Regular.ttf")), weight: 400, style: "normal" }],
16
+ draw: ({ locale, place, page }) => {
17
+ const t = TEXT[locale];
18
+ const f = { place: place?.name[locale] ?? site.brand.name, phone: site.brand.phone };
19
+ return (
20
+ <div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%", padding: 72, background: c.background, color: c.ink, fontFamily: "Inter" }}>
21
+ <div style={{ display: "flex", fontSize: 44, color: c.primary }}>{site.brand.name}</div>
22
+ <div style={{ display: "flex", flex: 1, alignItems: "center", fontSize: 68 }}>{t.pages[page].title(f)}</div>
23
+ {f.phone && <div style={{ display: "flex", fontSize: 28, color: c.inkSoft }}>{f.phone}</div>}
24
+ </div>
25
+ );
26
+ },
27
+ });
@@ -0,0 +1,17 @@
1
+ import { quoteRoute } from "@evinvest/kitstart/next";
2
+ import { TEXT } from "@/entities/content";
3
+ import { notifier, serverEnv } from "@/shared/config/env";
4
+ import { site } from "@/shared/config/site";
5
+
6
+ /** The no-JS path: a plain form POST answered with a 303. */
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export const POST = quoteRoute(site, {
10
+ env: serverEnv,
11
+ notifier,
12
+ unavailable: locale => {
13
+ const t = TEXT[locale];
14
+ const f = { place: site.brand.name, phone: site.brand.phone };
15
+ return { title: t.serverError.title, heading: t.serverError.headline.join(""), body: t.serverError.body(f), callLabel: t.callLabel(f) };
16
+ },
17
+ });
@@ -0,0 +1,6 @@
1
+ import { robotsRoute } from "@evinvest/kitstart/next";
2
+ import { site } from "@/shared/config/site";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export default robotsRoute(site);
@@ -0,0 +1,8 @@
1
+ import { sitemapRoute } from "@evinvest/kitstart/next";
2
+ import { places } from "@/shared/config/env";
3
+ import { site } from "@/shared/config/site";
4
+
5
+ /** Per host, strict: a failing live source is a 5xx, never a shorter list. */
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export default sitemapRoute(site, places);
@@ -0,0 +1,68 @@
1
+ # The brand palette, in the kit's token vocabulary — the source of truth.
2
+ # `npm run palette` (the kit's `evinvest-palette`) writes app/brand.css from it
3
+ # and fails on a hole in either scope; the OG card reads [colors.dark] at build;
4
+ # the Figma modes mirror it. These are the kit's demo values: replace them.
5
+
6
+ wordmark = ["BRA", "ND"] # the second half takes primary
7
+
8
+ [colors.light]
9
+ background = "#ffffff"
10
+ card = "#f6f8fa"
11
+ muted = "#d2dbe6"
12
+ popover = "#ffffff"
13
+ ink = "#051726"
14
+ ink-soft = "#5a6b7c"
15
+ brand = "#0a2540"
16
+ primary = "#c2703d"
17
+ on-primary = "#051726"
18
+ primary-ink = "#9a5424"
19
+ secondary = "#edf1f5"
20
+ on-secondary = "#051726"
21
+ positive = "#1e9e6a"
22
+ on-positive = "#ffffff"
23
+ accent-trace = "#5a6b7c"
24
+ on-accent-trace = "#ffffff"
25
+ accent-debug = "#5a6b7c"
26
+ on-accent-debug = "#ffffff"
27
+ accent-info = "#5a6b7c"
28
+ on-accent-info = "#ffffff"
29
+ accent-warn = "#e08a3c"
30
+ on-accent-warn = "#051726"
31
+ accent-error = "#d8362a"
32
+ on-accent-error = "#ffffff"
33
+ chart-1 = "#9a5424"
34
+ chart-2 = "#1e9e6a"
35
+ chart-3 = "#e08a3c"
36
+ chart-4 = "#d8362a"
37
+ chart-5 = "#0a2540"
38
+
39
+ [colors.dark]
40
+ background = "#051726"
41
+ card = "#0a2540"
42
+ muted = "#12395c"
43
+ popover = "#12395c"
44
+ ink = "#ffffff"
45
+ ink-soft = "#a9bdd1"
46
+ brand = "#0a2540"
47
+ primary = "#c2703d"
48
+ on-primary = "#051726"
49
+ primary-ink = "#e08f5c"
50
+ secondary = "#12395c"
51
+ on-secondary = "#ffffff"
52
+ positive = "#1e9e6a"
53
+ on-positive = "#ffffff"
54
+ accent-trace = "#a9bdd1"
55
+ on-accent-trace = "#051726"
56
+ accent-debug = "#a9bdd1"
57
+ on-accent-debug = "#051726"
58
+ accent-info = "#a9bdd1"
59
+ on-accent-info = "#051726"
60
+ accent-warn = "#e08a3c"
61
+ on-accent-warn = "#051726"
62
+ accent-error = "#d8362a"
63
+ on-accent-error = "#ffffff"
64
+ chart-1 = "#e08f5c"
65
+ chart-2 = "#1e9e6a"
66
+ chart-3 = "#e08a3c"
67
+ chart-4 = "#d8362a"
68
+ chart-5 = "#a9bdd1"
@@ -0,0 +1,8 @@
1
+ # The one place a contact fact is written — the printed card, the vCards and
2
+ # the site all read it (`withLanding` inlines it at build).
3
+ #
4
+ # `phone` and `site` are optional for the web: without `site` the whole site
5
+ # is noindex and robots disallow everything — the state before launch. Add
6
+ # them when the owner has them; `phone` must be international ("+33 …").
7
+ name = "Brand"
8
+ email = "hello@brand.example"
@@ -0,0 +1,93 @@
1
+ Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86.6 100"><path fill="currentColor" d="M43.3 0 86.6 25v50L43.3 100 0 75V25z"/></svg>
@@ -0,0 +1,24 @@
1
+ # The prod environment of the server, baked into the image as plain env
2
+ # (flake.nix: `prodEnv`). Secret-free: SMTP_URL, SMS_TOKEN and POSTHOG_KEY
3
+ # (and LEAD_NOTIFY_TO/FROM, LOCATIONS_API_URL when used) arrive from the
4
+ # container environment a Secret injects.
5
+ #
6
+ # Explicit because the defaults are dev's: without HOSTNAME the standalone
7
+ # server binds one interface the readiness probe may not reach; without
8
+ # LEADS_DB_PATH or TRUSTED_PROXY the server refuses to start in production
9
+ # (instrumentation.ts), so the pod never turns ready rather than losing a lead
10
+ # or counting the wrong address against the rate limit.
11
+ { port }:
12
+ {
13
+ # The mount, not $HOME. Leads are the only durable state this service has.
14
+ LEADS_DB_PATH = "/data/leads.db";
15
+ # "cloudflare" is right only when the origin cannot be reached around the
16
+ # tunnel (no public port, no ingress of its own): then CF-Connecting-IP is
17
+ # the visitor. If anything else can reach the pod, a client could write that
18
+ # header itself — use "xff:<n>" for the n proxies of yours in front instead.
19
+ TRUSTED_PROXY = "cloudflare";
20
+ HOSTNAME = "0.0.0.0";
21
+ PORT = toString port;
22
+ NODE_ENV = "production";
23
+ NEXT_TELEMETRY_DISABLED = "1";
24
+ }
@@ -0,0 +1,28 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ // Next's rules, plus the one boundary a landing must hold that steiger does
6
+ // not see: a client module never imports the server halves of the package.
7
+ export default defineConfig([
8
+ ...nextVitals,
9
+ ...nextTs,
10
+ {
11
+ // The client boundaries; add a client module here when you write one.
12
+ files: ["src/views/not-found/ui/NotFound.tsx", "src/views/server-error/**", "app/**/error.tsx", "app/**/not-found.tsx"],
13
+ rules: {
14
+ "no-restricted-imports": [
15
+ "error",
16
+ {
17
+ patterns: [
18
+ {
19
+ group: ["@evinvest/kitstart/server", "@evinvest/kitstart/next", "@evinvest/kitstart/next/*", "@/shared/config/env", "@/shared/config/site"],
20
+ message: "a client module reads BRAND_PUBLIC and the core, never the server halves or the whole site config",
21
+ },
22
+ ],
23
+ },
24
+ ],
25
+ },
26
+ },
27
+ globalIgnores([".next/**", "node_modules/**", "next-env.d.ts"]),
28
+ ]);
@@ -0,0 +1,46 @@
1
+ {
2
+ inputs = {
3
+ v_flakes.url = "github:valeratrades/v_flakes?ref=v1.6";
4
+ # The lib flake at the tag of the @evinvest/kitstart version in
5
+ # package-lock.json — mkLanding refuses a mismatch.
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.1.0";
7
+ ev.inputs.v_flakes.follows = "v_flakes";
8
+ };
9
+
10
+ outputs = { self, v_flakes, ev }:
11
+ v_flakes.flake-utils.lib.eachDefaultSystem (
12
+ system:
13
+ let
14
+ pkgs = import v_flakes.default_nixpkgs { inherit system; };
15
+ sitePort = "59082";
16
+ landing = ev.lib.mkLanding {
17
+ inherit pkgs v_flakes sitePort;
18
+ root = ./.;
19
+ pname = "brand-landing";
20
+ buildFiles = [
21
+ "package.json"
22
+ "package-lock.json"
23
+ "app"
24
+ "src"
25
+ "assets"
26
+ "next.config.ts"
27
+ "tsconfig.json"
28
+ "postcss.config.mjs"
29
+ "proxy.ts"
30
+ "instrumentation.ts"
31
+ ];
32
+ prodEnv = import ./deploy/config.nix { port = sitePort; };
33
+ requiredFiles = [ "assets/fonts/Inter-Regular.ttf" ];
34
+ smoke = {
35
+ page = "/fr";
36
+ og = "/og?l=paris";
37
+ quote = { location = "paris"; subject = "standard"; locality = "75011"; mobile = "0612345678"; };
38
+ };
39
+ };
40
+ in
41
+ {
42
+ inherit (landing) packages checks apps containers;
43
+ devShells.default = landing.devShell;
44
+ }
45
+ );
46
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Runs once when the server starts. A missing prod setting, a lead store that
3
+ * cannot open, or SMTP with no sender fails startup — every request, `/health`
4
+ * included, answers 500 and the pod never turns ready — instead of the first
5
+ * customer's submission.
6
+ */
7
+ export async function register(): Promise<void> {
8
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
9
+ const { serverEnv, notifier } = await import("@/shared/config/env");
10
+ const { checkLeadStore } = await import("@evinvest/kitstart/server");
11
+ notifier();
12
+ await checkLeadStore(serverEnv());
13
+ }
@@ -0,0 +1,5 @@
1
+ import { withLanding } from "@evinvest/kitstart/next/config";
2
+
3
+ // Everything shared is in `withLanding` (standalone output, the card inlined,
4
+ // ISR bounds, no image optimiser); what is left here is the brand's own.
5
+ export default withLanding({}, { root: process.cwd(), ogFiles: ["./assets/fonts/*.ttf"] });
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "brand-landing",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.13"
8
+ },
9
+ "scripts": {
10
+ "palette": "evinvest-palette --brand brand --out app/brand.css assets/brand.toml",
11
+ "dev": "npm run palette && next dev",
12
+ "build": "npm run palette && next build",
13
+ "postbuild": "rm -rf .next/standalone/.next/static && cp -R .next/static .next/standalone/.next/static",
14
+ "start": "node .next/standalone/server.js",
15
+ "typecheck": "next typegen && tsc --noEmit",
16
+ "test": "vitest run",
17
+ "size": "kitstart-size",
18
+ "lint": "eslint . && steiger ./src"
19
+ },
20
+ "dependencies": {
21
+ "@evinvest/analytics": "^0.3.0",
22
+ "@evinvest/i18n": "^0.9.0",
23
+ "@evinvest/kitstart": "^0.1.0",
24
+ "@evinvest/marketing": "^0.3.0",
25
+ "@evinvest/uikit": "^0.20.0",
26
+ "next": "16.3.6",
27
+ "react": "19.3.0",
28
+ "react-dom": "19.3.0",
29
+ "server-only": "^0.0.1"
30
+ },
31
+ "devDependencies": {
32
+ "@feature-sliced/steiger-plugin": "^0.7.0",
33
+ "@tailwindcss/postcss": "^4.3.3",
34
+ "@types/node": "^22.20.4",
35
+ "@types/react": "^19.3.0",
36
+ "@types/react-dom": "^19.3.0",
37
+ "eslint": "^9.39.5",
38
+ "eslint-config-next": "16.3.6",
39
+ "steiger": "^0.6.0",
40
+ "tailwindcss": "^4.3.3",
41
+ "typescript": "5.9.3",
42
+ "vitest": "^3.2.4"
43
+ }
44
+ }
@@ -0,0 +1,3 @@
1
+ const config = { plugins: { "@tailwindcss/postcss": {} } };
2
+
3
+ export default config;
@@ -0,0 +1,11 @@
1
+ import { createProxy } from "@evinvest/kitstart/proxy";
2
+ import { site } from "@/shared/config/site";
3
+
4
+ export const proxy = createProxy(site);
5
+
6
+ export const config = {
7
+ // A literal: Next reads it statically. Everything but the build output,
8
+ // files included: `decide` passes the routes outside `[locale]` and
9
+ // `site.publicFiles`, and sends any other path to the 404.
10
+ matcher: ["/((?!_next/).*)"],
11
+ };
@@ -0,0 +1,12 @@
1
+ import type { Locale } from "@/shared/config/i18n";
2
+ import { EN } from "./model/en";
3
+ import { FR } from "./model/fr";
4
+ import type { Copy, Facts, Text } from "./model/types";
5
+
6
+ export type { Copy, Facts, Text } from "./model/types";
7
+
8
+ export const TEXT = { fr: FR, en: EN } satisfies Record<Locale, Text>;
9
+
10
+ export function copyFor(locale: Locale, f: Facts): Copy {
11
+ return { locale, t: TEXT[locale], f };
12
+ }
@@ -0,0 +1,47 @@
1
+ import type { StatusCopy } from "@evinvest/kitstart";
2
+ import type { Facts, Text } from "./types";
3
+
4
+ const status = (code: string, title: string, headline: readonly [string, string], body: string): StatusCopy<Facts> => ({
5
+ code,
6
+ title,
7
+ eyebrow: title.toUpperCase(),
8
+ headline,
9
+ body: () => body,
10
+ primary: "call",
11
+ secondary: "home",
12
+ });
13
+
14
+ export const EN = {
15
+ pages: {
16
+ home: { title: f => `Cleaning in ${f.place}`, description: f => `Cleaning in ${f.place}: a fixed price, quoted before we come.` },
17
+ prices: { title: () => "Our prices", description: f => `Every price in ${f.place}.` },
18
+ },
19
+ quoteForm: {
20
+ title: "Get a price",
21
+ lede: "An answer within the hour.",
22
+ submit: "Get the price",
23
+ privacy: "Your number is used only to answer you.",
24
+ reassurance: () => "No commitment.",
25
+ honeypotLabel: "Website",
26
+ },
27
+ notFound: status("404", "Page not found", ["This page ", "does not exist."], "The link may be old."),
28
+ serverError: status("500", "Error", ["Something broke ", "on our side."], "Try again in a moment."),
29
+ thanks: status("✓", "Request received", ["Thank you, ", "we will call you back."], "We have your request."),
30
+ callLabel: f => (f.phone ? `Call ${f.phone}` : "Call us"),
31
+ whatsappLabel: "WhatsApp",
32
+ whatsappShort: "WhatsApp",
33
+ whatsappMessage: f => `Hello, I am writing from the ${f.place} page.`,
34
+ ctaShort: "Quote",
35
+ backHome: "Back home",
36
+ tryAgain: "Try again",
37
+ statusStrip: ["Fixed price", "No commitment"],
38
+ langName: "English",
39
+ facts: () => ["Brand SAS"],
40
+ hero: { title: f => `Cleaning in ${f.place}, at a fixed price.`, lede: "A price quoted before we come." },
41
+ coverageTitle: "Where we work",
42
+ brandPage: { title: "Brand", description: "Cleaning at a fixed price.", open: "Open" },
43
+ contactLabel: "Contact us",
44
+ subjects: { standard: "Regular cleaning", deep: "Deep cleaning", other: "Other" },
45
+ quoteLabels: { subject: "Service", locality: "Town or postcode", mobile: "Mobile" },
46
+ faqs: [{ q: "Can the price change?", a: "No: the price quoted is the price you pay." }],
47
+ } satisfies Text;
@@ -0,0 +1,47 @@
1
+ import type { StatusCopy } from "@evinvest/kitstart";
2
+ import type { Facts, Text } from "./types";
3
+
4
+ const status = (code: string, title: string, headline: readonly [string, string], body: string): StatusCopy<Facts> => ({
5
+ code,
6
+ title,
7
+ eyebrow: title.toUpperCase(),
8
+ headline,
9
+ body: () => body,
10
+ primary: "call",
11
+ secondary: "home",
12
+ });
13
+
14
+ export const FR = {
15
+ pages: {
16
+ home: { title: f => `Ménage à ${f.place}`, description: f => `Ménage à ${f.place} : un prix fixe annoncé avant de venir.` },
17
+ prices: { title: () => "Nos prix", description: f => `Tous nos prix à ${f.place}.` },
18
+ },
19
+ quoteForm: {
20
+ title: "Recevoir un prix",
21
+ lede: "Réponse dans l’heure.",
22
+ submit: "Recevoir le prix",
23
+ privacy: "Votre numéro ne sert qu’à vous répondre.",
24
+ reassurance: () => "Sans engagement.",
25
+ honeypotLabel: "Site web",
26
+ },
27
+ notFound: status("404", "Page introuvable", ["Cette page ", "n’existe pas."], "Le lien est peut-être ancien."),
28
+ serverError: status("500", "Erreur", ["Un problème ", "de notre côté."], "Réessayez dans un instant."),
29
+ thanks: status("✓", "Demande reçue", ["Merci, ", "nous vous rappelons."], "Nous avons bien reçu votre demande."),
30
+ callLabel: f => (f.phone ? `Appeler le ${f.phone}` : "Nous appeler"),
31
+ whatsappLabel: "WhatsApp",
32
+ whatsappShort: "WhatsApp",
33
+ whatsappMessage: f => `Bonjour, je vous contacte depuis la page de ${f.place}.`,
34
+ ctaShort: "Devis",
35
+ backHome: "Retour à l’accueil",
36
+ tryAgain: "Réessayer",
37
+ statusStrip: ["Prix fixe", "Sans engagement"],
38
+ langName: "Français",
39
+ facts: () => ["Brand SAS"],
40
+ hero: { title: f => `Le ménage à ${f.place}, à prix fixe.`, lede: "Un prix annoncé avant de venir." },
41
+ coverageTitle: "Où nous intervenons",
42
+ brandPage: { title: "Brand", description: "Le ménage à prix fixe.", open: "Ouvrir" },
43
+ contactLabel: "Nous contacter",
44
+ subjects: { standard: "Ménage courant", deep: "Grand ménage", other: "Autre" },
45
+ quoteLabels: { subject: "Prestation", locality: "Ville ou code postal", mobile: "Mobile" },
46
+ faqs: [{ q: "Le prix peut-il changer ?", a: "Non : le prix annoncé est celui que vous payez." }],
47
+ } satisfies Text;
@@ -0,0 +1,37 @@
1
+ import type { CopySlice, CoreText, Said as CoreSaid } from "@evinvest/kitstart";
2
+ import type { Locale } from "@/shared/config/i18n";
3
+ import type { Subject } from "@/shared/config/lead";
4
+ import type { PageKey } from "@/shared/config/site";
5
+
6
+ /**
7
+ * The facts a sentence may quote. Prose that names the phone or the place
8
+ * takes them as an argument, so the number in the sentence and the number on
9
+ * the card are one field.
10
+ */
11
+ export interface Facts {
12
+ place: string;
13
+ /** `null` until the card has a phone. */
14
+ phone: string | null;
15
+ }
16
+
17
+ export type Said = CoreSaid<Facts>;
18
+
19
+ /**
20
+ * Every string that differs between languages. It extends `CoreText`, the
21
+ * words the machinery prints; the rest is this brand's own sections. `FR` and
22
+ * `EN` are checked with `satisfies Text`, so a key in one and not the other is
23
+ * a compile error.
24
+ */
25
+ export interface Text extends CoreText<PageKey, Facts> {
26
+ hero: { title: Said; lede: string };
27
+ /** The apex of a network of places: its `<head>` and the directory's link. */
28
+ brandPage: { title: string; description: string; open: string };
29
+ /** The contact bar's accessible name. */
30
+ contactLabel: string;
31
+ coverageTitle: string;
32
+ subjects: Record<Subject, string>;
33
+ quoteLabels: { subject: string; locality: string; mobile: string };
34
+ faqs: readonly { q: string; a: string }[];
35
+ }
36
+
37
+ export type Copy = CopySlice<Locale, Text, Facts>;
@@ -0,0 +1,12 @@
1
+ import "server-only";
2
+ import { createPlaceLoader } from "@evinvest/kitstart/next";
3
+ import { places } from "@/shared/config/env";
4
+ import { site } from "@/shared/config/site";
5
+
6
+ /**
7
+ * The place and how its links are written, from the route params alone — the
8
+ * link mode is in the `[location]` param, so the page is cached (ISR).
9
+ * The words for it are composed a layer up (`views`), not here: an entity
10
+ * does not reach into a sibling slice.
11
+ */
12
+ export const loadPlaceView = createPlaceLoader(site, places);
@@ -0,0 +1,3 @@
1
+ // The place model is the package's; this slice binds it to the site. The
2
+ // loader is server-only: `server.ts`.
3
+ export type { Place, PlaceView } from "@evinvest/kitstart";
@@ -0,0 +1 @@
1
+ export { loadPlaceView } from "./api/load";