ad2app-lib 1.9.2 → 1.10.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/.github/workflows/test.yml +21 -0
- package/brand/brand.d.ts +89 -0
- package/brand/brand.mjs +91 -0
- package/dist/analytics/index.d.ts +34 -7
- package/dist/analytics/index.js +14 -5
- package/dist/api/apiDriver.js +19 -4
- package/package.json +6 -2
- package/src/analytics/index.test.ts +70 -0
- package/src/analytics/index.ts +25 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [master, main]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-node@v4
|
|
14
|
+
with:
|
|
15
|
+
node-version: 20
|
|
16
|
+
cache: npm
|
|
17
|
+
- run: npm ci
|
|
18
|
+
- name: typecheck
|
|
19
|
+
run: npx tsc --noEmit
|
|
20
|
+
- name: test
|
|
21
|
+
run: npm test
|
package/brand/brand.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `brand/brand.mjs` — the canonical ad2app brand source.
|
|
3
|
+
*
|
|
4
|
+
* Hand-maintained to mirror brand.mjs (plain ESM, imported buildless by the
|
|
5
|
+
* node scripts). Published via the `./brand` export so TS consumers (frontend,
|
|
6
|
+
* landing) can import these tokens + facts with types. Keep in sync with brand.mjs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ColorTokens {
|
|
10
|
+
bg: string;
|
|
11
|
+
surface: string;
|
|
12
|
+
surfaceMuted: string;
|
|
13
|
+
text: string;
|
|
14
|
+
muted: string;
|
|
15
|
+
border: string;
|
|
16
|
+
primary: string;
|
|
17
|
+
primaryHover: string;
|
|
18
|
+
primaryTint: string;
|
|
19
|
+
primarySolidHover: string;
|
|
20
|
+
onPrimary: string;
|
|
21
|
+
success: string;
|
|
22
|
+
successText: string;
|
|
23
|
+
error: string;
|
|
24
|
+
/** The PRODUCT blue, deliberately distinct from the marketing primary. */
|
|
25
|
+
productPrimary: string;
|
|
26
|
+
}
|
|
27
|
+
export const color: ColorTokens;
|
|
28
|
+
|
|
29
|
+
export interface FontTokens {
|
|
30
|
+
family: string;
|
|
31
|
+
fallback: string;
|
|
32
|
+
}
|
|
33
|
+
export const font: FontTokens;
|
|
34
|
+
|
|
35
|
+
export interface CanvasSize {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
}
|
|
39
|
+
export interface SizeTokens {
|
|
40
|
+
story: CanvasSize;
|
|
41
|
+
portrait: CanvasSize;
|
|
42
|
+
square: CanvasSize;
|
|
43
|
+
link: CanvasSize;
|
|
44
|
+
xLandscape: CanvasSize;
|
|
45
|
+
xPortrait: CanvasSize;
|
|
46
|
+
}
|
|
47
|
+
export const size: SizeTokens;
|
|
48
|
+
|
|
49
|
+
export interface RadiusTokens {
|
|
50
|
+
card: number;
|
|
51
|
+
pill: number;
|
|
52
|
+
chip: number;
|
|
53
|
+
}
|
|
54
|
+
export const radius: RadiusTokens;
|
|
55
|
+
|
|
56
|
+
export interface AestheticTokens {
|
|
57
|
+
feel: string[];
|
|
58
|
+
avoid: string[];
|
|
59
|
+
prefer: string[];
|
|
60
|
+
bannedCliches: string[];
|
|
61
|
+
}
|
|
62
|
+
export const aesthetic: AestheticTokens;
|
|
63
|
+
|
|
64
|
+
/** A connectable social platform's canonical brand identity. */
|
|
65
|
+
export interface PlatformBrand {
|
|
66
|
+
slug: string;
|
|
67
|
+
name: string;
|
|
68
|
+
/** Official brand color (hex). */
|
|
69
|
+
color: string;
|
|
70
|
+
/** On-dark override for marks that are near-black (invert to white in dark mode). */
|
|
71
|
+
darkColor?: string;
|
|
72
|
+
}
|
|
73
|
+
/** The connectable platforms, canonical order (largest-audience first). */
|
|
74
|
+
export const PLATFORMS: PlatformBrand[];
|
|
75
|
+
export const PLATFORM_COUNT: number;
|
|
76
|
+
|
|
77
|
+
export interface PlanPricing {
|
|
78
|
+
usd: number;
|
|
79
|
+
note?: string;
|
|
80
|
+
accounts?: number;
|
|
81
|
+
}
|
|
82
|
+
export interface PricingTokens {
|
|
83
|
+
free: PlanPricing;
|
|
84
|
+
starter: PlanPricing;
|
|
85
|
+
pro: PlanPricing;
|
|
86
|
+
}
|
|
87
|
+
export const PRICING: PricingTokens;
|
|
88
|
+
|
|
89
|
+
export const ENTITY_SENTENCE: string;
|
package/brand/brand.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// ad2app canonical brand source: design tokens + facts. Plain ESM on purpose, so the
|
|
2
|
+
// on-device node scripts (graphics renderer, the marketing claims gate) import it directly
|
|
3
|
+
// today with no build and no publish. The landing + product join via a typed, published
|
|
4
|
+
// export under spec 009-design-system-hardening. Change a token or a fact HERE, once.
|
|
5
|
+
// Values currently mirror ad2app-landing globals.css + the marketing positioning. _(scope: rule)_
|
|
6
|
+
|
|
7
|
+
// ---- Design tokens ----
|
|
8
|
+
export const color = {
|
|
9
|
+
bg: '#f7f8ff',
|
|
10
|
+
surface: '#ffffff',
|
|
11
|
+
surfaceMuted: '#f0f1fa',
|
|
12
|
+
text: '#1a1a1a',
|
|
13
|
+
muted: '#5f6672',
|
|
14
|
+
border: '#d0d0d8',
|
|
15
|
+
primary: '#0042ff', // marketing primary
|
|
16
|
+
primaryHover: '#0036d6',
|
|
17
|
+
primaryTint: '#eaf0ff',
|
|
18
|
+
primarySolidHover: '#3366ff',
|
|
19
|
+
onPrimary: '#ffffff',
|
|
20
|
+
success: '#34c759',
|
|
21
|
+
successText: '#0f7a33',
|
|
22
|
+
error: '#ff3b30',
|
|
23
|
+
productPrimary: '#0000ff', // the PRODUCT blue, deliberately distinct from marketing #0042ff
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const font = {
|
|
27
|
+
family: 'Funnel Sans',
|
|
28
|
+
fallback: '-apple-system, Segoe UI, Roboto, Helvetica Neue, sans-serif',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Social canvas sizes (px), the canonical ad set.
|
|
32
|
+
export const size = {
|
|
33
|
+
story: { width: 1080, height: 1920 }, // 9:16 TikTok/Reels/Shorts/Stories
|
|
34
|
+
portrait: { width: 1080, height: 1350 }, // 4:5 IG/FB feed
|
|
35
|
+
square: { width: 1080, height: 1080 }, // 1:1 Meta/X/Reddit feed
|
|
36
|
+
link: { width: 1200, height: 628 }, // 1.91:1 link/display
|
|
37
|
+
xLandscape: { width: 1200, height: 675 }, // X single image
|
|
38
|
+
xPortrait: { width: 1080, height: 1350 }, // back-compat alias of portrait
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const radius = { card: 28, pill: 999, chip: 12 };
|
|
42
|
+
|
|
43
|
+
// Visual identity, machine-readable. The PRINCIPLES + art direction live in
|
|
44
|
+
// ad2app-graphics/data/design-trends.md (the design source); this is the encoding the
|
|
45
|
+
// graphics generate/critique loop consumes.
|
|
46
|
+
export const aesthetic = {
|
|
47
|
+
// Look doctrine = ad2app-graphics/data/design-trends.md (4 directions: human/life-given-back [default],
|
|
48
|
+
// type-led minimalism, soft-3D, minimaximalism). North star: WHITE / light, calm, warm, intentional —
|
|
49
|
+
// NEVER the dark premium-dev/attention-economy look. Brand blue #0042ff is the HERO accent; oversized
|
|
50
|
+
// Funnel Sans type. Audience = creators + young founders, but the bar is warm + scroll-stopping, not "Linear".
|
|
51
|
+
feel: ['calm', 'intentional', 'warm', 'human', 'confident', 'spacious', 'crafted', 'present'],
|
|
52
|
+
avoid: ['dark / moody / premium-dev sterility (Linear-tier coldness) as a default', 'frantic, attention-economy energy', 'cluttered', 'growth-hack energy', 'busy collage', 'stock-photo gloss', 'golden-hour brunch / wellness-stock lifestyle', 'timid small centered type on an empty field'],
|
|
53
|
+
// Hero = confident oversized type + #0042ff on a calm light ground. Real product UI is fine and trust-building;
|
|
54
|
+
// invented metric/dashboard chrome is banned (use a real screenshot). Human shots show the life given back.
|
|
55
|
+
prefer: ['WHITE / light calm ground (#f7f8ff / #ffffff), not dark', 'confident OVERSIZED Funnel Sans type as the hero', 'exact brand blue #0042ff as the HERO accent — highlight blocks, blue fields — never navy or grey', 'minimaximalism: a sleek minimal base + a few vibrant, oversized accents', 'the life given back for human shots (closed laptop, free evening, off the phone)', 'generous, composed negative space', 'one idea per asset'],
|
|
56
|
+
bannedCliches: ['network nodes', 'circuit lines / digital-tech glow', 'floating app icons', 'AI brain', 'fake dashboards', 'invented metric chrome', 'emoji spam', 'dashes', 'dark moody tech sterility'],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ---- Facts (stable, machine-consumable) ----
|
|
60
|
+
// The 10 connectable platforms, canonical order (largest-audience first).
|
|
61
|
+
// `color` is each platform's official brand spec; `darkColor` is the on-dark
|
|
62
|
+
// override for marks that are near-black (they invert to white in dark mode).
|
|
63
|
+
// These are the single source for per-platform brand color across product +
|
|
64
|
+
// landing (the typed published export joins them under spec 009).
|
|
65
|
+
export const PLATFORMS = [
|
|
66
|
+
{ slug: 'instagram', name: 'Instagram', color: '#E1306C' },
|
|
67
|
+
{ slug: 'tiktok', name: 'TikTok', color: '#000000', darkColor: '#ffffff' },
|
|
68
|
+
{ slug: 'youtube', name: 'YouTube', color: '#FF0000' },
|
|
69
|
+
{ slug: 'x-twitter', name: 'X (Twitter)', color: '#000000', darkColor: '#ffffff' },
|
|
70
|
+
{ slug: 'facebook', name: 'Facebook', color: '#1877F2' },
|
|
71
|
+
{ slug: 'linkedin', name: 'LinkedIn', color: '#0A66C2' },
|
|
72
|
+
{ slug: 'threads', name: 'Threads', color: '#000000', darkColor: '#ffffff' },
|
|
73
|
+
{ slug: 'pinterest', name: 'Pinterest', color: '#E60023' },
|
|
74
|
+
{ slug: 'reddit', name: 'Reddit', color: '#FF4500' },
|
|
75
|
+
{ slug: 'bluesky', name: 'Bluesky', color: '#0085FF' },
|
|
76
|
+
];
|
|
77
|
+
export const PLATFORM_COUNT = PLATFORMS.length; // 10
|
|
78
|
+
|
|
79
|
+
// Pricing. free is view-only (NOT a trial). Plan keys: free / starter / pro.
|
|
80
|
+
// NOTE: the landing FAQ currently labels pro as "All-Platforms" — a name drift to reconcile in 009.
|
|
81
|
+
export const PRICING = {
|
|
82
|
+
free: { usd: 0, note: 'view-only, cannot connect accounts' },
|
|
83
|
+
starter: { usd: 16, accounts: 4 },
|
|
84
|
+
pro: { usd: 33, accounts: 10 },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Canonical entity sentence (byte-identical across landing footer/JSON-LD/metadata/manifest).
|
|
88
|
+
// Identity-aligned 2026-06-29. Still a clean factual sentence for SEO/AEO (keeps the 10-platform list);
|
|
89
|
+
// the landing adopts this via spec 009 (today the landing still carries its own copy).
|
|
90
|
+
export const ENTITY_SENTENCE =
|
|
91
|
+
'ad2app is a social scheduler and unified inbox for creators and founders: schedule and publish across 10 platforms (Instagram, TikTok, X, YouTube, LinkedIn, Facebook, Threads, Pinterest, Reddit, and Bluesky), then handle every comment and DM in one place. Social on purpose.';
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single source of truth for event names, event property shapes, and person
|
|
5
5
|
* property keys across ALL surfaces (frontend, backend, landing, future MCP).
|
|
6
|
-
* Spec:
|
|
6
|
+
* Spec: ad2app-specs/analytics-posthog/posthog-implementation.md §3.
|
|
7
7
|
*
|
|
8
8
|
* Naming convention: snake_case, object_verb_past_tense.
|
|
9
9
|
* Do NOT rename events after they ship — historical data does not migrate.
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
/** Canonical PostHog event names. */
|
|
12
12
|
export declare const EVENTS: {
|
|
13
13
|
readonly LANDING_CTA_CLICKED: "landing_cta_clicked";
|
|
14
|
+
readonly CONNECT_ARTIFACT_COPIED: "connect_artifact_copied";
|
|
15
|
+
readonly FREE_SKILLS_REQUESTED: "free_skills_requested";
|
|
16
|
+
readonly ACCOUNT_CONNECT_BLOCKED: "account_connect_blocked";
|
|
17
|
+
readonly MCP_POST_BLOCKED_FREE_TIER: "mcp_post_blocked_free_tier";
|
|
18
|
+
readonly EMAIL_DELIVERED: "email_delivered";
|
|
19
|
+
readonly EMAIL_OPENED: "email_opened";
|
|
20
|
+
readonly EMAIL_CLICKED: "email_clicked";
|
|
21
|
+
readonly EMAIL_BOUNCED: "email_bounced";
|
|
22
|
+
readonly EMAIL_COMPLAINED: "email_complained";
|
|
14
23
|
readonly SIGNED_UP: "signed_up";
|
|
15
24
|
readonly PROFILE_COMPLETED: "profile_completed";
|
|
16
25
|
readonly LOGGED_IN: "logged_in";
|
|
@@ -19,7 +28,6 @@ export declare const EVENTS: {
|
|
|
19
28
|
readonly POST_SCHEDULED: "post_scheduled";
|
|
20
29
|
readonly POST_PUBLISHED: "post_published";
|
|
21
30
|
readonly ANALYTICS_VIEWED: "analytics_viewed";
|
|
22
|
-
readonly TRIAL_STARTED: "trial_started";
|
|
23
31
|
readonly LIMIT_REACHED: "limit_reached";
|
|
24
32
|
readonly UPGRADE_CLICKED: "upgrade_clicked";
|
|
25
33
|
readonly CHECKOUT_STARTED: "checkout_started";
|
|
@@ -27,11 +35,34 @@ export declare const EVENTS: {
|
|
|
27
35
|
readonly SUBSCRIPTION_CANCELED: "subscription_canceled";
|
|
28
36
|
};
|
|
29
37
|
export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
|
|
38
|
+
/** Shared shape for the Resend email lifecycle events (AD2-894). */
|
|
39
|
+
export interface EmailEventProperties {
|
|
40
|
+
email_id?: string;
|
|
41
|
+
subject?: string;
|
|
42
|
+
link?: string;
|
|
43
|
+
}
|
|
30
44
|
/** Property shape per event. Keeps emitters honest across repos. */
|
|
31
45
|
export interface EventProperties {
|
|
32
46
|
[EVENTS.LANDING_CTA_CLICKED]: {
|
|
33
|
-
location: 'hero' | 'pricing' | 'final_cta' | 'nav';
|
|
47
|
+
location: 'hero' | 'pricing' | 'final_cta' | 'nav' | 'ai_connector' | 'free_skills';
|
|
48
|
+
};
|
|
49
|
+
[EVENTS.CONNECT_ARTIFACT_COPIED]: {
|
|
50
|
+
target: 'app' | 'ask' | 'terminal' | 'editor';
|
|
51
|
+
};
|
|
52
|
+
[EVENTS.FREE_SKILLS_REQUESTED]: {
|
|
53
|
+
source: string;
|
|
54
|
+
};
|
|
55
|
+
[EVENTS.ACCOUNT_CONNECT_BLOCKED]: {
|
|
56
|
+
platform?: string;
|
|
34
57
|
};
|
|
58
|
+
[EVENTS.MCP_POST_BLOCKED_FREE_TIER]: {
|
|
59
|
+
target_count?: number;
|
|
60
|
+
};
|
|
61
|
+
[EVENTS.EMAIL_DELIVERED]: EmailEventProperties;
|
|
62
|
+
[EVENTS.EMAIL_OPENED]: EmailEventProperties;
|
|
63
|
+
[EVENTS.EMAIL_CLICKED]: EmailEventProperties;
|
|
64
|
+
[EVENTS.EMAIL_BOUNCED]: EmailEventProperties;
|
|
65
|
+
[EVENTS.EMAIL_COMPLAINED]: EmailEventProperties;
|
|
35
66
|
[EVENTS.SIGNED_UP]: {
|
|
36
67
|
method: 'email' | 'google';
|
|
37
68
|
role: string;
|
|
@@ -62,9 +93,6 @@ export interface EventProperties {
|
|
|
62
93
|
[EVENTS.ANALYTICS_VIEWED]: {
|
|
63
94
|
scope?: 'overview' | 'account' | 'post';
|
|
64
95
|
};
|
|
65
|
-
[EVENTS.TRIAL_STARTED]: {
|
|
66
|
-
plan: string;
|
|
67
|
-
};
|
|
68
96
|
[EVENTS.LIMIT_REACHED]: {
|
|
69
97
|
limit: 'x_link_posts' | 'accounts';
|
|
70
98
|
plan: string;
|
|
@@ -94,6 +122,5 @@ export declare const PERSON_PROPS: {
|
|
|
94
122
|
readonly ROLE: "role";
|
|
95
123
|
readonly PLAN: "plan";
|
|
96
124
|
readonly ACCOUNT_COUNT: "account_count";
|
|
97
|
-
readonly TRIAL_ENDS_AT: "trial_ends_at";
|
|
98
125
|
};
|
|
99
126
|
export type PersonPropKey = (typeof PERSON_PROPS)[keyof typeof PERSON_PROPS];
|
package/dist/analytics/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Single source of truth for event names, event property shapes, and person
|
|
6
6
|
* property keys across ALL surfaces (frontend, backend, landing, future MCP).
|
|
7
|
-
* Spec:
|
|
7
|
+
* Spec: ad2app-specs/analytics-posthog/posthog-implementation.md §3.
|
|
8
8
|
*
|
|
9
9
|
* Naming convention: snake_case, object_verb_past_tense.
|
|
10
10
|
* Do NOT rename events after they ship — historical data does not migrate.
|
|
@@ -15,6 +15,17 @@ exports.PERSON_PROPS = exports.EVENTS = void 0;
|
|
|
15
15
|
exports.EVENTS = {
|
|
16
16
|
// Acquisition (landing)
|
|
17
17
|
LANDING_CTA_CLICKED: 'landing_cta_clicked',
|
|
18
|
+
CONNECT_ARTIFACT_COPIED: 'connect_artifact_copied', // /connect install-snippet copy
|
|
19
|
+
// Free Tier (skills lead magnet + connect wall)
|
|
20
|
+
FREE_SKILLS_REQUESTED: 'free_skills_requested', // email submitted for the free skills (AD2-889)
|
|
21
|
+
ACCOUNT_CONNECT_BLOCKED: 'account_connect_blocked', // free user hits the connect wall (AD2-892)
|
|
22
|
+
MCP_POST_BLOCKED_FREE_TIER: 'mcp_post_blocked_free_tier', // free user tries to post in the MCP (AD2-898)
|
|
23
|
+
// Email lifecycle (Resend webhook -> PostHog, AD2-894)
|
|
24
|
+
EMAIL_DELIVERED: 'email_delivered',
|
|
25
|
+
EMAIL_OPENED: 'email_opened',
|
|
26
|
+
EMAIL_CLICKED: 'email_clicked',
|
|
27
|
+
EMAIL_BOUNCED: 'email_bounced',
|
|
28
|
+
EMAIL_COMPLAINED: 'email_complained',
|
|
18
29
|
// Activation (web app)
|
|
19
30
|
SIGNED_UP: 'signed_up', // server-owned (backend, on user creation)
|
|
20
31
|
PROFILE_COMPLETED: 'profile_completed', // the /complete-profile step (influencers)
|
|
@@ -24,8 +35,7 @@ exports.EVENTS = {
|
|
|
24
35
|
POST_SCHEDULED: 'post_scheduled',
|
|
25
36
|
POST_PUBLISHED: 'post_published', // server-confirmed (backend)
|
|
26
37
|
ANALYTICS_VIEWED: 'analytics_viewed',
|
|
27
|
-
// Revenue (backend = source of truth)
|
|
28
|
-
TRIAL_STARTED: 'trial_started',
|
|
38
|
+
// Revenue (backend = source of truth). Paid-only product: no trial.
|
|
29
39
|
LIMIT_REACHED: 'limit_reached', // cap hit (e.g. X link-post cap) — the upsell trigger
|
|
30
40
|
UPGRADE_CLICKED: 'upgrade_clicked', // client intent
|
|
31
41
|
CHECKOUT_STARTED: 'checkout_started',
|
|
@@ -36,7 +46,6 @@ exports.EVENTS = {
|
|
|
36
46
|
exports.PERSON_PROPS = {
|
|
37
47
|
EMAIL: 'email',
|
|
38
48
|
ROLE: 'role',
|
|
39
|
-
PLAN: 'plan', // '
|
|
49
|
+
PLAN: 'plan', // 'free' | 'starter' | 'pro' (SchedulingSubscriptionTier)
|
|
40
50
|
ACCOUNT_COUNT: 'account_count',
|
|
41
|
-
TRIAL_ENDS_AT: 'trial_ends_at',
|
|
42
51
|
};
|
package/dist/api/apiDriver.js
CHANGED
|
@@ -37,13 +37,27 @@ async function fetchCall(args) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
const res = await fetch(fullPath, init);
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Read the body ONCE as text. res.json() returns a promise, so the old
|
|
41
|
+
// synchronous try/catch around it never caught a parse failure (the text()
|
|
42
|
+
// fallback was dead code), and a plain-text error body threw inside res.json().
|
|
43
|
+
const rawBody = await res.text();
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
let message = rawBody;
|
|
46
|
+
try {
|
|
47
|
+
message = JSON.stringify(JSON.parse(rawBody));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// non-JSON error body: keep the raw text
|
|
51
|
+
}
|
|
52
|
+
throw new Error(message || `Request failed with status ${res.status}`);
|
|
53
|
+
}
|
|
54
|
+
if (!rawBody)
|
|
55
|
+
return undefined;
|
|
42
56
|
try {
|
|
43
|
-
return
|
|
57
|
+
return JSON.parse(rawBody);
|
|
44
58
|
}
|
|
45
59
|
catch {
|
|
46
|
-
return
|
|
60
|
+
return rawBody;
|
|
47
61
|
}
|
|
48
62
|
}
|
|
49
63
|
function apiDriver(baseUrl) {
|
|
@@ -64,6 +78,7 @@ function apiDriver(baseUrl) {
|
|
|
64
78
|
...args[1],
|
|
65
79
|
path: args[0],
|
|
66
80
|
method: "put",
|
|
81
|
+
baseUrl,
|
|
67
82
|
}),
|
|
68
83
|
patch: (...args) => fetchCall({
|
|
69
84
|
...args[1],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ad2app-lib",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"./types": "./dist/types/index.js",
|
|
10
10
|
"./api": "./dist/api/index.js",
|
|
11
11
|
"./legal": "./dist/legal/index.js",
|
|
12
|
-
"./analytics": "./dist/analytics/index.js"
|
|
12
|
+
"./analytics": "./dist/analytics/index.js",
|
|
13
|
+
"./brand": "./brand/brand.mjs"
|
|
13
14
|
},
|
|
14
15
|
"typesVersions": {
|
|
15
16
|
"*": {
|
|
@@ -28,6 +29,9 @@
|
|
|
28
29
|
"analytics": [
|
|
29
30
|
"dist/analytics/index.d.ts"
|
|
30
31
|
],
|
|
32
|
+
"brand": [
|
|
33
|
+
"brand/brand.d.ts"
|
|
34
|
+
],
|
|
31
35
|
"*": [
|
|
32
36
|
"dist/index.d.ts"
|
|
33
37
|
]
|
|
@@ -9,6 +9,7 @@ import assert from "node:assert/strict";
|
|
|
9
9
|
import { test } from "node:test";
|
|
10
10
|
|
|
11
11
|
import { EVENTS, PERSON_PROPS } from "./index";
|
|
12
|
+
import type { EventProperties, EmailEventProperties } from "./index";
|
|
12
13
|
|
|
13
14
|
const SNAKE_CASE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
|
|
14
15
|
|
|
@@ -49,3 +50,72 @@ test("critical person property keys are locked", () => {
|
|
|
49
50
|
assert.equal(PERSON_PROPS.PLAN, "plan");
|
|
50
51
|
assert.equal(PERSON_PROPS.ROLE, "role");
|
|
51
52
|
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* AD2-952 — property-shape coverage. The compile-time 1:1 guarantee between EVENTS
|
|
56
|
+
* and EventProperties lives in index.ts (enforced by `tsc --noEmit`); these runtime
|
|
57
|
+
* tests lock the same contract under `npm test` and pin the email-lifecycle shape.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
// One witness payload per event, typed by EventProperties so the map is forced to
|
|
61
|
+
// carry EXACTLY the EventProperties keys; the assertions below verify it stays 1:1
|
|
62
|
+
// with the runtime EVENTS const.
|
|
63
|
+
const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E] } = {
|
|
64
|
+
[EVENTS.LANDING_CTA_CLICKED]: { location: "hero" },
|
|
65
|
+
[EVENTS.CONNECT_ARTIFACT_COPIED]: { target: "app" },
|
|
66
|
+
[EVENTS.FREE_SKILLS_REQUESTED]: { source: "landing" },
|
|
67
|
+
[EVENTS.ACCOUNT_CONNECT_BLOCKED]: {},
|
|
68
|
+
[EVENTS.MCP_POST_BLOCKED_FREE_TIER]: {},
|
|
69
|
+
[EVENTS.EMAIL_DELIVERED]: {},
|
|
70
|
+
[EVENTS.EMAIL_OPENED]: {},
|
|
71
|
+
[EVENTS.EMAIL_CLICKED]: {},
|
|
72
|
+
[EVENTS.EMAIL_BOUNCED]: {},
|
|
73
|
+
[EVENTS.EMAIL_COMPLAINED]: {},
|
|
74
|
+
[EVENTS.SIGNED_UP]: { method: "email", role: "creator" },
|
|
75
|
+
[EVENTS.PROFILE_COMPLETED]: { role: "creator" },
|
|
76
|
+
[EVENTS.LOGGED_IN]: {},
|
|
77
|
+
[EVENTS.SOCIAL_ACCOUNT_CONNECTED]: { platform: "instagram" },
|
|
78
|
+
[EVENTS.COMPOSE_STARTED]: {},
|
|
79
|
+
[EVENTS.POST_SCHEDULED]: { platforms: ["instagram"], platform_count: 1 },
|
|
80
|
+
[EVENTS.POST_PUBLISHED]: { platforms: ["instagram"], platform_count: 1, status: "success" },
|
|
81
|
+
[EVENTS.ANALYTICS_VIEWED]: {},
|
|
82
|
+
[EVENTS.LIMIT_REACHED]: { limit: "accounts", plan: "free", cap: 1 },
|
|
83
|
+
[EVENTS.UPGRADE_CLICKED]: { surface: "compose" },
|
|
84
|
+
[EVENTS.CHECKOUT_STARTED]: { plan: "starter" },
|
|
85
|
+
[EVENTS.SUBSCRIPTION_STARTED]: { plan: "starter" },
|
|
86
|
+
[EVENTS.SUBSCRIPTION_CANCELED]: { plan: "starter" },
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
test("EVENTS values are 1:1 with EventProperties keys (no missing or typo'd event)", () => {
|
|
90
|
+
const eventValues = Object.values(EVENTS).sort();
|
|
91
|
+
const propertyKeys = Object.keys(EVENT_PROPERTY_WITNESS).sort();
|
|
92
|
+
assert.deepEqual(propertyKeys, eventValues);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("the 5 EMAIL_* events are locked and share the EmailEventProperties shape (AD2-894)", () => {
|
|
96
|
+
const emailEvents = [
|
|
97
|
+
EVENTS.EMAIL_DELIVERED,
|
|
98
|
+
EVENTS.EMAIL_OPENED,
|
|
99
|
+
EVENTS.EMAIL_CLICKED,
|
|
100
|
+
EVENTS.EMAIL_BOUNCED,
|
|
101
|
+
EVENTS.EMAIL_COMPLAINED,
|
|
102
|
+
];
|
|
103
|
+
assert.deepEqual(emailEvents, [
|
|
104
|
+
"email_delivered",
|
|
105
|
+
"email_opened",
|
|
106
|
+
"email_clicked",
|
|
107
|
+
"email_bounced",
|
|
108
|
+
"email_complained",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
// The shared shape exposes only email_id / subject / link (all optional).
|
|
112
|
+
const allowedKeys = ["email_id", "link", "subject"];
|
|
113
|
+
const sample: EmailEventProperties = { email_id: "re_1", subject: "Welcome", link: "https://ad2.app" };
|
|
114
|
+
assert.deepEqual(Object.keys(sample).sort(), allowedKeys);
|
|
115
|
+
for (const event of emailEvents) {
|
|
116
|
+
const witness = EVENT_PROPERTY_WITNESS[event];
|
|
117
|
+
for (const key of Object.keys(witness)) {
|
|
118
|
+
assert.ok(allowedKeys.includes(key), `unexpected key on ${event}: ${key}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
package/src/analytics/index.ts
CHANGED
|
@@ -123,3 +123,28 @@ export const PERSON_PROPS = {
|
|
|
123
123
|
} as const;
|
|
124
124
|
|
|
125
125
|
export type PersonPropKey = (typeof PERSON_PROPS)[keyof typeof PERSON_PROPS];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compile-time taxonomy contract (AD2-952). These are pure type-level assertions:
|
|
129
|
+
* if EVENTS and EventProperties ever drift, the `tsc --noEmit` CI gate fails the
|
|
130
|
+
* build — a renamed, typo'd, or unmapped event cannot ship silently. They emit no
|
|
131
|
+
* runtime code and, being non-exported, do not appear in the package's public types.
|
|
132
|
+
*/
|
|
133
|
+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
134
|
+
? true
|
|
135
|
+
: false;
|
|
136
|
+
type Expect<T extends true> = T;
|
|
137
|
+
|
|
138
|
+
// AC2: every EVENTS value maps to exactly one EventProperties entry — no missing
|
|
139
|
+
// property shape, no orphan or typo'd key (1:1).
|
|
140
|
+
type _EventsAreOneToOneWithProperties = Expect<Equal<EventName, keyof EventProperties>>;
|
|
141
|
+
|
|
142
|
+
// AC3: the five EMAIL_* lifecycle events all carry the shared EmailEventProperties
|
|
143
|
+
// shape (the Resend -> PostHog bridge contract, AD2-894).
|
|
144
|
+
type EmailLifecycleEvent =
|
|
145
|
+
| (typeof EVENTS)['EMAIL_DELIVERED']
|
|
146
|
+
| (typeof EVENTS)['EMAIL_OPENED']
|
|
147
|
+
| (typeof EVENTS)['EMAIL_CLICKED']
|
|
148
|
+
| (typeof EVENTS)['EMAIL_BOUNCED']
|
|
149
|
+
| (typeof EVENTS)['EMAIL_COMPLAINED'];
|
|
150
|
+
type _EmailEventsUseSharedShape = Expect<Equal<EventProperties[EmailLifecycleEvent], EmailEventProperties>>;
|