ad2app-lib 1.32.0 → 1.35.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.
@@ -48,6 +48,7 @@ export declare const EVENTS: {
48
48
  readonly PAYWALL_DISMISSED: "paywall_dismissed";
49
49
  readonly ACCESS_DENIED: "access_denied";
50
50
  readonly ONBOARDING_STEP_COMPLETED: "onboarding_step_completed";
51
+ readonly ONBOARDING_STEP_VIEWED: "onboarding_step_viewed";
51
52
  readonly EXIT_INTENT_SHOWN: "exit_intent_shown";
52
53
  readonly EXIT_INTENT_DISMISSED: "exit_intent_dismissed";
53
54
  readonly EXIT_INTENT_CAPTURED: "exit_intent_captured";
@@ -162,6 +163,39 @@ export type ComposeStep = 'platforms' | 'content' | 'details' | 'review';
162
163
  * PublishFailureReason. Do NOT rename after shipping — wire values.
163
164
  */
164
165
  export type ComposeBlockReason = 'empty' | 'missing_fields' | 'over_limit' | 'media' | 'channel_blocked' | 'poll';
166
+ /**
167
+ * Why a connect attempt ended without a connected account (spec 155 US3).
168
+ *
169
+ * Wire values, never renamed: historical rows do not migrate. A new cause is a
170
+ * new value appended here, never a re-meaning of an old one.
171
+ *
172
+ * - `paywall` — a free-tier user hit the connect wall (the pre-existing emit).
173
+ * - `canceled` — the person denied or dismissed the platform's OAuth.
174
+ * - `failed` — the vendor returned a failure on the callback.
175
+ * - `returned_without_account` — they came back and no account arrived: the
176
+ * silent abandon, e.g. Facebook's Page picker offering an empty list to
177
+ * someone who only has a personal profile.
178
+ * - `personal_profile` — they told us before OAuth that they post from a
179
+ * personal Facebook profile, which Meta's API cannot publish to.
180
+ */
181
+ /**
182
+ * Which finish line an onboarding completion crossed (094 US6/US8).
183
+ *
184
+ * Until 2026-09-01 the guided tour ended at a scheduled post, and almost
185
+ * nobody crossed it: four completions by two people in ninety days, against
186
+ * fifteen skips that all landed on the compose step. It now ends at a
187
+ * connected account. Both lines appear in the same series, so a completion
188
+ * that does not say which one it crossed turns a redefinition into an
189
+ * unexplained jump.
190
+ *
191
+ * Wire values, never renamed.
192
+ */
193
+ export type OnboardingCompletedAt = 'connect' | 'install' | 'schedule';
194
+ /** The finish lines, in the order the flow can reach them. */
195
+ export declare const ONBOARDING_COMPLETED_AT: readonly ["connect", "install", "schedule"];
196
+ export type ConnectBlockReason = 'paywall' | 'canceled' | 'failed' | 'returned_without_account' | 'personal_profile';
197
+ /** The reason set, in the order the spec lists it. Iterable for contract tests and dashboards. */
198
+ export declare const CONNECT_BLOCK_REASONS: readonly ["paywall", "canceled", "failed", "returned_without_account", "personal_profile"];
165
199
  export type PublishFailureReason = 'auth' | 'rate_limit' | 'media' | 'content' | 'network' | 'platform' | 'unknown';
166
200
  /** Property shape per event. Keeps emitters honest across repos. */
167
201
  export interface EventProperties {
@@ -176,6 +210,7 @@ export interface EventProperties {
176
210
  };
177
211
  [EVENTS.ACCOUNT_CONNECT_BLOCKED]: {
178
212
  platform?: string;
213
+ reason: ConnectBlockReason;
179
214
  };
180
215
  [EVENTS.MCP_POST_BLOCKED_FREE_TIER]: {
181
216
  target_count?: number;
@@ -312,11 +347,16 @@ export interface EventProperties {
312
347
  app_locale: AppLocale;
313
348
  };
314
349
  [EVENTS.ONBOARDING_STEP_COMPLETED]: {
315
- flow: 'complete_profile';
350
+ flow: 'complete_profile' | 'beta_consent';
316
351
  step: string;
317
352
  step_index: number;
318
353
  app_locale: AppLocale;
319
354
  };
355
+ [EVENTS.ONBOARDING_STEP_VIEWED]: {
356
+ flow: 'complete_profile' | 'beta_consent';
357
+ step: string;
358
+ app_locale?: AppLocale;
359
+ };
320
360
  [EVENTS.EXIT_INTENT_SHOWN]: {
321
361
  surface: 'connect_wall';
322
362
  dwell_ms?: number;
@@ -374,6 +414,7 @@ export interface EventProperties {
374
414
  [EVENTS.ONBOARDING_COMPLETED]: {
375
415
  flow?: string;
376
416
  steps_completed?: number;
417
+ completed_at: OnboardingCompletedAt;
377
418
  };
378
419
  [EVENTS.ONBOARDING_SKIPPED]: {
379
420
  flow?: string;
@@ -10,7 +10,7 @@
10
10
  * Do NOT rename events after they ship — historical data does not migrate.
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.PERSON_PROPS = exports.EMAIL_PROPS = exports.EVENTS = void 0;
13
+ exports.PERSON_PROPS = exports.CONNECT_BLOCK_REASONS = exports.ONBOARDING_COMPLETED_AT = exports.EMAIL_PROPS = exports.EVENTS = void 0;
14
14
  /** Canonical PostHog event names. */
15
15
  exports.EVENTS = {
16
16
  // Acquisition (landing)
@@ -72,6 +72,12 @@ exports.EVENTS = {
72
72
  // trust — it is part of why a REAL retry defect sat unnoticed in the same list.
73
73
  ACCESS_DENIED: 'access_denied', // entitlement 403 at the API choke point (beta wall / subscription)
74
74
  ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed', // one step of a multi-step flow (complete-profile)
75
+ // Promoted from a raw string literal at ad2app-frontend's
76
+ // OnboardingFlow.tsx:887 (spec 158, 2026-09-03) — it was already firing in
77
+ // production (15 events, 3 people) ungoverned since spec 095. `flow` is
78
+ // widened alongside ONBOARDING_STEP_COMPLETED below to cover the beta
79
+ // consent step, which is not part of the complete-profile flow.
80
+ ONBOARDING_STEP_VIEWED: 'onboarding_step_viewed', // a step was actually shown (drop-off, not just completion)
75
81
  EXIT_INTENT_SHOWN: 'exit_intent_shown', // exit-intent capture modal armed (065, verbatim wire values)
76
82
  EXIT_INTENT_DISMISSED: 'exit_intent_dismissed',
77
83
  EXIT_INTENT_CAPTURED: 'exit_intent_captured', // email captured into the beta list
@@ -126,6 +132,20 @@ exports.EMAIL_PROPS = {
126
132
  EDITION_VERSION: 'edition_version',
127
133
  FORMAT: 'format',
128
134
  };
135
+ /** The finish lines, in the order the flow can reach them. */
136
+ exports.ONBOARDING_COMPLETED_AT = [
137
+ 'connect',
138
+ 'install',
139
+ 'schedule',
140
+ ];
141
+ /** The reason set, in the order the spec lists it. Iterable for contract tests and dashboards. */
142
+ exports.CONNECT_BLOCK_REASONS = [
143
+ 'paywall',
144
+ 'canceled',
145
+ 'failed',
146
+ 'returned_without_account',
147
+ 'personal_profile',
148
+ ];
129
149
  /** Canonical person property keys (set via identify / $set). */
130
150
  exports.PERSON_PROPS = {
131
151
  EMAIL: 'email',
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The canonical table of what each platform needs before it can be connected.
3
+ *
4
+ * ONE home for these facts: the app renders them on the connect step and on
5
+ * /accounts, and nothing may restate them in a component. A line is rendered
6
+ * only where `requirement !== 'account'` — six platforms deliberately carry
7
+ * empty strings, and the UI renders no caption node for them at all.
8
+ *
9
+ * The cap on a rendered line is 70 characters, enforced by the contract test:
10
+ * it is what fits on one line under the platform name at 390px, measured in
11
+ * the spec 155 design preview.
12
+ *
13
+ * Copy: English authored here, Polish composed as Polish (spec 155
14
+ * `copy-pl.md`, Wariant F). Never edit a line inline — it is a copywriter pass.
15
+ */
16
+ import type { ConnectRequirement, ConnectRequirementPlatform } from './types';
17
+ export type { ConnectRequirement, ConnectRequirementKind, ConnectRequirementPlatform, } from './types';
18
+ export declare const CONNECT_REQUIREMENT_PLATFORMS: readonly ConnectRequirementPlatform[];
19
+ export declare const CONNECT_REQUIREMENTS: Record<ConnectRequirementPlatform, ConnectRequirement>;
20
+ /**
21
+ * The line to render under a platform's name, or '' when the platform imposes
22
+ * no account condition. Callers render nothing at all for an empty string.
23
+ */
24
+ export declare function connectRequirementLine(platform: ConnectRequirementPlatform, locale: 'en' | 'pl'): string;
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CONNECT_REQUIREMENTS = exports.CONNECT_REQUIREMENT_PLATFORMS = void 0;
4
+ exports.connectRequirementLine = connectRequirementLine;
5
+ /** Verified against the sources below on this date; re-check when a platform changes its rules. */
6
+ const VERIFIED_ON = '2026-09-01';
7
+ const ZERNIO_DOCS = 'https://docs.zernio.com/platforms';
8
+ exports.CONNECT_REQUIREMENT_PLATFORMS = [
9
+ 'instagram',
10
+ 'tiktok',
11
+ 'facebook',
12
+ 'youtube',
13
+ 'twitter',
14
+ 'linkedin',
15
+ 'pinterest',
16
+ 'threads',
17
+ 'bluesky',
18
+ 'reddit',
19
+ ];
20
+ /** A platform with nothing to warn about: the account is the only requirement. */
21
+ function plain(platform) {
22
+ return {
23
+ platform,
24
+ requirement: 'account',
25
+ en: '',
26
+ pl: '',
27
+ sourceUrl: `${ZERNIO_DOCS}/${platform}`,
28
+ verifiedOn: VERIFIED_ON,
29
+ };
30
+ }
31
+ exports.CONNECT_REQUIREMENTS = {
32
+ // "Facebook API only posts to Pages, not personal profiles." Professional
33
+ // mode on a profile does not open the API either; that fuller explanation
34
+ // lives in the Facebook sheet, not on the list.
35
+ facebook: {
36
+ platform: 'facebook',
37
+ requirement: 'page',
38
+ en: 'You need a Page where you are an admin or editor.',
39
+ pl: 'Potrzebna strona, na której jesteś administratorem lub redaktorem.',
40
+ sourceUrl: `${ZERNIO_DOCS}/facebook`,
41
+ verifiedOn: VERIFIED_ON,
42
+ },
43
+ // "Instagram requires a Business or Creator account. Personal accounts
44
+ // cannot post via API." Instagram Login is the default, so no Page is needed.
45
+ instagram: {
46
+ platform: 'instagram',
47
+ requirement: 'professional_account',
48
+ en: 'You need a professional account: Creator or Business.',
49
+ pl: 'Potrzebne konto profesjonalne: Twórca albo Firma.',
50
+ sourceUrl: `${ZERNIO_DOCS}/instagram`,
51
+ verifiedOn: VERIFIED_ON,
52
+ },
53
+ // Threads rides the Instagram account: "You must have an Instagram Business
54
+ // or Creator account with Threads enabled."
55
+ threads: {
56
+ platform: 'threads',
57
+ requirement: 'professional_account',
58
+ en: 'Your Instagram account: professional, with Threads turned on.',
59
+ pl: 'Twoje konto na Instagramie: profesjonalne i z włączonym Threads.',
60
+ sourceUrl: `${ZERNIO_DOCS}/threads`,
61
+ verifiedOn: VERIFIED_ON,
62
+ },
63
+ // Bluesky signs in with an app password, never the account password.
64
+ bluesky: {
65
+ platform: 'bluesky',
66
+ requirement: 'app_password',
67
+ en: 'Sign in with an app password, not your main account password.',
68
+ pl: 'Zaloguj się hasłem aplikacji, nie głównym hasłem do konta.',
69
+ sourceUrl: `${ZERNIO_DOCS}/bluesky`,
70
+ verifiedOn: VERIFIED_ON,
71
+ },
72
+ tiktok: plain('tiktok'),
73
+ youtube: plain('youtube'),
74
+ twitter: plain('twitter'),
75
+ linkedin: plain('linkedin'),
76
+ pinterest: plain('pinterest'),
77
+ reddit: plain('reddit'),
78
+ };
79
+ /**
80
+ * The line to render under a platform's name, or '' when the platform imposes
81
+ * no account condition. Callers render nothing at all for an empty string.
82
+ */
83
+ function connectRequirementLine(platform, locale) {
84
+ return exports.CONNECT_REQUIREMENTS[platform][locale];
85
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-platform connect requirements (spec 155).
3
+ *
4
+ * What a creator has to have BEFORE a platform's OAuth can succeed. Only four
5
+ * of the ten platforms impose anything; the rest only ever meet the platform's
6
+ * own consent screens, which is not worth a line on the connect list (design
7
+ * verdict F, Jan 2026-09-01, after the two-column preview showed ten lines
8
+ * crowding the step).
9
+ */
10
+ /** The 10 platforms ad2app can connect. Matches the frontend's CONNECTABLE_PLATFORMS. */
11
+ export type ConnectRequirementPlatform = 'instagram' | 'tiktok' | 'facebook' | 'youtube' | 'twitter' | 'linkedin' | 'pinterest' | 'threads' | 'bluesky' | 'reddit';
12
+ /**
13
+ * What the platform demands of the account itself. Wire values: they drive
14
+ * whether a line renders, so renaming one silently blanks a warning.
15
+ *
16
+ * - `page` — publishing goes to a Page, never a personal profile (Facebook).
17
+ * - `professional_account` — the account must be a Creator/Business account
18
+ * (Instagram, and Threads by way of the Instagram account behind it).
19
+ * - `app_password` — sign-in uses a per-app password, not the account password
20
+ * (Bluesky).
21
+ * - `account` — no condition beyond having the account. Renders NO line.
22
+ */
23
+ export type ConnectRequirementKind = 'page' | 'professional_account' | 'app_password' | 'account';
24
+ export interface ConnectRequirement {
25
+ platform: ConnectRequirementPlatform;
26
+ requirement: ConnectRequirementKind;
27
+ /** The line shown under the platform name. Empty when `requirement` is `account`. */
28
+ en: string;
29
+ /** Polish is its own composition, not a translation of `en`. Empty when `requirement` is `account`. */
30
+ pl: string;
31
+ /** The vendor or platform document the claim rests on. */
32
+ sourceUrl: string;
33
+ /** ISO date the claim was last checked against `sourceUrl`. */
34
+ verifiedOn: string;
35
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ /**
3
+ * Per-platform connect requirements (spec 155).
4
+ *
5
+ * What a creator has to have BEFORE a platform's OAuth can succeed. Only four
6
+ * of the ten platforms impose anything; the rest only ever meet the platform's
7
+ * own consent screens, which is not worth a line on the connect list (design
8
+ * verdict F, Jan 2026-09-01, after the two-column preview showed ten lines
9
+ * crowding the step).
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.32.0",
3
+ "version": "1.35.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -11,6 +11,7 @@
11
11
  "./legal": "./dist/legal/index.js",
12
12
  "./analytics": "./dist/analytics/index.js",
13
13
  "./publish-limits": "./dist/publish-limits/index.js",
14
+ "./connect-requirements": "./dist/connect-requirements/index.js",
14
15
  "./brand": "./brand/brand.mjs"
15
16
  },
16
17
  "typesVersions": {
@@ -36,6 +37,9 @@
36
37
  "brand": [
37
38
  "brand/brand.d.ts"
38
39
  ],
40
+ "connect-requirements": [
41
+ "dist/connect-requirements/index.d.ts"
42
+ ],
39
43
  "*": [
40
44
  "dist/index.d.ts"
41
45
  ]
@@ -47,7 +51,7 @@
47
51
  "prepare": "npm run build"
48
52
  },
49
53
  "keywords": [],
50
- "author": "Maciej G\u00f3rski@ad2.app",
54
+ "author": "Maciej Górski@ad2.app",
51
55
  "license": "ISC",
52
56
  "description": "Package to share types and utils across the ad2app projects",
53
57
  "dependencies": {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The connect-abandon contract (spec 155 US3).
3
+ *
4
+ * Every connect attempt that does not end with a connected account leaves one
5
+ * row with one reason. Before this, the event existed but only the paywall
6
+ * emitted it, so a person who walked into Meta's OAuth with a personal profile
7
+ * and never came back left no trace at all: their last event was the click
8
+ * (measured on a real tester, 2026-09-01 10:56:14).
9
+ */
10
+ import assert from 'node:assert/strict';
11
+ import { test } from 'node:test';
12
+
13
+ import { CONNECT_BLOCK_REASONS, EVENTS } from './index';
14
+ import type { ConnectBlockReason, EventProperties } from './index';
15
+
16
+ test('the reason set is the five causes, as wire values', () => {
17
+ assert.deepEqual([...CONNECT_BLOCK_REASONS], [
18
+ 'paywall',
19
+ 'canceled',
20
+ 'failed',
21
+ 'returned_without_account',
22
+ 'personal_profile',
23
+ ]);
24
+ });
25
+
26
+ test('an emit without a reason does not type-check', () => {
27
+ // @ts-expect-error reason is required: a reasonless row is exactly the
28
+ // silent abandon this event exists to end.
29
+ const bad: EventProperties[typeof EVENTS.ACCOUNT_CONNECT_BLOCKED] = { platform: 'facebook' };
30
+ assert.ok(bad);
31
+ });
32
+
33
+ test('a full emit type-checks and carries the platform', () => {
34
+ const reason: ConnectBlockReason = 'personal_profile';
35
+ const good: EventProperties[typeof EVENTS.ACCOUNT_CONNECT_BLOCKED] = {
36
+ platform: 'facebook',
37
+ reason,
38
+ };
39
+ assert.equal(good.reason, 'personal_profile');
40
+ });
@@ -105,6 +105,7 @@ const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E]
105
105
  [EVENTS.PAYWALL_DISMISSED]: { surface: "upgrade_modal", trigger: "user_click", hit_number: 1, app_locale: "en" },
106
106
  [EVENTS.ACCESS_DENIED]: { reason: "subscription-required", app_locale: "en" },
107
107
  [EVENTS.ONBOARDING_STEP_COMPLETED]: { flow: "complete_profile", step: "role", step_index: 1, app_locale: "en" },
108
+ [EVENTS.ONBOARDING_STEP_VIEWED]: { flow: "beta_consent", step: "consent" },
108
109
  [EVENTS.EXIT_INTENT_SHOWN]: { surface: "connect_wall" },
109
110
  [EVENTS.EXIT_INTENT_DISMISSED]: { surface: "connect_wall" },
110
111
  [EVENTS.EXIT_INTENT_CAPTURED]: { surface: "connect_wall" },
@@ -186,6 +187,10 @@ test("080 paywall / onboarding wire names are locked (rename breaks the baseline
186
187
  assert.equal(EVENTS.ONBOARDING_STEP_COMPLETED, "onboarding_step_completed");
187
188
  });
188
189
 
190
+ test("158: onboarding_step_viewed wire name is locked — it was already live in production as a raw string before this promotion (rename breaks the existing 15 events)", () => {
191
+ assert.equal(EVENTS.ONBOARDING_STEP_VIEWED, "onboarding_step_viewed");
192
+ });
193
+
189
194
  test("080 subscription-lifecycle wire names are locked (backend = source of truth)", () => {
190
195
  assert.deepEqual(
191
196
  [
@@ -78,6 +78,12 @@ export const EVENTS = {
78
78
  // trust — it is part of why a REAL retry defect sat unnoticed in the same list.
79
79
  ACCESS_DENIED: 'access_denied', // entitlement 403 at the API choke point (beta wall / subscription)
80
80
  ONBOARDING_STEP_COMPLETED: 'onboarding_step_completed', // one step of a multi-step flow (complete-profile)
81
+ // Promoted from a raw string literal at ad2app-frontend's
82
+ // OnboardingFlow.tsx:887 (spec 158, 2026-09-03) — it was already firing in
83
+ // production (15 events, 3 people) ungoverned since spec 095. `flow` is
84
+ // widened alongside ONBOARDING_STEP_COMPLETED below to cover the beta
85
+ // consent step, which is not part of the complete-profile flow.
86
+ ONBOARDING_STEP_VIEWED: 'onboarding_step_viewed', // a step was actually shown (drop-off, not just completion)
81
87
  EXIT_INTENT_SHOWN: 'exit_intent_shown', // exit-intent capture modal armed (065, verbatim wire values)
82
88
  EXIT_INTENT_DISMISSED: 'exit_intent_dismissed',
83
89
  EXIT_INTENT_CAPTURED: 'exit_intent_captured', // email captured into the beta list
@@ -231,6 +237,58 @@ export type ComposeBlockReason =
231
237
  | 'channel_blocked' // the channel itself refused (e.g. a failed prerequisite read)
232
238
  | 'poll'; // an incomplete or unpostable poll
233
239
 
240
+ /**
241
+ * Why a connect attempt ended without a connected account (spec 155 US3).
242
+ *
243
+ * Wire values, never renamed: historical rows do not migrate. A new cause is a
244
+ * new value appended here, never a re-meaning of an old one.
245
+ *
246
+ * - `paywall` — a free-tier user hit the connect wall (the pre-existing emit).
247
+ * - `canceled` — the person denied or dismissed the platform's OAuth.
248
+ * - `failed` — the vendor returned a failure on the callback.
249
+ * - `returned_without_account` — they came back and no account arrived: the
250
+ * silent abandon, e.g. Facebook's Page picker offering an empty list to
251
+ * someone who only has a personal profile.
252
+ * - `personal_profile` — they told us before OAuth that they post from a
253
+ * personal Facebook profile, which Meta's API cannot publish to.
254
+ */
255
+ /**
256
+ * Which finish line an onboarding completion crossed (094 US6/US8).
257
+ *
258
+ * Until 2026-09-01 the guided tour ended at a scheduled post, and almost
259
+ * nobody crossed it: four completions by two people in ninety days, against
260
+ * fifteen skips that all landed on the compose step. It now ends at a
261
+ * connected account. Both lines appear in the same series, so a completion
262
+ * that does not say which one it crossed turns a redefinition into an
263
+ * unexplained jump.
264
+ *
265
+ * Wire values, never renamed.
266
+ */
267
+ export type OnboardingCompletedAt = 'connect' | 'install' | 'schedule';
268
+
269
+ /** The finish lines, in the order the flow can reach them. */
270
+ export const ONBOARDING_COMPLETED_AT = [
271
+ 'connect',
272
+ 'install',
273
+ 'schedule',
274
+ ] as const satisfies readonly OnboardingCompletedAt[];
275
+
276
+ export type ConnectBlockReason =
277
+ | 'paywall'
278
+ | 'canceled'
279
+ | 'failed'
280
+ | 'returned_without_account'
281
+ | 'personal_profile';
282
+
283
+ /** The reason set, in the order the spec lists it. Iterable for contract tests and dashboards. */
284
+ export const CONNECT_BLOCK_REASONS = [
285
+ 'paywall',
286
+ 'canceled',
287
+ 'failed',
288
+ 'returned_without_account',
289
+ 'personal_profile',
290
+ ] as const satisfies readonly ConnectBlockReason[];
291
+
234
292
  export type PublishFailureReason =
235
293
  | 'auth' // token expired / revoked / missing scope / permission denied
236
294
  | 'rate_limit' // provider throttled us (429 / quota)
@@ -255,7 +313,7 @@ export interface EventProperties {
255
313
  target: 'app' | 'ask' | 'terminal' | 'editor';
256
314
  };
257
315
  [EVENTS.FREE_SKILLS_REQUESTED]: { source: string };
258
- [EVENTS.ACCOUNT_CONNECT_BLOCKED]: { platform?: string };
316
+ [EVENTS.ACCOUNT_CONNECT_BLOCKED]: { platform?: string; reason: ConnectBlockReason };
259
317
  [EVENTS.MCP_POST_BLOCKED_FREE_TIER]: { target_count?: number };
260
318
  [EVENTS.EMAIL_SENT]: EmailEventProperties;
261
319
  [EVENTS.EMAIL_DELIVERED]: EmailEventProperties;
@@ -384,11 +442,19 @@ export interface EventProperties {
384
442
  app_locale: AppLocale;
385
443
  };
386
444
  [EVENTS.ONBOARDING_STEP_COMPLETED]: {
387
- flow: 'complete_profile'; // closed set, extensible when a new flow ships
445
+ flow: 'complete_profile' | 'beta_consent'; // widened 2026-09-03 (spec 158) for the beta consent step
388
446
  step: string; // stable slug derived from the real wizard, not guessed
389
447
  step_index: number; // 1-based
390
448
  app_locale: AppLocale;
391
449
  };
450
+ // Promoted from a raw string (spec 158) — same `flow`/`step` shape as
451
+ // ONBOARDING_STEP_COMPLETED, fired once per step actually SHOWN rather than
452
+ // finished, so drop-off is visible per step, not only per completion.
453
+ [EVENTS.ONBOARDING_STEP_VIEWED]: {
454
+ flow: 'complete_profile' | 'beta_consent';
455
+ step: string;
456
+ app_locale?: AppLocale; // optional: the pre-existing raw call site does not have it yet (T001a repairs the call site, not the type's honesty about what ships today)
457
+ };
392
458
  // Exit-intent (065). surface value is VERBATIM from the ExitIntentModal
393
459
  // component ('connect_wall'); dwell_ms / page_views / app_locale are optional
394
460
  // additions T003 wires (the component does not emit them yet — fidelity gate).
@@ -408,7 +474,11 @@ export interface EventProperties {
408
474
 
409
475
  // Onboarding flow (promoted 2026-07-19). flow defaults to the main app onboarding.
410
476
  [EVENTS.ONBOARDING_STARTED]: { flow?: string };
411
- [EVENTS.ONBOARDING_COMPLETED]: { flow?: string; steps_completed?: number };
477
+ [EVENTS.ONBOARDING_COMPLETED]: {
478
+ flow?: string;
479
+ steps_completed?: number;
480
+ completed_at: OnboardingCompletedAt;
481
+ };
412
482
  [EVENTS.ONBOARDING_SKIPPED]: { flow?: string; step?: string };
413
483
 
414
484
  // Inbox engagement — platform is the social platform the comment/DM is on.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Which finish line a tour completion crossed (094 US8).
3
+ *
4
+ * The tour used to end at a scheduled post and now ends at a connected
5
+ * account. Both lines will appear in the same series, so the event has to say
6
+ * which one it was; without that the change reads as a sudden jump in
7
+ * completions rather than a redefinition.
8
+ */
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+
12
+ import { EVENTS, ONBOARDING_COMPLETED_AT } from './index';
13
+ import type { EventProperties, OnboardingCompletedAt } from './index';
14
+
15
+ test('the finish lines are a closed set of wire values', () => {
16
+ assert.deepEqual([...ONBOARDING_COMPLETED_AT], ['connect', 'install', 'schedule']);
17
+ });
18
+
19
+ test('a completion without its finish line does not type-check', () => {
20
+ // @ts-expect-error completed_at is required: an unlabelled completion is the
21
+ // ambiguity this property exists to remove.
22
+ const bad: EventProperties[typeof EVENTS.ONBOARDING_COMPLETED] = { steps_completed: 4 };
23
+ assert.ok(bad);
24
+ });
25
+
26
+ test('a labelled completion type-checks', () => {
27
+ const via: OnboardingCompletedAt = 'connect';
28
+ const good: EventProperties[typeof EVENTS.ONBOARDING_COMPLETED] = { completed_at: via };
29
+ assert.equal(good.completed_at, 'connect');
30
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Contract tests for the per-platform connect requirements (spec 155, design
3
+ * verdict F, Jan 2026-09-01).
4
+ *
5
+ * The rule these lock: a line is rendered ONLY where the platform's API
6
+ * imposes a real account condition. Six of the ten platforms impose none, so
7
+ * they carry empty strings and the UI renders no caption node at all. A line
8
+ * that IS rendered has to fit one line under the platform name on a 390px
9
+ * phone, which is where the 70-character cap comes from (measured in the
10
+ * design preview: at 2 columns the same text truncated after three words).
11
+ */
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+
15
+ import {
16
+ CONNECT_REQUIREMENTS,
17
+ CONNECT_REQUIREMENT_PLATFORMS,
18
+ connectRequirementLine,
19
+ } from './index';
20
+ import type { ConnectRequirement, ConnectRequirementPlatform } from './types';
21
+
22
+ const MAX_LINE = 70;
23
+ const CONSTRAINED: Record<string, string> = {
24
+ facebook: 'page',
25
+ instagram: 'professional_account',
26
+ threads: 'professional_account',
27
+ bluesky: 'app_password',
28
+ };
29
+
30
+ test('covers the ten connectable platforms, once each', () => {
31
+ assert.equal(CONNECT_REQUIREMENT_PLATFORMS.length, 10);
32
+ assert.equal(new Set(CONNECT_REQUIREMENT_PLATFORMS).size, 10);
33
+ for (const platform of CONNECT_REQUIREMENT_PLATFORMS) {
34
+ assert.ok(CONNECT_REQUIREMENTS[platform], `missing record: ${platform}`);
35
+ assert.equal(CONNECT_REQUIREMENTS[platform].platform, platform);
36
+ }
37
+ });
38
+
39
+ test('assigns the requirement kind each platform actually has', () => {
40
+ for (const platform of CONNECT_REQUIREMENT_PLATFORMS) {
41
+ const expected = CONNECT_REQUIREMENTS[platform].requirement;
42
+ assert.equal(expected, CONSTRAINED[platform] ?? 'account', `wrong kind: ${platform}`);
43
+ }
44
+ });
45
+
46
+ test('a constrained platform carries one short line in both locales', () => {
47
+ for (const platform of Object.keys(CONSTRAINED) as ConnectRequirementPlatform[]) {
48
+ const record = CONNECT_REQUIREMENTS[platform];
49
+ for (const locale of ['en', 'pl'] as const) {
50
+ const line = record[locale];
51
+ assert.ok(line.length > 0, `${platform}.${locale} is empty`);
52
+ assert.ok(
53
+ line.length <= MAX_LINE,
54
+ `${platform}.${locale} is ${line.length} chars, cap is ${MAX_LINE}`,
55
+ );
56
+ }
57
+ }
58
+ });
59
+
60
+ test('an unconstrained platform carries no line at all', () => {
61
+ const unconstrained = CONNECT_REQUIREMENT_PLATFORMS.filter((p) => !(p in CONSTRAINED));
62
+ assert.equal(unconstrained.length, 6);
63
+ for (const platform of unconstrained) {
64
+ assert.equal(CONNECT_REQUIREMENTS[platform].en, '');
65
+ assert.equal(CONNECT_REQUIREMENTS[platform].pl, '');
66
+ assert.equal(connectRequirementLine(platform, 'pl'), '');
67
+ assert.equal(connectRequirementLine(platform, 'en'), '');
68
+ }
69
+ });
70
+
71
+ test('no line carries a dash character or a screen count', () => {
72
+ for (const platform of CONNECT_REQUIREMENT_PLATFORMS) {
73
+ for (const locale of ['en', 'pl'] as const) {
74
+ const line = CONNECT_REQUIREMENTS[platform][locale];
75
+ assert.ok(!/[—–]/.test(line), `${platform}.${locale} contains a dash`);
76
+ assert.ok(
77
+ !/\d+\s*(screens?|ekran)/i.test(line),
78
+ `${platform}.${locale} promises a screen count`,
79
+ );
80
+ }
81
+ }
82
+ });
83
+
84
+ test('every record cites a source and the date it was verified', () => {
85
+ for (const platform of CONNECT_REQUIREMENT_PLATFORMS) {
86
+ const record: ConnectRequirement = CONNECT_REQUIREMENTS[platform];
87
+ assert.ok(record.sourceUrl.startsWith('https://'), `${platform} sourceUrl is not https`);
88
+ assert.match(record.verifiedOn, /^\d{4}-\d{2}-\d{2}$/, `${platform} verifiedOn is not a date`);
89
+ }
90
+ });
91
+
92
+ test('connectRequirementLine returns the locale asked for', () => {
93
+ assert.equal(connectRequirementLine('facebook', 'pl'), CONNECT_REQUIREMENTS.facebook.pl);
94
+ assert.equal(connectRequirementLine('facebook', 'en'), CONNECT_REQUIREMENTS.facebook.en);
95
+ assert.notEqual(CONNECT_REQUIREMENTS.facebook.pl, CONNECT_REQUIREMENTS.facebook.en);
96
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The canonical table of what each platform needs before it can be connected.
3
+ *
4
+ * ONE home for these facts: the app renders them on the connect step and on
5
+ * /accounts, and nothing may restate them in a component. A line is rendered
6
+ * only where `requirement !== 'account'` — six platforms deliberately carry
7
+ * empty strings, and the UI renders no caption node for them at all.
8
+ *
9
+ * The cap on a rendered line is 70 characters, enforced by the contract test:
10
+ * it is what fits on one line under the platform name at 390px, measured in
11
+ * the spec 155 design preview.
12
+ *
13
+ * Copy: English authored here, Polish composed as Polish (spec 155
14
+ * `copy-pl.md`, Wariant F). Never edit a line inline — it is a copywriter pass.
15
+ */
16
+ import type {
17
+ ConnectRequirement,
18
+ ConnectRequirementPlatform,
19
+ } from './types';
20
+
21
+ export type {
22
+ ConnectRequirement,
23
+ ConnectRequirementKind,
24
+ ConnectRequirementPlatform,
25
+ } from './types';
26
+
27
+ /** Verified against the sources below on this date; re-check when a platform changes its rules. */
28
+ const VERIFIED_ON = '2026-09-01';
29
+
30
+ const ZERNIO_DOCS = 'https://docs.zernio.com/platforms';
31
+
32
+ export const CONNECT_REQUIREMENT_PLATFORMS: readonly ConnectRequirementPlatform[] = [
33
+ 'instagram',
34
+ 'tiktok',
35
+ 'facebook',
36
+ 'youtube',
37
+ 'twitter',
38
+ 'linkedin',
39
+ 'pinterest',
40
+ 'threads',
41
+ 'bluesky',
42
+ 'reddit',
43
+ ];
44
+
45
+ /** A platform with nothing to warn about: the account is the only requirement. */
46
+ function plain(platform: ConnectRequirementPlatform): ConnectRequirement {
47
+ return {
48
+ platform,
49
+ requirement: 'account',
50
+ en: '',
51
+ pl: '',
52
+ sourceUrl: `${ZERNIO_DOCS}/${platform}`,
53
+ verifiedOn: VERIFIED_ON,
54
+ };
55
+ }
56
+
57
+ export const CONNECT_REQUIREMENTS: Record<ConnectRequirementPlatform, ConnectRequirement> = {
58
+ // "Facebook API only posts to Pages, not personal profiles." Professional
59
+ // mode on a profile does not open the API either; that fuller explanation
60
+ // lives in the Facebook sheet, not on the list.
61
+ facebook: {
62
+ platform: 'facebook',
63
+ requirement: 'page',
64
+ en: 'You need a Page where you are an admin or editor.',
65
+ pl: 'Potrzebna strona, na której jesteś administratorem lub redaktorem.',
66
+ sourceUrl: `${ZERNIO_DOCS}/facebook`,
67
+ verifiedOn: VERIFIED_ON,
68
+ },
69
+ // "Instagram requires a Business or Creator account. Personal accounts
70
+ // cannot post via API." Instagram Login is the default, so no Page is needed.
71
+ instagram: {
72
+ platform: 'instagram',
73
+ requirement: 'professional_account',
74
+ en: 'You need a professional account: Creator or Business.',
75
+ pl: 'Potrzebne konto profesjonalne: Twórca albo Firma.',
76
+ sourceUrl: `${ZERNIO_DOCS}/instagram`,
77
+ verifiedOn: VERIFIED_ON,
78
+ },
79
+ // Threads rides the Instagram account: "You must have an Instagram Business
80
+ // or Creator account with Threads enabled."
81
+ threads: {
82
+ platform: 'threads',
83
+ requirement: 'professional_account',
84
+ en: 'Your Instagram account: professional, with Threads turned on.',
85
+ pl: 'Twoje konto na Instagramie: profesjonalne i z włączonym Threads.',
86
+ sourceUrl: `${ZERNIO_DOCS}/threads`,
87
+ verifiedOn: VERIFIED_ON,
88
+ },
89
+ // Bluesky signs in with an app password, never the account password.
90
+ bluesky: {
91
+ platform: 'bluesky',
92
+ requirement: 'app_password',
93
+ en: 'Sign in with an app password, not your main account password.',
94
+ pl: 'Zaloguj się hasłem aplikacji, nie głównym hasłem do konta.',
95
+ sourceUrl: `${ZERNIO_DOCS}/bluesky`,
96
+ verifiedOn: VERIFIED_ON,
97
+ },
98
+ tiktok: plain('tiktok'),
99
+ youtube: plain('youtube'),
100
+ twitter: plain('twitter'),
101
+ linkedin: plain('linkedin'),
102
+ pinterest: plain('pinterest'),
103
+ reddit: plain('reddit'),
104
+ };
105
+
106
+ /**
107
+ * The line to render under a platform's name, or '' when the platform imposes
108
+ * no account condition. Callers render nothing at all for an empty string.
109
+ */
110
+ export function connectRequirementLine(
111
+ platform: ConnectRequirementPlatform,
112
+ locale: 'en' | 'pl',
113
+ ): string {
114
+ return CONNECT_REQUIREMENTS[platform][locale];
115
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-platform connect requirements (spec 155).
3
+ *
4
+ * What a creator has to have BEFORE a platform's OAuth can succeed. Only four
5
+ * of the ten platforms impose anything; the rest only ever meet the platform's
6
+ * own consent screens, which is not worth a line on the connect list (design
7
+ * verdict F, Jan 2026-09-01, after the two-column preview showed ten lines
8
+ * crowding the step).
9
+ */
10
+
11
+ /** The 10 platforms ad2app can connect. Matches the frontend's CONNECTABLE_PLATFORMS. */
12
+ export type ConnectRequirementPlatform =
13
+ | 'instagram'
14
+ | 'tiktok'
15
+ | 'facebook'
16
+ | 'youtube'
17
+ | 'twitter'
18
+ | 'linkedin'
19
+ | 'pinterest'
20
+ | 'threads'
21
+ | 'bluesky'
22
+ | 'reddit';
23
+
24
+ /**
25
+ * What the platform demands of the account itself. Wire values: they drive
26
+ * whether a line renders, so renaming one silently blanks a warning.
27
+ *
28
+ * - `page` — publishing goes to a Page, never a personal profile (Facebook).
29
+ * - `professional_account` — the account must be a Creator/Business account
30
+ * (Instagram, and Threads by way of the Instagram account behind it).
31
+ * - `app_password` — sign-in uses a per-app password, not the account password
32
+ * (Bluesky).
33
+ * - `account` — no condition beyond having the account. Renders NO line.
34
+ */
35
+ export type ConnectRequirementKind =
36
+ | 'page'
37
+ | 'professional_account'
38
+ | 'app_password'
39
+ | 'account';
40
+
41
+ export interface ConnectRequirement {
42
+ platform: ConnectRequirementPlatform;
43
+ requirement: ConnectRequirementKind;
44
+ /** The line shown under the platform name. Empty when `requirement` is `account`. */
45
+ en: string;
46
+ /** Polish is its own composition, not a translation of `en`. Empty when `requirement` is `account`. */
47
+ pl: string;
48
+ /** The vendor or platform document the claim rests on. */
49
+ sourceUrl: string;
50
+ /** ISO date the claim was last checked against `sourceUrl`. */
51
+ verifiedOn: string;
52
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Every published subpath resolves BOTH at runtime and for TypeScript.
3
+ *
4
+ * Spec 155 caught this the expensive way: `exports` gained
5
+ * `./connect-requirements` but `typesVersions` did not, so `npm run build`
6
+ * passed in the lib and the consumer failed with "Cannot find module
7
+ * 'ad2app-lib/connect-requirements' or its corresponding type declarations".
8
+ * The two maps are one fact and drift silently, so pin them together.
9
+ */
10
+ import assert from 'node:assert/strict';
11
+ import { readFileSync } from 'node:fs';
12
+ import { test } from 'node:test';
13
+
14
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
15
+ exports: Record<string, string>;
16
+ typesVersions: Record<string, Record<string, string[]>>;
17
+ };
18
+
19
+ test('every export subpath has a matching typesVersions entry', () => {
20
+ const subpaths = Object.keys(pkg.exports)
21
+ .filter((key) => key !== '.')
22
+ .map((key) => key.replace(/^\.\//, ''));
23
+ const typed = pkg.typesVersions['*'];
24
+ for (const subpath of subpaths) {
25
+ assert.ok(typed[subpath], `exports has "${subpath}" but typesVersions does not`);
26
+ assert.match(
27
+ typed[subpath][0],
28
+ /\.d\.(ts|mts)$/,
29
+ `typesVersions["${subpath}"] must point at a declaration file`,
30
+ );
31
+ }
32
+ });
33
+
34
+ test('every typesVersions entry except the catch-all has an export', () => {
35
+ const exported = new Set(
36
+ Object.keys(pkg.exports)
37
+ .filter((key) => key !== '.')
38
+ .map((key) => key.replace(/^\.\//, '')),
39
+ );
40
+ for (const subpath of Object.keys(pkg.typesVersions['*'])) {
41
+ if (subpath === '*') continue;
42
+ assert.ok(exported.has(subpath), `typesVersions has "${subpath}" but exports does not`);
43
+ }
44
+ });