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 +30 -0
- package/brand/brand.mjs +35 -0
- package/dist/analytics/index.d.ts +78 -4
- package/dist/analytics/index.js +34 -2
- package/dist/api/apiDriver.d.ts +8 -0
- package/dist/api/apiDriver.js +1 -0
- package/dist/legal/terms.js +2 -2
- package/dist/legal/terms.pl.js +2 -2
- package/dist/types/scheduling/I_SchedulingPost.d.ts +27 -0
- package/dist/types/scheduling/I_SchedulingPost.js +19 -1
- package/package.json +1 -1
- package/src/analytics/index.test.ts +114 -5
- package/src/analytics/index.ts +103 -10
- package/src/api/apiDriver.test.ts +26 -3
- package/src/api/apiDriver.ts +9 -0
- package/src/brand/v5-accent-tokens.test.ts +145 -0
- package/src/legal/terms.pl.ts +2 -2
- package/src/legal/terms.ts +2 -2
- package/src/types/scheduling/I_SchedulingPost.test.ts +16 -0
- package/src/types/scheduling/I_SchedulingPost.ts +42 -0
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
|
-
/**
|
|
70
|
-
export
|
|
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;
|
package/dist/analytics/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Do NOT rename events after they ship — historical data does not migrate.
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.PERSON_PROPS = exports.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',
|
package/dist/api/apiDriver.d.ts
CHANGED
|
@@ -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.
|
package/dist/api/apiDriver.js
CHANGED
package/dist/legal/terms.js
CHANGED
|
@@ -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
|
|
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,
|
|
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.' },
|
package/dist/legal/terms.pl.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
@@ -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 {
|
|
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
|
-
//
|
|
145
|
-
|
|
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(),
|
|
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
|
+
});
|
package/src/analytics/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
101
|
-
export
|
|
102
|
-
|
|
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
|
-
|
|
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:
|
|
344
|
-
//
|
|
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
|
-
|
|
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
|
+
});
|
package/src/api/apiDriver.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/legal/terms.pl.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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.' },
|
package/src/legal/terms.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|