ad2app-lib 1.25.0 → 1.28.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/brand/brand.d.ts CHANGED
@@ -23,12 +23,42 @@ export interface ColorTokens {
23
23
  error: string;
24
24
  /** The PRODUCT blue, deliberately distinct from the marketing primary. */
25
25
  productPrimary: string;
26
+ /**
27
+ * v5 accent ramp (spec 124): the landing's color-field language — tonal
28
+ * hi/lo stops for ramped fields, tint for card fills. WHITE text on
29
+ * magenta/violet fields, DARK text on lime/aqua.
30
+ */
31
+ v5: {
32
+ magenta: string;
33
+ magentaHi: string;
34
+ magentaLo: string;
35
+ magentaTint: string;
36
+ violet: string;
37
+ violetHi: string;
38
+ violetLo: string;
39
+ lime: string;
40
+ limeHi: string;
41
+ limeLo: string;
42
+ aqua: string;
43
+ aquaHi: string;
44
+ aquaLo: string;
45
+ borderStrong: string;
46
+ };
26
47
  }
27
48
  export const color: ColorTokens;
28
49
 
50
+ export interface DisplayFontTokens {
51
+ /** Direction-5 display face (spec 099 US5, OFL). Display roles only. */
52
+ family: string;
53
+ weights: readonly number[];
54
+ /** Below this size the face is illegible by design — fall back to `family`. */
55
+ minSizePx: number;
56
+ role: string;
57
+ }
29
58
  export interface FontTokens {
30
59
  family: string;
31
60
  fallback: string;
61
+ display: DisplayFontTokens;
32
62
  }
33
63
  export const font: FontTokens;
34
64
 
package/brand/brand.mjs CHANGED
@@ -21,11 +21,46 @@ export const color = {
21
21
  successText: '#0f7a33',
22
22
  error: '#ff3b30',
23
23
  productPrimary: '#0000ff', // the PRODUCT blue, deliberately distinct from marketing #0042ff
24
+
25
+ // v5 accent ramp (spec 124): the landing's color-field language — tonal hi/lo stops for
26
+ // ramped fields, tint for card fills. Mirrors ad2app-landing globals.css EXACTLY (the
27
+ // contract test pins equality). Contrast rule from the landing: WHITE text on
28
+ // magenta/violet fields, DARK text on lime/aqua (lime additionally wants heavy grain).
29
+ v5: {
30
+ magenta: '#ff2d78',
31
+ magentaHi: '#ff5c95',
32
+ magentaLo: '#e0175f',
33
+ magentaTint: '#fff0f5',
34
+ violet: '#7c3aed',
35
+ violetHi: '#9d6ff5',
36
+ violetLo: '#6323d0',
37
+ lime: '#d9f24b',
38
+ limeHi: '#e9fa74',
39
+ limeLo: '#becc25',
40
+ aqua: '#4dd6f5',
41
+ aquaHi: '#7ce4fa',
42
+ aquaLo: '#22b8dd',
43
+ borderStrong: '#8e8e9a',
44
+ },
24
45
  };
25
46
 
26
47
  export const font = {
27
48
  family: 'Funnel Sans',
28
49
  fallback: '-apple-system, Segoe UI, Roboto, Helvetica Neue, sans-serif',
50
+ // The Direction-5 DISPLAY face (spec 099 US5; Jan-picked from a five-font exploration; OFL).
51
+ // It lived only in the landing (SiteShell.tsx wires it through next/font, globals.css exposes it
52
+ // as --font-display) until 2026-08-12, when a rendered story card was judged against the live
53
+ // landing and read as another company's: every headline in the motion lane was being set in the
54
+ // BODY face, because this token had no home here. Display roles ONLY — H1/H2 and big numerals.
55
+ // Below ~24px Unbounded is illegible by design, so body and UI stay Funnel Sans.
56
+ // A non-Next consumer (the Remotion lane) must self-host the same family; there is no CDN at
57
+ // render time.
58
+ display: {
59
+ family: 'Unbounded',
60
+ weights: [500, 700],
61
+ minSizePx: 24,
62
+ role: 'display only: h1, h2, big numerals — never body or UI',
63
+ },
29
64
  };
30
65
 
31
66
  // Social canvas sizes (px), the canonical ad set.
