ad2app-lib 1.24.0 → 1.27.1

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,6 +23,27 @@ 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
 
package/brand/brand.mjs CHANGED
@@ -21,6 +21,27 @@ 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 = {
@@ -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) {
@@ -194,14 +194,16 @@ exports.PRIVACY_SECTIONS = [
194
194
  {
195
195
  kind: 'ul',
196
196
  items: [
197
- { text: '**Strictly necessary cookies:** required for authentication sessions and core platform functionality. Cannot be disabled without breaking the Service. Legal basis: Art. 6(1)(b), contract performance; no consent required. Duration: session cookies expire when you close your browser; authentication cookies expire after 30 days of inactivity.' },
197
+ { text: '**Strictly necessary cookies and equivalent device storage:** required for authentication sessions and core platform functionality. Cannot be disabled without breaking the Service. Legal basis: Art. 6(1)(b), contract performance; no consent required. Your session token is held in your browser\'s local storage rather than in a cookie; a cookie is used only as a fallback where local storage is unavailable (for example private browsing). Duration: your session expires after 30 days of inactivity. Each time you open the app the 30 days start again, and if you do not return within 30 days you are signed out.' },
198
198
  { text: '**Functional cookies:** set only in direct response to an action you take (e.g. selecting a language or theme preference), and strictly necessary to deliver that specific function you have requested. They do not track you across sessions beyond preserving your chosen setting. Legal basis: strictly necessary to fulfil your explicit request under Art. 173 of the Polish Telecommunications Act (ePrivacy); no separate consent required. Duration: up to 12 months, or cleared when you clear your browser data.' },
199
199
  { text: '**Analytics cookies (PostHog):** collect usage event data in pseudonymised form (other than the identifying flows described in Section 6), enable session replay (with form-field values masked; see Section 3), and capture error reports to help us understand and improve how the Service is used. Legal basis: Art. 6(1)(a), consent. **No analytics cookies are set and no analytics events are captured before you make a choice** in the cookie consent banner shown on first visit. If you accept, PostHog sets a first-party cookie (name beginning `ph_`) on the `ad2.app` domain, valid for up to 1 year, shared between our website and the app so you are not asked twice. If you decline, no analytics cookie is set and no events are collected. Analytics data is processed on PostHog Cloud EU servers in Frankfurt, Germany (see Section 6).' },
200
200
  ],
201
201
  },
202
202
  { kind: 'p', text: 'You may withdraw or update your cookie consent at any time via the "Cookie settings" link in the footer of our website, or on this Privacy Policy page in the app. Withdrawing analytics consent does not affect platform functionality.' },
203
- { kind: 'subheading', text: 'Browser local storage' },
204
- { kind: 'p', text: 'In addition to cookies, we use browser local storage to preserve application state between sessions. This includes: your language and theme preferences; a cached copy of your subscription tier and status (retained for up to 30 days then invalidated); and draft campaign deadline data. Local storage data is stored on your device only and is not transmitted to our servers independently of your normal usage. It is cleared when you clear your browser data or log out.' },
203
+ { kind: 'subheading', text: 'Browser local storage and on-device cache' },
204
+ { kind: 'p', text: 'In addition to cookies, we use browser local storage to preserve application state between sessions. This includes: your session token (see "Strictly necessary" above); your language and theme preferences; a cached copy of your subscription tier and status (retained for up to 30 days then invalidated); and draft campaign deadline data.' },
205
+ { kind: 'p', text: 'We also keep a working copy of data you have already loaded in your browser\'s IndexedDB storage, so the app can show your most recent screens immediately when you reopen it instead of leaving you on a loading spinner. This copy holds up to 50 of your most recent responses from our API and can include your posts and drafts, your analytics figures, your connected account details, and your inbox, which contains comments and messages written by other people on your social media posts. It is limited to data your account is already entitled to see, is scoped to the signed-in account so a different user signing in on the same device cannot read it, and is deleted when you sign out, when your session ends, or when you clear your browser data.' },
206
+ { kind: 'p', text: 'All of the above is stored on your device only and is not transmitted to our servers independently of your normal usage. You can remove it at any time by signing out or clearing your browser data.' },
205
207
  ],
206
208
  },
