Files
scrabble-game/ui/src/lib/vk.ts
T
Ilia Denisov dbd76d53e8
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads
(VKWebAppShowNativeAds) expose only a client-side watch result — no
server-to-server verify — so the credit is client-attested, guarded by a server
daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10).
The caps are both anti-abuse (bounding a forger who skips the ad and calls the
endpoint directly) and an economic conversion lever (limiting free chips so a
player who wants more buys). D29 amended to VK's reality.

Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored
by the caps; payout from config rewarded_payout_chips, default 0 = off) + the
wallet.reward edge op returning the updated wallet (reward_chips gates the
"watch for chips" CTA). Additive migration (two config columns).

Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge
(vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub
(VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary
diagnostic that logs the raw VK data, so we confirm on the contour exactly what
VK returns (harden to signature-verify if it carries one).

Tests: backend integration (credit, nonce idempotency, hourly cap, disabled,
non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend,
PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
2026-07-10 00:41:42 +02:00

380 lines
15 KiB
TypeScript

// VK Mini App SDK access via @vkontakte/vk-bridge. The bridge is imported lazily inside the
// functions that need it — not at module top level — because the SDK reads browser globals on
// import: the lazy import keeps the pure URL helpers below importable in the node test environment,
// and code-splits the bridge into a chunk loaded only on the /vk/ entry. This wraps the subset the
// app uses: launch detection, the signed launch parameters (for auth.vk) and the user's display name
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
// call outside VK.
import { isExternalHttpUrl, type Haptic } from './telegram';
async function bridge() {
return (await import('@vkontakte/vk-bridge')).default;
}
/**
* onVKPath reports whether the app is served under the dedicated VK entry path (/vk/).
*/
export function onVKPath(): boolean {
if (typeof location === 'undefined') return false;
return location.pathname.startsWith('/vk/');
}
/**
* vkLaunchParams returns the raw signed VK launch query string (the vk_* parameters plus sign) from
* the page URL — the exact form the gateway verifies — or '' when the URL carries no signed launch
* (an ordinary browser tab, or the /vk/ path opened directly).
*/
export function vkLaunchParams(): string {
if (typeof location === 'undefined') return '';
const query = location.search.replace(/^\?/, '');
return new URLSearchParams(query).has('sign') ? query : '';
}
/**
* insideVK reports whether the app launched as a VK Mini App — the URL carries signed launch
* parameters (an ordinary browser tab has none).
*/
export function insideVK(): boolean {
return vkLaunchParams() !== '';
}
/**
* vkInit signals to the VK client that the Mini App has loaded (VKWebAppInit), dismissing VK's own
* loading cover. Best-effort: it resolves even if the bridge is unavailable (outside VK), so the
* caller can await it unconditionally.
*/
export async function vkInit(): Promise<void> {
try {
await (await bridge()).send('VKWebAppInit', {});
} catch {
// Outside VK there is no client to receive it; the launch continues regardless.
}
}
/**
* vkClose closes the VK Mini App (VKWebAppClose), used on the terminal account-deleted
* screen. Best-effort: a no-op outside VK.
*/
export async function vkClose(): Promise<void> {
try {
await (await bridge()).send('VKWebAppClose', { status: 'success' });
} catch {
// Outside VK there is no client to receive it.
}
}
/**
* vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it
* from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back
* to a generated placeholder. The value is a cosmetic seed only — being unsigned, the gateway never
* trusts it for identity.
*/
export async function vkUserName(): Promise<string> {
try {
const u = await (await bridge()).send('VKWebAppGetUserInfo', {});
return [u.first_name, u.last_name].filter(Boolean).join(' ').trim();
} catch {
return '';
}
}
/**
* vkAppId returns the launching VK app id (vk_app_id) from the signed launch parameters in the URL —
* so the app can build its own vk.com/app<id> links without a VK API call — or '' outside a VK launch.
*/
export function vkAppId(): string {
if (typeof location === 'undefined') return '';
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_app_id') ?? '';
}
/**
* vkStartParam returns the VK direct-link deep-link payload. VK passes everything after the '#' in a
* vk.com/app<id>#<payload> link to the app as the `hash` query parameter (it is also in location.hash,
* but that collides with the app's hash router, so the query parameter is the safe source). Empty when
* the launch carried no deep link.
*/
export function vkStartParam(): string {
if (typeof location === 'undefined') return '';
return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? '';
}
/**
* vkPlatform returns the VK client platform from the launch parameters (vk_platform:
* mobile_android, mobile_iphone, desktop_web, mobile_web, …), or '' outside a VK launch.
*/
export function vkPlatform(): string {
if (typeof location === 'undefined') return '';
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_platform') ?? '';
}
/**
* vkAndroidWebView reports whether the app runs inside the Android VK client's WebView
* (vk_platform mobile_android / mobile_android_messenger) — the one environment whose WebView
* ignores target=_blank and navigates the app's own window instead of opening a browser.
*/
export function vkAndroidWebView(): boolean {
return insideVK() && vkPlatform().startsWith('mobile_android');
}
/**
* vkExternalBrowserUrl wraps url in VK's own leave-VK redirect (vk.com/away.php). The VK mobile
* client intercepts vk.com navigations natively and hands the redirect target to the system
* browser, so an external link escapes the Mini App WebView instead of replacing the game.
* vk-bridge (3.x) offers no method to open an external URL, so this redirect is the supported
* escape hatch. Pure, so it is unit-tested.
*/
export function vkExternalBrowserUrl(url: string): string {
return `https://vk.com/away.php?to=${encodeURIComponent(url)}`;
}
/**
* vkOpenExternalUrl opens url in the system browser from inside the Android VK client by routing
* it through vkExternalBrowserUrl. Returns false outside the Android VK WebView — iOS and the
* desktop iframe open target=_blank correctly and are left alone — so the caller falls back to
* the anchor's own navigation or a plain window.open.
*/
export function vkOpenExternalUrl(url: string): boolean {
if (typeof window === 'undefined' || !vkAndroidWebView()) return false;
window.open(vkExternalBrowserUrl(url), '_blank');
return true;
}
/**
* routeExternalLinkInVK decides whether a clicked anchor must be opened through VK's external
* redirect rather than the WebView's default navigation: only inside the Android VK client, and
* only for an external http(s) link opened in a new tab (target=_blank). Returns true when it
* opened the link — the caller should then preventDefault — and false to let the browser handle
* the click normally.
*/
export function routeExternalLinkInVK(anchor: { href: string; target: string }): boolean {
if (anchor.target !== '_blank') return false;
if (!isExternalHttpUrl(anchor.href)) return false;
return vkOpenExternalUrl(anchor.href);
}
/**
* vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for
* navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was
* handled, false on any failure or outside VK.
*/
export async function vkShare(link: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppShare', { link });
return true;
} catch {
return false;
}
}
/**
* vkShowImages opens VK's native image viewer on url — on the Android client this is the
* PNG delivery: the viewer's own "save" works there, while VKWebAppDownloadFile hangs
* indefinitely (on-device finding). Resolves false on any failure, so the caller can
* fall back.
*/
export async function vkShowImages(url: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppShowImages', { images: [url] });
return true;
} catch {
return false;
}
}
/**
* vkShowOrderBox opens VK's payment box for a "голоса" item purchase; item is the order id the
* backend created (VK echoes it to our payment callback, which credits). Resolves true when the
* payment completes, false on cancel/failure or outside VK. The chips are credited by the server
* callback, so the wallet refreshes from the payment push regardless of this result.
*/
export async function vkShowOrderBox(item: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppShowOrderBox', { type: 'item', item });
return true;
} catch {
return false;
}
}
/**
* vkRewardedReady reports whether a rewarded ad is preloaded and ready to show
* (VKWebAppCheckNativeAds, required before a rewarded view). A false — or an unavailable method —
* hides the "watch for chips" button, since tapping it would show nothing.
*/
export async function vkRewardedReady(): Promise<boolean> {
try {
const data = await (await bridge()).send('VKWebAppCheckNativeAds', { ad_format: 'reward' });
return !!(data as { result?: boolean }).result;
} catch {
return false;
}
}
/**
* vkShowRewarded shows a rewarded ad (VKWebAppShowNativeAds) and reports whether it was watched
* (data.result) plus the full raw provider result as JSON — forwarded to the backend for the
* temporary contour diagnostic (to confirm exactly what VK returns). A rejected call reports
* not-watched with the error captured.
*/
export async function vkShowRewarded(): Promise<{ watched: boolean; data: string }> {
try {
const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'reward' });
return { watched: !!(data as { result?: boolean }).result, data: JSON.stringify(data) };
} catch (e) {
return { watched: false, data: JSON.stringify({ error: String(e) }) };
}
}
/**
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
* on any failure or where the method is unavailable (notably the desktop iframe), so the
* caller falls back to a plain browser download of the same URL.
*/
export async function vkDownloadFile(url: string, filename: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppDownloadFile', { url, filename });
return true;
} catch {
return false;
}
}
/**
* vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
* where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.
*/
export async function vkCopyText(text: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppCopyText', { text });
return true;
} catch {
return false;
}
}
/**
* vkOnScheme subscribes to VK's appearance (VKWebAppUpdateConfig) and calls handler with the mapped
* 'light' | 'dark' scheme on launch and whenever the user switches the VK client theme — so the app's
* "auto" theme can follow the VK client instead of the (often wrong) webview prefers-color-scheme.
* A no-op outside VK.
*/
export async function vkOnScheme(handler: (scheme: 'light' | 'dark') => void): Promise<void> {
try {
const b = await bridge();
b.subscribe((e) => {
const detail = (e as { detail?: { type?: string; data?: { scheme?: string; appearance?: string } } }).detail;
if (detail?.type !== 'VKWebAppUpdateConfig') return;
const scheme = detail.data?.scheme ?? detail.data?.appearance ?? '';
handler(/dark|space_gray/i.test(scheme) ? 'dark' : 'light');
});
} catch {
// Outside VK / bridge unavailable: leave the app on its own theme.
}
}
/** VKInsets is the device safe-area the VK client reports (px). */
export interface VKInsets {
top: number;
bottom: number;
left: number;
right: number;
}
/**
* vkOnInsets subscribes to VK's safe-area insets and calls handler with them on launch and on change.
* VK reports them via VKWebAppUpdateConfig (iOS) and the dedicated VKWebAppUpdateInsets event; the VK
* mobile webview does not expose them through CSS env() the way iOS Safari does, so the app reads them
* from the bridge to clear the home bar (notably on Android). A no-op outside VK.
*/
export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise<void> {
try {
const b = await bridge();
b.subscribe((e) => {
const d = (e as { detail?: { type?: string; data?: { insets?: Partial<VKInsets> } } }).detail;
if (d?.type !== 'VKWebAppUpdateConfig' && d?.type !== 'VKWebAppUpdateInsets') return;
const i = d.data?.insets;
if (!i) return;
handler({ top: i.top ?? 0, bottom: i.bottom ?? 0, left: i.left ?? 0, right: i.right ?? 0 });
});
} catch {
// Outside VK / bridge unavailable: the CSS env() safe-area fallback applies.
}
}
/**
* vkHaptic fires a VK Bridge haptic mirroring the Telegram set: a selection tick
* (VKWebAppTapticSelectionChanged), a success/warning/error notification
* (VKWebAppTapticNotificationOccurred) or a light/medium/heavy impact (VKWebAppTapticImpactOccurred).
* Best-effort and a no-op outside VK or on a client without taptic support (the send rejects and is
* swallowed), so callers fire it unconditionally.
*/
export async function vkHaptic(kind: Haptic): Promise<void> {
try {
const b = await bridge();
if (kind === 'select') {
await b.send('VKWebAppTapticSelectionChanged', {});
} else if (kind === 'success' || kind === 'warning' || kind === 'error') {
await b.send('VKWebAppTapticNotificationOccurred', { type: kind });
} else {
await b.send('VKWebAppTapticImpactOccurred', { style: kind });
}
} catch {
// Outside VK / no taptic support: silent, like the Telegram haptic off-platform.
}
}
/**
* vkDisableSwipeBack turns off VK's horizontal swipe-back gesture (VKWebAppSetSwipeSettings with
* history:false) so it does not fight the app's own edge-swipe-back and on-board tile drag — the
* app owns navigation through its back chevron, as on Telegram (telegramDisableVerticalSwipes).
* Best-effort; a no-op outside VK or on a client without the method.
*/
export async function vkDisableSwipeBack(): Promise<void> {
try {
await (await bridge()).send('VKWebAppSetSwipeSettings', { history: false });
} catch {
// Outside VK / unsupported: VK's default gesture handling stays as-is.
}
}
/**
* appearanceForBg maps a background colour to the VK view appearance: a dark background wants the
* 'dark' appearance (light status-bar icons), a light one 'light'. It accepts a `#rgb` / `#rrggbb`
* string (the app's `--bg` token); anything unparseable defaults to 'light'. Pure, so it is
* unit-tested.
*/
export function appearanceForBg(bg: string): 'light' | 'dark' {
const hex = bg.trim().replace(/^#/, '');
const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex;
if (full.length !== 6 || /[^0-9a-fA-F]/.test(full)) return 'light';
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
// Rec. 601 luma; below the midpoint is a dark background.
return 0.299 * r + 0.587 * g + 0.114 * b < 128 ? 'dark' : 'light';
}
/**
* vkSetViewSettings paints VK's status bar — and, on Android, the action and navigation bars — to
* match the app. appearance is the VK status-bar appearance ('light' | 'dark'); actionBarColor and
* navigationBarColor are Android-only hex colours (ignored elsewhere). Best-effort; a no-op outside
* VK or on a client without the method.
*/
export async function vkSetViewSettings(
appearance: 'light' | 'dark',
actionBarColor: string,
navigationBarColor: string,
): Promise<void> {
try {
await (await bridge()).send('VKWebAppSetViewSettings', {
status_bar_style: appearance,
action_bar_color: actionBarColor,
navigation_bar_color: navigationBarColor,
});
} catch {
// Outside VK / unsupported: the client keeps its default bars.
}
}