@wenathlan/cobaia 2.0.164 → 2.0.166
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/Dockerfile +8 -4
- package/build.gradle.kts +3 -3
- package/bun.lock +1619 -341
- package/cobaia.csproj +1 -1
- package/cobaia.gemspec +1 -1
- package/cobaia.java +2 -2
- package/db.ts +87 -3
- package/docs/cobaia.md +31 -0
- package/docs/distributionlanes.md +1 -1
- package/labels.ts +23 -1
- package/onboardingstate.ts +211 -0
- package/package.json +423 -77
- package/pom.xml +3 -3
- package/schema.prisma +23 -0
- package/sol/Sol.tsx +20 -15
- package/sol/home/home.tsx +13 -3
- package/sol/intro/intro.tsx +13 -5
- package/sol/onboarding/onboarding.tsx +146 -0
- package/sol/sol.css +153 -0
- package/sol/wingets/HomeEntrygate.tsx +64 -0
- package/sol/wingets/index.ts +2 -0
- package/tauri.conf.json +1 -1
- package/tests/entryflow.test.ts +124 -0
package/cobaia.csproj
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<AssemblyName>cobaia</AssemblyName>
|
|
5
5
|
<RootNamespace>cobaia</RootNamespace>
|
|
6
6
|
<PackageId>cobaia</PackageId>
|
|
7
|
-
<Version>2.0.
|
|
7
|
+
<Version>2.0.166</Version>
|
|
8
8
|
<Authors>wenathlan</Authors>
|
|
9
9
|
<Description>Distribution package containing the cobaia documentation set and the tests directory. cobaia is the monetization surface — the plans, the support rails and the honest advertisement slots.</Description>
|
|
10
10
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
package/cobaia.gemspec
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Gem::Specification.new do |spec|
|
|
2
2
|
spec.name = "cobaia"
|
|
3
|
-
spec.version = ENV.fetch("COBAIA_VERSION", "2.0.
|
|
3
|
+
spec.version = ENV.fetch("COBAIA_VERSION", "2.0.166")
|
|
4
4
|
spec.authors = ["wenathlan"]
|
|
5
5
|
spec.email = ["support@users.noreply.github.com"]
|
|
6
6
|
spec.summary = "The cobaia documentation and tests envelope (the monetization surface — the plans, the support rails and the honest advertisement slots)."
|
package/cobaia.java
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package com.wenathlan.cobaia;
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The cobaia version envelope carrier.
|
|
@@ -10,7 +10,7 @@ package io.github.wenathlan.cobaia;
|
|
|
10
10
|
*/
|
|
11
11
|
final class Cobaia {
|
|
12
12
|
/** The version of the cobaia envelope (the six-distribution completeness wave). */
|
|
13
|
-
public static final String VERSION = "2.0.
|
|
13
|
+
public static final String VERSION = "2.0.166";
|
|
14
14
|
private Cobaia() {
|
|
15
15
|
}
|
|
16
16
|
}
|
package/db.ts
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* concatenation. The schema mirrors schema.prisma (the documentation of
|
|
6
6
|
* record) and the boot DDL is the idempotent seed-on-first-run: the tables
|
|
7
7
|
* exist after the first open, always. The platform gateway serves the same
|
|
8
|
-
* tables over HTTPS (GET/POST /api/v1/onboarding);
|
|
9
|
-
* here.
|
|
8
|
+
* tables over HTTPS (GET/POST /api/v1/onboarding, GET /api/v1/ui/content);
|
|
9
|
+
* no server route lives here.
|
|
10
10
|
*/
|
|
11
11
|
import { openStorageLane } from "@wenathlan/vault/storage";
|
|
12
|
+
import { CONTENTKINDS, type contentrow, seedAdsLabel, seedAdsEmpty, seedAdsSlots, seedPlans, seedPosts, seedRoster, seedSupportLinks } from "./content";
|
|
12
13
|
import { LABEL_MANIFEST, LABEL_REVISION } from "./labels";
|
|
13
14
|
|
|
14
15
|
/** the correlated tables the runtime keeps (the family sync contract names). */
|
|
15
|
-
export const tables = ["onboarding_states", "labels"] as const;
|
|
16
|
+
export const tables = ["onboarding_states", "labels", "content_rows"] as const;
|
|
16
17
|
|
|
17
18
|
/** the one storage lane of the site store (WAL, foreign keys, patient locks — the app's own file, the shared mechanism of the vault). */
|
|
18
19
|
const lane = openStorageLane({ defaultFile: "./cobaia.db" });
|
|
@@ -41,6 +42,12 @@ CREATE TABLE IF NOT EXISTS labels (
|
|
|
41
42
|
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
|
|
42
43
|
translated INTEGER NOT NULL DEFAULT 0
|
|
43
44
|
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS content_rows (
|
|
46
|
+
kind TEXT NOT NULL,
|
|
47
|
+
ordinal INTEGER NOT NULL,
|
|
48
|
+
payload TEXT NOT NULL,
|
|
49
|
+
PRIMARY KEY (kind, ordinal)
|
|
50
|
+
);
|
|
44
51
|
`;
|
|
45
52
|
|
|
46
53
|
/** Creates the tables when they do not exist (idempotent, mirrors the prisma schema). */
|
|
@@ -217,3 +224,80 @@ try {
|
|
|
217
224
|
} catch {
|
|
218
225
|
/* the manifest answers; the store catches up on the next boot */
|
|
219
226
|
}
|
|
227
|
+
|
|
228
|
+
/* --------------------------------------------------------------------- */
|
|
229
|
+
/* content rows — the site content the pages render */
|
|
230
|
+
/* --------------------------------------------------------------------- */
|
|
231
|
+
|
|
232
|
+
/** the manifest seed of one content kind: the rows in ordinal order. */
|
|
233
|
+
function seedrowsOf(kind: (typeof CONTENTKINDS)[number]): readonly unknown[] {
|
|
234
|
+
switch (kind) {
|
|
235
|
+
case "posts":
|
|
236
|
+
return seedPosts;
|
|
237
|
+
case "roster":
|
|
238
|
+
return seedRoster;
|
|
239
|
+
case "plans":
|
|
240
|
+
return seedPlans;
|
|
241
|
+
case "supportlinks":
|
|
242
|
+
return [seedSupportLinks];
|
|
243
|
+
case "ads.slots":
|
|
244
|
+
return [seedAdsSlots];
|
|
245
|
+
case "ads.label":
|
|
246
|
+
return [seedAdsLabel];
|
|
247
|
+
case "ads.empty":
|
|
248
|
+
return [seedAdsEmpty];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Encodes the manifest rows of every kind as content rows (kind, ordinal,
|
|
253
|
+
* json payload) — the encoding the api answers and the client loader parses. */
|
|
254
|
+
function seedContentRows(): contentrow[] {
|
|
255
|
+
const rows: contentrow[] = [];
|
|
256
|
+
for (const kind of CONTENTKINDS) {
|
|
257
|
+
seedrowsOf(kind).forEach((value, ordinal) => {
|
|
258
|
+
rows.push({ kind, ordinal, payload: JSON.stringify(value) });
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return rows;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Ingests the content manifest into the content_rows table — idempotent:
|
|
266
|
+
* every manifest row upserts over its (kind, ordinal) seat, so the db stays
|
|
267
|
+
* the single living config of the site content the pages render (the
|
|
268
|
+
* content law: the literals live in the manifest, the rows live here).
|
|
269
|
+
*
|
|
270
|
+
* @returns the number of rows seated.
|
|
271
|
+
*/
|
|
272
|
+
export function seedContentTable(): number {
|
|
273
|
+
ensureSchema();
|
|
274
|
+
for (const row of seedContentRows()) {
|
|
275
|
+
execute(
|
|
276
|
+
`INSERT INTO content_rows (kind, ordinal, payload) VALUES (?, ?, ?)
|
|
277
|
+
ON CONFLICT(kind, ordinal) DO UPDATE SET payload = excluded.payload`,
|
|
278
|
+
[row.kind, row.ordinal, row.payload],
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return seedContentRows().length;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Reads every content row of the store, ordered by kind then ordinal — the
|
|
286
|
+
* rows the platform gateway answers over GET /api/v1/ui/content.
|
|
287
|
+
*
|
|
288
|
+
* @returns the content rows of the site store.
|
|
289
|
+
*/
|
|
290
|
+
export function readContentRows(): contentrow[] {
|
|
291
|
+
return queryAll<Pick<contentrow, "kind" | "ordinal" | "payload">>(
|
|
292
|
+
"SELECT kind, ordinal, payload FROM content_rows ORDER BY kind ASC, ordinal ASC",
|
|
293
|
+
).map((row) => ({ kind: row.kind as contentrow["kind"], ordinal: row.ordinal, payload: row.payload }));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* the content law rides the db boot beside the labels: the manifest rows
|
|
297
|
+
* land in the content_rows table from the first open, always (idempotent,
|
|
298
|
+
* best effort — the manifest keeps answering when the store is sealed). */
|
|
299
|
+
try {
|
|
300
|
+
seedContentTable();
|
|
301
|
+
} catch {
|
|
302
|
+
/* the manifest answers; the store catches up on the next boot */
|
|
303
|
+
}
|
package/docs/cobaia.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# cobaia — the dossier
|
|
2
|
+
|
|
3
|
+
The public monetization surface of the family: plans, MRR tracking, honest ad slots and the support rails.
|
|
4
|
+
|
|
5
|
+
## Identity
|
|
6
|
+
|
|
7
|
+
- Category: monetization surface.
|
|
8
|
+
- Competitor space: GitHub Sponsors, Ko-fi honest monetization.
|
|
9
|
+
|
|
10
|
+
## Objective
|
|
11
|
+
|
|
12
|
+
Give the family an honest monetization engine: every plan grants resources (never powers), every ad slot stays empty until approval, every rail is public and auditable.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Plans: the resource grants the devthink system sells (storage quotas, engine seats).
|
|
17
|
+
- MRR tracking and churn seats.
|
|
18
|
+
- Ad slots that stay empty until the approval flows answer (zero tracking before approval).
|
|
19
|
+
- Support rails: sponsorship links, badges, roster.
|
|
20
|
+
- Billing seats with the honest playbook: cashback becomes store credit, never real money.
|
|
21
|
+
- Absolute ethical limits codified: no invisible click mirroring, no dark patterns.
|
|
22
|
+
|
|
23
|
+
## Interactions
|
|
24
|
+
|
|
25
|
+
- Public surface with banner, plans and support sections.
|
|
26
|
+
- The roster of supporters and the honest badges.
|
|
27
|
+
|
|
28
|
+
## Doctrine
|
|
29
|
+
|
|
30
|
+
- Honest monetization: every limit is a plan limit, never an engine limit.
|
|
31
|
+
- Ethical absolute: the ad seat refuses to track before the approval answers.
|
|
@@ -6,7 +6,7 @@ The eleven distribution lanes of the PR14 law and the honest status each lane an
|
|
|
6
6
|
| --- | --- | --- | --- |
|
|
7
7
|
| npmjs | package.json — @wenathlan/cobaia | live | the family-npmjs lane publishes the envelope every release rung |
|
|
8
8
|
| GitHub Packages npm | package.json — @wenathlan/cobaia | live | the family-githubnpm lane publishes the same envelope idempotently |
|
|
9
|
-
| Maven | pom.xml —
|
|
9
|
+
| Maven | pom.xml — com.wenathlan:cobaia | live | the family-maven lane deploys the jar |
|
|
10
10
|
| NuGet | cobaia.csproj | live | the family-nuget lane packs the envelope |
|
|
11
11
|
| Gradle | build.gradle.kts — com.wenathlan:cobaia | live | the family-gradle lane publishes the module |
|
|
12
12
|
| Ruby Gems | cobaia.gemspec | live | the family-rubygems lane pushes the gem |
|
package/labels.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { createLabelStore } from "@wenathlan/vault/labelstore";
|
|
|
26
26
|
|
|
27
27
|
/** the label revision of the manifest: bump on every copy wave so the seed
|
|
28
28
|
* refreshes the store rows of the changed strings. */
|
|
29
|
-
export const LABEL_REVISION =
|
|
29
|
+
export const LABEL_REVISION = 5;
|
|
30
30
|
|
|
31
31
|
/** the english manifest: the page.widget.key path answers the surface text.
|
|
32
32
|
* every entry is the verbatim string the wingets and page anchors rendered
|
|
@@ -257,6 +257,28 @@ export const LABEL_MANIFEST = {
|
|
|
257
257
|
"intro.footer.familyAria": "the family row",
|
|
258
258
|
"intro.footer.family": "the family",
|
|
259
259
|
"intro.footer.memberTip": "open {slug} — the door beside this site",
|
|
260
|
+
// the onboarding page anchor (the first-run frames of the entry flow —
|
|
261
|
+
// the standardized intro → onboarding → entrada law the family rides;
|
|
262
|
+
// cobaia's frames present the real offer: what the site is, what it
|
|
263
|
+
// carries, and the hand-over to the content pages)
|
|
264
|
+
"onboarding.aria": "cobaia — the first-run steps",
|
|
265
|
+
"onboarding.title": "cobaia",
|
|
266
|
+
"onboarding.skip": "skip — straight to the site",
|
|
267
|
+
"onboarding.back": "back",
|
|
268
|
+
"onboarding.next": "next",
|
|
269
|
+
"onboarding.enterCta": "enter the site",
|
|
270
|
+
"onboarding.steps.is.eyebrow": "01 — what this is",
|
|
271
|
+
"onboarding.steps.is.title": "The family's front door.",
|
|
272
|
+
"onboarding.steps.is.text": "cobaia is the monetization face of the devthink family — the front door kept apart so the ten applications stay clean. the directory, the plans, the support channels and the update blog live on this one static site.",
|
|
273
|
+
"onboarding.steps.does.eyebrow": "02 — what it carries",
|
|
274
|
+
"onboarding.steps.does.title": "The plans, the support rails, the honest ads.",
|
|
275
|
+
"onboarding.steps.does.text": "the real offer, nothing invented: the three plans with their placeholder prices said out loud, the support rails that activate when the owner wires them, and the reserved ad boxes that stay empty until a network approves the site.",
|
|
276
|
+
"onboarding.steps.enter.eyebrow": "03 — the entrada",
|
|
277
|
+
"onboarding.steps.enter.title": "One responsive site, every screen.",
|
|
278
|
+
"onboarding.steps.enter.text": "the same responsive pages answer on the phone, the tv, the extension and the desktop — the presentation adapts, the content never changes. the entrada is the front door itself.",
|
|
279
|
+
"onboarding.facts.plans": "the plans — us$0, 5 and 19 a month, placeholder prices until billing lands, honest until then",
|
|
280
|
+
"onboarding.facts.support": "the support rails — the sponsor channels, configured the day the owner wires them",
|
|
281
|
+
"onboarding.facts.ads": "the ad slots — reserved boxes, visibly labeled, empty until a network approves the site",
|
|
260
282
|
// the ad slot widget (the reserved box of the content pages)
|
|
261
283
|
"ads.slot.reserved": "reserved · {format}",
|
|
262
284
|
} as const;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* onboardingstate.ts — the ONE writer of the visitor's seen flags, the state
|
|
3
|
+
* of the entry-flow contract (intro → onboarding → the site). The state is
|
|
4
|
+
* { introseen, onboardingseen } and it is catalogued, not just browser: the
|
|
5
|
+
* accessor consults the platform database over GET /api/v1/onboarding and
|
|
6
|
+
* writes it over POST /api/v1/onboarding, both HTTPS only — the base url
|
|
7
|
+
* arrives through VITE_ONBOARDING_URL and every candidate host is validated
|
|
8
|
+
* first (localhost, loopback, private, CGNAT, link-local and reserved ranges
|
|
9
|
+
* are refused, mapped IPv6 is normalized, so the flags never ride a dev or
|
|
10
|
+
* internal endpoint). The localStorage key cobaia.onboarding.seen is purely
|
|
11
|
+
* the offline cache of the database (the DB is the source of truth; the
|
|
12
|
+
* cache avoids replaying the flow while offline) and module memory covers
|
|
13
|
+
* the client-side re-navigation between the gates. Reads reconcile with a
|
|
14
|
+
* seen-wins merge — a stage seen anywhere stays seen, the flow is never
|
|
15
|
+
* forced twice. The page anchors never touch the flags directly: the intro
|
|
16
|
+
* marks introseen, the onboarding marks onboardingseen, the home entry gate
|
|
17
|
+
* only reads (the family flow contract the sibling apps already ride —
|
|
18
|
+
* cobaia joins the standardized intro → onboarding → entrada law).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** the visitor state the entry flow consults (the two gates of the site). */
|
|
22
|
+
export type VisitorOnboarding = {
|
|
23
|
+
/** the presentation intro was seen — the splash never replays */
|
|
24
|
+
introseen: boolean;
|
|
25
|
+
/** the first-run onboarding was seen (finish or skip) — never forced again */
|
|
26
|
+
onboardingseen: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** the stages one call can mark seen. */
|
|
30
|
+
export type OnboardingStage = "intro" | "onboarding";
|
|
31
|
+
|
|
32
|
+
/** the localStorage cache key (the DB mirror, never the source of truth). */
|
|
33
|
+
const CACHE_KEY = "cobaia.onboarding.seen";
|
|
34
|
+
|
|
35
|
+
/** the anonymous visitor of this surface (the platform gateway may key its own). */
|
|
36
|
+
const VISITOR = "web";
|
|
37
|
+
|
|
38
|
+
/** the configured platform base (HTTPS self-hosted gateway); empty answers cache-only. */
|
|
39
|
+
const BASE =
|
|
40
|
+
(typeof import.meta !== "undefined" ? (import.meta.env?.VITE_ONBOARDING_URL as string | undefined) : undefined)
|
|
41
|
+
?.trim()
|
|
42
|
+
.replace(/\/+$/, "") ?? "";
|
|
43
|
+
|
|
44
|
+
/** the platform route the accessor rides (the gateway serves /api/v1/*). */
|
|
45
|
+
const API_PATH = "/api/v1/onboarding";
|
|
46
|
+
|
|
47
|
+
/** the abort window of the platform read (ms) — the flow never blocks on a slow gateway. */
|
|
48
|
+
const READ_TIMEOUT_MS = 2500;
|
|
49
|
+
|
|
50
|
+
/** module memory: the flags of the session, consulted before any i/o. */
|
|
51
|
+
let memory: VisitorOnboarding | null = null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validates a platform base url: HTTPS only, and the host must be public —
|
|
55
|
+
* localhost, loopback, private, link-local, CGNAT, unique-local and
|
|
56
|
+
* reserved documentation ranges are refused; IPv4-mapped IPv6 literals are
|
|
57
|
+
* normalized to their IPv4 side and any other IPv6 literal is refused (the
|
|
58
|
+
* gateway rides a name).
|
|
59
|
+
*
|
|
60
|
+
* @param base the configured base url candidate.
|
|
61
|
+
* @returns true when the base may be consulted.
|
|
62
|
+
*/
|
|
63
|
+
export function hostallowed(base: string): boolean {
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(base);
|
|
66
|
+
if (url.protocol !== "https:") return false;
|
|
67
|
+
const host = url.hostname
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.replace(/\.+$/, "")
|
|
70
|
+
.replace(/^\[|\]$/g, "");
|
|
71
|
+
if (host.length === 0) return false;
|
|
72
|
+
if (host === "localhost" || host === "0.0.0.0" || host === "::1") return false;
|
|
73
|
+
if (host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return false;
|
|
74
|
+
// the ipv4-mapped ipv6 form (the url parser normalizes the decimals away)
|
|
75
|
+
if (host.startsWith("::ffff:")) {
|
|
76
|
+
const mapped = host.slice("::ffff:".length).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
77
|
+
return mapped !== null && publicipv4(Number(mapped[1]), Number(mapped[2]));
|
|
78
|
+
}
|
|
79
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
80
|
+
if (v4) return publicipv4(Number(v4[1]), Number(v4[2]));
|
|
81
|
+
// every other ipv6 literal: the gateway rides a name, not an address
|
|
82
|
+
if (host.includes(":")) return false;
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** the public side of an ipv4 address (the first two octets decide). */
|
|
90
|
+
function publicipv4(a: number, b: number): boolean {
|
|
91
|
+
if (a === 0 || a === 10 || a === 127) return false;
|
|
92
|
+
if (a === 172 && b >= 16 && b <= 31) return false;
|
|
93
|
+
if (a === 192 && b === 168) return false;
|
|
94
|
+
if (a === 169 && b === 254) return false;
|
|
95
|
+
if (a === 100 && b >= 64 && b <= 127) return false;
|
|
96
|
+
if (a === 192 && b === 0) return false;
|
|
97
|
+
if (a === 198 && (b === 51 || b === 52)) return false;
|
|
98
|
+
if (a === 203 && b === 0) return false;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Answers the flags after one stage was marked seen (pure: the writer and
|
|
104
|
+
* the tests share this merge — seen-wins, a stage never unsees).
|
|
105
|
+
*
|
|
106
|
+
* @param state the flags before the write.
|
|
107
|
+
* @param stage the stage being marked seen.
|
|
108
|
+
* @returns the flags after the write.
|
|
109
|
+
*/
|
|
110
|
+
export function mergeStage(state: VisitorOnboarding, stage: OnboardingStage): VisitorOnboarding {
|
|
111
|
+
return stage === "intro" ? { ...state, introseen: true } : { ...state, onboardingseen: true };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Reads the localStorage cache; any corruption answers null (the cache is best effort). */
|
|
115
|
+
function readCache(): VisitorOnboarding | null {
|
|
116
|
+
if (typeof window === "undefined") return null;
|
|
117
|
+
try {
|
|
118
|
+
const raw = window.localStorage.getItem(CACHE_KEY);
|
|
119
|
+
if (!raw) return null;
|
|
120
|
+
const parsed = JSON.parse(raw) as Partial<VisitorOnboarding>;
|
|
121
|
+
return { introseen: parsed.introseen === true, onboardingseen: parsed.onboardingseen === true };
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Mirrors the flags into the localStorage cache (silently skipped when storage is sealed). */
|
|
128
|
+
function writeCache(state: VisitorOnboarding): void {
|
|
129
|
+
if (typeof window === "undefined") return;
|
|
130
|
+
try {
|
|
131
|
+
window.localStorage.setItem(CACHE_KEY, JSON.stringify(state));
|
|
132
|
+
} catch {
|
|
133
|
+
// the cache is best effort: the DB and the memory stay authoritative
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Reads the flags off the platform database; unreachable or unconfigured answers null. */
|
|
138
|
+
async function readRemote(): Promise<VisitorOnboarding | null> {
|
|
139
|
+
if (!BASE || !hostallowed(BASE) || typeof fetch === "undefined") return null;
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const timer = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetch(`${BASE}${API_PATH}?visitor=${encodeURIComponent(VISITOR)}`, {
|
|
144
|
+
headers: { accept: "application/json" },
|
|
145
|
+
signal: controller.signal,
|
|
146
|
+
});
|
|
147
|
+
if (!response.ok) return null;
|
|
148
|
+
const answer = (await response.json()) as Partial<VisitorOnboarding>;
|
|
149
|
+
return { introseen: answer.introseen === true, onboardingseen: answer.onboardingseen === true };
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
} finally {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Writes the flags to the platform database over POST; failures leave the cache holding them. */
|
|
158
|
+
async function writeRemote(state: VisitorOnboarding): Promise<void> {
|
|
159
|
+
if (!BASE || !hostallowed(BASE) || typeof fetch === "undefined") return;
|
|
160
|
+
try {
|
|
161
|
+
await fetch(`${BASE}${API_PATH}`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "content-type": "application/json" },
|
|
164
|
+
body: JSON.stringify({ visitor: VISITOR, ...state }),
|
|
165
|
+
});
|
|
166
|
+
} catch {
|
|
167
|
+
// the cache mirrors the flags until the platform answers again
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Reads the visitor state: module memory first, then the platform database
|
|
173
|
+
* over GET /api/v1/onboarding, then the localStorage cache — reconciled with
|
|
174
|
+
* the seen-wins merge so a stage seen anywhere stays seen. The session
|
|
175
|
+
* answer is memoized; re-navigation never refetches.
|
|
176
|
+
*
|
|
177
|
+
* @returns the visitor onboarding flags.
|
|
178
|
+
*/
|
|
179
|
+
export async function readOnboardingState(): Promise<VisitorOnboarding> {
|
|
180
|
+
if (memory) return memory;
|
|
181
|
+
const cache = readCache();
|
|
182
|
+
const remote = await readRemote();
|
|
183
|
+
const state: VisitorOnboarding = {
|
|
184
|
+
introseen: (cache?.introseen ?? false) || (remote?.introseen ?? false),
|
|
185
|
+
onboardingseen: (cache?.onboardingseen ?? false) || (remote?.onboardingseen ?? false),
|
|
186
|
+
};
|
|
187
|
+
memory = state;
|
|
188
|
+
if (remote) writeCache(state);
|
|
189
|
+
return state;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Marks one stage seen and persists it everywhere: module memory, the
|
|
194
|
+
* localStorage cache and — when a platform base is configured — the
|
|
195
|
+
* database over POST /api/v1/onboarding. The ONLY writer of the flags.
|
|
196
|
+
*
|
|
197
|
+
* @param stage the stage the visitor just saw (or deliberately skipped).
|
|
198
|
+
* @returns the flags after the write.
|
|
199
|
+
*/
|
|
200
|
+
export async function markOnboardingStage(stage: OnboardingStage): Promise<VisitorOnboarding> {
|
|
201
|
+
const previous = memory ?? (await readOnboardingState());
|
|
202
|
+
memory = mergeStage(previous, stage);
|
|
203
|
+
writeCache(memory);
|
|
204
|
+
await writeRemote(memory);
|
|
205
|
+
return memory;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Test seam: clears the module memory (the cache and the platform are untouched). */
|
|
209
|
+
export function resetOnboardingMemory(): void {
|
|
210
|
+
memory = null;
|
|
211
|
+
}
|