@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,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,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,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.
|
|
Binary file
|
|
Binary file
|
|
@@ -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,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 @@
|
|
|
1
|
+
export { loadPlaceView } from "./api/load";
|