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.
- package/dist/analytics/index.d.ts +42 -1
- package/dist/analytics/index.js +21 -1
- package/dist/connect-requirements/index.d.ts +24 -0
- package/dist/connect-requirements/index.js +85 -0
- package/dist/connect-requirements/types.d.ts +35 -0
- package/dist/connect-requirements/types.js +11 -0
- package/package.json +6 -2
- package/src/analytics/account-connect-blocked.test.ts +40 -0
- package/src/analytics/index.test.ts +5 -0
- package/src/analytics/index.ts +73 -3
- package/src/analytics/onboarding-completion.test.ts +30 -0
- package/src/connect-requirements/index.test.ts +96 -0
- package/src/connect-requirements/index.ts +115 -0
- package/src/connect-requirements/types.ts +52 -0
- package/src/package-exports.test.ts +44 -0
|
@@ -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;
|
package/dist/analytics/index.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
[
|
package/src/analytics/index.ts
CHANGED
|
@@ -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'; //
|
|
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]: {
|
|
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
|
+
});
|