@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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three cheap barriers, none of which needs the visitor to run a script — the
|
|
3
|
+
* form has to work before any JavaScript arrives, so a challenge widget is not
|
|
4
|
+
* an option. A rejected submission is answered exactly like an accepted one,
|
|
5
|
+
* so a bot learns nothing from the response.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The field a human never sees and a form-filling bot fills. A neutral name:
|
|
9
|
+
* a browser's autofill knows `website`, `url` or `company` and would fill the
|
|
10
|
+
* trap for a real person.
|
|
11
|
+
*/
|
|
12
|
+
export const HONEYPOT_FIELD = "hp_ref";
|
|
13
|
+
/** The trap's name on pages cached before it was renamed; still read. */
|
|
14
|
+
export const LEGACY_HONEYPOT_FIELDS = ["website"];
|
|
15
|
+
/** When the form was rendered, in ms since the epoch. */
|
|
16
|
+
export const RENDERED_AT_FIELD = "t";
|
|
17
|
+
/** Faster than this from render to submit is a script, not a person. */
|
|
18
|
+
export const MIN_FILL_MS = 3_000;
|
|
19
|
+
/** A render time this far ahead of the clock was forged. */
|
|
20
|
+
const MAX_SKEW_MS = 60_000;
|
|
21
|
+
export function checkTiming(renderedAt, now) {
|
|
22
|
+
const t = renderedAt === null || renderedAt.trim() === "" ? Number.NaN : Number(renderedAt);
|
|
23
|
+
if (!Number.isFinite(t) || t - now > MAX_SKEW_MS)
|
|
24
|
+
return "too-fast";
|
|
25
|
+
return now - t < MIN_FILL_MS ? "too-fast" : "ok";
|
|
26
|
+
}
|
|
27
|
+
/** How many distinct keys a limiter tracks before new ones share one bucket. */
|
|
28
|
+
export const RATE_LIMIT_MAX_KEYS = 10_000;
|
|
29
|
+
/** The shared bucket keys past the cap count against. */
|
|
30
|
+
export const RATE_LIMIT_OVERFLOW_KEY = "\u0000overflow";
|
|
31
|
+
const PRUNE_EVERY_MS = 1_000;
|
|
32
|
+
/**
|
|
33
|
+
* A fixed-window counter per client address, in memory. Per process by design:
|
|
34
|
+
* it is a speed bump for one bot hammering one pod, not an accounting system,
|
|
35
|
+
* and a restart forgetting it costs nothing.
|
|
36
|
+
*
|
|
37
|
+
* Bounded: past `maxKeys` distinct addresses in one window, new ones count
|
|
38
|
+
* against one shared bucket — a flood of forged addresses throttles itself
|
|
39
|
+
* instead of growing the map. Expired windows are swept at most once a second.
|
|
40
|
+
*/
|
|
41
|
+
export class RateLimiter {
|
|
42
|
+
limit;
|
|
43
|
+
windowMs;
|
|
44
|
+
maxKeys;
|
|
45
|
+
hits = new Map();
|
|
46
|
+
lastPrune = Number.NEGATIVE_INFINITY;
|
|
47
|
+
constructor(limit, windowMs, maxKeys = RATE_LIMIT_MAX_KEYS) {
|
|
48
|
+
this.limit = limit;
|
|
49
|
+
this.windowMs = windowMs;
|
|
50
|
+
this.maxKeys = maxKeys;
|
|
51
|
+
}
|
|
52
|
+
/** `true` if this hit is allowed. */
|
|
53
|
+
hit(key, now) {
|
|
54
|
+
this.prune(now);
|
|
55
|
+
const bucket = this.hits.has(key) || this.hits.size < this.maxKeys ? key : RATE_LIMIT_OVERFLOW_KEY;
|
|
56
|
+
const entry = this.hits.get(bucket);
|
|
57
|
+
if (!entry || now - entry.start >= this.windowMs) {
|
|
58
|
+
this.hits.set(bucket, { start: now, count: 1 });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
entry.count += 1;
|
|
62
|
+
return entry.count <= this.limit;
|
|
63
|
+
}
|
|
64
|
+
/** Distinct buckets held now, for tests and diagnostics. */
|
|
65
|
+
get size() {
|
|
66
|
+
return this.hits.size;
|
|
67
|
+
}
|
|
68
|
+
prune(now) {
|
|
69
|
+
if (now - this.lastPrune < PRUNE_EVERY_MS)
|
|
70
|
+
return;
|
|
71
|
+
this.lastPrune = now;
|
|
72
|
+
for (const [key, entry] of this.hits) {
|
|
73
|
+
if (now - entry.start >= this.windowMs)
|
|
74
|
+
this.hits.delete(key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The barriers in their order. Every submission past the honeypot spends the
|
|
80
|
+
* limiter — a script that omits the render stamp must not get unlimited
|
|
81
|
+
* `too-fast` rows — and the verdict ranks the server's own evidence first:
|
|
82
|
+
* honeypot, then rate-limited, then the client's stamp (too-fast).
|
|
83
|
+
*/
|
|
84
|
+
export function screen(input) {
|
|
85
|
+
if (input.honeypot !== null && input.honeypot.trim() !== "")
|
|
86
|
+
return "honeypot";
|
|
87
|
+
if (!input.limiter.hit(input.clientKey, input.now))
|
|
88
|
+
return "rate-limited";
|
|
89
|
+
return checkTiming(input.renderedAt, input.now);
|
|
90
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The copy contract the machinery reads. A brand's `Text` extends
|
|
3
|
+
* `CoreText` and adds everything its own sections say; a shared widget takes
|
|
4
|
+
* only its slice (`StatusCopy`, `QuoteFormCopy`, `PageMetaCopy`…), so a brand
|
|
5
|
+
* can reword its hero without the status screen's type noticing, and a
|
|
6
|
+
* widget's signature says which words it can print.
|
|
7
|
+
*
|
|
8
|
+
* `F` is the brand's facts — the phone, the place, the prices a sentence may
|
|
9
|
+
* quote. Prose that quotes a fact takes it as an argument instead of spelling
|
|
10
|
+
* it out, so the number in the sentence and the number on the card are one.
|
|
11
|
+
*/
|
|
12
|
+
/** A sentence that quotes a fact. */
|
|
13
|
+
export type Said<F> = (f: F) => string;
|
|
14
|
+
/** What `<head>`, the OG card and the breadcrumbs read for one page. */
|
|
15
|
+
export interface PageMetaCopy<F> {
|
|
16
|
+
/** `<title>`, the OG title and the breadcrumb label. */
|
|
17
|
+
title: Said<F>;
|
|
18
|
+
/** Read by `<head>`, the OG card and the sitemap — one field, three readers. */
|
|
19
|
+
description: Said<F>;
|
|
20
|
+
}
|
|
21
|
+
export type StatusAction = "call" | "home" | "retry";
|
|
22
|
+
/** The 404, the 500 and the post-submit confirmation. */
|
|
23
|
+
export interface StatusCopy<F> {
|
|
24
|
+
code: string;
|
|
25
|
+
/** The `<title>`: these pages are `noindex` and carry no page meta. */
|
|
26
|
+
title: string;
|
|
27
|
+
eyebrow: string;
|
|
28
|
+
/** Split at the accented half, which is how the design draws it. */
|
|
29
|
+
headline: readonly [string, string];
|
|
30
|
+
body: Said<F>;
|
|
31
|
+
primary: StatusAction;
|
|
32
|
+
secondary: StatusAction;
|
|
33
|
+
}
|
|
34
|
+
/** The quote form's frame; the fields a brand asks for label themselves. */
|
|
35
|
+
export interface QuoteFormCopy<F> {
|
|
36
|
+
title: string;
|
|
37
|
+
lede: string;
|
|
38
|
+
submit: string;
|
|
39
|
+
privacy: string;
|
|
40
|
+
reassurance: Said<F>;
|
|
41
|
+
/** The honeypot's label — read only by a bot filling every field. */
|
|
42
|
+
honeypotLabel: string;
|
|
43
|
+
}
|
|
44
|
+
/** Every string the shared machinery prints, per language. */
|
|
45
|
+
export interface CoreText<P extends string, F> {
|
|
46
|
+
pages: Record<P, PageMetaCopy<F>>;
|
|
47
|
+
quoteForm: QuoteFormCopy<F>;
|
|
48
|
+
notFound: StatusCopy<F>;
|
|
49
|
+
serverError: StatusCopy<F>;
|
|
50
|
+
thanks: StatusCopy<F>;
|
|
51
|
+
/** The accessible name of every `tel:` link. */
|
|
52
|
+
callLabel: Said<F>;
|
|
53
|
+
whatsappLabel: string;
|
|
54
|
+
/** The same channel where only a word fits. */
|
|
55
|
+
whatsappShort: string;
|
|
56
|
+
whatsappMessage: Said<F>;
|
|
57
|
+
/** The one CTA label where only a word fits. */
|
|
58
|
+
ctaShort: string;
|
|
59
|
+
backHome: string;
|
|
60
|
+
tryAgain: string;
|
|
61
|
+
/** The offer's terms, compressed under a status screen. */
|
|
62
|
+
statusStrip: readonly string[];
|
|
63
|
+
langName: string;
|
|
64
|
+
/**
|
|
65
|
+
* The business's registration facts (SIRET, insurer…) as short items — the
|
|
66
|
+
* status screen prints them under its offer strip.
|
|
67
|
+
*/
|
|
68
|
+
facts: (f: F) => readonly string[];
|
|
69
|
+
}
|
|
70
|
+
/** What the status screen prints besides its own `StatusCopy`. */
|
|
71
|
+
export type StatusScreenText<F> = Pick<CoreText<string, F>, "callLabel" | "backHome" | "tryAgain" | "statusStrip" | "facts">;
|
|
72
|
+
/** What the mobile call bar prints. */
|
|
73
|
+
export type CallBarText<F> = Pick<CoreText<string, F>, "callLabel" | "whatsappMessage" | "whatsappShort" | "ctaShort">;
|
|
74
|
+
/**
|
|
75
|
+
* What a widget needs to say something: the language, its slice of the words,
|
|
76
|
+
* and the facts they quote. A brand's full `Copy` is assignable to any slice.
|
|
77
|
+
*/
|
|
78
|
+
export interface CopySlice<L extends string, T, F> {
|
|
79
|
+
locale: L;
|
|
80
|
+
t: T;
|
|
81
|
+
f: F;
|
|
82
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The copy contract the machinery reads. A brand's `Text` extends
|
|
3
|
+
* `CoreText` and adds everything its own sections say; a shared widget takes
|
|
4
|
+
* only its slice (`StatusCopy`, `QuoteFormCopy`, `PageMetaCopy`…), so a brand
|
|
5
|
+
* can reword its hero without the status screen's type noticing, and a
|
|
6
|
+
* widget's signature says which words it can print.
|
|
7
|
+
*
|
|
8
|
+
* `F` is the brand's facts — the phone, the place, the prices a sentence may
|
|
9
|
+
* quote. Prose that quotes a fact takes it as an argument instead of spelling
|
|
10
|
+
* it out, so the number in the sentence and the number on the card are one.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lead as data. Every landing asks for a subject (what the job is), a
|
|
3
|
+
* locality (where to go) and a mobile (how to answer); what else it asks for
|
|
4
|
+
* is the brand's `extras`, and what it refuses is the brand's `validate`. The
|
|
5
|
+
* funnel's order — validate → screen → insert → deferred notify — is the
|
|
6
|
+
* machinery's, not the schema's.
|
|
7
|
+
*
|
|
8
|
+
* `Lead.subject` is a plain `string` (a stale form's value is still a job) and
|
|
9
|
+
* `LeadSchema.wire` names the posted fields (a page cached before a rename
|
|
10
|
+
* still delivers) — both deliberate departures from the first design.
|
|
11
|
+
*/
|
|
12
|
+
/** Why a submission was kept but not acted on. `null` is a clean lead. */
|
|
13
|
+
export type SpamVerdict = "honeypot" | "too-fast" | "rate-limited";
|
|
14
|
+
/**
|
|
15
|
+
* One more field a brand's form asks for, capped at its own `max` characters —
|
|
16
|
+
* not at `MAX_FIELD`, which caps the core fields: a free-text note may be longer.
|
|
17
|
+
*/
|
|
18
|
+
export interface LeadExtra {
|
|
19
|
+
name: string;
|
|
20
|
+
max: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The form field each core value is posted under. Brand config, not a
|
|
24
|
+
* constant: a page cached before a rename still posts the old names, and a
|
|
25
|
+
* lead from it is still a customer.
|
|
26
|
+
*/
|
|
27
|
+
export interface LeadWire {
|
|
28
|
+
subject: string;
|
|
29
|
+
locality: string;
|
|
30
|
+
mobile: string;
|
|
31
|
+
}
|
|
32
|
+
export interface LeadSchema<S extends string> {
|
|
33
|
+
/** What the form's subject control offers, in order. */
|
|
34
|
+
subjects: readonly S[];
|
|
35
|
+
wire: LeadWire;
|
|
36
|
+
extras?: readonly LeadExtra[];
|
|
37
|
+
/**
|
|
38
|
+
* The one rule worth enforcing, as a reason for the log (never rendered), or
|
|
39
|
+
* `null` to accept. Absent → every candidate is a lead.
|
|
40
|
+
*/
|
|
41
|
+
validate?: (lead: LeadCandidate) => string | null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A submitted lead. Values are trimmed and capped but not otherwise parsed: a
|
|
45
|
+
* lead that cannot be fully validated is still a lead, and rejecting it loses
|
|
46
|
+
* a customer to protect a column type. `subject` is kept as posted even when
|
|
47
|
+
* it is not one of `subjects` — a stale form's value is still a job.
|
|
48
|
+
*/
|
|
49
|
+
export interface Lead {
|
|
50
|
+
subject: string;
|
|
51
|
+
locality: string;
|
|
52
|
+
mobile: string;
|
|
53
|
+
extras: Readonly<Record<string, string>>;
|
|
54
|
+
/** The place it was submitted from — its slug — or `null` when unknown. */
|
|
55
|
+
placeSlug: string | null;
|
|
56
|
+
spamVerdict: SpamVerdict | null;
|
|
57
|
+
}
|
|
58
|
+
export type LeadCandidate = Omit<Lead, "spamVerdict">;
|
|
59
|
+
/**
|
|
60
|
+
* The port the funnel writes through — the commit point. `insert` resolves
|
|
61
|
+
* with the row's id only once the lead is durable; a rejection is a lead the
|
|
62
|
+
* visitor must not be thanked for. Async because not every adapter is
|
|
63
|
+
* in-process: SQLite answers synchronously, a network database does not.
|
|
64
|
+
*
|
|
65
|
+
* An adapter migrates its storage forward on open, versioned by
|
|
66
|
+
* `LEAD_SCHEMA_VERSION`, and must pass the shared contract suite
|
|
67
|
+
* (`@evinvest/kitstart/testing`) before `LEADS_DB_URL` may select it.
|
|
68
|
+
*/
|
|
69
|
+
export interface LeadStore {
|
|
70
|
+
insert(lead: Lead): Promise<number>;
|
|
71
|
+
count(): Promise<number>;
|
|
72
|
+
/** The schema version the storage stands at after opening. */
|
|
73
|
+
schemaVersion(): Promise<number>;
|
|
74
|
+
/** Resolves while the store can take a lead; rejects with why it cannot. */
|
|
75
|
+
health(): Promise<void>;
|
|
76
|
+
close(): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The lead schema's version, shared by every adapter: 1 the Rust server's
|
|
80
|
+
* table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
|
|
81
|
+
* 4 + the brand's extras. Append only.
|
|
82
|
+
*/
|
|
83
|
+
export declare const LEAD_SCHEMA_VERSION = 4;
|
|
84
|
+
/** A field is capped, not rejected: a long answer is still a customer. */
|
|
85
|
+
export declare const MAX_FIELD = 200;
|
|
86
|
+
/** The candidate a form posted, read through the schema's field names. */
|
|
87
|
+
export declare function readCandidate(schema: LeadSchema<string>, form: FormData, placeSlug: string | null): LeadCandidate;
|
|
88
|
+
export declare function validateCandidate(schema: LeadSchema<string>, lead: LeadCandidate): string | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lead as data. Every landing asks for a subject (what the job is), a
|
|
3
|
+
* locality (where to go) and a mobile (how to answer); what else it asks for
|
|
4
|
+
* is the brand's `extras`, and what it refuses is the brand's `validate`. The
|
|
5
|
+
* funnel's order — validate → screen → insert → deferred notify — is the
|
|
6
|
+
* machinery's, not the schema's.
|
|
7
|
+
*
|
|
8
|
+
* `Lead.subject` is a plain `string` (a stale form's value is still a job) and
|
|
9
|
+
* `LeadSchema.wire` names the posted fields (a page cached before a rename
|
|
10
|
+
* still delivers) — both deliberate departures from the first design.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The lead schema's version, shared by every adapter: 1 the Rust server's
|
|
14
|
+
* table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
|
|
15
|
+
* 4 + the brand's extras. Append only.
|
|
16
|
+
*/
|
|
17
|
+
export const LEAD_SCHEMA_VERSION = 4;
|
|
18
|
+
/** A field is capped, not rejected: a long answer is still a customer. */
|
|
19
|
+
export const MAX_FIELD = 200;
|
|
20
|
+
function field(form, name, max) {
|
|
21
|
+
const value = form.get(name);
|
|
22
|
+
return typeof value === "string" ? value.trim().slice(0, max) : null;
|
|
23
|
+
}
|
|
24
|
+
/** The candidate a form posted, read through the schema's field names. */
|
|
25
|
+
export function readCandidate(schema, form, placeSlug) {
|
|
26
|
+
const extras = {};
|
|
27
|
+
for (const extra of schema.extras ?? []) {
|
|
28
|
+
const value = field(form, extra.name, extra.max);
|
|
29
|
+
if (value)
|
|
30
|
+
extras[extra.name] = value;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
subject: field(form, schema.wire.subject, MAX_FIELD) ?? "",
|
|
34
|
+
locality: field(form, schema.wire.locality, MAX_FIELD) ?? "",
|
|
35
|
+
mobile: field(form, schema.wire.mobile, MAX_FIELD) ?? "",
|
|
36
|
+
extras,
|
|
37
|
+
placeSlug,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function validateCandidate(schema, lead) {
|
|
41
|
+
return schema.validate?.(lead) ?? null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fn` memoised per key for the life of the process, the pending promise
|
|
3
|
+
* included, so concurrent first requests share one computation. A rejection is
|
|
4
|
+
* forgotten, so the next caller retries instead of inheriting the failure.
|
|
5
|
+
*
|
|
6
|
+
* Unbounded by design: callers pass only keys drawn from a closed set.
|
|
7
|
+
*/
|
|
8
|
+
export declare function memoByKey<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fn` memoised per key for the life of the process, the pending promise
|
|
3
|
+
* included, so concurrent first requests share one computation. A rejection is
|
|
4
|
+
* forgotten, so the next caller retries instead of inheriting the failure.
|
|
5
|
+
*
|
|
6
|
+
* Unbounded by design: callers pass only keys drawn from a closed set.
|
|
7
|
+
*/
|
|
8
|
+
export function memoByKey(fn) {
|
|
9
|
+
const cache = new Map();
|
|
10
|
+
return key => {
|
|
11
|
+
let hit = cache.get(key);
|
|
12
|
+
if (!hit) {
|
|
13
|
+
hit = fn(key);
|
|
14
|
+
cache.set(key, hit);
|
|
15
|
+
hit.catch(() => cache.delete(key));
|
|
16
|
+
}
|
|
17
|
+
return hit;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { servedLocalities, storefrontOf, type DayOfWeek, type Geo, type OpeningHours, type Place, type PostalAddress, type Presence, type Rating, type ServiceArea, } from "./types.js";
|
|
2
|
+
export { isPublished, publicationGaps, SERVICE_AREA_GATE, STOREFRONT_GATE, type PublicationField, type PublicationPolicy, } from "./publication.js";
|
|
3
|
+
export { freshRating, parseInstant, RATING_MAX_AGE_DAYS } from "./rating.js";
|
|
4
|
+
export { mergeLive, parsePlaceLive, type PlaceLive } from "./live.js";
|
|
5
|
+
export { createPlaceView, placeHref, placeOrigin, placeUrl, siteOrigin, type LinkBase, type LinkMode, type OriginFacts, type PlaceView, } from "./view.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { servedLocalities, storefrontOf, } from "./types.js";
|
|
2
|
+
export { isPublished, publicationGaps, SERVICE_AREA_GATE, STOREFRONT_GATE, } from "./publication.js";
|
|
3
|
+
export { freshRating, parseInstant, RATING_MAX_AGE_DAYS } from "./rating.js";
|
|
4
|
+
export { mergeLive, parsePlaceLive } from "./live.js";
|
|
5
|
+
export { createPlaceView, placeHref, placeOrigin, placeUrl, siteOrigin, } from "./view.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Geo, OpeningHours, Place, PostalAddress, Rating, ServiceArea } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The live half of a place, as `GET <source>/locations/<slug>` answers it.
|
|
4
|
+
* Every field is optional: whatever the source leaves out, the baked config
|
|
5
|
+
* supplies. Validated here rather than cast — a response is a promise from
|
|
6
|
+
* another service, not a type. The wire shape is the source's own:
|
|
7
|
+
* `serviceArea` is a list of commune names.
|
|
8
|
+
*/
|
|
9
|
+
export interface PlaceLive<L extends string> {
|
|
10
|
+
phone?: string;
|
|
11
|
+
whatsapp?: string;
|
|
12
|
+
address?: Omit<PostalAddress, "region" | "country">;
|
|
13
|
+
geo?: Geo;
|
|
14
|
+
storefrontPhoto?: string;
|
|
15
|
+
landmark?: Readonly<Record<L, string>>;
|
|
16
|
+
serviceArea?: readonly ServiceArea[];
|
|
17
|
+
hours?: readonly OpeningHours[];
|
|
18
|
+
rating?: Rating;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Drops what does not validate instead of failing the page on one bad field.
|
|
22
|
+
* A landmark counts only when it is given in every one of `locales`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parsePlaceLive<L extends string>(body: unknown, locales: readonly L[]): PlaceLive<L>;
|
|
25
|
+
/**
|
|
26
|
+
* The live fields over the baked ones. A service-area place takes none of the
|
|
27
|
+
* storefront's: an address, a pin or a photo from the source would give it a
|
|
28
|
+
* front it does not have.
|
|
29
|
+
*/
|
|
30
|
+
export declare function mergeLive<L extends string>(baked: Place<L>, live: PlaceLive<L>): Place<L>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { parseInstant } from "./rating.js";
|
|
2
|
+
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
3
|
+
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
4
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5
|
+
const str = (v) => (typeof v === "string" && v.trim() !== "" ? v : undefined);
|
|
6
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
7
|
+
const isDay = (d) => DAYS.some(day => day === d);
|
|
8
|
+
function hours(v) {
|
|
9
|
+
if (!Array.isArray(v))
|
|
10
|
+
return undefined;
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const row of v) {
|
|
13
|
+
if (!isObject(row) || !Array.isArray(row.days))
|
|
14
|
+
return undefined;
|
|
15
|
+
const days = row.days.filter(isDay);
|
|
16
|
+
const opens = str(row.opens);
|
|
17
|
+
const closes = str(row.closes);
|
|
18
|
+
if (days.length !== row.days.length || !opens || !closes || !TIME.test(opens) || !TIME.test(closes)) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
out.push({ days, opens, closes });
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
function rating(v) {
|
|
26
|
+
if (!isObject(v))
|
|
27
|
+
return undefined;
|
|
28
|
+
const value = num(v.value);
|
|
29
|
+
const count = num(v.count);
|
|
30
|
+
const fetchedAt = str(v.fetchedAt);
|
|
31
|
+
if (value === undefined || count === undefined || !fetchedAt || parseInstant(fetchedAt) === null)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (value < 1 || value > 5 || count < 0)
|
|
34
|
+
return undefined;
|
|
35
|
+
return { value, count, fetchedAt };
|
|
36
|
+
}
|
|
37
|
+
function httpsUrl(v) {
|
|
38
|
+
const s = str(v);
|
|
39
|
+
if (!s)
|
|
40
|
+
return undefined;
|
|
41
|
+
try {
|
|
42
|
+
return new URL(s).protocol === "https:" ? s : undefined;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function landmark(v, locales) {
|
|
49
|
+
if (!isObject(v))
|
|
50
|
+
return undefined;
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const locale of locales) {
|
|
53
|
+
const text = str(v[locale]);
|
|
54
|
+
if (!text)
|
|
55
|
+
return undefined;
|
|
56
|
+
out[locale] = text;
|
|
57
|
+
}
|
|
58
|
+
return isComplete(out, locales) ? out : undefined;
|
|
59
|
+
}
|
|
60
|
+
function isComplete(record, locales) {
|
|
61
|
+
return locales.every(locale => typeof record[locale] === "string");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Drops what does not validate instead of failing the page on one bad field.
|
|
65
|
+
* A landmark counts only when it is given in every one of `locales`.
|
|
66
|
+
*/
|
|
67
|
+
export function parsePlaceLive(body, locales) {
|
|
68
|
+
if (!isObject(body))
|
|
69
|
+
throw new Error("live location: body is not an object");
|
|
70
|
+
const live = {};
|
|
71
|
+
const phone = str(body.phone);
|
|
72
|
+
if (phone?.startsWith("+"))
|
|
73
|
+
live.phone = phone;
|
|
74
|
+
const whatsapp = str(body.whatsapp);
|
|
75
|
+
if (whatsapp?.startsWith("+"))
|
|
76
|
+
live.whatsapp = whatsapp;
|
|
77
|
+
if (isObject(body.address)) {
|
|
78
|
+
const street = str(body.address.street);
|
|
79
|
+
const postalCode = str(body.address.postalCode);
|
|
80
|
+
const locality = str(body.address.locality);
|
|
81
|
+
if (street && postalCode && /^\d{5}$/.test(postalCode) && locality)
|
|
82
|
+
live.address = { street, postalCode, locality };
|
|
83
|
+
}
|
|
84
|
+
if (isObject(body.geo)) {
|
|
85
|
+
const lat = num(body.geo.lat);
|
|
86
|
+
const lng = num(body.geo.lng);
|
|
87
|
+
if (lat !== undefined && lng !== undefined)
|
|
88
|
+
live.geo = { lat, lng };
|
|
89
|
+
}
|
|
90
|
+
const h = hours(body.hours);
|
|
91
|
+
if (h && h.length > 0)
|
|
92
|
+
live.hours = h;
|
|
93
|
+
const photo = httpsUrl(body.storefrontPhoto);
|
|
94
|
+
if (photo)
|
|
95
|
+
live.storefrontPhoto = photo;
|
|
96
|
+
const mark = landmark(body.landmark, locales);
|
|
97
|
+
if (mark)
|
|
98
|
+
live.landmark = mark;
|
|
99
|
+
if (Array.isArray(body.serviceArea)) {
|
|
100
|
+
const names = body.serviceArea.map(str).filter((a) => a !== undefined);
|
|
101
|
+
if (names.length > 0)
|
|
102
|
+
live.serviceArea = [{ kind: "localities", names }];
|
|
103
|
+
}
|
|
104
|
+
const r = rating(body.rating);
|
|
105
|
+
if (r)
|
|
106
|
+
live.rating = r;
|
|
107
|
+
return live;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The live fields over the baked ones. A service-area place takes none of the
|
|
111
|
+
* storefront's: an address, a pin or a photo from the source would give it a
|
|
112
|
+
* front it does not have.
|
|
113
|
+
*/
|
|
114
|
+
export function mergeLive(baked, live) {
|
|
115
|
+
const front = baked.presence;
|
|
116
|
+
return {
|
|
117
|
+
...baked,
|
|
118
|
+
presence: front.kind === "storefront"
|
|
119
|
+
? {
|
|
120
|
+
...front,
|
|
121
|
+
address: live.address ? { ...front.address, ...live.address } : front.address,
|
|
122
|
+
geo: live.geo ?? front.geo,
|
|
123
|
+
storefrontPhoto: live.storefrontPhoto ?? front.storefrontPhoto,
|
|
124
|
+
landmark: live.landmark ?? front.landmark,
|
|
125
|
+
}
|
|
126
|
+
: front,
|
|
127
|
+
channels: { phone: live.phone ?? baked.channels.phone, whatsapp: live.whatsapp ?? baked.channels.whatsapp },
|
|
128
|
+
serviceArea: live.serviceArea ?? baked.serviceArea,
|
|
129
|
+
hours: live.hours ?? baked.hours,
|
|
130
|
+
rating: live.rating ?? baked.rating,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Place } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The fields that make a place's page about *that* place. Google's spam policy
|
|
4
|
+
* names "many similar pages that funnel to one business" as doorway pages, and
|
|
5
|
+
* a network of pages that differ only by address is exactly that. So a place
|
|
6
|
+
* is indexable only once it says something its neighbours cannot.
|
|
7
|
+
*/
|
|
8
|
+
export type PublicationField = "storefrontPhoto" | "landmark" | "serviceArea" | "hours";
|
|
9
|
+
/** Which fields a place must fill before it may be indexed. */
|
|
10
|
+
export interface PublicationPolicy {
|
|
11
|
+
required(place: Place<string>): readonly PublicationField[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A storefront network: what the place looks like, how to find it, where its
|
|
15
|
+
* van goes, and when it opens.
|
|
16
|
+
*/
|
|
17
|
+
export declare const STOREFRONT_GATE: PublicationPolicy;
|
|
18
|
+
/** A service-area business has no front to photograph: its zone and its hours. */
|
|
19
|
+
export declare const SERVICE_AREA_GATE: PublicationPolicy;
|
|
20
|
+
/** What is still missing, in the policy's order; empty means the place may be indexed. */
|
|
21
|
+
export declare function publicationGaps(place: Place<string>, policy: PublicationPolicy): PublicationField[];
|
|
22
|
+
/**
|
|
23
|
+
* An unpublished place still answers — its phone is real and someone may have
|
|
24
|
+
* been given the link — but it carries `noindex` and stays out of the sitemap.
|
|
25
|
+
* A site with no domain yet publishes nothing.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isPublished(place: Place<string>, policy: PublicationPolicy, site: {
|
|
28
|
+
domain: string | null;
|
|
29
|
+
}): boolean;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A storefront network: what the place looks like, how to find it, where its
|
|
3
|
+
* van goes, and when it opens.
|
|
4
|
+
*/
|
|
5
|
+
export const STOREFRONT_GATE = {
|
|
6
|
+
required: () => ["storefrontPhoto", "landmark", "serviceArea", "hours"],
|
|
7
|
+
};
|
|
8
|
+
/** A service-area business has no front to photograph: its zone and its hours. */
|
|
9
|
+
export const SERVICE_AREA_GATE = {
|
|
10
|
+
required: () => ["serviceArea", "hours"],
|
|
11
|
+
};
|
|
12
|
+
function filled(place, field) {
|
|
13
|
+
const front = place.presence.kind === "storefront" ? place.presence : null;
|
|
14
|
+
switch (field) {
|
|
15
|
+
case "storefrontPhoto":
|
|
16
|
+
return Boolean(front?.storefrontPhoto);
|
|
17
|
+
case "landmark": {
|
|
18
|
+
// In every language the place is named in — an empty `{}` says nothing.
|
|
19
|
+
const landmark = front?.landmark ?? null;
|
|
20
|
+
if (!landmark)
|
|
21
|
+
return false;
|
|
22
|
+
const locales = Object.keys(place.name);
|
|
23
|
+
return locales.length > 0 && locales.every(l => (landmark[l] ?? "").trim() !== "");
|
|
24
|
+
}
|
|
25
|
+
case "serviceArea":
|
|
26
|
+
return place.serviceArea !== null && place.serviceArea.length > 0;
|
|
27
|
+
case "hours":
|
|
28
|
+
return place.hours !== null && place.hours.length > 0;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** What is still missing, in the policy's order; empty means the place may be indexed. */
|
|
32
|
+
export function publicationGaps(place, policy) {
|
|
33
|
+
return policy.required(place).filter(field => !filled(place, field));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* An unpublished place still answers — its phone is real and someone may have
|
|
37
|
+
* been given the link — but it carries `noindex` and stays out of the sitemap.
|
|
38
|
+
* A site with no domain yet publishes nothing.
|
|
39
|
+
*/
|
|
40
|
+
export function isPublished(place, policy, site) {
|
|
41
|
+
return site.domain !== null && publicationGaps(place, policy).length === 0;
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Place, Rating } from "./types.js";
|
|
2
|
+
/** Google Business Profile API policy: no cached copy older than this. */
|
|
3
|
+
export declare const RATING_MAX_AGE_DAYS = 30;
|
|
4
|
+
/**
|
|
5
|
+
* An instant from the live source: RFC 3339 with an offset or `Z`, or a bare
|
|
6
|
+
* date (midnight UTC). A date-time without an offset is refused — `Date.parse`
|
|
7
|
+
* would read it in the server's own zone, so one timestamp would mean two
|
|
8
|
+
* instants on two pods (and in the Rust port). `null` for anything else.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseInstant(value: string): number | null;
|
|
11
|
+
/**
|
|
12
|
+
* The rating, only while it may still be shown. A stale or future-dated copy
|
|
13
|
+
* is treated as absent — the page then says nothing about a rating rather than
|
|
14
|
+
* something the terms no longer allow.
|
|
15
|
+
*/
|
|
16
|
+
export declare function freshRating(place: Place<string>, now: Date): Rating | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Google Business Profile API policy: no cached copy older than this. */
|
|
2
|
+
export const RATING_MAX_AGE_DAYS = 30;
|
|
3
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
4
|
+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
5
|
+
/**
|
|
6
|
+
* An instant from the live source: RFC 3339 with an offset or `Z`, or a bare
|
|
7
|
+
* date (midnight UTC). A date-time without an offset is refused — `Date.parse`
|
|
8
|
+
* would read it in the server's own zone, so one timestamp would mean two
|
|
9
|
+
* instants on two pods (and in the Rust port). `null` for anything else.
|
|
10
|
+
*/
|
|
11
|
+
export function parseInstant(value) {
|
|
12
|
+
if (!DATE.test(value) && !DATE_TIME.test(value))
|
|
13
|
+
return null;
|
|
14
|
+
const t = Date.parse(value);
|
|
15
|
+
return Number.isNaN(t) ? null : t;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The rating, only while it may still be shown. A stale or future-dated copy
|
|
19
|
+
* is treated as absent — the page then says nothing about a rating rather than
|
|
20
|
+
* something the terms no longer allow.
|
|
21
|
+
*/
|
|
22
|
+
export function freshRating(place, now) {
|
|
23
|
+
const rating = place.rating;
|
|
24
|
+
if (!rating)
|
|
25
|
+
return null;
|
|
26
|
+
const fetched = parseInstant(rating.fetchedAt);
|
|
27
|
+
if (fetched === null)
|
|
28
|
+
return null;
|
|
29
|
+
const age = now.getTime() - fetched;
|
|
30
|
+
if (age < 0 || age > RATING_MAX_AGE_DAYS * 86_400_000)
|
|
31
|
+
return null;
|
|
32
|
+
return rating;
|
|
33
|
+
}
|