207
209
  {
@@ -206,14 +206,16 @@ exports.PRIVACY_SECTIONS_PL = [
206
206
  {
207
207
  kind: 'ul',
208
208
  items: [
209
- { text: '**Pliki cookie ściśle niezbędne:** wymagane do obsługi sesji uwierzytelniania i podstawowych funkcji platformy. Nie można ich wyłączyć bez zakłócenia działania Usługi. Podstawa prawna: art. 6 ust. 1 lit. b, wykonanie umowy; zgoda nie jest wymagana. Czas trwania: sesyjne pliki cookie wygasają po zamknięciu przeglądarki; uwierzytelniające pliki cookie wygasają po 30 dniach bezczynności.' },
209
+ { text: '**Pliki cookie ściśle niezbędne i równoważna pamięć urządzenia:** wymagane do obsługi sesji uwierzytelniania i podstawowych funkcji platformy. Nie można ich wyłączyć bez zakłócenia działania Usługi. Podstawa prawna: art. 6 ust. 1 lit. b, wykonanie umowy; zgoda nie jest wymagana. Token Twojej sesji przechowujemy w pamięci lokalnej przeglądarki, a nie w pliku cookie; plik cookie służy wyłącznie jako rozwiązanie zapasowe tam, gdzie pamięć lokalna jest niedostępna (na przykład w trybie prywatnym). Czas trwania: Twoja sesja wygasa po 30 dniach bezczynności. Przy każdym otwarciu aplikacji te 30 dni liczone jest od nowa, a jeżeli nie wrócisz w ciągu 30 dni, nastąpi wylogowanie.' },
210
210
  { text: '**Funkcjonalne pliki cookie:** ustawiane wyłącznie w bezpośredniej reakcji na podjętą przez Ciebie czynność (np. wybór języka lub motywu) i ściśle niezbędne do wykonania tej konkretnej, zażądanej przez Ciebie funkcji. Nie śledzą Cię między sesjami poza zachowaniem wybranego przez Ciebie ustawienia. Podstawa prawna: ścisła niezbędność do spełnienia Twojego wyraźnego żądania na podstawie art. 173 Prawa telekomunikacyjnego (ePrivacy); odrębna zgoda nie jest wymagana. Czas trwania: do 12 miesięcy lub do wyczyszczenia przez Ciebie danych przeglądarki.' },
211
211
  { text: '**Analityczne pliki cookie (PostHog):** zbierają dane o zdarzeniach korzystania w postaci spseudonimizowanej (poza przepływami identyfikującymi opisanymi w sekcji 6), umożliwiają nagrywanie sesji (z maskowaniem wartości wpisywanych w pola formularzy; zobacz sekcję 3) i rejestrują raporty o błędach, abyśmy mogli rozumieć i ulepszać sposób korzystania z Usługi. Podstawa prawna: art. 6 ust. 1 lit. a, zgoda. **Zanim dokonasz wyboru w banerze zgody na pliki cookie, wyświetlanym przy pierwszej wizycie, nie ustawiamy żadnych analitycznych plików cookie i nie rejestrujemy żadnych zdarzeń analitycznych.** Jeżeli wyrazisz zgodę, PostHog ustawia własny plik cookie (o nazwie zaczynającej się od `ph_`) w domenie `ad2.app`, ważny do 1 roku, współdzielony między naszą stroną internetową a aplikacją, aby nie pytać Cię o to dwa razy. Jeżeli odmówisz, żaden analityczny plik cookie nie zostanie ustawiony i żadne zdarzenia nie będą zbierane. Dane analityczne są przetwarzane na serwerach PostHog Cloud EU we Frankfurcie w Niemczech (zobacz sekcję 6).' },
212
212
  ],
213
213
  },
214
214
  { kind: 'p', text: 'Zgodę na pliki cookie możesz wycofać lub zaktualizować w każdej chwili poprzez link "Ustawienia cookie" w stopce naszej strony internetowej albo na stronie niniejszej Polityki Prywatności w aplikacji. Wycofanie zgody analitycznej nie wpływa na działanie platformy.' },
215
- { kind: 'subheading', text: 'Pamięć lokalna przeglądarki' },
216
- { kind: 'p', text: 'Poza plikami cookie korzystamy z pamięci lokalnej przeglądarki, aby zachować stan aplikacji między sesjami. Obejmuje to: Twoje preferencje języka i motywu; zbuforowaną kopię poziomu i statusu Twojej subskrypcji (przechowywaną do 30 dni, a następnie unieważnianą); oraz robocze dane o terminach kampanii. Dane z pamięci lokalnej są przechowywane wyłącznie na Twoim urządzeniu i nie są przesyłane na nasze serwery niezależnie od Twojego zwykłego korzystania z Usługi. Są usuwane, gdy wyczyścisz dane przeglądarki lub się wylogujesz.' },
215
+ { kind: 'subheading', text: 'Pamięć lokalna przeglądarki i podręczna kopia na urządzeniu' },
216
+ { kind: 'p', text: 'Poza plikami cookie korzystamy z pamięci lokalnej przeglądarki, aby zachować stan aplikacji między sesjami. Obejmuje to: token Twojej sesji (zobacz „ściśle niezbędne" powyżej); Twoje preferencje języka i motywu; zbuforowaną kopię poziomu i statusu Twojej subskrypcji (przechowywaną do 30 dni, a następnie unieważnianą); oraz robocze dane o terminach kampanii.' },
217
+ { kind: 'p', text: 'Przechowujemy również roboczą kopię danych, które już wcześniej wczytałeś, w pamięci IndexedDB Twojej przeglądarki, aby po ponownym otwarciu aplikacji od razu pokazać Twoje ostatnie ekrany zamiast zostawiać Cię przy animacji ładowania. Kopia ta obejmuje do 50 Twoich najnowszych odpowiedzi z naszego API i może zawierać Twoje posty i wersje robocze, Twoje dane statystyczne, dane połączonych kont oraz Twoją skrzynkę odbiorczą, która zawiera komentarze i wiadomości napisane przez inne osoby pod Twoimi postami w mediach społecznościowych. Ogranicza się do danych, do których Twoje konto i tak ma dostęp, jest przypisana do zalogowanego konta, więc inny użytkownik logujący się na tym samym urządzeniu nie może jej odczytać, i jest usuwana przy wylogowaniu, po zakończeniu sesji oraz po wyczyszczeniu danych przeglądarki.' },
218
+ { kind: 'p', text: 'Wszystkie powyższe dane są przechowywane wyłącznie na Twoim urządzeniu i nie są przesyłane na nasze serwery niezależnie od Twojego zwykłego korzystania z Usługi. Możesz je w każdej chwili usunąć, wylogowując się lub czyszcząc dane przeglądarki.' },
217
219
  ],
218
220
  },
