@monetize.software/sdk 3.0.0-alpha.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.
@@ -0,0 +1,1695 @@
1
+ import { ComponentType } from 'preact';
2
+
3
+ declare type Acquiring = 'stripe' | 'paddle' | 'chargebee' | 'overpay' | 'freemius';
4
+
5
+ export declare interface AnalyticsOptions {
6
+ enabled?: boolean;
7
+ /** Полный URL до /events. По умолчанию — `${apiOrigin}/api/v1/paywall/${id}/events`. */
8
+ endpoint?: string;
9
+ flushIntervalMs?: number;
10
+ maxBufferSize?: number;
11
+ /** Тестовый override fetch'а (jsdom/Vitest). */
12
+ fetch?: typeof fetch;
13
+ /** Тестовый override sendBeacon'а. */
14
+ sendBeacon?: (url: string, data: BodyInit) => boolean;
15
+ }
16
+
17
+ export declare class ApiClient {
18
+ private opts;
19
+ constructor(opts: ApiClientOptions);
20
+ request<T>(path: string, init?: RequestInit): Promise<T>;
21
+ }
22
+
23
+ export declare interface ApiClientOptions {
24
+ apiOrigin: string;
25
+ paywallId: string;
26
+ getAuthToken?: () => string | null | Promise<string | null>;
27
+ capabilities?: string[];
28
+ fetch?: typeof fetch;
29
+ }
30
+
31
+ export declare interface ApiGatewayCallParams {
32
+ /** UUID api-провайдера из платформы (`paywall_internal_api_providers.id`). */
33
+ providerId: string;
34
+ /** Путь после провайдера: `v1/chat/completions`, `messages`, и т.д.
35
+ * Конкатенируется через `/`. Пустая строка/undefined = root провайдера. */
36
+ path?: string;
37
+ method?: 'GET' | 'POST';
38
+ /** JSON-сериализуемый объект → application/json. FormData → multipart с
39
+ * авто-boundary. ReadableStream/Blob/string — пробрасываются как есть.
40
+ * Если undefined и method='POST' — отправляется пустое тело. */
41
+ body?: unknown;
42
+ /** Дополнительные хедеры. Перетирают наши, кроме Authorization (его всегда
43
+ * ставим из auth) и X-Paywall-Id. */
44
+ headers?: Record<string, string>;
45
+ signal?: AbortSignal;
46
+ }
47
+
48
+ export declare class ApiGatewayClient {
49
+ readonly paywallId: string;
50
+ readonly apiOrigin: string;
51
+ private auth;
52
+ private userId;
53
+ private capabilities;
54
+ private customFetch;
55
+ private onChargeSuccess;
56
+ private onQuotaExceeded;
57
+ constructor(opts: ApiGatewayClientOptions);
58
+ call(params: ApiGatewayCallParams): Promise<Response>;
59
+ }
60
+
61
+ export declare interface ApiGatewayClientOptions {
62
+ paywallId: string;
63
+ apiOrigin?: string;
64
+ /** AuthClient — Bearer добавляется автоматически. На 401 от gateway клиент
65
+ * не делает refresh: AuthClient уже сделал lazy-refresh в getAccessToken. */
66
+ auth?: AuthClient;
67
+ /** Headless-сценарий или legacy-флоу: явный userId вместо Bearer.
68
+ * Передаётся как `X-User-ID`. Если задан и `auth` — Bearer выигрывает. */
69
+ userId?: string;
70
+ capabilities?: string[];
71
+ fetch?: typeof fetch;
72
+ /** Хук для оптимистичного декремента балансов в BillingClient.
73
+ * ApiGatewayClient его дёргает на 200 (success), передавая queryType из
74
+ * ответа (если бэк его прислал в `X-Query-Type`) или undefined.
75
+ * Парсить body для извлечения queryType ApiGatewayClient НЕ умеет — это
76
+ * было бы лишним чтением body, а главное — body может быть стримом. */
77
+ onChargeSuccess?: (queryType: string | undefined) => void;
78
+ /** Хук для рефетча балансов после 402. BillingClient ходит к /balances
79
+ * и обновляет state, чтобы UI показал актуальный счётчик. */
80
+ onQuotaExceeded?: (err: QuotaExceededError) => void;
81
+ }
82
+
83
+ export declare type AuthChangeListener = (session: AuthSession | null) => void;
84
+
85
+ export declare class AuthClient {
86
+ readonly paywallId: string;
87
+ readonly apiOrigin: string;
88
+ private storage;
89
+ private api;
90
+ private openPopup;
91
+ private session;
92
+ private hydrated;
93
+ private inflightRefresh;
94
+ /** Дедупликация параллельных signInAnonymously: два click'а на «Войти как
95
+ * гость» должны попасть в одного юзера, не плодить двух (двойная капча +
96
+ * второй /signup создал бы вторую запись с потерянным trial-балансом). */
97
+ private inflightAnonSignin;
98
+ private listeners;
99
+ private storageUnwatch;
100
+ private destroyed;
101
+ /** Pending OAuth flows: state → {verifier, userMeta, startedAt}. Между
102
+ * startOAuthFlow и completeOAuthFlow. GC'атся через OAUTH_FLOW_TTL_MS. */
103
+ private oauthFlows;
104
+ constructor(opts: AuthClientOptions);
105
+ /**
106
+ * Подписывается на изменения session-ключа в storage из других контекстов:
107
+ * - Chrome Extension: `chrome.storage.onChanged` шарится popup ↔ background ↔
108
+ * options ↔ content script. Логин в одном контексте → остальные сразу
109
+ * эмитят onAuthChange и в getAccessToken отдают свежий Bearer.
110
+ * - Web: `window.storage` event фаерится в ДРУГИХ вкладках того же origin'а
111
+ * (своя вкладка свой setItem не получает — петель нет).
112
+ *
113
+ * Loop-guard: сравниваем content по полям session перед applySession, чтобы
114
+ * не фрить лишних onAuthChange при идентичной перезаписи. Вызовы из других
115
+ * контекстов с тем же содержимым (пересохранение) — no-op.
116
+ */
117
+ private startStorageWatch;
118
+ private applyExternalSession;
119
+ /**
120
+ * Promise гидратации session из storage. До его resolve getCachedSession()
121
+ * может ещё вернуть null. getAccessToken/refresh/signOut/sign* awaitят его
122
+ * сами, наружу выставляем для UI'я, чтобы он мог дождаться initial state
123
+ * прежде чем рисовать «logged-out» вспышку.
124
+ */
125
+ ready(): Promise<void>;
126
+ /** Sync snapshot без сетевых запросов. null = разлогинен или ещё не гидрировались. */
127
+ getCachedSession(): AuthSession | null;
128
+ getCachedUser(): AuthUser | null;
129
+ /**
130
+ * access_token для Authorization-хедера. Если до expiry < REFRESH_LEEWAY_MS,
131
+ * делает lazy refresh. null = разлогинен или refresh упал на 401 (refresh
132
+ * token revoked) — вызывающему стоит редиректить на логин.
133
+ *
134
+ * Сетевые/5xx ошибки refresh бросаются — текущий access ещё валиден,
135
+ * вызывающий может попробовать запрос с ним; следующий getAccessToken
136
+ * попробует refresh снова.
137
+ */
138
+ getAccessToken(): Promise<string | null>;
139
+ signInWithEmail(input: {
140
+ email: string;
141
+ password: string;
142
+ userMeta?: Record<string, string>;
143
+ /** Idempotency-key (UUID) — повторный submit при двойном клике вернёт
144
+ * тот же результат вместо второго запроса в GoTrue. Без передачи
145
+ * inflight-дедупликации нет; SDK не дедуплицирует auth по умолчанию,
146
+ * потому что email/password можно поменять между кликами. */
147
+ idempotencyKey?: string;
148
+ }): Promise<AuthSession>;
149
+ /**
150
+ * Signup. Если в Supabase включён email confirm — сервер возвращает
151
+ * `{status: 'confirmation_required', user}` и НЕ выдаёт токены. В этом
152
+ * случае setSession не зовётся, юзер должен пройти OTP/magic-link
153
+ * (отдельная фича следующего PR).
154
+ */
155
+ signUp(input: {
156
+ email: string;
157
+ password: string;
158
+ userMeta?: Record<string, string>;
159
+ /** Idempotency-key (UUID). Защита от двойного клика на «Sign Up» —
160
+ * без неё бэк может создать trial-balances и отправить confirmation-email
161
+ * дважды. */
162
+ idempotencyKey?: string;
163
+ }): Promise<SignUpResult>;
164
+ /**
165
+ * Повторная отправка confirmation-email после signUp с включённым
166
+ * email-confirm. Использует GoTrue `/resend` type='signup'. Бэк всегда
167
+ * отдаёт ok (anti-enumeration), кроме 429 при rate-limit (~1 раз/мин на
168
+ * email на стороне Supabase). Host обрабатывает 429 показом «подождите
169
+ * минуту»; остальное — как success.
170
+ */
171
+ resendConfirmation(input: {
172
+ email: string;
173
+ /** Защита от двойного клика. */
174
+ idempotencyKey?: string;
175
+ }): Promise<void>;
176
+ /**
177
+ * Email-OTP / signin без password. Шлёт 6-значный код юзеру на email.
178
+ * Anti-enumeration: бэк всегда отдаёт ok, поэтому метод не различает
179
+ * «email не существует» и «отправлено» — следующий шаг (verifyOtp) сам
180
+ * упадёт invalid_otp если юзера нет. Под капотом GoTrue с create_user=true,
181
+ * так что новые юзеры через OTP логинятся за один шаг (отправка → ввод
182
+ * кода → session).
183
+ */
184
+ sendOtp(input: {
185
+ email: string;
186
+ createUser?: boolean;
187
+ userMeta?: Record<string, unknown>;
188
+ }): Promise<void>;
189
+ /**
190
+ * Верификация OTP. type='email' (signin/signup-by-otp) — после успеха
191
+ * setSession и onAuthChange. type='recovery' — после /requestPasswordReset:
192
+ * выдаётся короткоживущий access_token для последующего updatePassword.
193
+ * Мы храним recovery-session так же, как обычную: SDK не различает «можно
194
+ * залогиниться» vs «можно сменить пароль» — это одна и та же session.
195
+ */
196
+ verifyOtp(input: {
197
+ email: string;
198
+ token: string;
199
+ type?: OtpVerifyType;
200
+ userMeta?: Record<string, string>;
201
+ }): Promise<AuthSession>;
202
+ /**
203
+ * Запрос recovery email. Бэк всегда ok, чтобы не палить enumeration.
204
+ * Юзер вводит код из письма в SDK-ui → verifyOtp({type:'recovery'}) →
205
+ * получает session → updatePassword.
206
+ */
207
+ requestPasswordReset(input: {
208
+ email: string;
209
+ }): Promise<void>;
210
+ /**
211
+ * Меняет пароль текущей session. Работает после verifyOtp({type:'recovery'})
212
+ * (recovery-session) и после обычного логина — оба случая дают валидный
213
+ * access_token. Если session нет — бросаем PaywallError('not_authenticated')
214
+ * до сетевого запроса, чтобы UI не дёргал бэк впустую.
215
+ */
216
+ updatePassword(input: {
217
+ password: string;
218
+ }): Promise<void>;
219
+ /**
220
+ * Анонимный signin (Supabase user без email). Лестница попыток:
221
+ *
222
+ * 1. Если уже залогинены анонимно (session.user.is_anonymous === true) —
223
+ * no-op, возвращаем текущую session. Идемпотентно для UI'я, который
224
+ * может звать signInAnonymously() в render-loop'е, не отслеживая state.
225
+ *
226
+ * 2. Resume через сохранённый anon refresh_token (`STORAGE_KEYS.anonRefreshToken`).
227
+ * Если токен есть — пробуем `/auth/refresh` им. Success → setSession,
228
+ * возвращаем юзера ТОГО ЖЕ id что был при предыдущем anon signin'е
229
+ * (обещание из user-фидбека: «если разлогинился из анонимного —
230
+ * логинить в этот же акк»).
231
+ *
232
+ * 3. Иначе → POST /auth/anonymous/signin → setSession + сохраняем
233
+ * refresh_token в anonRefreshToken.
234
+ *
235
+ * `captchaToken` сейчас не требуется — captcha protection в Supabase
236
+ * отключена, защита от per-IP abuse держится на rate-limit'е Supabase'а
237
+ * (30/час per real-IP, см. IP forwarding setup в supabaseAuthRest.ts) +
238
+ * CF Bot Fight Mode на edge. Поле оставлено optional для forward-compat:
239
+ * когда сервер начнёт возвращать challenge_required в риск-сценариях,
240
+ * SDK сможет передать proof-of-something обратно без breaking change.
241
+ *
242
+ * `forceCaptcha: true` пропускает шаги 1-2 и сразу делает /signin (создаёт
243
+ * нового anon-юзера). Используется в switch-account flow. Имя поля исторически
244
+ * остаётся `forceCaptcha`, хотя капчи там больше нет — менять имя ломает
245
+ * host-сигнатуру; смысл «принудительно новая anon-сессия» сохранён.
246
+ *
247
+ * Параллельные вызовы дедуплицируются через `inflightAnonSignin` — два
248
+ * click'а на «Войти как гость» не создадут двух anon-юзеров (два /signup =
249
+ * два user_id, второй trial-баланс улетает в нирвану).
250
+ */
251
+ signInAnonymously(input?: {
252
+ captchaToken?: string;
253
+ userMeta?: Record<string, string>;
254
+ forceCaptcha?: boolean;
255
+ }): Promise<AuthSession>;
256
+ /**
257
+ * Внутренний resume — пробует /auth/refresh с сохранённым anon refresh_token.
258
+ * Возвращает session при успехе, null если токена нет или он отозван (401).
259
+ * Сетевые ошибки бросает наружу — caller сам решает, ретраить или просить
260
+ * пользователя пройти капчу.
261
+ */
262
+ private resumeAnonymous;
263
+ /**
264
+ * Анон → email/password upgrade. Сохраняет тот же auth.user.id, балансы
265
+ * и trial-quotas остаются. Поведение зависит от Supabase email-confirm
266
+ * настройки проекта:
267
+ *
268
+ * - Confirmation OFF → backend сразу обновляет email + password в auth.users.
269
+ * Возвращаем `kind: 'updated'`, локально патчим session.user.email +
270
+ * is_anonymous=false (текущий access_token остаётся валидным, перевыдавать
271
+ * не нужно — GoTrue не вращает токены на updateUser).
272
+ *
273
+ * - Confirmation ON → backend отдаёт `confirmation_required`. Текущая
274
+ * session ОСТАЁТСЯ анонимной до клика юзером по confirmation-ссылке.
275
+ * Password применяется сразу (можно дальше логиниться по нему даже до
276
+ * confirm'а). После клика — следующий /auth/refresh подтянет обновлённый
277
+ * is_anonymous=false из JWT (refresh не возвращает user, так что
278
+ * UI может явно подёргать `auth.refresh()` через минуту-другую, либо
279
+ * дождаться lazy-refresh при истечении access).
280
+ *
281
+ * Без активной session бросает `not_authenticated`. Дедупликации нет —
282
+ * двойной submit формы UI должен предотвратить idempotencyKey'ом.
283
+ */
284
+ upgradeAnonymousToEmail(input: {
285
+ email: string;
286
+ password: string;
287
+ userMeta?: Record<string, string>;
288
+ /** Idempotency-key для защиты от двойного клика. GoTrue PUT /user не
289
+ * идемпотентен сам по себе — повторный submit при двойном клике может
290
+ * вызвать race с email-confirmation (две confirmation-ссылки на тот же
291
+ * адрес). UI должен передать UUID. */
292
+ idempotencyKey?: string;
293
+ }): Promise<UpgradeAnonymousResult>;
294
+ /**
295
+ * OAuth signin через popup с PKCE. Жизненный цикл:
296
+ * 1. Генерим verifier+challenge+state локально (verifier не уходит на бэк
297
+ * до /exchange — это защита от перехвата code'а).
298
+ * 2. POST /oauth/init с challenge → бэк отдаёт authorize_url.
299
+ * 3. Открываем popup, ждём postMessage с типом 'pw-oauth' и нашим state.
300
+ * 4. POST /oauth/exchange с {auth_code, code_verifier} → session.
301
+ *
302
+ * Таймаут — 5 минут от открытия popup'а. Если юзер закрыл popup до конца
303
+ * флоу (window.closed → true) — бросаем PaywallError('oauth_cancelled').
304
+ * Параллельные вызовы НЕ дедупятся — каждый открывает свой popup; вызывать
305
+ * параллельно не имеет смысла, но защищаться от этого код не должен.
306
+ *
307
+ * onPopupOpened вызывается сразу после успешного window.open (до ожидания
308
+ * code'а). UI использует это, чтобы сбросить loading-state кнопки: дальше
309
+ * ответственность за флоу у popup'а, основная страница не должна висеть.
310
+ * Если popup'ом не вернулся code (юзер закрыл вкладку, closed-detection
311
+ * не сработал из-за COOP-severance) — promise дойдёт до oauth_timeout
312
+ * через 5 минут, но кнопка к этому моменту уже свободна.
313
+ */
314
+ signInWithOAuth(input: {
315
+ provider: OAuthProvider;
316
+ scopes?: string;
317
+ userMeta?: Record<string, string>;
318
+ onPopupOpened?: () => void;
319
+ }): Promise<AuthSession>;
320
+ /**
321
+ * Шаг 1 OAuth split-API: инициирует flow на бэке, генерит PKCE verifier
322
+ * + state, сохраняет их у себя, возвращает `{authorize_url, state}` для
323
+ * открытия popup'а. Верификатор НЕ выходит наружу — его держит AuthClient
324
+ * до `completeOAuthFlow`.
325
+ *
326
+ * Используется в offscreen-архитектуре (@monetize/sdk-extension): start
327
+ * вызывается через RPC из content-script'а, content открывает popup
328
+ * нативно (gesture preserved), затем зовёт completeOAuthFlow с code'ом.
329
+ * AuthClient (в offscreen'е) делает /exchange с сохранённым verifier'ом.
330
+ *
331
+ * Pending flows GC'атся через 10мин — больше чем юзеру нужно прокликать
332
+ * Google. Без cleanup'а Map бы рос на каждый закрытый popup.
333
+ */
334
+ startOAuthFlow(input: {
335
+ provider: OAuthProvider;
336
+ scopes?: string;
337
+ userMeta?: Record<string, string>;
338
+ }): Promise<{
339
+ authorize_url: string;
340
+ state: string;
341
+ }>;
342
+ /**
343
+ * Шаг 2 OAuth split-API: обменивает code (полученный из popup) на session,
344
+ * используя verifier, сохранённый при startOAuthFlow. После успеха — set
345
+ * session и эмит onAuthChange.
346
+ *
347
+ * Если flow не найден (state не из startOAuthFlow или GC'нулся за TTL'ом) —
348
+ * бросает `oauth_invalid_state`. Caller должен начать заново через
349
+ * startOAuthFlow.
350
+ */
351
+ completeOAuthFlow(input: {
352
+ state: string;
353
+ code: string;
354
+ }): Promise<AuthSession>;
355
+ private gcOAuthFlows;
356
+ /**
357
+ * Refresh access/refresh пары через текущий refresh_token. Дедуплицирует
358
+ * параллельные вызовы (один in-flight promise на весь клиент).
359
+ *
360
+ * - 401 → refresh_token отозван/невалиден → чистим session, эмитим logout.
361
+ * - Сеть/5xx → пробрасываем ошибку, session оставляем — юзер не должен
362
+ * разлогиниваться из-за временной сетевой проблемы.
363
+ * - Нет session → возвращаем null без сетевого запроса.
364
+ */
365
+ refresh(): Promise<AuthSession | null>;
366
+ /**
367
+ * Глобальный logout — инвалидирует ВСЕ refresh-токены юзера на всех
368
+ * устройствах/контекстах через GoTrue `/logout?scope=global`. Используется
369
+ * для compromise-account флоу («подозрительная активность, разлогинить
370
+ * везде»).
371
+ *
372
+ * Local-side: чистим текущую session, остальные контексты (другие вкладки
373
+ * / extension popup и background) подхватят logout через storage-watch
374
+ * автоматически. Active access-токены в других контекстах останутся валидны
375
+ * до их естественного истечения (1 час max), но refresh уже не сработает —
376
+ * после первого `getAccessToken()` каждый контекст разлогинится сам.
377
+ *
378
+ * Безопасность: бэк не принимает целевой user_id — резолвит юзера из
379
+ * Bearer, нельзя разлогинить чужой аккаунт.
380
+ */
381
+ revokeAllSessions(): Promise<void>;
382
+ /**
383
+ * Signout: чистит локальную session СРАЗУ (UX — мгновенный logout без
384
+ * ожидания сети), потом best-effort POST /auth/signout с текущим access.
385
+ * Ошибка сети/5xx тут уже не критична — на бэке токен и так истечёт.
386
+ *
387
+ * Anon-aware: по умолчанию anonRefreshToken сохраняется. Это позволяет
388
+ * после signOut() позвать signInAnonymously() и попасть в ТОТ ЖЕ
389
+ * анон-аккаунт без капчи (см. resumeAnonymous). Поведение предсказуемое
390
+ * для UX'а «гость → залогинился → разлогинился → снова гость с теми же
391
+ * балансами».
392
+ *
393
+ * `forgetAnonymous: true` — полное забытие, вместе с anonRefreshToken.
394
+ * Нужно для сценариев типа «свич аккаунта на устройстве» или жалоб на
395
+ * приватность («очисти все мои следы»).
396
+ */
397
+ signOut(opts?: {
398
+ forgetAnonymous?: boolean;
399
+ }): Promise<void>;
400
+ /**
401
+ * Подписка на изменения session: signin/signup/refresh/signOut/expired-401.
402
+ * Колбек вызывается с текущим snapshot через microtask (если session есть)
403
+ * + на каждое реальное изменение. Возвращает unsubscribe.
404
+ */
405
+ onAuthChange(cb: AuthChangeListener): () => void;
406
+ private isFresh;
407
+ private toSession;
408
+ private setSession;
409
+ private emit;
410
+ private storageKey;
411
+ private hydrate;
412
+ private rehydrateFromStorage;
413
+ /**
414
+ * Освобождает ресурсы AuthClient'а: отписывает storage-watch, чистит
415
+ * listener'ы, выставляет destroyed-флаг. После destroy все async-операции
416
+ * (inflight refresh, OAuth popup, applyExternalSession) early-return'ят
417
+ * через `isDestroyed()` guard'ы — никаких write-back'ов в storage,
418
+ * никаких эмитов на пустые listener'ы.
419
+ *
420
+ * destroy() идемпотентен: повторный вызов — no-op.
421
+ */
422
+ destroy(): void;
423
+ /** Sync-проверка: был ли вызван destroy(). Полезно для UI / тестов. */
424
+ isDestroyed(): boolean;
425
+ private persist;
426
+ private readAnonRefreshToken;
427
+ private writeAnonRefreshToken;
428
+ private clearAnonRefreshToken;
429
+ /**
430
+ * Читает stable visitor_id из storage если он там уже есть. НЕ генерит:
431
+ * AuthClient может быть инстанцирован раньше BillingClient, а синтетический
432
+ * visitor_id без касания пейвола не имеет смысла (нет гостевых покупок,
433
+ * которые надо бы линковать). undefined → бэк сам пропустит ветку
434
+ * "merge guest purchases".
435
+ */
436
+ private readVisitorId;
437
+ }
438
+
439
+ export declare interface AuthClientOptions {
440
+ paywallId: string;
441
+ apiOrigin?: string;
442
+ storage?: StorageAdapter;
443
+ fetch?: typeof fetch;
444
+ openPopup?: (url: string, name: string) => Window | null;
445
+ }
446
+
447
+ /**
448
+ * Managed-auth конфиг. Передай `auth: true` — PaywallUI создаёт `AuthClient`
449
+ * сам (с тем же `paywallId/apiOrigin/storage`, как BillingClient). Передай
450
+ * объект — те же дефолты + override опций. Передай готовый `AuthClient` —
451
+ * PaywallUI просто прокинет его в BillingClient (полезно, если хост хочет
452
+ * иметь общий AuthClient на несколько пейволов / делать manual signIn/signOut
453
+ * из своего UI до открытия модалки).
454
+ *
455
+ * Без `auth` опции SDK работает в hybrid-режиме: identity передаётся снаружи
456
+ * через `opts.identity` или `paywall.open({identity})`.
457
+ */
458
+ declare type AuthOption = true | AuthClient | Partial<Omit<AuthClientOptions, 'paywallId'>>;
459
+
460
+ export declare interface AuthSession {
461
+ access_token: string;
462
+ refresh_token: string;
463
+ /** Absolute timestamp в ms (Date.now() сравнимо). null/0 не пишем. */
464
+ expires_at: number;
465
+ user: AuthUser;
466
+ }
467
+
468
+ export declare interface AuthUser {
469
+ id: string;
470
+ /** null для анонимного юзера (signInAnonymously). Для всех остальных flow — заполнен. */
471
+ email: string | null;
472
+ country?: string | null;
473
+ /** true — Supabase anonymous user. UI использует, чтобы решать «sign in» vs
474
+ * «signed in as ...», и чтобы при OAuth-апгрейде звать linkIdentity вместо
475
+ * signInWithOAuth (зеркалит легаси StartAuthPage.tsx). */
476
+ is_anonymous?: boolean;
477
+ }
478
+
479
+ /** Балансы AI-провайдеров пейвола: один элемент на `query_type` из
480
+ * `paywall_settings.tokenization_queries`. count = доступно вызовов. */
481
+ export declare interface Balance {
482
+ type: string;
483
+ count: number;
484
+ }
485
+
486
+ declare type BalancesListener = (balances: Balance[]) => void;
487
+
488
+ export declare class BillingClient {
489
+ readonly paywallId: string;
490
+ readonly apiOrigin: string;
491
+ readonly capabilities: string[] | undefined;
492
+ /** AuthClient, если был передан в options. Иначе undefined. */
493
+ readonly auth: AuthClient | undefined;
494
+ private api;
495
+ private storage;
496
+ private identity;
497
+ private apiKey;
498
+ private fetchImpl;
499
+ private cachedBootstrap;
500
+ private cachedBootstrapAt;
501
+ private inflightBootstrap;
502
+ private bootstrapListeners;
503
+ private bootstrapStorageUnwatch;
504
+ private authUnsubscribe;
505
+ private cachedUser;
506
+ private cachedUserAt;
507
+ private inflightUser;
508
+ private userListeners;
509
+ private visitorIdPromise;
510
+ private visitorId;
511
+ private inflightCheckouts;
512
+ private cachedBalances;
513
+ private cachedBalancesAt;
514
+ private balancesStorageUnwatch;
515
+ private inflightBalances;
516
+ private balanceListeners;
517
+ constructor(opts: BillingClientOptions);
518
+ /**
519
+ * Stable visitor_id (UUID v4). Первый вызов awaitит первичный резолв из
520
+ * storage; последующие — мгновенно из in-memory кеша. Используется
521
+ * EventTracker'ом для атрибуции аналитики.
522
+ */
523
+ getVisitorId(): Promise<string>;
524
+ /** Sync-доступ к visitor_id. null если ещё не зарезолвили (первые ms жизни). */
525
+ getCachedVisitorId(): string | null;
526
+ setIdentity(identity: Identity | undefined): void;
527
+ /**
528
+ * Отписаться от auth-event'ов и сбросить listener'ы. Вызывать когда
529
+ * BillingClient больше не нужен (тесты, hot-reload, переинициализация).
530
+ * Без destroy() listener на AuthClient переживёт BillingClient и будет
531
+ * дёргать setIdentity на освобождённом инстансе. Слушатели user/balance
532
+ * чистятся, чтобы упавший host (например, размонтированный React-tree)
533
+ * не держал замыкания на эти колбеки.
534
+ */
535
+ destroy(): void;
536
+ getIdentity(): Identity | undefined;
537
+ getStorage(): StorageAdapter;
538
+ bootstrap(forceOrOpts?: boolean | {
539
+ force?: boolean;
540
+ signal?: AbortSignal;
541
+ }): Promise<PaywallBootstrap>;
542
+ /**
543
+ * Подписка на изменения bootstrap'а: applyBootstrap (сетевой revalidate,
544
+ * cross-context storage.watch). Срабатывает ТОЛЬКО при реальном изменении
545
+ * `version` (unchanged-ответ от сервера не дёргает listener'ов). Возвращает
546
+ * unsubscribe.
547
+ */
548
+ onBootstrapChange(cb: (b: PaywallBootstrap) => void): () => void;
549
+ private fetchBootstrap;
550
+ private revalidateBootstrap;
551
+ private applyBootstrap;
552
+ private hydrateBootstrapFromStorage;
553
+ private persistBootstrap;
554
+ private subscribeBootstrapStorage;
555
+ /** Возвращает последний загруженный bootstrap без сетевого запроса.
556
+ * null = bootstrap ещё не загружали. Удобно для post-checkout-логики
557
+ * (PaywallUI читает success_redirect_url, не делая второго round-trip'а). */
558
+ getCachedBootstrap(): PaywallBootstrap | null;
559
+ /**
560
+ * Шорткат поверх `bootstrap()`: ждёт загрузку структуры пейвола и возвращает
561
+ * цены. Полезно когда host рисует цены вне модалки (карточки на лендинге,
562
+ * "Pricing" page и т.п.) и не хочет руками распаковывать bootstrap.
563
+ *
564
+ * Locale-оверрайды (`label`/`description` под `navigator.language`) уже
565
+ * применены — массив готов к рендеру. Кэш/TTL/stale-while-revalidate — те
566
+ * же, что у `bootstrap()`: повторный вызов не штурмует сервер.
567
+ */
568
+ getPrices(opts?: {
569
+ force?: boolean;
570
+ signal?: AbortSignal;
571
+ }): Promise<PaywallPrice[]>;
572
+ /** Sync-снимок цен из последнего bootstrap'а. null = ещё не загружали. */
573
+ getCachedPrices(): PaywallPrice[] | null;
574
+ /**
575
+ * Снимок того, какой язык SDK сейчас считает «языком юзера». Полезно для
576
+ * синхронизации i18n хоста с тем, что фактически показывает пейвол — чтобы
577
+ * окружающий UI не противоречил модалке (например, host рисует кнопку
578
+ * "Subscribe" на английском, а пейвол показывает «Подписаться» на русском).
579
+ *
580
+ * Возвращает структуру, а не один тэг, чтобы интегратор мог:
581
+ * - быстро взять `tag` для своих переводов;
582
+ * - отличить «пейвол реально на этом языке» (`applied !== null`) от
583
+ * «SDK угадал, но локали для этого языка нет — рендерится база»;
584
+ * - решить, чему доверять при противоречии browserLanguage vs countryLanguage
585
+ * (тур, expat, VPN — у каждого свой ответ).
586
+ *
587
+ * Sync-вызов: данные уже в bootstrap'е, отдельных запросов не делает.
588
+ * Если `bootstrap()` ещё не вызывался — `applied` и `countryLanguage`
589
+ * будут `null`, но `browserLanguage` и `tag` всё равно отдадутся, если
590
+ * есть `navigator.language`.
591
+ */
592
+ getUserLanguage(): UserLanguageInfo;
593
+ /**
594
+ * Получить актуальное состояние подписки/покупок.
595
+ *
596
+ * - In-memory cache TTL 5с — naïve setInterval(1000) не нагружает сервер.
597
+ * - In-flight dedupe — параллельные вызовы получают один promise.
598
+ * - `force: true` обходит кеш (для post-checkout проверки).
599
+ * - Без identity возвращает empty-state (сервер тоже так делает).
600
+ */
601
+ getUser({ force, signal }?: {
602
+ force?: boolean;
603
+ signal?: AbortSignal;
604
+ }): Promise<PaywallUser>;
605
+ /**
606
+ * Подписка на изменения user-state. Колбек вызывается:
607
+ * - сразу с last-known user (если есть в кеше) — по умолчанию через
608
+ * microtask, опционально SYNC (см. опции);
609
+ * - на каждое реальное изменение (getUser/bootstrap принёс другой shape).
610
+ *
611
+ * `opts.immediate`:
612
+ * - `'microtask'` (default) — initial snapshot отдаётся в queueMicrotask,
613
+ * чтобы host успел доресетнуть state в том же тике. Безопасный выбор
614
+ * для большинства интеграций.
615
+ * - `'sync'` — initial snapshot отдаётся прямо в текущем frame'е, до
616
+ * возврата из onUserChange. Удобно для React/Vue useEffect-cleanup'а
617
+ * (избегаем лишнего ре-рендера) и SSR (мгновенная синхронизация).
618
+ * - `'none'` — не отдавать initial snapshot, только реальные изменения.
619
+ *
620
+ * Возвращает функцию отписки.
621
+ */
622
+ onUserChange(cb: UserListener, opts?: {
623
+ immediate?: 'microtask' | 'sync' | 'none';
624
+ }): () => void;
625
+ /** Текущий cached user без сетевого запроса. null = ещё не загружали. */
626
+ getCachedUser(): PaywallUser | null;
627
+ private applyUser;
628
+ private storageKey;
629
+ private hydrateUserFromStorage;
630
+ private persistUser;
631
+ /**
632
+ * Балансы AI-провайдеров (`paywall_balances` × `tokenization_queries`).
633
+ *
634
+ * - In-memory cache TTL 5с — параллельные UI-renders не дёргают сеть;
635
+ * - In-flight dedupe — параллельные `getBalances` получают один promise;
636
+ * - `force: true` обходит кеш (типичный кейс — после QuotaExceededError);
637
+ * - Без auth (Bearer не выдан) возвращает пустой массив без сетевого
638
+ * запроса: бэк всё равно ответит 401, нет смысла тратить round-trip.
639
+ *
640
+ * Если у пейвола `tokenization=false` — бэк отдаёт `[]`, как для гостя.
641
+ * SDK не различает «нет квоты» и «нет квот вообще» — caller сам решает
642
+ * по `currentBalance` в QuotaExceededError или `balances.length`.
643
+ */
644
+ getBalances({ force, signal }?: {
645
+ force?: boolean;
646
+ signal?: AbortSignal;
647
+ }): Promise<Balance[]>;
648
+ private fetchBalances;
649
+ /** Sync snapshot. null = ещё не загружали (или explicit clear на re-login). */
650
+ getCachedBalances(): Balance[] | null;
651
+ /**
652
+ * Подписка на изменения балансов: getBalances/decrementBalanceLocal/setIdentity.
653
+ * `opts.immediate` работает так же, как в `onUserChange`: 'microtask'
654
+ * (default), 'sync' (для React/Vue useEffect), 'none' (только изменения).
655
+ * Возвращает unsubscribe.
656
+ */
657
+ onBalanceChange(cb: BalancesListener, opts?: {
658
+ immediate?: 'microtask' | 'sync' | 'none';
659
+ }): () => void;
660
+ /**
661
+ * Оптимистично уменьшает count для `queryType` на 1 и нотифицирует
662
+ * listener'ов. Используется ApiGatewayClient'ом сразу после успешного
663
+ * gateway-вызова (бэк уже снял кредит, см. `chargeApiQueries`).
664
+ *
665
+ * Если queryType отсутствует в кеше или count<=0 — no-op (не уходим в
666
+ * отрицательные значения, бэк всё равно правильный source-of-truth).
667
+ * Если кеша нет вовсе — тоже no-op: явный getBalances({force:true}) на
668
+ * следующем рендере подтянет актуальный shape.
669
+ *
670
+ * queryType может быть undefined (gateway не прислал X-Query-Type) —
671
+ * в этом случае декремент не делаем, но просим refreshBalances() для
672
+ * выравнивания.
673
+ */
674
+ decrementBalanceLocal(queryType: string | undefined): void;
675
+ /** Принудительный re-fetch — типичный вызов после QuotaExceededError, чтобы
676
+ * UI получил актуальный balance=0 и нарисовал upgrade-prompt. */
677
+ refreshBalances(): Promise<Balance[]>;
678
+ /**
679
+ * Фабрика ApiGatewayClient'а с подключённым к этому billing'у balance-стейтом:
680
+ * - Bearer/identity берутся из текущего auth/identity;
681
+ * - на success декрементим cachedBalances оптимистично;
682
+ * - на 402 (QuotaExceededError) триггерим refreshBalances() для актуального snapshot'а.
683
+ *
684
+ * Если переопределить опции через `overrides` — принимаются как есть, но
685
+ * `onChargeSuccess`/`onQuotaExceeded` всё равно вызываются (composable, host
686
+ * может добавить свой колбек поверх).
687
+ */
688
+ createApiGatewayClient(overrides?: Partial<Omit<ApiGatewayClientOptions, 'paywallId' | 'auth' | 'userId'>>): ApiGatewayClient;
689
+ private applyBalances;
690
+ private balancesStorageKey;
691
+ private hydrateBalancesFromStorage;
692
+ private persistBalances;
693
+ private subscribeBalancesStorage;
694
+ createCheckout(params: {
695
+ priceId: string;
696
+ successUrl?: string;
697
+ errorUrl?: string;
698
+ shopUrl?: string;
699
+ trialDays?: number;
700
+ /**
701
+ * Stage 1 защиты от дубликатов покупок. Идемпотентный ключ запроса
702
+ * (UUID). Повторный вызов с тем же ключом вернёт тот же checkout-URL
703
+ * без второго обращения к платёжному провайдеру. Если не передан —
704
+ * SDK генерит UUID v4 сам и дедуплицирует параллельные клики по
705
+ * `auto:${priceId}`.
706
+ */
707
+ idempotencyKey?: string;
708
+ /** Renewal/upgrade flow — игнорирует у бэка проверку has_active_subscription.
709
+ * По умолчанию /start-checkout возвращает 409 если у юзера уже есть
710
+ * active subscription (защита от случайных двойных оплат). С
711
+ * `ignoreActivePurchase: true` бэк создаёт новый checkout, прежняя
712
+ * подписка отменится после успешной оплаты. Передавать только когда
713
+ * юзер явно выбрал "Renew/Upgrade" в host-UI. */
714
+ ignoreActivePurchase?: boolean;
715
+ /** Отмена inflight-запроса. Параллельные вызовы дедуплицируются по
716
+ * `inflightKey`, поэтому signal отменяет ВСЕ ожидающие на этот ключ —
717
+ * это OK для типичного UX (юзер закрыл модалку — все checkout'ы отменены). */
718
+ signal?: AbortSignal;
719
+ }): Promise<CheckoutResult>;
720
+ /**
721
+ * URL Stripe/Paddle/Chargebee customer portal — место, где залогиненный
722
+ * юзер может управлять подпиской (отменить, обновить карту, скачать
723
+ * инвойсы). Опен-флоу управляется host'ом:
724
+ *
725
+ * ```ts
726
+ * const { url } = await billing.getCustomerPortalUrl();
727
+ * window.open(url, '_blank');
728
+ * ```
729
+ *
730
+ * Auth: Bearer (через AuthClient) или server-side `apiKey`. Без auth и
731
+ * без apiKey бросает PaywallError('identity_required'). 403 от бэка
732
+ * (нет активной подписки / acquiring не поддерживает portal) пробрасывается
733
+ * как PaywallError('forbidden') с `status: 403` — host рендерит "no
734
+ * subscription to manage".
735
+ */
736
+ getCustomerPortalUrl(opts?: {
737
+ signal?: AbortSignal;
738
+ }): Promise<{
739
+ url: string;
740
+ }>;
741
+ /**
742
+ * Список покупок юзера с rich-полями (цена, валюта, interval, discount,
743
+ * cancel-метаданные). Подходит для customer-portal UI: cards с кнопками
744
+ * Cancel/Renew/Manage. Менее cache-friendly чем `getUser` — ходит в
745
+ * `/api/v1/paywall/[id]/user` без unstable_cache, потому что list для UI
746
+ * должен быть свежим после cancel-а.
747
+ *
748
+ * Auth: Bearer обязателен (через AuthClient). Без Bearer — 401 от бэка,
749
+ * пробрасываем как PaywallError('http_401'). Гость → пустой список.
750
+ */
751
+ listPurchases(opts?: {
752
+ signal?: AbortSignal;
753
+ }): Promise<PaywallPurchaseDetailed[]>;
754
+ /**
755
+ * Отменить подписку. Бэк проверит что subscription принадлежит auth-юзеру
756
+ * и сделает cancel у acquiring'а (Stripe/Paddle/Chargebee). По умолчанию
757
+ * cancel в конце текущего периода — юзер сохраняет access до renewal date'ы.
758
+ *
759
+ * `reason` обязательна (валидация на бэке). Удобно собрать через select
760
+ * причин в host-UI, как в legacy customer portal'е.
761
+ *
762
+ * Auth: Bearer обязателен.
763
+ */
764
+ cancelSubscription(params: {
765
+ subscriptionId: string;
766
+ reason: string;
767
+ signal?: AbortSignal;
768
+ }): Promise<{
769
+ subscription: {
770
+ status: string | null;
771
+ canceled_at: string | null;
772
+ cancel_at: string | null;
773
+ cancel_at_period_end: boolean | null;
774
+ };
775
+ }>;
776
+ /**
777
+ * Создаёт саппорт-тикет. Если есть `files` — multipart/form-data, иначе JSON.
778
+ * Email берётся (1) из явного поля payload.email; (2) из identity если оно есть.
779
+ * Если ни того, ни другого нет — бэк отвергнет тикет (`email_required`).
780
+ *
781
+ * Bearer-токен (если AuthClient подключён) добавляется автоматически — бэк
782
+ * перевешивает customer_email на email из сессии (защита от подделки).
783
+ */
784
+ createSupportTicket(payload: {
785
+ subject: string;
786
+ content: string;
787
+ email?: string;
788
+ files?: File[];
789
+ }): Promise<{
790
+ ticket: {
791
+ id: number;
792
+ status: string;
793
+ };
794
+ }>;
795
+ }
796
+
797
+ export declare interface BillingClientOptions {
798
+ paywallId: string;
799
+ apiOrigin?: string;
800
+ identity?: Identity;
801
+ storage?: StorageAdapter;
802
+ capabilities?: string[];
803
+ fetch?: typeof fetch;
804
+ /**
805
+ * Server SDK API key. Используется для `/start-checkout` в headless/hybrid-сценариях,
806
+ * где вызов идёт из trusted-окружения (backend клиента). В client-native пути
807
+ * ключ НЕ передавать — приватный токен утечёт в браузер.
808
+ */
809
+ apiKey?: string;
810
+ /**
811
+ * AuthClient для подключения Bearer-авторизации и автосинка identity. Если
812
+ * передан — все запросы получают `Authorization: Bearer <access_token>`,
813
+ * а identity пересчитывается из auth.user на каждом login/logout/refresh
814
+ * (перетирает явно заданный `opts.identity` после первого auth-event'а).
815
+ *
816
+ * Без auth BillingClient работает как раньше: identity приходит снаружи
817
+ * через `setIdentity`, Bearer не отправляется.
818
+ */
819
+ auth?: AuthClient;
820
+ }
821
+
822
+ export declare type BlockComponent<B extends LayoutBlock = LayoutBlock> = ComponentType<BlockProps<B>>;
823
+
824
+ export declare interface BlockContext {
825
+ bootstrap: PaywallBootstrap;
826
+ selectedPriceId: string | null;
827
+ setSelectedPriceId: (id: string) => void;
828
+ onAction: (action: string, payload?: unknown) => void;
829
+ /** AuthClient, если PaywallUI был сконфигурирован с managed-auth. Без него
830
+ * auth_panel-блок рендерит fallback ("auth not configured"). */
831
+ auth?: AuthClient;
832
+ /** Текущая auth-session (snapshot из AuthClient). null = разлогинен. PaywallRoot
833
+ * подписан на onAuthChange и пробрасывает свежий snapshot сюда. */
834
+ authSession: AuthSession | null;
835
+ }
836
+
837
+ export declare interface BlockProps<B extends LayoutBlock = LayoutBlock> {
838
+ block: B;
839
+ ctx: BlockContext;
840
+ }
841
+
842
+ export declare const blockRegistry: Record<LayoutBlock['type'], BlockComponent<any>>;
843
+
844
+ export declare interface CheckoutResult {
845
+ url: string;
846
+ sessionId?: string;
847
+ /** Платёжный процессор, к которому ушёл checkout. Полезно для аналитики
848
+ * конверсии по эквайрингам (host может ветвить UX по acquiring). */
849
+ acquiring?: Acquiring;
850
+ }
851
+
852
+ export declare function createStorage(override?: StorageAdapter): StorageAdapter;
853
+
854
+ export declare function ensureVisitorId(storage: StorageAdapter): Promise<string>;
855
+
856
+ export declare class EventTracker {
857
+ private opts;
858
+ private buffer;
859
+ private flushTimer;
860
+ private destroyed;
861
+ private unloadHandler;
862
+ private visibilityHandler;
863
+ constructor(opts: EventTrackerOptions);
864
+ private isEnabled;
865
+ track(type: string, props?: Record<string, unknown>): void;
866
+ private scheduleFlush;
867
+ flush(): Promise<void>;
868
+ /**
869
+ * Отправка через navigator.sendBeacon — для unload/pagehide. Гарантированно
870
+ * долетает (POST с keepalive тоже почти, но beacon сделан именно под это).
871
+ * Headers ставить нельзя (спецификация), поэтому SDK metadata едет в body
872
+ * как fallback-поля, которые сервер читает в дополнение к headers.
873
+ */
874
+ flushBeacon(): void;
875
+ private buildHeaders;
876
+ private attachUnloadHandlers;
877
+ private detachUnloadHandlers;
878
+ destroy(): void;
879
+ }
880
+
881
+ export declare interface EventTrackerOptions {
882
+ endpoint: string;
883
+ paywallId: string;
884
+ capabilities?: string[];
885
+ getVisitorId: () => Promise<string>;
886
+ getCachedVisitorId?: () => string | null;
887
+ getUserId?: () => string | null | undefined;
888
+ enabled?: boolean;
889
+ flushIntervalMs?: number;
890
+ maxBufferSize?: number;
891
+ /** Тестовый override fetch'а. */
892
+ fetch?: typeof fetch;
893
+ /** Тестовый override sendBeacon'а — позволяет проверить unload-flow в jsdom. */
894
+ sendBeacon?: (url: string, data: BodyInit) => boolean;
895
+ }
896
+
897
+ export declare function generateVisitorId(): string;
898
+
899
+ export declare interface GetAccessOptions {
900
+ skipTrial?: boolean;
901
+ skipVisibility?: boolean;
902
+ signal?: AbortSignal;
903
+ }
904
+
905
+ export declare interface Identity {
906
+ email?: string;
907
+ userId?: string;
908
+ anonymousId?: string;
909
+ }
910
+
911
+ export declare interface Layout {
912
+ type: 'modal';
913
+ blocks: LayoutBlock[];
914
+ }
915
+
916
+ export declare type LayoutBlock = {
917
+ type: 'heading';
918
+ text: string;
919
+ level?: 1 | 2 | 3;
920
+ } | {
921
+ type: 'text';
922
+ text: string;
923
+ } | {
924
+ type: 'price_grid';
925
+ priceIds?: string[];
926
+ /** Раскладка карточек цен. `vertical` (default) — стек сверху вниз;
927
+ * `horizontal` — ряд side-by-side. v2-аналог `view: 'default' | 'telegram'`. */
928
+ view?: 'vertical' | 'horizontal';
929
+ /** ID цены, которая помечается лейблом «популярный план». v2-аналог
930
+ * пары `price_label_id` + `price_label`. */
931
+ popular_price_id?: string;
932
+ /** Текст лейбла «популярный план». По умолчанию "Most popular".
933
+ * v2-аналог `price_label_text`. Локализация — через bootstrap.locales. */
934
+ popular_label?: string;
935
+ } | {
936
+ type: 'cta_button';
937
+ label: string;
938
+ action: 'checkout' | 'close';
939
+ priceId?: string;
940
+ } | {
941
+ /** Footer-блок под cta_button: залогинен — рисует "Signed in as <email> | Sign out",
942
+ * иначе — кнопку "Restore purchases", которая открывает auth-gate без pendingCheckout
943
+ * (после signIn gate просто схлопывается, юзер видит свой signed-in state). */
944
+ type: 'current_session';
945
+ } | {
946
+ type: 'auth_panel';
947
+ /** OAuth-провайдеры в порядке отображения. Пусто/опущено — только email-форма. */
948
+ providers?: Array<'google' | 'apple' | 'github' | 'facebook'>;
949
+ /** Показывать toggle "Sign up". По умолчанию true. */
950
+ allow_signup?: boolean;
951
+ /** Показывать ссылку "Forgot password?". По умолчанию true. */
952
+ allow_password_reset?: boolean;
953
+ /** Скрывать панель, если юзер уже залогинен. По умолчанию true.
954
+ * false — показываем "Signed in as ... [Sign out]" даже после логина. */
955
+ hide_when_authenticated?: boolean;
956
+ /** Заголовок над формой (h2). Если опущен — заголовок не рендерится. */
957
+ heading?: string;
958
+ } | {
959
+ /** Список фич/преимуществ продукта. v2-аналог `features_list` + `features_view`.
960
+ * До 5 элементов — рендерим как чек-лист с заголовком и описанием. */
961
+ type: 'features_list';
962
+ items: Array<{
963
+ id: string;
964
+ name: string;
965
+ desc?: string;
966
+ }>;
967
+ } | {
968
+ /** Информационный список «что включено в выбранный план» — рендерится
969
+ * под price_grid, без интерактивности. v2-аналог `tokenization` +
970
+ * `tokenization_queries`. Для каждого query показываем count,
971
+ * умноженный на множитель интервала выбранной цены (`week=0.25`,
972
+ * `month=1`, `year=12`) — т.е. count в БД хранится как месячная
973
+ * норма. Заголовок реактивно отражает текущий interval. */
974
+ type: 'tokenization_gate';
975
+ queries: Array<{
976
+ id: string;
977
+ name: string;
978
+ desc: string;
979
+ count: number;
980
+ }>;
981
+ };
982
+
983
+ /** Локализационные оверрайды для одного языка. Накатываются поверх дефолтного
984
+ * layout/prices при матче `navigator.language` ↔ ключа в `bootstrap.locales`.
985
+ * v2-аналог поля `translations` JSON в paywall_settings. */
986
+ export declare interface LocaleOverrides {
987
+ /** Полная замена layout для языка. Если опущен — берётся дефолтный
988
+ * bootstrap.layout. */
989
+ layout?: Layout;
990
+ /** Точечные оверрайды текстовых полей цен. Ключ — price.id, значения
991
+ * накатываются на label/description. */
992
+ prices?: Record<string, {
993
+ label?: string;
994
+ description?: string;
995
+ }>;
996
+ }
997
+
998
+ export declare type OAuthProvider = 'google' | 'apple' | 'github' | 'facebook';
999
+
1000
+ export declare interface OpenOptions {
1001
+ identity?: Identity;
1002
+ /** Принудительно открыть, минуя pre-paywall trial check. По умолчанию SDK
1003
+ * читает `bootstrap.settings.trial` и блокирует open(), пока триал активен.
1004
+ * Эскейп-хатч для случаев типа «host решил показать всё-таки» или дев-режим. */
1005
+ skipTrial?: boolean;
1006
+ /** Принудительно открыть, минуя targeting-gate. По умолчанию SDK читает
1007
+ * `bootstrap.settings.visibility` и эмитит `visibility_blocked` без
1008
+ * открытия модалки, если visible=false (страна/девайс/visibility-флаг
1009
+ * не сошлись). Эскейп-хатч для дев-отладки. */
1010
+ skipVisibility?: boolean;
1011
+ /** Renewal/upgrade flow. По умолчанию (false) SDK после bootstrap'а или
1012
+ * signIn проверяет `user.has_active_subscription` и переключается в
1013
+ * restored success-view, не показывая тарифы — open() для уже подписанного
1014
+ * юзера превращается в подтверждение «у вас уже есть подписка». С
1015
+ * `renew: true` все эти проверки пропускаются: тарифы показываются всегда,
1016
+ * и при checkout SDK передаёт `ignoreActivePurchase: true` на бэк, чтобы
1017
+ * /start-checkout не вернул 409. Использовать когда host-UI явно
1018
+ * показывает кнопку «Renew»/«Upgrade plan». */
1019
+ renew?: boolean;
1020
+ }
1021
+
1022
+ declare interface OpensTrialStatus {
1023
+ mode: 'opens';
1024
+ /** true — триал ещё активен, паывол не показывается. */
1025
+ blocked: boolean;
1026
+ /** Сколько ещё «бесплатных» открытий осталось. 0 — триал истёк. */
1027
+ remainingActions: number;
1028
+ /** Полное число «бесплатных» открытий (payload). */
1029
+ totalActions: number;
1030
+ }
1031
+
1032
+ export declare type OtpVerifyType = 'email' | 'recovery' | 'signup' | 'magiclink' | 'invite';
1033
+
1034
+ /**
1035
+ * Результат `paywall.getAccess()` — отвечает на главный вопрос хоста: «нужно
1036
+ * ли блокировать фичу для этого юзера?». Без побочных эффектов: на trial-storage
1037
+ * `recordBlock` не вызывается (счётчики не двигаются), модалка не монтируется.
1038
+ *
1039
+ * Семантика `access`:
1040
+ * - `granted` — фичу НЕ блокировать. Один из сценариев:
1041
+ * - `has_subscription` — у юзера активная подписка/покупка;
1042
+ * - `visibility_blocked` — таргетинг (страна/девайс/visibility-флаг) не
1043
+ * сошёлся, юзер вне monetization-scope'а пейвола → монетизация неприменима;
1044
+ * - `trial_blocked` — пре-пейвольный триал ещё активен.
1045
+ * - `blocked` — фичу заблокировать и вызвать `paywall.open()`. Reason всегда
1046
+ * `no_subscription`.
1047
+ *
1048
+ * Discriminated union по `access`: type-narrowing на `result.access === 'blocked'`
1049
+ * сужает `reason` до `'no_subscription'`, на `'granted'` — до трёх granted-вариантов.
1050
+ */
1051
+ export declare type PaywallAccessResult = {
1052
+ access: 'granted';
1053
+ reason: 'has_subscription' | 'visibility_blocked' | 'trial_blocked';
1054
+ visibility: VisibilityStatus | null;
1055
+ trial: TrialStatus | null;
1056
+ user: PaywallUser | null;
1057
+ } | {
1058
+ access: 'blocked';
1059
+ reason: 'no_subscription';
1060
+ visibility: VisibilityStatus | null;
1061
+ trial: TrialStatus | null;
1062
+ user: PaywallUser | null;
1063
+ };
1064
+
1065
+ export declare interface PaywallBootstrap {
1066
+ settings: PaywallSettings;
1067
+ prices: PaywallPrice[];
1068
+ offers: PaywallOffer[];
1069
+ layout?: Layout;
1070
+ /** Snapshot user-state на момент bootstrap'а. Без identity (гость) — всё пусто.
1071
+ * Дальше обновляется через BillingClient.getUser() / PaywallUI.onUserChange. */
1072
+ user?: PaywallUser;
1073
+ /** Локализационные оверрайды по BCP-47 кодам (`en`, `en-US`, `ru`, ...).
1074
+ * BillingClient.bootstrap() матчит `navigator.language` с fallback на
1075
+ * `settings.locale_default` и применяет оверрайды поверх layout/prices. */
1076
+ locales?: Record<string, LocaleOverrides>;
1077
+ /** Stable content-hash структурной части bootstrap'а (без user). SDK
1078
+ * персистит payload в StorageAdapter и шлёт `?if_version=<v>` на
1079
+ * ревалидации — бэк отвечает `{unchanged:true, version, user}` без
1080
+ * полного payload, если version совпала. Optional для совместимости
1081
+ * с старыми бэками. */
1082
+ version?: string;
1083
+ }
1084
+
1085
+ export declare class PaywallError extends Error {
1086
+ readonly code: string;
1087
+ readonly status?: number;
1088
+ readonly cause?: unknown;
1089
+ constructor(code: string, message: string, opts?: {
1090
+ status?: number;
1091
+ cause?: unknown;
1092
+ });
1093
+ }
1094
+
1095
+ export declare type PaywallEvent = keyof PaywallEventPayloads;
1096
+
1097
+ export declare type PaywallEventHandler<E extends PaywallEvent = PaywallEvent> = (payload: PaywallEventPayloads[E]) => void;
1098
+
1099
+ declare interface PaywallEventPayloads {
1100
+ /** Модалка открыта (запрос на открытие — данные могут ещё грузиться). */
1101
+ open: void;
1102
+ /** Модалка закрыта. */
1103
+ close: void;
1104
+ /** Bootstrap загружен, модалка показывает контент. Подходит для impression-метрик. */
1105
+ ready: PaywallBootstrap;
1106
+ /** Любая ошибка SDK (bootstrap, checkout). */
1107
+ error: PaywallError;
1108
+ /** Юзер выбрал тариф (клик по плану), ещё не инициировал checkout. */
1109
+ price_selected: {
1110
+ priceId: string;
1111
+ price: PaywallPrice;
1112
+ };
1113
+ /** Checkout URL получен с бэка и открыт в новой вкладке. `acquiring` —
1114
+ * имя платёжного процессора, на который ушёл checkout (для конверсии
1115
+ * по эквайрингам в host-аналитике). */
1116
+ checkout_started: {
1117
+ priceId: string;
1118
+ url: string;
1119
+ acquiring?: Acquiring;
1120
+ };
1121
+ /** Юзер вернулся с успешной оплатой (через URL-маркеры или postMessage),
1122
+ * либо после signIn / попытки checkout-а выяснилось, что подписка уже
1123
+ * активна (`restored: true`). priceId = null когда payment-интент не
1124
+ * был привязан к конкретной цене (UserWatcher-tick, restore-flow). */
1125
+ purchase_completed: {
1126
+ priceId: string | null;
1127
+ sessionId: string | null;
1128
+ /** true — это не свежая оплата, а активная подписка, которую SDK обнаружил
1129
+ * и показал juзеру success/restored view. Hostу полезно различать (для
1130
+ * metrics — «restore» vs «new purchase»). */
1131
+ restored?: boolean;
1132
+ };
1133
+ /** Юзер вернулся с ошибкой/cancel от провайдера. */
1134
+ purchase_failed: {
1135
+ reason: string | null;
1136
+ };
1137
+ /** User-state изменился (bootstrap snapshot, getUser refresh, watcher tick).
1138
+ * Дёргается также сразу с last-known user после первой подписки. */
1139
+ userChange: PaywallUser;
1140
+ /** Auth-session изменилась (signin / signup / refresh / signout / 401-revoke).
1141
+ * null = разлогинен. Дёргается сразу с last-known session после первой подписки. */
1142
+ authChange: AuthSession | null;
1143
+ /** Триал заблокировал показ модалки. payload содержит свежий статус (после
1144
+ * recordBlock). Для `mode: 'time'` — startedAt/expiresAt/remainingMs;
1145
+ * для `mode: 'opens'` — remainingActions/totalActions. Хост может
1146
+ * использовать payload для показа собственного UI («осталось 3 показа»). */
1147
+ trial_blocked: TrialStatus;
1148
+ /** Триал истёк, паывол показывается впервые после истечения. Эмитится
1149
+ * раз за жизнь PaywallUI-инстанса (не персистится между перезагрузками
1150
+ * страницы — на каждом page-load событие может стрельнуть один раз). */
1151
+ trial_expired: void;
1152
+ /** Targeting не сошёлся — паывол не открывается. payload содержит
1153
+ * server-computed snapshot из bootstrap (visible=false + reason + country +
1154
+ * tier). Хост может показать собственный fallback («сервис недоступен в
1155
+ * вашей стране») или просто залогировать impression для аналитики. */
1156
+ visibility_blocked: VisibilityStatus;
1157
+ }
1158
+
1159
+ export declare interface PaywallOffer {
1160
+ id: string;
1161
+ discount_percent: number | null;
1162
+ expires_at: string | null;
1163
+ price_id: string | null;
1164
+ label?: string | null;
1165
+ }
1166
+
1167
+ export declare interface PaywallPrice {
1168
+ id: string;
1169
+ currency: string;
1170
+ amount: number;
1171
+ interval: 'month' | 'year' | 'week' | 'day' | 'lifetime' | null;
1172
+ interval_count: number | null;
1173
+ trial_days: number | null;
1174
+ label?: string | null;
1175
+ description?: string | null;
1176
+ local?: {
1177
+ currency: string;
1178
+ amount: number;
1179
+ } | null;
1180
+ }
1181
+
1182
+ /** Rich-shape от `/api/v1/paywall/[id]/user` для customer-portal UX (cancel,
1183
+ * renew, история платежей). В отличие от `PaywallUserPurchase` (которая
1184
+ * идёт из `/user-state` и имеет минимум для access-gate'а), этот shape
1185
+ * включает цену/валюту/discount — чтобы host мог нарисовать список подписок
1186
+ * как в legacy customer portal'е. */
1187
+ declare interface PaywallPurchaseDetailed {
1188
+ id: string;
1189
+ status: string | null;
1190
+ cancel_at: string | null;
1191
+ cancel_at_period_end: boolean;
1192
+ canceled_at: string | null;
1193
+ created: string;
1194
+ ended_at: string | null;
1195
+ current_period_end: string | null;
1196
+ current_period_start: string | null;
1197
+ /** Цена в minor units (центах). Для legacy совместимости — sometimes из
1198
+ * `paywall_internal_prices.unit_amount * 100`, иногда из local_amount. */
1199
+ unit_amount: number;
1200
+ currency: string;
1201
+ interval: string | null;
1202
+ /** Скидка в процентах от offer (если был применён). undefined — без offer'а. */
1203
+ discount?: number;
1204
+ }
1205
+
1206
+ export declare interface PaywallSettings {
1207
+ id: string;
1208
+ name: string;
1209
+ brand_color?: string | null;
1210
+ custom_css?: string | null;
1211
+ locale_default?: string | null;
1212
+ runtime_mode?: 'client' | 'hybrid' | 'server' | 'client-native' | 'hybrid-native';
1213
+ /** true, если эквайринг пейвола в test-mode — SDK рисует TEST MODE бейдж. */
1214
+ is_test_mode?: boolean;
1215
+ /** Auth-flow относительно checkout. `guest` (default) — без auth перед оплатой;
1216
+ * `preauth` — клик по cta_button=checkout сначала открывает AuthPanel-gate,
1217
+ * после signIn auto-resume исходного createCheckout. Поле общее с legacy v2. */
1218
+ checkout_mode?: 'guest' | 'preauth';
1219
+ /** OAuth-провайдеры для preauth-gate в порядке отображения. Бэк сейчас отдаёт
1220
+ * фиксированный список (google + apple); если поле не задано — gate рисует
1221
+ * только email-форму. Не путать с `block.providers` у inline-блока auth_panel. */
1222
+ auth_providers?: Array<'google' | 'apple' | 'github' | 'facebook'>;
1223
+ /** Разрешён ли вход без email — анонимный юзер. `paywall.signInAnonymously()`
1224
+ * падает с code='anonymous_disabled', если флаг = false. Поле дублирует
1225
+ * `paywall_settings.allow_anonymous` из БД, то же что используется в legacy
1226
+ * v2 (PayWallIframeOpener.tsx). Защита от abuse — на стороне сервера (Supabase
1227
+ * rate-limit per real-IP + CF Bot Fight Mode), capтча в SDK не используется. */
1228
+ allow_anonymous?: boolean;
1229
+ /** Можно ли закрыть модалку (крестик, клик по overlay, ESC). По умолчанию true.
1230
+ * false — модалка показывается до успешной покупки или явного host-close().
1231
+ * v2-аналог `allow_close`. */
1232
+ allow_close?: boolean;
1233
+ /** Авто-подгонка размера шрифта heading-блока, чтобы заголовок влезал в 2
1234
+ * строки. v2-аналог `title_auto_fit`. По умолчанию false. */
1235
+ title_auto_fit?: boolean;
1236
+ /** URL, куда редиректить вкладку после успешной покупки (server-confirmed
1237
+ * через UserWatcher). null/undefined — остаёмся на месте, показываем
1238
+ * PurchaseSuccessView. v2-аналог `success_redirect_url`. */
1239
+ success_redirect_url?: string | null;
1240
+ /** URL "Вернуться в магазин" — пробрасывается в createCheckout как `shopUrl`
1241
+ * для Stripe/Paddle страницы оплаты. v2-аналог `checkout_shop_url`. */
1242
+ checkout_shop_url?: string | null;
1243
+ /** Имя продукта на странице оплаты Stripe/Paddle (line_item.name). Бэк
1244
+ * использует при создании checkout-сессии. v2-аналог `checkout_product_name`. */
1245
+ checkout_product_name?: string | null;
1246
+ /** Конфиг pre-paywall триала (паывол не показывается, пока триал активен).
1247
+ * null/undefined — триал отключён, `paywall.open()` сразу открывает модалку.
1248
+ * v2-аналог пары `trial` + `trial_payload` в paywall_settings. Не путать с
1249
+ * card-trial (PaywallPrice.trial_days) — это автосписание после оплаты. */
1250
+ trial?: TrialConfig | null;
1251
+ /** Server-computed targeting-gate: матчится ли текущий юзер (страна/девайс)
1252
+ * под настройки таргетинга пейвола, плюс общий on/off-флаг. SDK перед open()
1253
+ * читает `visible`: false → эмитит `visibility_blocked` и не монтирует
1254
+ * модалку. country/tier выдаются всегда — host'ы используют для аналитики.
1255
+ * v2-аналог `visibilityEnabledAndTargetingMatch` + `detectInvisible` в
1256
+ * PaywallClient.tsx + StateService. */
1257
+ visibility?: VisibilityStatus;
1258
+ }
1259
+
1260
+ declare type PaywallStateListener = (state: PaywallStateSnapshot) => void;
1261
+
1262
+ /**
1263
+ * Публичный snapshot состояния PaywallUI для host'а. Производится из internal
1264
+ * LoadState + GateState + open/purchased флагов. Каждое реальное изменение —
1265
+ * один call onState; повторы (`useSyncExternalStore`-friendly).
1266
+ */
1267
+ export declare interface PaywallStateSnapshot {
1268
+ /** Модалка отрендерена и видна. False — closed (или ещё не открывалась). */
1269
+ open: boolean;
1270
+ /** Что показывается в модалке. null когда `open=false`. */
1271
+ view: 'loading' | 'error' | 'layout' | 'auth' | 'anon' | 'support' | 'awaiting_payment' | 'popup_blocked' | 'purchased' | null;
1272
+ /** Заполнено только когда `view === 'error'`. */
1273
+ error: PaywallError | null;
1274
+ }
1275
+
1276
+ export declare class PaywallUI {
1277
+ readonly billing: BillingClient;
1278
+ /** AuthClient (managed-auth) или undefined в hybrid-режиме. Доступен публично:
1279
+ * host может вызывать `paywall.auth?.signOut()`, читать `getCachedSession()`,
1280
+ * подписываться на `onAuthChange` напрямую. */
1281
+ readonly auth: AuthClient | undefined;
1282
+ private ownsAuth;
1283
+ private host?;
1284
+ private shadowMode;
1285
+ private handle;
1286
+ private isOpen;
1287
+ private listeners;
1288
+ private userUnsub;
1289
+ private authUnsub;
1290
+ private watcher;
1291
+ private tracker;
1292
+ private purchased;
1293
+ /** Lazy-инстанс TrialStore. Резолвится при первом open(), когда уже знаем
1294
+ * `bootstrap.settings.trial`. null — триал отключён в конфиге пейвола. */
1295
+ private trialStore;
1296
+ /** Конфиг, под который создан текущий trialStore — пересобираем, если он
1297
+ * поменялся между bootstrap-фетчами (например, владелец переключил режим
1298
+ * в админке между сессиями SDK). */
1299
+ private trialStoreConfig;
1300
+ /** In-memory snapshot последнего check() — для синхронного getTrialStatus(). */
1301
+ private lastTrialStatus;
1302
+ /** Флаг dedupe для `trial_expired` события в рамках жизни инстанса. */
1303
+ private trialExpiredFired;
1304
+ /** In-memory snapshot последнего bootstrap'а — для синхронного getVisibility(). */
1305
+ private lastVisibility;
1306
+ /** Поведение open() при холодном bootstrap'е. См. PaywallUIOptions.mountThenLoad. */
1307
+ private mountThenLoad;
1308
+ /** Текущий snapshot UI state-machine. Обновляется PaywallRoot'ом через
1309
+ * `onState` prop; при close сбрасывается обратно в CLOSED_STATE. */
1310
+ private currentState;
1311
+ private stateListeners;
1312
+ constructor(opts: PaywallUIOptions);
1313
+ private initTracker;
1314
+ /**
1315
+ * Отправить произвольное аналитическое событие. Имена из системного whitelist'а
1316
+ * (`app_opened`, `paywall_viewed`, ...) разрешены как есть. Кастомные —
1317
+ * с префиксом `host:` (например `host:user_clicked_upgrade`). Сервер
1318
+ * дропает события с неразрешёнными именами.
1319
+ *
1320
+ * Самый частый кейс — `track('app_opened')` от хоста сразу после загрузки
1321
+ * приложения, чтобы зафиксировать воронку до открытия пейвола.
1322
+ */
1323
+ track(name: string, props?: Record<string, unknown>): void;
1324
+ /**
1325
+ * Удобный шорткат вместо `paywall.on('userChange', cb)` — самый частый
1326
+ * паттерн в host-коде, поэтому отдельный named метод. Колбек получает
1327
+ * last-known user из кеша синхронно через microtask, если он есть.
1328
+ */
1329
+ onUserChange(handler: PaywallEventHandler<'userChange'>): () => void;
1330
+ on<E extends PaywallEvent>(event: E, handler: PaywallEventHandler<E>): () => void;
1331
+ off<E extends PaywallEvent>(event: E, handler: PaywallEventHandler<E>): void;
1332
+ private emit;
1333
+ open(opts?: OpenOptions): void;
1334
+ /**
1335
+ * Прогревает bootstrap-кеш и balance-кеш заранее, без открытия модалки.
1336
+ * Полезно когда host знает, что юзер скоро откроет paywall (hover на CTA,
1337
+ * mount компонента) — первый `open()` рендерится мгновенно, без loading-flash.
1338
+ *
1339
+ * Не throw'ает: если сеть упала, тихо игнорирует (повторный open() сделает
1340
+ * fresh-bootstrap с error-state как обычно). `signal` для отмены — например,
1341
+ * если хост размонтирует компонент быстрее, чем bootstrap вернётся.
1342
+ *
1343
+ * Вызывать можно сколько угодно раз — последующие вызовы возвращают cached
1344
+ * Promise (BillingClient уже дедуплицирует).
1345
+ */
1346
+ preload(opts?: {
1347
+ signal?: AbortSignal;
1348
+ }): Promise<void>;
1349
+ /**
1350
+ * Открывает модалку сразу с саппорт-формой (минуя layout с тарифами).
1351
+ * Полезно, когда host-приложение хочет дать юзеру кнопку «Help / Support»,
1352
+ * не связанную с пейволом-апгрейдом. Back/Done в саппорт-форме закрывают
1353
+ * модалку (не возвращают к тарифам), потому что юзер пришёл сюда напрямую.
1354
+ *
1355
+ * Из обычного `paywall.open()`-flow саппорт всё равно доступен через
1356
+ * Contact Support-ссылку в `current_session`-блоке (там Back возвращает
1357
+ * к layout).
1358
+ */
1359
+ openSupport(opts?: OpenOptions): void;
1360
+ /**
1361
+ * Открывает модалку сразу с auth-gate (логин/регистрация), без layout с
1362
+ * тарифами. Сценарий: returning customer уже купил, ему просто нужно
1363
+ * залогиниться, чтобы SDK подцепил его purchases. После signIn модалка
1364
+ * закрывается; Back тоже закрывает (юзер пришёл только за логином).
1365
+ *
1366
+ * Без `auth` (managed-auth не подключён) метод — no-op: некому делать
1367
+ * signIn. Если юзер уже залогинен — модалка всё равно откроется и
1368
+ * закроется через auto-resume в auth_gate effect'е (мгновение).
1369
+ *
1370
+ * Триал не блокирует этот флоу — auth не connect'ится с trial-механикой.
1371
+ */
1372
+ openAuth(opts?: OpenOptions): void;
1373
+ /**
1374
+ * Открывает модалку с anonymous-gate. AnonGate сразу зовёт
1375
+ * `auth.signInAnonymously()`:
1376
+ * - если в storage есть `anonRefreshToken` — silent resume через
1377
+ * /auth/refresh, модалка схлопывается мгновенно (юзер видит лёгкий
1378
+ * спиннер ~100мс);
1379
+ * - иначе — fresh POST /auth/anonymous/signin без captcha (защита от
1380
+ * abuse держится на Supabase per-real-IP rate-limit + CF Bot Fight Mode).
1381
+ *
1382
+ * После успешного signIn'а модалка закрывается; host подхватывает свежую
1383
+ * session через `auth.onAuthChange` или `paywall.onUserChange`. Для UX
1384
+ * "просто залогиниться чтобы api-gateway работал, без покупок".
1385
+ *
1386
+ * Без managed-auth (`auth` не подключён) метод — no-op. Триал и
1387
+ * visibility-таргетинг этот flow обходит: анон-логин не должен зависеть
1388
+ * от страны юзера или trial-стейджа.
1389
+ */
1390
+ openAnonGate(opts?: OpenOptions): void;
1391
+ private openInternal;
1392
+ /** Применить gates ПОСЛЕ того, как модалка уже смонтирована (mount-then-load
1393
+ * путь). Если gate блокирует — close() + emit. Если юзер уже сам закрыл
1394
+ * модалку до резолва bootstrap'а — no-op (isOpen=false). */
1395
+ private runDelayedGates;
1396
+ private runOpenGates;
1397
+ private gateThroughTrial;
1398
+ private ensureTrialStore;
1399
+ private mountAndShow;
1400
+ private applyState;
1401
+ /**
1402
+ * Sync-snapshot текущего состояния модалки. Подходит для `useSyncExternalStore`
1403
+ * в React (`useSyncExternalStore(paywall.onStateChange, paywall.getState)`)
1404
+ * и для одноразовых проверок («открыт ли пейвол сейчас?»).
1405
+ *
1406
+ * Snapshot стабилен — пока state не изменился, повторный getState() вернёт
1407
+ * `===`-равный объект (важно для useSyncExternalStore чтобы не ре-рендерить).
1408
+ */
1409
+ getState(): PaywallStateSnapshot;
1410
+ /**
1411
+ * Подписка на изменения state. Колбек вызывается при каждом реальном
1412
+ * изменении (closed → loading → ready → ...). По умолчанию initial snapshot
1413
+ * отдаётся через microtask после подписки; через `{immediate: 'sync'|'none'}`
1414
+ * можно сделать sync-доставку (для useSyncExternalStore — там она не нужна,
1415
+ * snapshot читается через getSnapshot отдельно) или вовсе пропустить
1416
+ * initial.
1417
+ *
1418
+ * Возвращает unsubscribe.
1419
+ */
1420
+ onStateChange(cb: PaywallStateListener, opts?: {
1421
+ immediate?: 'microtask' | 'sync' | 'none';
1422
+ }): () => void;
1423
+ /** Sync-доступ к последнему известному статусу триала. null — `paywall.open()`
1424
+ * ещё не вызывался либо триал отключён в конфиге пейвола. Удобно для
1425
+ * собственного UI хоста («осталось 3 показа», «триал истечёт через 2ч»). */
1426
+ getTrialStatus(): TrialStatus | null;
1427
+ /** Sync-доступ к последнему server-computed visibility-статусу. null —
1428
+ * bootstrap ещё не загружен или сервер не отдаёт `settings.visibility`
1429
+ * (например, старая версия online без targeting-патча). Хост может
1430
+ * использовать для собственного fallback'а: «сервис недоступен в вашей
1431
+ * стране». Обновляется на каждом open(), который проходит через gate. */
1432
+ getVisibility(): VisibilityStatus | null;
1433
+ /**
1434
+ * Цены пейвола — шорткат над `bootstrap()`. Локали уже применены, кэш и
1435
+ * stale-while-revalidate идентичны `billing.bootstrap()`. Подходит для
1436
+ * pricing-страниц/карточек на сайте, где host хочет показать те же цены,
1437
+ * что и в модалке, не вытаскивая bootstrap руками.
1438
+ */
1439
+ getPrices(opts?: {
1440
+ force?: boolean;
1441
+ signal?: AbortSignal;
1442
+ }): Promise<PaywallPrice[]>;
1443
+ /** Sync-снимок цен. null — bootstrap ещё не загружали. */
1444
+ getCachedPrices(): PaywallPrice[] | null;
1445
+ /** Снимок текущего «языка юзера» — proxy над `billing.getUserLanguage()`.
1446
+ * Используй, чтобы синхронизировать i18n host'а с тем, что фактически
1447
+ * показывает пейвол. См. подробности в `BillingClient.getUserLanguage`. */
1448
+ getUserLanguage(): UserLanguageInfo;
1449
+ /**
1450
+ * Решает, нужно ли блокировать фичу для текущего юзера. Без побочных эффектов
1451
+ * (на trial-storage `recordBlock` не вызывается, модалка не монтируется).
1452
+ *
1453
+ * Порядок проверок (первый сработавший — финальный):
1454
+ * 1. `has_active_subscription` — самый сильный сигнал, перебивает остальные.
1455
+ * Юзер с подпиской получает доступ независимо от visibility/trial.
1456
+ * 2. `visibility` (страна/девайс/disabled-флаг) — юзер вне monetization-scope'а
1457
+ * пейвола, гейтить нельзя.
1458
+ * 3. `trial` — пре-пейвольный бесплатный период активен.
1459
+ * 4. Иначе — `blocked`, host лочит фичу и зовёт `paywall.open()`.
1460
+ *
1461
+ * Bootstrap кешируется в BillingClient — `getAccess()` можно дёргать на
1462
+ * каждый рендер host-компонента, /bootstrap не дублируется. При упавшей сети
1463
+ * fallback на persistent-cached user из storage: юзер с прошлой подпиской
1464
+ * получает `granted` офлайн, иначе `blocked` (host покажет пейвол с
1465
+ * error-state, юзер сможет ретрайнуть). Side-эффект: обновляются
1466
+ * `lastVisibility` / `lastTrialStatus`, чтобы синхронные геттеры
1467
+ * `getVisibility()` / `getTrialStatus()` видели свежие данные после первого
1468
+ * `getAccess()`, а не только после первого `open()`.
1469
+ */
1470
+ getAccess(opts?: GetAccessOptions): Promise<PaywallAccessResult>;
1471
+ /** Сбросить состояние триала в storage. Полезно для дев-режима / админ-кнопки
1472
+ * «прогнать сценарий заново». В проде хост обычно не дёргает. */
1473
+ resetTrial(): Promise<void>;
1474
+ private startUserWatcher;
1475
+ close(): void;
1476
+ /**
1477
+ * Сканирует текущий URL на маркеры возврата с checkout и эмитит
1478
+ * purchase_completed / purchase_failed. Маркеры удаляются из URL
1479
+ * через history.replaceState. Ищет и в hash, и в search (hash приоритетнее —
1480
+ * защита от клиентских SPA-роутеров, перехватывающих query).
1481
+ */
1482
+ checkReturn(): void;
1483
+ destroy(): void;
1484
+ }
1485
+
1486
+ export declare interface PaywallUIOptions extends Omit<BillingClientOptions, 'auth'> {
1487
+ client?: BillingClient;
1488
+ host?: HTMLElement;
1489
+ /** Подключить managed-auth слой. См. {@link AuthOption}. */
1490
+ auth?: AuthOption;
1491
+ /**
1492
+ * Автоматически парсить URL при создании PaywallUI, чтобы поймать возврат
1493
+ * с checkout-провайдера (?paywall_status=paid|failed|cancelled). Дефолт: true.
1494
+ * Эмитит purchase_completed / purchase_failed через microtask — подпишись синхронно.
1495
+ */
1496
+ autoDetectReturn?: boolean;
1497
+ /**
1498
+ * Режим shadow DOM. По умолчанию `closed` — полная изоляция от хоста.
1499
+ * Для e2e тестов (Playwright) и live-preview в админке передавать `open`.
1500
+ */
1501
+ shadowMode?: 'open' | 'closed';
1502
+ /**
1503
+ * Аналитика SDK 3.0. По умолчанию включена. Передай `false` для полного
1504
+ * отключения (ничего не шлётся на бэк). Принимает объект с настройками
1505
+ * batch'а или endpoint-override.
1506
+ */
1507
+ analytics?: boolean | AnalyticsOptions;
1508
+ /**
1509
+ * Когда bootstrap не в кеше — модалку рендерить **сразу** со спиннером и
1510
+ * прогонять gates (visibility/trial) после получения данных, или **ждать**
1511
+ * bootstrap и монтировать только если gates прошли. Дефолт `true` —
1512
+ * snappy open, кнопка «открыть» отзывается мгновенно.
1513
+ *
1514
+ * Trade-off: при `true` и блокирующем gate'е модалка моргнёт (открылась
1515
+ * → закрылась через ~200-500мс). На extension'ах и сайтах с включённым
1516
+ * targeting-fallback'ом это редкий путь, поэтому дефолт оптимизирован
1517
+ * под основной 99%-кейс. Передай `false`, если для вашего use-case'а
1518
+ * флеш на blocked-странах/устройствах хуже воспринимаемой латентности.
1519
+ */
1520
+ mountThenLoad?: boolean;
1521
+ }
1522
+
1523
+ export declare interface PaywallUser {
1524
+ /** Главный флаг для большинства интеграций. true, если есть активная подписка
1525
+ * ИЛИ оплаченный lifetime ИЛИ активный trial. */
1526
+ has_active_subscription: boolean;
1527
+ purchases: PaywallUserPurchase[];
1528
+ trial: {
1529
+ started_at: string | null;
1530
+ expires_at: string | null;
1531
+ } | null;
1532
+ }
1533
+
1534
+ export declare interface PaywallUserPurchase {
1535
+ id: string;
1536
+ status: string | null;
1537
+ current_period_end: string | null;
1538
+ cancel_at_period_end: boolean | null;
1539
+ }
1540
+
1541
+ /** 402 от api-gateway: квота закончилась. UI ловит и открывает paywall;
1542
+ * headless caller — обрабатывает сам. balances/queryType/currentBalance —
1543
+ * то же, что отдаёт бэк в `details`. */
1544
+ export declare class QuotaExceededError extends PaywallError {
1545
+ readonly balances: Balance[];
1546
+ readonly queryType: string;
1547
+ readonly currentBalance: Balance | null;
1548
+ constructor(input: {
1549
+ balances: Balance[];
1550
+ queryType: string;
1551
+ currentBalance: Balance | null;
1552
+ message?: string;
1553
+ });
1554
+ }
1555
+
1556
+ export declare const SDK_VERSION = "3.0.0-alpha.0";
1557
+
1558
+ export declare type SignUpResult = {
1559
+ kind: 'signed_in';
1560
+ session: AuthSession;
1561
+ } | {
1562
+ kind: 'confirmation_required';
1563
+ user: {
1564
+ id: string;
1565
+ email: string;
1566
+ };
1567
+ };
1568
+
1569
+ export declare const STORAGE_KEYS: {
1570
+ visitorId: string;
1571
+ lastLoginMethod: (paywallId: string) => string;
1572
+ lastLoginEmail: (paywallId: string) => string;
1573
+ userState: (paywallId: string, identityKey: string) => string;
1574
+ authSession: (paywallId: string) => string;
1575
+ anonRefreshToken: (paywallId: string) => string;
1576
+ bootstrap: (paywallId: string) => string;
1577
+ balances: (paywallId: string, identityKey: string) => string;
1578
+ };
1579
+
1580
+ export declare interface StorageAdapter {
1581
+ getItem(key: string): Promise<string | null>;
1582
+ setItem(key: string, value: string): Promise<void>;
1583
+ removeItem(key: string): Promise<void>;
1584
+ /**
1585
+ * Опционально: подписка на изменение `key` извне (другая вкладка /
1586
+ * background-контекст extension'а). Возвращает unsubscribe.
1587
+ *
1588
+ * Контракт callback'а:
1589
+ * - `value` — новое значение (string) или `null` (удалили / нет).
1590
+ * - Вызывается ТОЛЬКО для cross-context изменений; собственный setItem
1591
+ * / removeItem callback дёргать НЕ обязан (для chrome.storage.onChanged
1592
+ * он дёрнется и так — потребитель обязан фильтровать сам).
1593
+ *
1594
+ * Адаптеры без поддержки (memory) опускают это поле, потребитель должен
1595
+ * проверять `typeof storage.watch === 'function'`.
1596
+ */
1597
+ watch?(key: string, cb: (value: string | null) => void): () => void;
1598
+ }
1599
+
1600
+ declare interface TimeTrialStatus {
1601
+ mode: 'time';
1602
+ /** true — триал ещё активен, паывол не показывается. */
1603
+ blocked: boolean;
1604
+ /** Unix ms первого `open()`. null — триал ещё не стартовал. */
1605
+ startedAt: number | null;
1606
+ /** Unix ms окончания триала. null — триал ещё не стартовал. */
1607
+ expiresAt: number | null;
1608
+ /** Сколько ещё ms триал активен. 0 — истёк или не активен. */
1609
+ remainingMs: number;
1610
+ /** Полная длина триала в ms (payload часов × 3_600_000). */
1611
+ totalMs: number;
1612
+ }
1613
+
1614
+ export declare interface TrackedEvent {
1615
+ type: string;
1616
+ ts: number;
1617
+ props?: Record<string, unknown>;
1618
+ }
1619
+
1620
+ declare interface TrialConfig {
1621
+ /** `time` — паывол скрыт N часов после первого open(); `opens` — N первых
1622
+ * open() закрываются молча, N+1-й уже показывает паывол. */
1623
+ mode: 'time' | 'opens';
1624
+ /** Часы для `time`, количество открытий для `opens`. */
1625
+ payload: number;
1626
+ /** Где живёт состояние триала. `client` — localStorage (default, мгновенно,
1627
+ * юзер может сбросить очисткой storage). `server` — серверный endpoint
1628
+ * (сейчас стаб; включится, когда будет серверный handler). */
1629
+ storage: 'client' | 'server';
1630
+ }
1631
+
1632
+ /** Статус триала на момент `paywall.open()`. SDK эмитит в payload событий
1633
+ * `trial_blocked`, и возвращает синхронно из `paywall.getTrialStatus()`. */
1634
+ declare type TrialStatus = {
1635
+ mode: 'none';
1636
+ blocked: false;
1637
+ } | TimeTrialStatus | OpensTrialStatus;
1638
+
1639
+ /** Результат `upgradeAnonymousToEmail`. `updated` — confirmation off либо
1640
+ * прошёл; session.user.email уже обновлён, is_anonymous=false. `confirmation_required` —
1641
+ * GoTrue отправил confirmation на новый email; session всё ещё анонимная,
1642
+ * юзер должен кликнуть ссылку (после чего может вызвать `auth.refresh()` —
1643
+ * токены обновятся с email'ом и is_anonymous=false). */
1644
+ declare type UpgradeAnonymousResult = {
1645
+ kind: 'updated';
1646
+ session: AuthSession;
1647
+ } | {
1648
+ kind: 'confirmation_required';
1649
+ email: string;
1650
+ };
1651
+
1652
+ /** Снимок language-resolution для синхронизации i18n host-приложения с тем, что
1653
+ * показывает пейвол. Возвращается из `BillingClient.getUserLanguage()` /
1654
+ * `PaywallUI.getUserLanguage()`. */
1655
+ export declare interface UserLanguageInfo {
1656
+ /** Best-guess BCP-47 тэг для host'а. Приоритет: `applied` → `browserLanguage`
1657
+ * → `countryLanguage`. null — bootstrap ещё не загружен и navigator
1658
+ * недоступен (например, ранний вызов в service worker). */
1659
+ tag: string | null;
1660
+ /** Ключ из `bootstrap.locales`, который SDK фактически применил к
1661
+ * layout/prices. null = match'а не было, рендерится база из layout/prices
1662
+ * без оверрайдов. */
1663
+ applied: string | null;
1664
+ /** `navigator.language` — что репортит браузер. null в окружениях без
1665
+ * navigator (service worker до пропатчивания, Node). */
1666
+ browserLanguage: string | null;
1667
+ /** Server-resolved язык по стране юзера (IP). Берётся из
1668
+ * `bootstrap.settings.locale_default` — AT→de, RU→ru, LV→en, и т.д.
1669
+ * null — bootstrap ещё не загружен или сервер не отдал поле. */
1670
+ countryLanguage: string | null;
1671
+ }
1672
+
1673
+ declare type UserListener = (user: PaywallUser) => void;
1674
+
1675
+ declare interface VisibilityStatus {
1676
+ /** true — паывол можно открывать. false — какой-то таргетинг не сошёлся,
1677
+ * смотри `reason`. */
1678
+ visible: boolean;
1679
+ /** Почему `visible=false`. null когда `visible=true`.
1680
+ * - `disabled` — владелец выключил visibility-флаг.
1681
+ * - `country_not_match` — страна юзера не в whitelist (countries_tier +
1682
+ * extra_countries).
1683
+ * - `device_not_match` — extension-канал (device_target=true), юзер не на
1684
+ * macOS. Имеет приоритет над country, потому что в этом канале device —
1685
+ * главное условие.
1686
+ */
1687
+ reason: 'country_not_match' | 'device_not_match' | 'disabled' | null;
1688
+ /** ISO-код страны юзера (по IP). null — не удалось определить. */
1689
+ country: string | null;
1690
+ /** Тир страны 1/2/3 (см. legacy `new_country_code_to_tier`). null — страна
1691
+ * не определилась. Все unmapped страны → 3. */
1692
+ tier: 1 | 2 | 3 | null;
1693
+ }
1694
+
1695
+ export { }