@@ -15,11 +15,18 @@ export declare const EVENTS: {
15
15
  readonly FREE_SKILLS_REQUESTED: "free_skills_requested";
16
16
  readonly ACCOUNT_CONNECT_BLOCKED: "account_connect_blocked";
17
17
  readonly MCP_POST_BLOCKED_FREE_TIER: "mcp_post_blocked_free_tier";
18
+ readonly EMAIL_SENT: "email_sent";
18
19
  readonly EMAIL_DELIVERED: "email_delivered";
19
20
  readonly EMAIL_OPENED: "email_opened";
20
21
  readonly EMAIL_CLICKED: "email_clicked";
21
22
  readonly EMAIL_BOUNCED: "email_bounced";
22
23
  readonly EMAIL_COMPLAINED: "email_complained";
24
+ readonly EMAIL_FAILED: "email_failed";
25
+ readonly EMAIL_DELIVERY_DELAYED: "email_delivery_delayed";
26
+ readonly EMAIL_UNSUBSCRIBED: "email_unsubscribed";
27
+ readonly EMAIL_SUPPRESSED: "email_suppressed";
28
+ readonly PLAYBOOK_OPENED: "playbook_opened";
29
+ readonly PLAYBOOK_DOWNLOADED: "playbook_downloaded";
23
30
  readonly SIGNED_UP: "signed_up";
24
31
  readonly PROFILE_COMPLETED: "profile_completed";
25
32
  readonly LOGGED_IN: "logged_in";
@@ -66,12 +73,56 @@ export declare const EVENTS: {
66
73
  readonly NOTIFICATION_PREFERENCES_UPDATED: "notification_preferences_updated";
67
74
  };
68
75
  export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
69
- /** Shared shape for the Resend email lifecycle events (AD2-894). */
70
- export interface EmailEventProperties {
76
+ /** Which email program a send belongs to (spec 120). Wire values — do not rename. */
77
+ export type EmailStream = 'transactional' | 'nurture' | 'broadcast';
78
+ /** How an unsubscribe arrived (spec 120). */
79
+ export type UnsubscribeMechanism = 'one_click' | 'footer_link';
80
+ /** Why an address entered the suppression list (spec 120). */
81
+ export type SuppressionReason = 'hard_bounce' | 'complaint';
82
+ /**
83
+ * BASE property set every email event carries (spec 120 T001 contract). All
84
+ * fields are optional AT THE TYPE LEVEL only because legacy tag-less sends
85
+ * resolve by email lookup and legitimately lack stream/trigger context (they
86
+ * carry `unjoined: true` instead); the backend tests enforce the full set on
87
+ * every NEW send. Identity (distinct_id) rides the capture call, not this shape.
88
+ */
89
+ export interface EmailEventBaseProperties {
90
+ email_stream?: EmailStream;
91
+ trigger_key?: string;
92
+ variant?: string;
93
+ has_attachment?: boolean;
71
94
  email_id?: string;
95
+ unjoined?: boolean;
96
+ }
97
+ /**
98
+ * Shared shape for the Resend email lifecycle events (AD2-894; widened by spec
99
+ * 120 to carry the base set — additive, wire names unchanged).
100
+ */
101
+ export interface EmailEventProperties extends EmailEventBaseProperties {
72
102
  subject?: string;
73
103
  link?: string;
74
104
  }
105
+ /**
106
+ * Canonical email event property KEYS (spec 120 T001). Emitters and tests
107
+ * reference these instead of retyping strings — the single home for the names.
108
+ */
109
+ export declare const EMAIL_PROPS: {
110
+ readonly EMAIL_STREAM: "email_stream";
111
+ readonly TRIGGER_KEY: "trigger_key";
112
+ readonly VARIANT: "variant";
113
+ readonly HAS_ATTACHMENT: "has_attachment";
114
+ readonly EMAIL_ID: "email_id";
115
+ readonly UNJOINED: "unjoined";
116
+ readonly SUBJECT: "subject";
117
+ readonly LINK: "link";
118
+ readonly BOUNCE_TYPE: "bounce_type";
119
+ readonly MPP_SUSPECTED: "mpp_suspected";
120
+ readonly REASON: "reason";
121
+ readonly MECHANISM: "mechanism";
122
+ readonly HOURS_SINCE_SEND: "hours_since_send";
123
+ readonly EDITION_VERSION: "edition_version";
124
+ readonly FORMAT: "format";
125
+ };
75
126
  /**
76
127
  * Where a paywall / upgrade / checkout moment was triggered from (080). A named,
77
128
  * stable, closed set so the revenue funnel can attribute conversions to their
@@ -113,11 +164,34 @@ export interface EventProperties {
113
164
  [EVENTS.MCP_POST_BLOCKED_FREE_TIER]: {
114
165
  target_count?: number;
115
166
  };
167
+ [EVENTS.EMAIL_SENT]: EmailEventProperties;
116
168
  [EVENTS.EMAIL_DELIVERED]: EmailEventProperties;
117
- [EVENTS.EMAIL_OPENED]: EmailEventProperties;
169
+ [EVENTS.EMAIL_OPENED]: EmailEventProperties & {
170
+ mpp_suspected?: boolean;
171
+ };
118
172
  [EVENTS.EMAIL_CLICKED]: EmailEventProperties;
119
- [EVENTS.EMAIL_BOUNCED]: EmailEventProperties;
173
+ [EVENTS.EMAIL_BOUNCED]: EmailEventProperties & {
174
+ bounce_type?: string;
175
+ };
120
176
  [EVENTS.EMAIL_COMPLAINED]: EmailEventProperties;
177
+ [EVENTS.EMAIL_FAILED]: EmailEventProperties & {
178
+ reason?: string;
179
+ };
180
+ [EVENTS.EMAIL_DELIVERY_DELAYED]: EmailEventProperties;
181
+ [EVENTS.EMAIL_UNSUBSCRIBED]: EmailEventProperties & {
182
+ mechanism?: UnsubscribeMechanism;
183
+ hours_since_send?: number;
184
+ };
185
+ [EVENTS.EMAIL_SUPPRESSED]: EmailEventProperties & {
186
+ reason?: SuppressionReason;
187
+ };
188
+ [EVENTS.PLAYBOOK_OPENED]: {
189
+ edition_version?: string;
190
+ };
191
+ [EVENTS.PLAYBOOK_DOWNLOADED]: {
192
+ edition_version?: string;
193
+ format?: string;
194
+ };
121
195
  [EVENTS.SIGNED_UP]: {
122
196
  method: 'email' | 'google';
123
197
  role: 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.EVENTS = void 0;
13
+ exports.PERSON_PROPS = exports.EMAIL_PROPS = exports.EVENTS = void 0;
14
14
  /** Canonical PostHog event names. */
15
15
  exports.EVENTS = {
16
16
  // Acquisition (landing)
@@ -20,12 +20,23 @@ exports.EVENTS = {
20
20
  FREE_SKILLS_REQUESTED: 'free_skills_requested', // email submitted for the free skills (AD2-889)
21
21
  ACCOUNT_CONNECT_BLOCKED: 'account_connect_blocked', // free user hits the connect wall (AD2-892)
22
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)
23
+ // Email lifecycle (Resend webhook -> PostHog, AD2-894; extended by spec 120:
24
+ // send-time denominator + failure/delay mappings + list-hygiene events, all
25
+ // carrying the EmailEventBaseProperties set per
26
+ // specs/120-email-program-v2/contracts/email-event-properties.md)
27
+ EMAIL_SENT: 'email_sent', // send-time, at the ResendService choke points (the denominator)
24
28
  EMAIL_DELIVERED: 'email_delivered',
25
29
  EMAIL_OPENED: 'email_opened',
26
30
  EMAIL_CLICKED: 'email_clicked',
27
31
  EMAIL_BOUNCED: 'email_bounced',
28
32
  EMAIL_COMPLAINED: 'email_complained',
33
+ EMAIL_FAILED: 'email_failed', // webhook email.failed
34
+ EMAIL_DELIVERY_DELAYED: 'email_delivery_delayed', // webhook email.delivery_delayed
35
+ EMAIL_UNSUBSCRIBED: 'email_unsubscribed', // one-click header or footer link
36
+ EMAIL_SUPPRESSED: 'email_suppressed', // suppression-list upsert (hard bounce / complaint)
37
+ // Posting Playbook (spec 120 US2) — hosted artifact engagement
38
+ PLAYBOOK_OPENED: 'playbook_opened',
39
+ PLAYBOOK_DOWNLOADED: 'playbook_downloaded',
29
40
  // Activation (web app)
30
41
  SIGNED_UP: 'signed_up', // server-owned (backend, on user creation)
31
42
  PROFILE_COMPLETED: 'profile_completed', // the /complete-profile step (influencers)
@@ -86,6 +97,27 @@ exports.EVENTS = {
86
97
  // Settings.
87
98
  NOTIFICATION_PREFERENCES_UPDATED: 'notification_preferences_updated',
88
99
  };
100
+ /**
101
+ * Canonical email event property KEYS (spec 120 T001). Emitters and tests
102
+ * reference these instead of retyping strings — the single home for the names.
103
+ */
104
+ exports.EMAIL_PROPS = {
105
+ EMAIL_STREAM: 'email_stream',
106
+ TRIGGER_KEY: 'trigger_key',
107
+ VARIANT: 'variant',
108
+ HAS_ATTACHMENT: 'has_attachment',
109
+ EMAIL_ID: 'email_id',
110
+ UNJOINED: 'unjoined',
111
+ SUBJECT: 'subject',
112
+ LINK: 'link',
113
+ BOUNCE_TYPE: 'bounce_type',
114
+ MPP_SUSPECTED: 'mpp_suspected',
115
+ REASON: 'reason',
116
+ MECHANISM: 'mechanism',
117
+ HOURS_SINCE_SEND: 'hours_since_send',
118
+ EDITION_VERSION: 'edition_version',
119
+ FORMAT: 'format',
120
+ };
89
121
  /** Canonical person property keys (set via identify / $set). */
90
122
  exports.PERSON_PROPS = {
91
123
  EMAIL: 'email',
@@ -3,6 +3,14 @@ type HttpMethod = "get" | "post" | "put" | "PATCH" | "delete";
3
3
  interface DriverConfig {
4
4
  apiUrl: string;
5
5
  getHeaders?: () => HeadersInit;
6
+ /**
7
+ * Forwarded verbatim to `fetch`'s own `credentials` option on every call
8
+ * (AD2-1297: the frontend needs `'include'` so the backend's HttpOnly
9
+ * session cookie actually rides along on a cross-subdomain request).
10
+ * Undefined preserves the previous, unconfigured behavior (the browser's
11
+ * `fetch` default, `'same-origin'`) for any other consumer of this driver.
12
+ */
13
+ credentials?: RequestCredentials;
6
14
  }
7
15
  /**
8
16
  * Thrown by `fetchCall` for any non-2xx response.
@@ -47,6 +47,7 @@ async function fetchCall(args) {
47
47
  const init = {
48
48
  method,
49
49
  headers,
50
+ ...(CONFIG?.credentials ? { credentials: CONFIG.credentials } : {}),
50
51
  };
51
52
  if (params?.body) {
52
53
  if (transformers?.body) {
@@ -23,7 +23,7 @@ exports.TERMS_SECTIONS = [
23
23
  { text: 'Media and content file management.' },
24
24
  { text: 'Optional connection of third-party AI assistants and tools that support the Model Context Protocol (MCP), letting you manage scheduling, posting, and analytics from within a tool of your choice. See Section 13 and our {PRIVACY}.' },
25
25
  ] },
26
- { kind: 'p', text: 'The Service is offered on three plans: **Free** (connecting new social accounts and publishing are not available on this plan; if you previously had a paid plan, your previously-connected accounts, post history, and analytics also become inaccessible while you are on the Free plan. That data is retained, not deleted, and becomes available again if you resubscribe), **Starter**, and **Pro** (Starter and Pro differ only in how many social accounts you may connect; all other features are identical). Current plan details and pricing are shown in the app and on our pricing page.' },
26
+ { kind: 'p', text: 'The Service is offered on three plans: **Free** (connecting new social accounts and publishing are not available on this plan; if you previously had a paid plan or a beta place, your previously-connected accounts, post history, and analytics remain visible to you in read-only form. That data is retained, not deleted, and full access returns if you subscribe), **Starter**, and **Pro** (Starter and Pro differ only in how many social accounts you may connect; all other features are identical). Current plan details and pricing are shown in the app and on our pricing page.' },
27
27
  { kind: 'p', text: 'Not all features described in these Terms are available in every plan. We may modify the Service where this is not necessary to keep it conforming with the contract **only for a valid reason stated here**: to comply with law or a decision of a court or authority; to maintain or restore security; to reflect a change in the third-party platforms we integrate with; or to replace a feature with an equivalent or better one. Where a modification negatively and more than minorly affects your access to or use of the Service, we will notify you on a durable medium (such as email) in advance, describing the change and when it takes effect, and, **if you are a consumer, you may terminate the contract free of charge within 30 days of the later of the date the change is made and the date you are informed of it**, with a pro-rata refund of any prepaid Fees for the unused period. This applies whether you are on a paid plan, the Free plan or the private beta.' },
28
28
  ],
29
29
  },
@@ -35,7 +35,7 @@ exports.TERMS_SECTIONS = [
35
35
  { kind: 'ul', items: [
36
36
  { text: 'The beta is **free of charge**. No payment is due, no payment method is collected, and your beta place never converts into a paid plan automatically.' },
37
37
  { text: 'The beta runs for a **fixed period**, stated to you before you accept and confirmed in the message we send you when you accept. Access begins when you accept and **ends automatically on the stated end date**. That end date is the agreed duration of the beta, not a change we make to the contract later.' },
38
- { text: 'When the beta period ends, **your account moves to the Free plan**. Nothing is charged and nothing is deleted. On the Free plan, connecting new social accounts and publishing are unavailable, and the social accounts, post history and analytics from your beta period become inaccessible while you remain on the Free plan. That data is **retained, not deleted**, and becomes available again if you subscribe to a paid plan. You can also ask us to export it at any time, not only when you leave (Section 12).' },
38
+ { text: 'When the beta period ends, **your account moves to the Free plan**. Nothing is charged and nothing is deleted. On the Free plan, connecting new social accounts and publishing are unavailable, but the social accounts, post history and analytics from your beta period **remain visible to you in read-only form**. That data is retained, not deleted, and full access returns if you subscribe to a paid plan. You can also ask us to export it at any time, not only when you leave (Section 12).' },
39
39
  { text: 'We will remind you before the beta period ends and tell you when it has ended. Neither message is a condition of the end date taking effect: the end date is already part of these Terms.' },
40
40
  { text: 'You may leave the beta at any time, for any reason, at no cost, by closing your account (Section 12) or by writing to us at {EMAIL}.' },
41
41
  { text: 'Your **statutory right of withdrawal (Section 8a) applies to the beta contract in full**, exactly as it applies to a paid plan. Nothing in this section limits it.' },
@@ -38,7 +38,7 @@ exports.TERMS_SECTIONS_PL = [
38
38
  { text: 'Zarządzanie plikami multimedialnymi i treściami.' },
39
39
  { text: 'Opcjonalne podłączenie zewnętrznych asystentów AI i narzędzi obsługujących Model Context Protocol (MCP), pozwalające Ci zarządzać planowaniem, publikowaniem i statystykami z poziomu wybranego przez Ciebie narzędzia. Zobacz sekcję 13 oraz naszą {PRIVACY}.' },
40
40
  ] },
41
- { kind: 'p', text: 'Usługa jest oferowana w trzech planach: **Free** (na tym planie łączenie nowych kont społecznościowych i publikowanie są niedostępne; jeżeli wcześniej korzystałeś z planu płatnego, wcześniej połączone konta, historia postów i statystyki również stają się niedostępne, dopóki pozostajesz na planie Free, przy czym dane te są zachowywane, a nie usuwane, i stają się ponownie dostępne, jeżeli ponownie wykupisz subskrypcję), **Starter** i **Pro** (Starter i Pro różnią się wyłącznie liczbą kont społecznościowych, które możesz połączyć; wszystkie pozostałe funkcje są identyczne). Aktualne szczegóły planów i ceny są pokazane w aplikacji oraz na naszej stronie z cennikiem.' },
41
+ { kind: 'p', text: 'Usługa jest oferowana w trzech planach: **Free** (na tym planie łączenie nowych kont społecznościowych i publikowanie są niedostępne; jeżeli wcześniej korzystałeś z planu płatnego albo z miejsca w wersji beta, wcześniej połączone konta, historia postów i statystyki pozostają dla Ciebie widoczne w trybie podglądu. Dane te są zachowywane, a nie usuwane, a pełny dostęp wraca, jeżeli wykupisz subskrypcję), **Starter** i **Pro** (Starter i Pro różnią się wyłącznie liczbą kont społecznościowych, które możesz połączyć; wszystkie pozostałe funkcje są identyczne). Aktualne szczegóły planów i ceny są pokazane w aplikacji oraz na naszej stronie z cennikiem.' },
42
42
  { kind: 'p', text: 'Nie wszystkie funkcje opisane w niniejszym Regulaminie są dostępne w każdym planie. Możemy zmodyfikować Usługę w zakresie, w jakim nie jest to niezbędne do zachowania jej zgodności z umową, **wyłącznie z ważnej przyczyny wskazanej tutaj**: aby zapewnić zgodność z prawem albo z orzeczeniem sądu lub decyzją organu; aby utrzymać lub przywrócić bezpieczeństwo; aby odzwierciedlić zmianę w platformach zewnętrznych, z którymi się integrujemy; albo aby zastąpić funkcję funkcją równoważną lub lepszą. Jeżeli modyfikacja negatywnie i w stopniu więcej niż nieznacznym wpływa na Twój dostęp do Usługi lub korzystanie z niej, powiadomimy Cię o tym z wyprzedzeniem na trwałym nośniku (na przykład e-mailem), opisując zmianę oraz termin jej wejścia w życie, a **jeżeli jesteś konsumentem, możesz wypowiedzieć umowę bez ponoszenia kosztów w terminie 30 dni od późniejszej z dat: dnia dokonania zmiany albo dnia poinformowania Cię o niej**, z proporcjonalnym zwrotem Opłat zapłaconych z góry za niewykorzystany okres. Dotyczy to zarówno planu płatnego, jak i planu Free oraz prywatnej wersji beta.' },
43
43
  ],
44
44
  },
@@ -50,7 +50,7 @@ exports.TERMS_SECTIONS_PL = [
50
50
  { kind: 'ul', items: [
51
51
  { text: 'Wersja beta jest **bezpłatna**. Nie jest należna żadna płatność, nie zbieramy metody płatności, a Twoje miejsce w wersji beta nigdy nie przekształca się automatycznie w plan płatny.' },
52
52
  { text: 'Wersja beta trwa przez **okres oznaczony**, podany Ci przed jej przyjęciem i potwierdzony w wiadomości, którą wysyłamy Ci w chwili przyjęcia. Dostęp rozpoczyna się z chwilą przyjęcia i **kończy się automatycznie w podanym dniu zakończenia**. Ten dzień zakończenia to uzgodniony czas trwania wersji beta, a nie zmiana, której dokonujemy w umowie później.' },
53
- { text: 'Gdy okres wersji beta się kończy, **Twoje konto przechodzi na plan Free**. Nic nie zostaje pobrane i nic nie zostaje usunięte. Na planie Free łączenie nowych kont społecznościowych i publikowanie są niedostępne, a konta społecznościowe, historia postów i statystyki z okresu wersji beta stają się niedostępne, dopóki pozostajesz na planie Free. Dane te są **zachowywane, a nie usuwane**, i stają się ponownie dostępne, jeżeli wykupisz plan płatny. Możesz też w każdej chwili poprosić nas o ich wyeksportowanie, nie tylko w chwili odejścia (sekcja 12).' },
53
+ { text: 'Gdy okres wersji beta się kończy, **Twoje konto przechodzi na plan Free**. Nic nie zostaje pobrane i nic nie zostaje usunięte. Na planie Free łączenie nowych kont społecznościowych i publikowanie są niedostępne, ale konta społecznościowe, historia postów i statystyki z okresu wersji beta **pozostają dla Ciebie widoczne w trybie podglądu**. Dane te są zachowywane, a nie usuwane, a pełny dostęp wraca, jeżeli wykupisz plan płatny. Możesz też w każdej chwili poprosić nas o ich wyeksportowanie, nie tylko w chwili odejścia (sekcja 12).' },
54
54
  { text: 'Przypomnimy Ci o zbliżającym się końcu okresu wersji beta i poinformujemy Cię, gdy ten okres się zakończy. Żadna z tych wiadomości nie jest warunkiem skuteczności dnia zakończenia: dzień zakończenia jest już częścią niniejszego Regulaminu.' },
55
55
  { text: 'Możesz opuścić wersję beta w każdej chwili, z dowolnego powodu i bez żadnych kosztów, zamykając konto (sekcja 12) lub pisząc do nas na {EMAIL}.' },
56
56
  { text: 'Twoje **ustawowe prawo odstąpienia od umowy (sekcja 8a) ma w pełni zastosowanie do umowy dotyczącej wersji beta**, dokładnie tak samo jak do planu płatnego. Nic w niniejszej sekcji go nie ogranicza.' },
@@ -28,8 +28,28 @@ export declare class SchedulingPlatformTargetDTO {
28
28
  export declare class SchedulingMediaItemDTO {
29
29
  type: 'image' | 'video';
30
30
  url: string;
31
+ /**
32
+ * Per-image accessibility text (spec 121 US3). Applied by the vendor on the
33
+ * platforms that support it (IG feed/FB/Threads/X ≤1000/LinkedIn/Bluesky/
34
+ * Pinterest ≤500); images only — video items never carry it.
35
+ */
36
+ altText?: string;
31
37
  constructor(data?: Partial<SchedulingMediaItemDTO>);
32
38
  }
39
+ /** TikTok commercial-content disclosure choice (spec 121 US2). Wire values = vendor enum. */
40
+ export type SchedulingTikTokCommercialContentType = 'none' | 'brand_organic' | 'brand_content';
41
+ /**
42
+ * The compose disclosure state (spec 121, AD2-1302): ONE model on the post,
43
+ * mapped per-platform at publish time (IG isAiGenerated / TikTok
44
+ * videoMadeWithAi / YouTube containsSyntheticMedia). Absent-when-off: an unset
45
+ * flag sends no vendor field at all.
46
+ */
47
+ export declare class SchedulingPostDisclosureDTO {
48
+ /** The media contains AI-generated content (media, not captions). */
49
+ aiGenerated?: boolean;
50
+ tiktokCommercial?: SchedulingTikTokCommercialContentType;
51
+ constructor(data?: Partial<SchedulingPostDisclosureDTO>);
52
+ }
33
53
  /** Input DTO for creating a new scheduled or immediate post. */
34
54
  export declare class SchedulingCreatePostDTO {
35
55
  content: string;
@@ -39,6 +59,7 @@ export declare class SchedulingCreatePostDTO {
39
59
  mediaItems?: SchedulingMediaItemDTO[];
40
60
  platformSpecificData?: Record<string, Record<string, unknown>>;
41
61
  tiktokSettings?: Record<string, unknown>;
62
+ disclosure?: SchedulingPostDisclosureDTO;
42
63
  constructor(data?: Partial<SchedulingCreatePostDTO>);
43
64
  }
44
65
  /** Input DTO for updating an existing post's content or scheduled time. */
@@ -134,6 +155,12 @@ export declare class SchedulingPostDTO {
134
155
  likes?: number;
135
156
  comments?: number;
136
157
  shares?: number;
158
+ /**
159
+ * What was SENT at publish time (spec 121) — the persisted disclosure state,
160
+ * never inferred. Absent on posts created before the feature or with no
161
+ * disclosure chosen.
162
+ */
163
+ disclosure?: SchedulingPostDisclosureDTO;
137
164
  constructor(data: SchedulingPostDTO);
138
165
  }
139
166
  /** Query parameters for listing posts with optional filters. */
@@ -6,7 +6,7 @@
6
6
  * and the media items attached to a post.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.SchedulingContentCalendarDTO = exports.SchedulingPostListParamsDTO = exports.SchedulingPostDTO = exports.SchedulingPostTargetDTO = exports.SchedulingPostMediaItemDTO = exports.SchedulingUpdatePostDTO = exports.SchedulingCreatePostDTO = exports.SchedulingMediaItemDTO = exports.SchedulingPlatformTargetDTO = exports.SchedulingPostStatus = void 0;
9
+ exports.SchedulingContentCalendarDTO = exports.SchedulingPostListParamsDTO = exports.SchedulingPostDTO = exports.SchedulingPostTargetDTO = exports.SchedulingPostMediaItemDTO = exports.SchedulingUpdatePostDTO = exports.SchedulingCreatePostDTO = exports.SchedulingPostDisclosureDTO = exports.SchedulingMediaItemDTO = exports.SchedulingPlatformTargetDTO = exports.SchedulingPostStatus = void 0;
10
10
  // ── Enums ─────────────────────────────────────────────────────────────────────
11
11
  var SchedulingPostStatus;
12
12
  (function (SchedulingPostStatus) {
@@ -41,9 +41,25 @@ class SchedulingMediaItemDTO {
41
41
  return;
42
42
  this.type = data.type;
43
43
  this.url = data.url;
44
+ this.altText = data.altText;
44
45
  }
45
46
  }
46
47
  exports.SchedulingMediaItemDTO = SchedulingMediaItemDTO;
48
+ /**
49
+ * The compose disclosure state (spec 121, AD2-1302): ONE model on the post,
50
+ * mapped per-platform at publish time (IG isAiGenerated / TikTok
51
+ * videoMadeWithAi / YouTube containsSyntheticMedia). Absent-when-off: an unset
52
+ * flag sends no vendor field at all.
53
+ */
54
+ class SchedulingPostDisclosureDTO {
55
+ constructor(data) {
56
+ if (!data)
57
+ return;
58
+ this.aiGenerated = data.aiGenerated;
59
+ this.tiktokCommercial = data.tiktokCommercial;
60
+ }
61
+ }
62
+ exports.SchedulingPostDisclosureDTO = SchedulingPostDisclosureDTO;
47
63
  // ── SchedulingCreatePostDTO ───────────────────────────────────────────────────
48
64
  /** Input DTO for creating a new scheduled or immediate post. */
49
65
  class SchedulingCreatePostDTO {
@@ -57,6 +73,7 @@ class SchedulingCreatePostDTO {
57
73
  this.mediaItems = data.mediaItems;
58
74
  this.platformSpecificData = data.platformSpecificData;
59
75
  this.tiktokSettings = data.tiktokSettings;
76
+ this.disclosure = data.disclosure;
60
77
  }
61
78
  }
62
79
  exports.SchedulingCreatePostDTO = SchedulingCreatePostDTO;
@@ -138,6 +155,7 @@ class SchedulingPostDTO {
138
155
  this.likes = data.likes;
139
156
  this.comments = data.comments;
140
157
  this.shares = data.shares;
158
+ this.disclosure = data.disclosure;
141
159
  }
142
160
  }
143
161
  exports.SchedulingPostDTO = SchedulingPostDTO;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ad2app-lib",
3
- "version": "1.25.0",
3
+ "version": "1.28.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "commonjs",
@@ -8,8 +8,14 @@
8
8
  import assert from "node:assert/strict";
9
9
  import { test } from "node:test";
10
10
 
11
- import { EVENTS, PERSON_PROPS } from "./index";
12
- import type { EventProperties, EmailEventProperties, TriggerSource, PublishFailureReason } from "./index";
11
+ import { EVENTS, PERSON_PROPS, EMAIL_PROPS } from "./index";
12
+ import type {
13
+ EventProperties,
14
+ EmailEventProperties,
15
+ EmailEventBaseProperties,
16
+ TriggerSource,
17
+ PublishFailureReason,
18
+ } from "./index";
13
19
 
14
20
  const SNAKE_CASE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
15
21
 
@@ -66,11 +72,18 @@ const EVENT_PROPERTY_WITNESS: { [E in keyof EventProperties]: EventProperties[E]
66
72
  [EVENTS.FREE_SKILLS_REQUESTED]: { source: "landing" },
67
73
  [EVENTS.ACCOUNT_CONNECT_BLOCKED]: {},
68
74
  [EVENTS.MCP_POST_BLOCKED_FREE_TIER]: {},
75
+ [EVENTS.EMAIL_SENT]: {},
69
76
  [EVENTS.EMAIL_DELIVERED]: {},
70
77
  [EVENTS.EMAIL_OPENED]: {},
71
78
  [EVENTS.EMAIL_CLICKED]: {},
72
79
  [EVENTS.EMAIL_BOUNCED]: {},
73
80
  [EVENTS.EMAIL_COMPLAINED]: {},
81
+ [EVENTS.EMAIL_FAILED]: {},
82
+ [EVENTS.EMAIL_DELIVERY_DELAYED]: {},
83
+ [EVENTS.EMAIL_UNSUBSCRIBED]: {},
84
+ [EVENTS.EMAIL_SUPPRESSED]: {},
85
+ [EVENTS.PLAYBOOK_OPENED]: {},
86
+ [EVENTS.PLAYBOOK_DOWNLOADED]: {},
74
87
  [EVENTS.SIGNED_UP]: { method: "email", role: "creator" },
75
88
  [EVENTS.PROFILE_COMPLETED]: { role: "creator" },
76
89
  [EVENTS.LOGGED_IN]: {},
@@ -141,10 +154,20 @@ test("the 5 EMAIL_* events are locked and share the EmailEventProperties shape (
141
154
  "email_complained",
142
155
  ]);
143
156
 
144
- // The shared shape exposes only email_id / subject / link (all optional).
145
- const allowedKeys = ["email_id", "link", "subject"];
157
+ // Spec 120 WIDENED the shared shape with the EmailEventBaseProperties set
158
+ // (additive; the AD2-894 trio of keys is unchanged and still allowed).
159
+ const allowedKeys = [
160
+ "email_id",
161
+ "email_stream",
162
+ "has_attachment",
163
+ "link",
164
+ "subject",
165
+ "trigger_key",
166
+ "unjoined",
167
+ "variant",
168
+ ];
146
169
  const sample: EmailEventProperties = { email_id: "re_1", subject: "Welcome", link: "https://ad2.app" };
147
- assert.deepEqual(Object.keys(sample).sort(), allowedKeys);
170
+ assert.deepEqual(Object.keys(sample).sort(), ["email_id", "link", "subject"]);
148
171
  for (const event of emailEvents) {
149
172
  const witness = EVENT_PROPERTY_WITNESS[event];
150
173
  for (const key of Object.keys(witness)) {
@@ -339,3 +362,89 @@ test("POST_PUBLISHED gains optional per-platform properties (AD2-1154 FR-5 lib p
339
362
  assert.equal(perPlatform.outcome, "failed");
340
363
  assert.equal(perPlatform.reason_class, "media");
341
364
  });
365
+
366
+ // ── spec 120 T002: email program v2 — events + canonical property names ────────
367
+ // The tests assert against contracts/email-event-properties.md (promised =
368
+ // asserted, the 012 lesson). Wire values are FINAL once shipped.
369
+
370
+ test("120 email-program event wire names are locked", () => {
371
+ assert.deepEqual(
372
+ [
373
+ EVENTS.EMAIL_SENT,
374
+ EVENTS.EMAIL_FAILED,
375
+ EVENTS.EMAIL_DELIVERY_DELAYED,
376
+ EVENTS.EMAIL_UNSUBSCRIBED,
377
+ EVENTS.EMAIL_SUPPRESSED,
378
+ EVENTS.PLAYBOOK_OPENED,
379
+ EVENTS.PLAYBOOK_DOWNLOADED,
380
+ ],
381
+ [
382
+ "email_sent",
383
+ "email_failed",
384
+ "email_delivery_delayed",
385
+ "email_unsubscribed",
386
+ "email_suppressed",
387
+ "playbook_opened",
388
+ "playbook_downloaded",
389
+ ],
390
+ );
391
+ });
392
+
393
+ test("120 EMAIL_PROPS canonical property keys match the T001 contract table", () => {
394
+ assert.deepEqual(EMAIL_PROPS, {
395
+ EMAIL_STREAM: "email_stream",
396
+ TRIGGER_KEY: "trigger_key",
397
+ VARIANT: "variant",
398
+ HAS_ATTACHMENT: "has_attachment",
399
+ EMAIL_ID: "email_id",
400
+ UNJOINED: "unjoined",
401
+ SUBJECT: "subject",
402
+ LINK: "link",
403
+ BOUNCE_TYPE: "bounce_type",
404
+ MPP_SUSPECTED: "mpp_suspected",
405
+ REASON: "reason",
406
+ MECHANISM: "mechanism",
407
+ HOURS_SINCE_SEND: "hours_since_send",
408
+ EDITION_VERSION: "edition_version",
409
+ FORMAT: "format",
410
+ });
411
+ });
412
+
413
+ test("120 email base properties ride every email event; event-specific fields type-check", () => {
414
+ const base: EmailEventBaseProperties = {
415
+ email_stream: "nurture",
416
+ trigger_key: "welcome",
417
+ variant: "default",
418
+ has_attachment: false,
419
+ email_id: "re_1",
420
+ unjoined: false,
421
+ };
422
+ const sent: EventProperties[typeof EVENTS.EMAIL_SENT] = { ...base, subject: "Welcome to ad2app" };
423
+ const opened: EventProperties[typeof EVENTS.EMAIL_OPENED] = { ...base, mpp_suspected: true };
424
+ const bounced: EventProperties[typeof EVENTS.EMAIL_BOUNCED] = { ...base, bounce_type: "hard" };
425
+ const failed: EventProperties[typeof EVENTS.EMAIL_FAILED] = { ...base, reason: "rejected" };
426
+ const delayed: EventProperties[typeof EVENTS.EMAIL_DELIVERY_DELAYED] = { ...base };
427
+ const unsub: EventProperties[typeof EVENTS.EMAIL_UNSUBSCRIBED] = {
428
+ ...base,
429
+ mechanism: "one_click",
430
+ hours_since_send: 4,
431
+ };
432
+ const suppressed: EventProperties[typeof EVENTS.EMAIL_SUPPRESSED] = { ...base, reason: "hard_bounce" };
433
+ assert.equal(sent.subject, "Welcome to ad2app");
434
+ assert.equal(opened.mpp_suspected, true);
435
+ assert.equal(bounced.bounce_type, "hard");
436
+ assert.equal(failed.reason, "rejected");
437
+ assert.equal(delayed.email_stream, "nurture");
438
+ assert.equal(unsub.mechanism, "one_click");
439
+ assert.equal(suppressed.reason, "hard_bounce");
440
+ });
441
+
442
+ test("120 playbook events carry the edition stamp", () => {
443
+ const opened: EventProperties[typeof EVENTS.PLAYBOOK_OPENED] = { edition_version: "2026.08" };
444
+ const downloaded: EventProperties[typeof EVENTS.PLAYBOOK_DOWNLOADED] = {
445
+ edition_version: "2026.08",
446
+ format: "pdf",
447
+ };
448
+ assert.equal(opened.edition_version, "2026.08");
449
+ assert.equal(downloaded.format, "pdf");
450
+ });
@@ -20,12 +20,24 @@ export const EVENTS = {
20
20
  ACCOUNT_CONNECT_BLOCKED: 'account_connect_blocked', // free user hits the connect wall (AD2-892)
21
21
  MCP_POST_BLOCKED_FREE_TIER: 'mcp_post_blocked_free_tier', // free user tries to post in the MCP (AD2-898)
22
22
 
23
- // Email lifecycle (Resend webhook -> PostHog, AD2-894)
23
+ // Email lifecycle (Resend webhook -> PostHog, AD2-894; extended by spec 120:
24
+ // send-time denominator + failure/delay mappings + list-hygiene events, all
25
+ // carrying the EmailEventBaseProperties set per
26
+ // specs/120-email-program-v2/contracts/email-event-properties.md)
27
+ EMAIL_SENT: 'email_sent', // send-time, at the ResendService choke points (the denominator)
24
28
  EMAIL_DELIVERED: 'email_delivered',
25
29
  EMAIL_OPENED: 'email_opened',
26
30
  EMAIL_CLICKED: 'email_clicked',
27
31
  EMAIL_BOUNCED: 'email_bounced',
28
32
  EMAIL_COMPLAINED: 'email_complained',
33
+ EMAIL_FAILED: 'email_failed', // webhook email.failed
34
+ EMAIL_DELIVERY_DELAYED: 'email_delivery_delayed', // webhook email.delivery_delayed
35
+ EMAIL_UNSUBSCRIBED: 'email_unsubscribed', // one-click header or footer link
36
+ EMAIL_SUPPRESSED: 'email_suppressed', // suppression-list upsert (hard bounce / complaint)
37
+
38
+ // Posting Playbook (spec 120 US2) — hosted artifact engagement
39
+ PLAYBOOK_OPENED: 'playbook_opened',
40
+ PLAYBOOK_DOWNLOADED: 'playbook_downloaded',
29
41
 
30
42
  // Activation (web app)
31
43
  SIGNED_UP: 'signed_up', // server-owned (backend, on user creation)
@@ -97,13 +109,62 @@ export const EVENTS = {
97
109
 
98
110
  export type EventName = (typeof EVENTS)[keyof typeof EVENTS];
99
111
 
100
- /** Shared shape for the Resend email lifecycle events (AD2-894). */
101
- export interface EmailEventProperties {
102
- email_id?: string;
112
+ /** Which email program a send belongs to (spec 120). Wire values — do not rename. */
113
+ export type EmailStream = 'transactional' | 'nurture' | 'broadcast';
114
+
115
+ /** How an unsubscribe arrived (spec 120). */
116
+ export type UnsubscribeMechanism = 'one_click' | 'footer_link';
117
+
118
+ /** Why an address entered the suppression list (spec 120). */
119
+ export type SuppressionReason = 'hard_bounce' | 'complaint';
120
+
121
+ /**
122
+ * BASE property set every email event carries (spec 120 T001 contract). All
123
+ * fields are optional AT THE TYPE LEVEL only because legacy tag-less sends
124
+ * resolve by email lookup and legitimately lack stream/trigger context (they
125
+ * carry `unjoined: true` instead); the backend tests enforce the full set on
126
+ * every NEW send. Identity (distinct_id) rides the capture call, not this shape.
127
+ */
128
+ export interface EmailEventBaseProperties {
129
+ email_stream?: EmailStream;
130
+ trigger_key?: string; // e.g. 'welcome', 'winback_canceled', 'consent_copy'
131
+ variant?: string; // A/B arm id, default 'default'
132
+ has_attachment?: boolean;
133
+ email_id?: string; // Resend id
134
+ unjoined?: boolean; // identity resolution failed — anonymous distinct_id
135
+ }
136
+
137
+ /**
138
+ * Shared shape for the Resend email lifecycle events (AD2-894; widened by spec
139
+ * 120 to carry the base set — additive, wire names unchanged).
140
+ */
141
+ export interface EmailEventProperties extends EmailEventBaseProperties {
103
142
  subject?: string;
104
143
  link?: string;
105
144
  }
106
145
 
146
+ /**
147
+ * Canonical email event property KEYS (spec 120 T001). Emitters and tests
148
+ * reference these instead of retyping strings — the single home for the names.
149
+ */
150
+ export const EMAIL_PROPS = {
151
+ EMAIL_STREAM: 'email_stream',
152
+ TRIGGER_KEY: 'trigger_key',
153
+ VARIANT: 'variant',
154
+ HAS_ATTACHMENT: 'has_attachment',
155
+ EMAIL_ID: 'email_id',
156
+ UNJOINED: 'unjoined',
157
+ SUBJECT: 'subject',
158
+ LINK: 'link',
159
+ BOUNCE_TYPE: 'bounce_type',
160
+ MPP_SUSPECTED: 'mpp_suspected',
161
+ REASON: 'reason',
162
+ MECHANISM: 'mechanism',
163
+ HOURS_SINCE_SEND: 'hours_since_send',
164
+ EDITION_VERSION: 'edition_version',
165
+ FORMAT: 'format',
166
+ } as const;
167
+
107
168
  /**
108
169
  * Where a paywall / upgrade / checkout moment was triggered from (080). A named,
109
170
  * stable, closed set so the revenue funnel can attribute conversions to their
@@ -161,11 +222,23 @@ export interface EventProperties {
161
222
  [EVENTS.FREE_SKILLS_REQUESTED]: { source: string };
162
223
  [EVENTS.ACCOUNT_CONNECT_BLOCKED]: { platform?: string };
163
224
  [EVENTS.MCP_POST_BLOCKED_FREE_TIER]: { target_count?: number };
225
+ [EVENTS.EMAIL_SENT]: EmailEventProperties;
164
226
  [EVENTS.EMAIL_DELIVERED]: EmailEventProperties;
165
- [EVENTS.EMAIL_OPENED]: EmailEventProperties;
227
+ // mpp_suspected: Apple privacy proxy or <60s after send — EXCLUDED from every
228
+ // success metric (spec 120 contract).
229
+ [EVENTS.EMAIL_OPENED]: EmailEventProperties & { mpp_suspected?: boolean };
166
230
  [EVENTS.EMAIL_CLICKED]: EmailEventProperties;
167
- [EVENTS.EMAIL_BOUNCED]: EmailEventProperties;
231
+ [EVENTS.EMAIL_BOUNCED]: EmailEventProperties & { bounce_type?: string };
168
232
  [EVENTS.EMAIL_COMPLAINED]: EmailEventProperties;
233
+ [EVENTS.EMAIL_FAILED]: EmailEventProperties & { reason?: string };
234
+ [EVENTS.EMAIL_DELIVERY_DELAYED]: EmailEventProperties;
235
+ [EVENTS.EMAIL_UNSUBSCRIBED]: EmailEventProperties & {
236
+ mechanism?: UnsubscribeMechanism;
237
+ hours_since_send?: number;
238
+ };
239
+ [EVENTS.EMAIL_SUPPRESSED]: EmailEventProperties & { reason?: SuppressionReason };
240
+ [EVENTS.PLAYBOOK_OPENED]: { edition_version?: string };
241
+ [EVENTS.PLAYBOOK_DOWNLOADED]: { edition_version?: string; format?: string };
169
242
  [EVENTS.SIGNED_UP]: { method: 'email' | 'google'; role: string };
170
243
  [EVENTS.PROFILE_COMPLETED]: { role: string };
171
244
  [EVENTS.LOGGED_IN]: { method?: 'email' | 'google' };
@@ -340,12 +413,32 @@ type Expect<T extends true> = T;
340
413
  // property shape, no orphan or typo'd key (1:1).
341
414
  type _EventsAreOneToOneWithProperties = Expect<Equal<EventName, keyof EventProperties>>;
342
415
 
343
- // AC3: the five EMAIL_* lifecycle events all carry the shared EmailEventProperties
344
- // shape (the Resend -> PostHog bridge contract, AD2-894).
416
+ // AC3 (AD2-894, widened by spec 120): every EMAIL_* lifecycle event carries the
417
+ // shared EmailEventProperties shape; opened/bounced/failed/unsubscribed/
418
+ // suppressed layer their event-specific fields ON TOP of it, never instead of it.
345
419
  type EmailLifecycleEvent =
420
+ | (typeof EVENTS)['EMAIL_SENT']
346
421
  | (typeof EVENTS)['EMAIL_DELIVERED']
347
422
  | (typeof EVENTS)['EMAIL_OPENED']
348
423
  | (typeof EVENTS)['EMAIL_CLICKED']
349
424
  | (typeof EVENTS)['EMAIL_BOUNCED']
350
- | (typeof EVENTS)['EMAIL_COMPLAINED'];
351
- type _EmailEventsUseSharedShape = Expect<Equal<EventProperties[EmailLifecycleEvent], EmailEventProperties>>;
425
+ | (typeof EVENTS)['EMAIL_COMPLAINED']
426
+ | (typeof EVENTS)['EMAIL_FAILED']
427
+ | (typeof EVENTS)['EMAIL_DELIVERY_DELAYED']
428
+ | (typeof EVENTS)['EMAIL_UNSUBSCRIBED']
429
+ | (typeof EVENTS)['EMAIL_SUPPRESSED'];
430
+ type _EmailEventsCarryTheSharedShape = Expect<
431
+ EventProperties[EmailLifecycleEvent] extends EmailEventProperties ? true : false
432
+ >;
433
+ // The unchanged AD2-894 trio still equals the shared shape exactly.
434
+ type _UnwidenedEmailEventsUseSharedShapeExactly = Expect<
435
+ Equal<
436
+ EventProperties[
437
+ | (typeof EVENTS)['EMAIL_SENT']
438
+ | (typeof EVENTS)['EMAIL_DELIVERED']
439
+ | (typeof EVENTS)['EMAIL_CLICKED']
440
+ | (typeof EVENTS)['EMAIL_COMPLAINED']
441
+ | (typeof EVENTS)['EMAIL_DELIVERY_DELAYED']],
442
+ EmailEventProperties
443
+ >
444
+ >;
@@ -10,7 +10,7 @@ import { ApiError, apiDriver, configureApiDriver, fetchCall } from "./apiDriver"
10
10
 
11
11
  configureApiDriver({ apiUrl: "https://default.test" });
12
12
 
13
- let calls: { url: string }[] = [];
13
+ let calls: { url: string; init?: RequestInit }[] = [];
14
14
  let body: string | null = JSON.stringify({ ok: true });
15
15
  let status = 200;
16
16
  const realFetch = globalThis.fetch;
@@ -20,8 +20,8 @@ beforeEach(() => {
20
20
  body = JSON.stringify({ ok: true });
21
21
  status = 200;
22
22
  // Fresh Response per call — a body can only be read once.
23
- globalThis.fetch = (async (url: unknown) => {
24
- calls.push({ url: String(url) });
23
+ globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
24
+ calls.push({ url: String(url), init });
25
25
  return new Response(body, { status });
26
26
  }) as typeof fetch;
27
27
  });
@@ -126,3 +126,26 @@ test("an empty error body still carries the status, not just a generic message",
126
126
  assert.equal(err.status, 502);
127
127
  assert.match(err.message, /502/);
128
128
  });
129
+
130
+ /**
131
+ * AD2-1297: the frontend needs the backend's HttpOnly session cookie to ride
132
+ * along on every request, which `fetch` only does when explicitly told to via
133
+ * `credentials`. Regression: before this, `DriverConfig` had no `credentials`
134
+ * field at all, so there was no way for a consumer to opt in — the cookie
135
+ * would silently never be sent and every request would 401.
136
+ */
137
+ test("configureApiDriver's credentials option is forwarded to fetch", async () => {
138
+ configureApiDriver({ apiUrl: "https://default.test", credentials: "include" });
139
+ try {
140
+ await fetchCall({ path: "x", method: "get" });
141
+ assert.equal(calls[0]?.init?.credentials, "include");
142
+ } finally {
143
+ // Restore the module-level CONFIG other tests in this file rely on.
144
+ configureApiDriver({ apiUrl: "https://default.test" });
145
+ }
146
+ });
147
+
148
+ test("credentials is omitted (not forced to a value) when the driver isn't configured with one", async () => {
149
+ await fetchCall({ path: "x", method: "get" });
150
+ assert.equal(calls[0]?.init?.credentials, undefined);
151
+ });
@@ -9,6 +9,14 @@ type HttpMethod = "get" | "post" | "put" | "PATCH" | "delete";
9
9
  interface DriverConfig {
10
10
  apiUrl: string;
11
11
  getHeaders?: () => HeadersInit;
12
+ /**
13
+ * Forwarded verbatim to `fetch`'s own `credentials` option on every call
14
+ * (AD2-1297: the frontend needs `'include'` so the backend's HttpOnly
15
+ * session cookie actually rides along on a cross-subdomain request).
16
+ * Undefined preserves the previous, unconfigured behavior (the browser's
17
+ * `fetch` default, `'same-origin'`) for any other consumer of this driver.
18
+ */
19
+ credentials?: RequestCredentials;
12
20
  }
13
21
 
14
22
  /**
@@ -77,6 +85,7 @@ export async function fetchCall<T, Q>(args: FetchCallArgs<Q>): Promise<T> {
77
85
  const init: RequestInit = {
78
86
  method,
79
87
  headers,
88
+ ...(CONFIG?.credentials ? { credentials: CONFIG.credentials } : {}),
80
89
  };
81
90
 
82
91
  if (params?.body) {
@@ -0,0 +1,145 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ // spec 124 US2 (playbook visual restyle): the landing's v5 accent ramp joins the
7
+ // canonical brand source so no consumer ever re-types a hex. Values must equal
8
+ // ad2app-landing globals.css EXACTLY — this fixture IS those values, copied once;
9
+ // if the landing ramp ever changes, both move together through brand.mjs.
10
+ //
11
+ // This hardcoded snapshot is a FLOOR, not the whole story: it only catches brand.mjs
12
+ // drifting from what was true when the fixture was copied. The tests further down
13
+ // additionally read ad2app-landing/src/app/globals.css directly (when that sibling
14
+ // repo is checked out) so real drift between the two repos actually fails CI/local
15
+ // runs, not just drift from a frozen copy. brand.mjs is plain ESM with no build step,
16
+ // so `tsx --test` (this repo's `npm test`) imports it as-is; the resolver finds the
17
+ // hand-maintained brand.d.ts alongside it, so no `@ts-ignore` is needed here.
18
+ import { color } from '../../brand/brand.mjs';
19
+
20
+ const LANDING_V5 = {
21
+ magenta: '#ff2d78',
22
+ magentaHi: '#ff5c95',
23
+ magentaLo: '#e0175f',
24
+ magentaTint: '#fff0f5',
25
+ violet: '#7c3aed',
26
+ violetHi: '#9d6ff5',
27
+ violetLo: '#6323d0',
28
+ lime: '#d9f24b',
29
+ limeHi: '#e9fa74',
30
+ limeLo: '#becc25',
31
+ aqua: '#4dd6f5',
32
+ aquaHi: '#7ce4fa',
33
+ aquaLo: '#22b8dd',
34
+ borderStrong: '#8e8e9a',
35
+ };
36
+
37
+ test('brand.mjs carries the v5 accent group with the exact landing values', () => {
38
+ assert.ok(color.v5, 'color.v5 accent group missing from brand.mjs');
39
+ assert.deepEqual(color.v5, LANDING_V5);
40
+ });
41
+
42
+ test('the pre-existing v5 base tokens still match the landing', () => {
43
+ assert.equal(color.surfaceMuted, '#f0f1fa');
44
+ assert.equal(color.primaryTint, '#eaf0ff');
45
+ assert.equal(color.primary, '#0042ff');
46
+ });
47
+
48
+ // ---- Live cross-repo check against ad2app-landing (when checked out as a sibling) ----
49
+ //
50
+ // In local dev both repos live side by side under the same parent directory
51
+ // (…/ad2app/ad2app-lib and …/ad2app/ad2app-landing — this is how Maciej reviews), so we
52
+ // can read the actual source of truth instead of trusting a copy-pasted fixture. CI and
53
+ // npm-published contexts won't have the sibling checked out, so that case is skipped
54
+ // with a clear note rather than failed — the hardcoded assertions above still run and
55
+ // still protect those contexts, just against a snapshot instead of the live file.
56
+ const LANDING_GLOBALS_CSS = path.resolve(__dirname, '../../../ad2app-landing/src/app/globals.css');
57
+
58
+ /** Extracts `--custom-property: #hex;` declarations from a CSS file's text. */
59
+ function readCssHexTokens(cssText: string): Record<string, string> {
60
+ const tokens: Record<string, string> = {};
61
+ const re = /--([a-z0-9-]+):\s*(#[0-9a-fA-F]{3,8})\s*[;]/g;
62
+ let match: RegExpExecArray | null;
63
+ while ((match = re.exec(cssText)) !== null) {
64
+ tokens[match[1]] = match[2];
65
+ }
66
+ return tokens;
67
+ }
68
+
69
+ // ad2app-landing CSS custom property name -> brand.mjs color.v5 key
70
+ const V5_CSS_VAR_TO_KEY: Record<string, keyof typeof LANDING_V5> = {
71
+ magenta: 'magenta',
72
+ 'magenta-hi': 'magentaHi',
73
+ 'magenta-lo': 'magentaLo',
74
+ 'magenta-tint': 'magentaTint',
75
+ violet: 'violet',
76
+ 'violet-hi': 'violetHi',
77
+ 'violet-lo': 'violetLo',
78
+ lime: 'lime',
79
+ 'lime-hi': 'limeHi',
80
+ 'lime-lo': 'limeLo',
81
+ aqua: 'aqua',
82
+ 'aqua-hi': 'aquaHi',
83
+ 'aqua-lo': 'aquaLo',
84
+ 'border-strong': 'borderStrong',
85
+ };
86
+
87
+ // ad2app-landing CSS custom property name -> brand.mjs color.<key>
88
+ const BASE_CSS_VAR_TO_KEY: Record<string, 'surfaceMuted' | 'primaryTint' | 'primary'> = {
89
+ 'surface-muted': 'surfaceMuted',
90
+ 'primary-tint': 'primaryTint',
91
+ primary: 'primary',
92
+ };
93
+
94
+ test('brand.mjs v5 accent group matches the LIVE ad2app-landing globals.css', (t) => {
95
+ if (!fs.existsSync(LANDING_GLOBALS_CSS)) {
96
+ console.log(
97
+ `[v5-accent-tokens.test] ad2app-landing sibling repo not found at ${LANDING_GLOBALS_CSS} — ` +
98
+ 'skipping the live cross-repo check (expected in CI / outside a local sibling checkout). ' +
99
+ 'The hardcoded LANDING_V5 snapshot above still ran as a floor.',
100
+ );
101
+ t.skip('ad2app-landing not checked out as a sibling repo');
102
+ return;
103
+ }
104
+
105
+ const cssTokens = readCssHexTokens(fs.readFileSync(LANDING_GLOBALS_CSS, 'utf8'));
106
+ const liveV5 = {} as typeof LANDING_V5;
107
+ for (const [cssVar, v5Key] of Object.entries(V5_CSS_VAR_TO_KEY)) {
108
+ assert.ok(
109
+ cssTokens[cssVar],
110
+ `--${cssVar} not found in ad2app-landing globals.css (renamed or removed?) at ${LANDING_GLOBALS_CSS}`,
111
+ );
112
+ liveV5[v5Key] = cssTokens[cssVar];
113
+ }
114
+
115
+ assert.deepEqual(
116
+ color.v5,
117
+ liveV5,
118
+ 'brand.mjs color.v5 has drifted from the live ad2app-landing globals.css — update brand.mjs (and the LANDING_V5 fixture above) to match',
119
+ );
120
+ });
121
+
122
+ test('brand.mjs base tokens match the LIVE ad2app-landing globals.css', (t) => {
123
+ if (!fs.existsSync(LANDING_GLOBALS_CSS)) {
124
+ console.log(
125
+ `[v5-accent-tokens.test] ad2app-landing sibling repo not found at ${LANDING_GLOBALS_CSS} — ` +
126
+ 'skipping the live cross-repo check (expected in CI / outside a local sibling checkout). ' +
127
+ 'The hardcoded base-token assertions above still ran as a floor.',
128
+ );
129
+ t.skip('ad2app-landing not checked out as a sibling repo');
130
+ return;
131
+ }
132
+
133
+ const cssTokens = readCssHexTokens(fs.readFileSync(LANDING_GLOBALS_CSS, 'utf8'));
134
+ for (const [cssVar, colorKey] of Object.entries(BASE_CSS_VAR_TO_KEY)) {
135
+ assert.ok(
136
+ cssTokens[cssVar],
137
+ `--${cssVar} not found in ad2app-landing globals.css (renamed or removed?) at ${LANDING_GLOBALS_CSS}`,
138
+ );
139
+ assert.equal(
140
+ color[colorKey],
141
+ cssTokens[cssVar],
142
+ `brand.mjs color.${colorKey} has drifted from the live ad2app-landing --${cssVar}`,
143
+ );
144
+ }
145
+ });
@@ -37,7 +37,7 @@ export const TERMS_SECTIONS_PL: LegalSection[] = [
37
37
  { text: 'Zarządzanie plikami multimedialnymi i treściami.' },
38
38
  { text: 'Opcjonalne podłączenie zewnętrznych asystentów AI i narzędzi obsługujących Model Context Protocol (MCP), pozwalające Ci zarządzać planowaniem, publikowaniem i statystykami z poziomu wybranego przez Ciebie narzędzia. Zobacz sekcję 13 oraz naszą {PRIVACY}.' },
39
39
  ] },
40
- { kind: 'p', text: 'Usługa jest oferowana w trzech planach: **Free** (na tym planie łączenie nowych kont społecznościowych i publikowanie są niedostępne; jeżeli wcześniej korzystałeś z planu płatnego, wcześniej połączone konta, historia postów i statystyki również stają się niedostępne, dopóki pozostajesz na planie Free, przy czym dane te są zachowywane, a nie usuwane, i stają się ponownie dostępne, jeżeli ponownie wykupisz subskrypcję), **Starter** i **Pro** (Starter i Pro różnią się wyłącznie liczbą kont społecznościowych, które możesz połączyć; wszystkie pozostałe funkcje są identyczne). Aktualne szczegóły planów i ceny są pokazane w aplikacji oraz na naszej stronie z cennikiem.' },
40
+ { kind: 'p', text: 'Usługa jest oferowana w trzech planach: **Free** (na tym planie łączenie nowych kont społecznościowych i publikowanie są niedostępne; jeżeli wcześniej korzystałeś z planu płatnego albo z miejsca w wersji beta, wcześniej połączone konta, historia postów i statystyki pozostają dla Ciebie widoczne w trybie podglądu. Dane te są zachowywane, a nie usuwane, a pełny dostęp wraca, jeżeli wykupisz subskrypcję), **Starter** i **Pro** (Starter i Pro różnią się wyłącznie liczbą kont społecznościowych, które możesz połączyć; wszystkie pozostałe funkcje są identyczne). Aktualne szczegóły planów i ceny są pokazane w aplikacji oraz na naszej stronie z cennikiem.' },
41
41
  { kind: 'p', text: 'Nie wszystkie funkcje opisane w niniejszym Regulaminie są dostępne w każdym planie. Możemy zmodyfikować Usługę w zakresie, w jakim nie jest to niezbędne do zachowania jej zgodności z umową, **wyłącznie z ważnej przyczyny wskazanej tutaj**: aby zapewnić zgodność z prawem albo z orzeczeniem sądu lub decyzją organu; aby utrzymać lub przywrócić bezpieczeństwo; aby odzwierciedlić zmianę w platformach zewnętrznych, z którymi się integrujemy; albo aby zastąpić funkcję funkcją równoważną lub lepszą. Jeżeli modyfikacja negatywnie i w stopniu więcej niż nieznacznym wpływa na Twój dostęp do Usługi lub korzystanie z niej, powiadomimy Cię o tym z wyprzedzeniem na trwałym nośniku (na przykład e-mailem), opisując zmianę oraz termin jej wejścia w życie, a **jeżeli jesteś konsumentem, możesz wypowiedzieć umowę bez ponoszenia kosztów w terminie 30 dni od późniejszej z dat: dnia dokonania zmiany albo dnia poinformowania Cię o niej**, z proporcjonalnym zwrotem Opłat zapłaconych z góry za niewykorzystany okres. Dotyczy to zarówno planu płatnego, jak i planu Free oraz prywatnej wersji beta.' },
42
42
  ],
43
43
  },
@@ -49,7 +49,7 @@ export const TERMS_SECTIONS_PL: LegalSection[] = [
49
49
  { kind: 'ul', items: [
50
50
  { text: 'Wersja beta jest **bezpłatna**. Nie jest należna żadna płatność, nie zbieramy metody płatności, a Twoje miejsce w wersji beta nigdy nie przekształca się automatycznie w plan płatny.' },
51
51
  { text: 'Wersja beta trwa przez **okres oznaczony**, podany Ci przed jej przyjęciem i potwierdzony w wiadomości, którą wysyłamy Ci w chwili przyjęcia. Dostęp rozpoczyna się z chwilą przyjęcia i **kończy się automatycznie w podanym dniu zakończenia**. Ten dzień zakończenia to uzgodniony czas trwania wersji beta, a nie zmiana, której dokonujemy w umowie później.' },
52
- { text: 'Gdy okres wersji beta się kończy, **Twoje konto przechodzi na plan Free**. Nic nie zostaje pobrane i nic nie zostaje usunięte. Na planie Free łączenie nowych kont społecznościowych i publikowanie są niedostępne, a konta społecznościowe, historia postów i statystyki z okresu wersji beta stają się niedostępne, dopóki pozostajesz na planie Free. Dane te są **zachowywane, a nie usuwane**, i stają się ponownie dostępne, jeżeli wykupisz plan płatny. Możesz też w każdej chwili poprosić nas o ich wyeksportowanie, nie tylko w chwili odejścia (sekcja 12).' },
52
+ { text: 'Gdy okres wersji beta się kończy, **Twoje konto przechodzi na plan Free**. Nic nie zostaje pobrane i nic nie zostaje usunięte. Na planie Free łączenie nowych kont społecznościowych i publikowanie są niedostępne, ale konta społecznościowe, historia postów i statystyki z okresu wersji beta **pozostają dla Ciebie widoczne w trybie podglądu**. Dane te są zachowywane, a nie usuwane, a pełny dostęp wraca, jeżeli wykupisz plan płatny. Możesz też w każdej chwili poprosić nas o ich wyeksportowanie, nie tylko w chwili odejścia (sekcja 12).' },
53
53
  { text: 'Przypomnimy Ci o zbliżającym się końcu okresu wersji beta i poinformujemy Cię, gdy ten okres się zakończy. Żadna z tych wiadomości nie jest warunkiem skuteczności dnia zakończenia: dzień zakończenia jest już częścią niniejszego Regulaminu.' },
54
54
  { text: 'Możesz opuścić wersję beta w każdej chwili, z dowolnego powodu i bez żadnych kosztów, zamykając konto (sekcja 12) lub pisząc do nas na {EMAIL}.' },
55
55
  { text: 'Twoje **ustawowe prawo odstąpienia od umowy (sekcja 8a) ma w pełni zastosowanie do umowy dotyczącej wersji beta**, dokładnie tak samo jak do planu płatnego. Nic w niniejszej sekcji go nie ogranicza.' },
@@ -22,7 +22,7 @@ export const TERMS_SECTIONS: LegalSection[] = [
22
22
  { text: 'Media and content file management.' },
23
23
  { text: 'Optional connection of third-party AI assistants and tools that support the Model Context Protocol (MCP), letting you manage scheduling, posting, and analytics from within a tool of your choice. See Section 13 and our {PRIVACY}.' },
24
24
  ] },
25
- { kind: 'p', text: 'The Service is offered on three plans: **Free** (connecting new social accounts and publishing are not available on this plan; if you previously had a paid plan, your previously-connected accounts, post history, and analytics also become inaccessible while you are on the Free plan. That data is retained, not deleted, and becomes available again if you resubscribe), **Starter**, and **Pro** (Starter and Pro differ only in how many social accounts you may connect; all other features are identical). Current plan details and pricing are shown in the app and on our pricing page.' },
25
+ { kind: 'p', text: 'The Service is offered on three plans: **Free** (connecting new social accounts and publishing are not available on this plan; if you previously had a paid plan or a beta place, your previously-connected accounts, post history, and analytics remain visible to you in read-only form. That data is retained, not deleted, and full access returns if you subscribe), **Starter**, and **Pro** (Starter and Pro differ only in how many social accounts you may connect; all other features are identical). Current plan details and pricing are shown in the app and on our pricing page.' },
26
26
  { kind: 'p', text: 'Not all features described in these Terms are available in every plan. We may modify the Service where this is not necessary to keep it conforming with the contract **only for a valid reason stated here**: to comply with law or a decision of a court or authority; to maintain or restore security; to reflect a change in the third-party platforms we integrate with; or to replace a feature with an equivalent or better one. Where a modification negatively and more than minorly affects your access to or use of the Service, we will notify you on a durable medium (such as email) in advance, describing the change and when it takes effect, and, **if you are a consumer, you may terminate the contract free of charge within 30 days of the later of the date the change is made and the date you are informed of it**, with a pro-rata refund of any prepaid Fees for the unused period. This applies whether you are on a paid plan, the Free plan or the private beta.' },
27
27
  ],
28
28
  },
@@ -34,7 +34,7 @@ export const TERMS_SECTIONS: LegalSection[] = [
34
34
  { kind: 'ul', items: [
35
35
  { text: 'The beta is **free of charge**. No payment is due, no payment method is collected, and your beta place never converts into a paid plan automatically.' },
36
36
  { text: 'The beta runs for a **fixed period**, stated to you before you accept and confirmed in the message we send you when you accept. Access begins when you accept and **ends automatically on the stated end date**. That end date is the agreed duration of the beta, not a change we make to the contract later.' },
37
- { text: 'When the beta period ends, **your account moves to the Free plan**. Nothing is charged and nothing is deleted. On the Free plan, connecting new social accounts and publishing are unavailable, and the social accounts, post history and analytics from your beta period become inaccessible while you remain on the Free plan. That data is **retained, not deleted**, and becomes available again if you subscribe to a paid plan. You can also ask us to export it at any time, not only when you leave (Section 12).' },
37
+ { text: 'When the beta period ends, **your account moves to the Free plan**. Nothing is charged and nothing is deleted. On the Free plan, connecting new social accounts and publishing are unavailable, but the social accounts, post history and analytics from your beta period **remain visible to you in read-only form**. That data is retained, not deleted, and full access returns if you subscribe to a paid plan. You can also ask us to export it at any time, not only when you leave (Section 12).' },
38
38
  { text: 'We will remind you before the beta period ends and tell you when it has ended. Neither message is a condition of the end date taking effect: the end date is already part of these Terms.' },
39
39
  { text: 'You may leave the beta at any time, for any reason, at no cost, by closing your account (Section 12) or by writing to us at {EMAIL}.' },
40
40
  { text: 'Your **statutory right of withdrawal (Section 8a) applies to the beta contract in full**, exactly as it applies to a paid plan. Nothing in this section limits it.' },
@@ -126,3 +126,19 @@ test('SchedulingPostDTO platformRetryCounts is absent by default (backward compa
126
126
  const post = basePost();
127
127
  assert.equal(post.platformRetryCounts, undefined);
128
128
  });
129
+
130
+ // ── spec 121: disclosure + per-image alt text ride the create DTO ─────────────
131
+ test('121: disclosure + altText carry through the DTO constructors (absent when unset)', () => {
132
+ const { SchedulingCreatePostDTO, SchedulingMediaItemDTO, SchedulingPostDisclosureDTO } = require('./I_SchedulingPost');
133
+ const dto = new SchedulingCreatePostDTO({
134
+ content: 'hello',
135
+ platforms: [{ platform: 'instagram', accountId: 'a1' }],
136
+ mediaItems: [new SchedulingMediaItemDTO({ type: 'image', url: 'https://x/1.jpg', altText: 'A creator at a desk' })],
137
+ disclosure: new SchedulingPostDisclosureDTO({ aiGenerated: true, tiktokCommercial: 'brand_organic' }),
138
+ });
139
+ assert.equal(dto.disclosure.aiGenerated, true);
140
+ assert.equal(dto.disclosure.tiktokCommercial, 'brand_organic');
141
+ assert.equal(dto.mediaItems[0].altText, 'A creator at a desk');
142
+ const bare = new SchedulingCreatePostDTO({ content: 'x', platforms: [] });
143
+ assert.equal(bare.disclosure, undefined);
144
+ });
@@ -53,11 +53,44 @@ export class SchedulingPlatformTargetDTO {
53
53
  export class SchedulingMediaItemDTO {
54
54
  type: 'image' | 'video';
55
55
  url: string;
56
+ /**
57
+ * Per-image accessibility text (spec 121 US3). Applied by the vendor on the
58
+ * platforms that support it (IG feed/FB/Threads/X ≤1000/LinkedIn/Bluesky/
59
+ * Pinterest ≤500); images only — video items never carry it.
60
+ */
61
+ altText?: string;
56
62
 
57
63
  constructor(data?: Partial<SchedulingMediaItemDTO>) {
58
64
  if (!data) return;
59
65
  this.type = data.type;
60
66
  this.url = data.url;
67
+ this.altText = data.altText;
68
+ }
69
+ }
70
+
71
+ // ── SchedulingPostDisclosureDTO ───────────────────────────────────────────────
72
+
73
+ /** TikTok commercial-content disclosure choice (spec 121 US2). Wire values = vendor enum. */
74
+ export type SchedulingTikTokCommercialContentType =
75
+ | 'none'
76
+ | 'brand_organic'
77
+ | 'brand_content';
78
+
79
+ /**
80
+ * The compose disclosure state (spec 121, AD2-1302): ONE model on the post,
81
+ * mapped per-platform at publish time (IG isAiGenerated / TikTok
82
+ * videoMadeWithAi / YouTube containsSyntheticMedia). Absent-when-off: an unset
83
+ * flag sends no vendor field at all.
84
+ */
85
+ export class SchedulingPostDisclosureDTO {
86
+ /** The media contains AI-generated content (media, not captions). */
87
+ aiGenerated?: boolean;
88
+ tiktokCommercial?: SchedulingTikTokCommercialContentType;
89
+
90
+ constructor(data?: Partial<SchedulingPostDisclosureDTO>) {
91
+ if (!data) return;
92
+ this.aiGenerated = data.aiGenerated;
93
+ this.tiktokCommercial = data.tiktokCommercial;
61
94
  }
62
95
  }
63
96
 
@@ -72,6 +105,7 @@ export class SchedulingCreatePostDTO {
72
105
  mediaItems?: SchedulingMediaItemDTO[];
73
106
  platformSpecificData?: Record<string, Record<string, unknown>>;
74
107
  tiktokSettings?: Record<string, unknown>;
108
+ disclosure?: SchedulingPostDisclosureDTO;
75
109
 
76
110
  constructor(data?: Partial<SchedulingCreatePostDTO>) {
77
111
  if (!data) return;
@@ -82,6 +116,7 @@ export class SchedulingCreatePostDTO {
82
116
  this.mediaItems = data.mediaItems;
83
117
  this.platformSpecificData = data.platformSpecificData;
84
118
  this.tiktokSettings = data.tiktokSettings;
119
+ this.disclosure = data.disclosure;
85
120
  }
86
121
  }
87
122
 
@@ -208,6 +243,12 @@ export class SchedulingPostDTO {
208
243
  likes?: number;
209
244
  comments?: number;
210
245
  shares?: number;
246
+ /**
247
+ * What was SENT at publish time (spec 121) — the persisted disclosure state,
248
+ * never inferred. Absent on posts created before the feature or with no
249
+ * disclosure chosen.
250
+ */
251
+ disclosure?: SchedulingPostDisclosureDTO;
211
252
 
212
253
  constructor(data: SchedulingPostDTO) {
213
254
  this.id = data.id;
@@ -232,6 +273,7 @@ export class SchedulingPostDTO {
232
273
  this.likes = data.likes;
233
274
  this.comments = data.comments;
234
275
  this.shares = data.shares;
276
+ this.disclosure = data.disclosure;
235
277
  }
236
278
  }
237
279