219
221
  {
@@ -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.24.0",
3
+ "version": "1.27.1",
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
+ });
@@ -205,14 +205,16 @@ export const PRIVACY_SECTIONS_PL: LegalSection[] = [
205
205
  {
206
206
  kind: 'ul',
207
207
  items: [
208
- { text: '**Pliki cookie ściśle niezbędne:** wymagane do obsługi sesji uwierzytelniania i podstawowych funkcji platformy. Nie można ich wyłączyć bez zakłócenia działania Usługi. Podstawa prawna: art. 6 ust. 1 lit. b, wykonanie umowy; zgoda nie jest wymagana. Czas trwania: sesyjne pliki cookie wygasają po zamknięciu przeglądarki; uwierzytelniające pliki cookie wygasają po 30 dniach bezczynności.' },
208
+ { text: '**Pliki cookie ściśle niezbędne i równoważna pamięć urządzenia:** wymagane do obsługi sesji uwierzytelniania i podstawowych funkcji platformy. Nie można ich wyłączyć bez zakłócenia działania Usługi. Podstawa prawna: art. 6 ust. 1 lit. b, wykonanie umowy; zgoda nie jest wymagana. Token Twojej sesji przechowujemy w pamięci lokalnej przeglądarki, a nie w pliku cookie; plik cookie służy wyłącznie jako rozwiązanie zapasowe tam, gdzie pamięć lokalna jest niedostępna (na przykład w trybie prywatnym). Czas trwania: Twoja sesja wygasa po 30 dniach bezczynności. Przy każdym otwarciu aplikacji te 30 dni liczone jest od nowa, a jeżeli nie wrócisz w ciągu 30 dni, nastąpi wylogowanie.' },
209
209
  { text: '**Funkcjonalne pliki cookie:** ustawiane wyłącznie w bezpośredniej reakcji na podjętą przez Ciebie czynność (np. wybór języka lub motywu) i ściśle niezbędne do wykonania tej konkretnej, zażądanej przez Ciebie funkcji. Nie śledzą Cię między sesjami poza zachowaniem wybranego przez Ciebie ustawienia. Podstawa prawna: ścisła niezbędność do spełnienia Twojego wyraźnego żądania na podstawie art. 173 Prawa telekomunikacyjnego (ePrivacy); odrębna zgoda nie jest wymagana. Czas trwania: do 12 miesięcy lub do wyczyszczenia przez Ciebie danych przeglądarki.' },
210
210
  { text: '**Analityczne pliki cookie (PostHog):** zbierają dane o zdarzeniach korzystania w postaci spseudonimizowanej (poza przepływami identyfikującymi opisanymi w sekcji 6), umożliwiają nagrywanie sesji (z maskowaniem wartości wpisywanych w pola formularzy; zobacz sekcję 3) i rejestrują raporty o błędach, abyśmy mogli rozumieć i ulepszać sposób korzystania z Usługi. Podstawa prawna: art. 6 ust. 1 lit. a, zgoda. **Zanim dokonasz wyboru w banerze zgody na pliki cookie, wyświetlanym przy pierwszej wizycie, nie ustawiamy żadnych analitycznych plików cookie i nie rejestrujemy żadnych zdarzeń analitycznych.** Jeżeli wyrazisz zgodę, PostHog ustawia własny plik cookie (o nazwie zaczynającej się od `ph_`) w domenie `ad2.app`, ważny do 1 roku, współdzielony między naszą stroną internetową a aplikacją, aby nie pytać Cię o to dwa razy. Jeżeli odmówisz, żaden analityczny plik cookie nie zostanie ustawiony i żadne zdarzenia nie będą zbierane. Dane analityczne są przetwarzane na serwerach PostHog Cloud EU we Frankfurcie w Niemczech (zobacz sekcję 6).' },
211
211
  ],
212
212
  },
213
213
  { kind: 'p', text: 'Zgodę na pliki cookie możesz wycofać lub zaktualizować w każdej chwili poprzez link "Ustawienia cookie" w stopce naszej strony internetowej albo na stronie niniejszej Polityki Prywatności w aplikacji. Wycofanie zgody analitycznej nie wpływa na działanie platformy.' },
214
- { kind: 'subheading', text: 'Pamięć lokalna przeglądarki' },
215
- { kind: 'p', text: 'Poza plikami cookie korzystamy z pamięci lokalnej przeglądarki, aby zachować stan aplikacji między sesjami. Obejmuje to: Twoje preferencje języka i motywu; zbuforowaną kopię poziomu i statusu Twojej subskrypcji (przechowywaną do 30 dni, a następnie unieważnianą); oraz robocze dane o terminach kampanii. Dane z pamięci lokalnej są przechowywane wyłącznie na Twoim urządzeniu i nie są przesyłane na nasze serwery niezależnie od Twojego zwykłego korzystania z Usługi. Są usuwane, gdy wyczyścisz dane przeglądarki lub się wylogujesz.' },
214
+ { kind: 'subheading', text: 'Pamięć lokalna przeglądarki i podręczna kopia na urządzeniu' },
215
+ { kind: 'p', text: 'Poza plikami cookie korzystamy z pamięci lokalnej przeglądarki, aby zachować stan aplikacji między sesjami. Obejmuje to: token Twojej sesji (zobacz „ściśle niezbędne" powyżej); Twoje preferencje języka i motywu; zbuforowaną kopię poziomu i statusu Twojej subskrypcji (przechowywaną do 30 dni, a następnie unieważnianą); oraz robocze dane o terminach kampanii.' },
216
+ { kind: 'p', text: 'Przechowujemy również roboczą kopię danych, które już wcześniej wczytałeś, w pamięci IndexedDB Twojej przeglądarki, aby po ponownym otwarciu aplikacji od razu pokazać Twoje ostatnie ekrany zamiast zostawiać Cię przy animacji ładowania. Kopia ta obejmuje do 50 Twoich najnowszych odpowiedzi z naszego API i może zawierać Twoje posty i wersje robocze, Twoje dane statystyczne, dane połączonych kont oraz Twoją skrzynkę odbiorczą, która zawiera komentarze i wiadomości napisane przez inne osoby pod Twoimi postami w mediach społecznościowych. Ogranicza się do danych, do których Twoje konto i tak ma dostęp, jest przypisana do zalogowanego konta, więc inny użytkownik logujący się na tym samym urządzeniu nie może jej odczytać, i jest usuwana przy wylogowaniu, po zakończeniu sesji oraz po wyczyszczeniu danych przeglądarki.' },
217
+ { kind: 'p', text: 'Wszystkie powyższe dane są przechowywane wyłącznie na Twoim urządzeniu i nie są przesyłane na nasze serwery niezależnie od Twojego zwykłego korzystania z Usługi. Możesz je w każdej chwili usunąć, wylogowując się lub czyszcząc dane przeglądarki.' },
216
218
  ],
217
219
  },
218
220
  {
@@ -193,14 +193,16 @@ export const PRIVACY_SECTIONS: LegalSection[] = [
193
193
  {
194
194
  kind: 'ul',
195
195
  items: [
196
- { text: '**Strictly necessary cookies:** required for authentication sessions and core platform functionality. Cannot be disabled without breaking the Service. Legal basis: Art. 6(1)(b), contract performance; no consent required. Duration: session cookies expire when you close your browser; authentication cookies expire after 30 days of inactivity.' },
196
+ { text: '**Strictly necessary cookies and equivalent device storage:** required for authentication sessions and core platform functionality. Cannot be disabled without breaking the Service. Legal basis: Art. 6(1)(b), contract performance; no consent required. Your session token is held in your browser\'s local storage rather than in a cookie; a cookie is used only as a fallback where local storage is unavailable (for example private browsing). Duration: your session expires after 30 days of inactivity. Each time you open the app the 30 days start again, and if you do not return within 30 days you are signed out.' },
197
197
  { text: '**Functional cookies:** set only in direct response to an action you take (e.g. selecting a language or theme preference), and strictly necessary to deliver that specific function you have requested. They do not track you across sessions beyond preserving your chosen setting. Legal basis: strictly necessary to fulfil your explicit request under Art. 173 of the Polish Telecommunications Act (ePrivacy); no separate consent required. Duration: up to 12 months, or cleared when you clear your browser data.' },
198
198
  { text: '**Analytics cookies (PostHog):** collect usage event data in pseudonymised form (other than the identifying flows described in Section 6), enable session replay (with form-field values masked; see Section 3), and capture error reports to help us understand and improve how the Service is used. Legal basis: Art. 6(1)(a), consent. **No analytics cookies are set and no analytics events are captured before you make a choice** in the cookie consent banner shown on first visit. If you accept, PostHog sets a first-party cookie (name beginning `ph_`) on the `ad2.app` domain, valid for up to 1 year, shared between our website and the app so you are not asked twice. If you decline, no analytics cookie is set and no events are collected. Analytics data is processed on PostHog Cloud EU servers in Frankfurt, Germany (see Section 6).' },
199
199
  ],
200
200
  },
201
201
  { kind: 'p', text: 'You may withdraw or update your cookie consent at any time via the "Cookie settings" link in the footer of our website, or on this Privacy Policy page in the app. Withdrawing analytics consent does not affect platform functionality.' },
202
- { kind: 'subheading', text: 'Browser local storage' },
203
- { kind: 'p', text: 'In addition to cookies, we use browser local storage to preserve application state between sessions. This includes: your language and theme preferences; a cached copy of your subscription tier and status (retained for up to 30 days then invalidated); and draft campaign deadline data. Local storage data is stored on your device only and is not transmitted to our servers independently of your normal usage. It is cleared when you clear your browser data or log out.' },
202
+ { kind: 'subheading', text: 'Browser local storage and on-device cache' },
203
+ { kind: 'p', text: 'In addition to cookies, we use browser local storage to preserve application state between sessions. This includes: your session token (see "Strictly necessary" above); your language and theme preferences; a cached copy of your subscription tier and status (retained for up to 30 days then invalidated); and draft campaign deadline data.' },
204
+ { kind: 'p', text: 'We also keep a working copy of data you have already loaded in your browser\'s IndexedDB storage, so the app can show your most recent screens immediately when you reopen it instead of leaving you on a loading spinner. This copy holds up to 50 of your most recent responses from our API and can include your posts and drafts, your analytics figures, your connected account details, and your inbox, which contains comments and messages written by other people on your social media posts. It is limited to data your account is already entitled to see, is scoped to the signed-in account so a different user signing in on the same device cannot read it, and is deleted when you sign out, when your session ends, or when you clear your browser data.' },
205
+ { kind: 'p', text: 'All of the above is stored on your device only and is not transmitted to our servers independently of your normal usage. You can remove it at any time by signing out or clearing your browser data.' },
204
206
  ],
205
207
  },
206
208
  {
@@ -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