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
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).
382 lines
13 KiB
Svelte
382 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import Modal from '../components/Modal.svelte';
|
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
|
import { gateway } from '../lib/gateway';
|
|
import { normalizeSubtype } from '../lib/platform';
|
|
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
|
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
|
|
import { isGooglePlayBuild } from '../lib/distribution';
|
|
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
|
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
|
import { telegramOpenInvoice } from '../lib/telegram';
|
|
import { rewardedReady, showRewarded } from '../lib/ads';
|
|
import { GatewayError } from '../lib/client';
|
|
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
|
|
|
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
|
// Values are bought with chips (they work once the player has any); chip packs are bought with
|
|
// money (the purchase itself arrives with payment intake — here they are shown priced only). On
|
|
// the Google Play build the money purchases are hidden behind a RuStore stub (store policy), while
|
|
// spending already-earned chips still works.
|
|
|
|
let wallet = $state<Wallet | null>(null);
|
|
let catalog = $state<Catalog | null>(null);
|
|
let loading = $state(true);
|
|
// The product whose purchase is in flight, or null. It both guards against a concurrent purchase
|
|
// (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button.
|
|
let busyId = $state<string | null>(null);
|
|
const busy = $derived(busyId !== null);
|
|
// The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips).
|
|
let warnProduct = $state<CatalogProduct | null>(null);
|
|
|
|
const context: SpendContext = executionContext();
|
|
const gpBuild = isGooglePlayBuild();
|
|
// Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown
|
|
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
|
|
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
|
|
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
|
|
// The "other version" link points at this deployment's own site root (the landing lists every
|
|
// platform), so it follows prod vs the contour without a hardcoded host.
|
|
const siteRoot = typeof location !== 'undefined' ? `${location.origin}/` : '/';
|
|
|
|
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
|
|
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
|
|
|
|
// Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in
|
|
// this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show.
|
|
let rewardReady = $state(false);
|
|
const canReward = $derived(!!wallet && wallet.rewardChips > 0 && rewardReady);
|
|
|
|
// onWatchAd shows a rewarded ad and, if watched, credits the payout server-side (client-attested +
|
|
// a server daily/hourly cap). A stub view (contour test) shows an "ad fired" marker; a reached cap
|
|
// is surfaced as its own toast, distinct from a generic error.
|
|
async function onWatchAd() {
|
|
if (busy) return;
|
|
busyId = 'reward';
|
|
try {
|
|
const res = await showRewarded();
|
|
if (res.stub) showToast('ad fired');
|
|
if (!res.watched) {
|
|
showToast(t('wallet.rewardFailed'));
|
|
return;
|
|
}
|
|
const nonce = crypto.randomUUID();
|
|
const before = wallet?.segments.find((s) => s.source === 'vk')?.chips ?? 0;
|
|
const w = await gateway.walletReward(nonce, res.data);
|
|
wallet = w;
|
|
const gained = (w.segments.find((s) => s.source === 'vk')?.chips ?? 0) - before;
|
|
showToast(gained > 0 ? t('wallet.rewardCredited', { n: gained }) : t('wallet.rewardCredited', { n: w.rewardChips }));
|
|
} catch (e) {
|
|
if (e instanceof GatewayError && e.code === 'reward_capped') {
|
|
showToast(t('wallet.rewardCapped'));
|
|
} else {
|
|
handleError(e);
|
|
}
|
|
} finally {
|
|
busyId = null;
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
const [w, c] = await Promise.all([gateway.wallet(), gateway.catalog()]);
|
|
wallet = w;
|
|
catalog = c;
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
onMount(() => {
|
|
void load();
|
|
// Probe rewarded-ad readiness once so the "watch for chips" button only shows when a view would
|
|
// actually play.
|
|
void rewardedReady().then((r) => (rewardReady = r));
|
|
// Fallback to the payment push: re-fetch when the tab regains focus, i.e. the user returned
|
|
// from the provider's payment window.
|
|
const onVisible = () => {
|
|
if (!document.hidden) void load();
|
|
};
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
return () => document.removeEventListener('visibilitychange', onVisible);
|
|
});
|
|
|
|
// Main path: a payment-intake push bumps app.walletRefresh; re-fetch in place when it changes
|
|
// (a credit landed while the screen is open).
|
|
let seenRefresh = app.walletRefresh;
|
|
$effect(() => {
|
|
if (app.walletRefresh !== seenRefresh) {
|
|
seenRefresh = app.walletRefresh;
|
|
void load();
|
|
}
|
|
});
|
|
|
|
function sourceLabel(source: string): string {
|
|
return t(`wallet.source.${source}` as MessageKey);
|
|
}
|
|
|
|
function currencyLabel(currency: string): string {
|
|
return t(`wallet.cur.${currency}` as MessageKey);
|
|
}
|
|
|
|
function adsLine(): string {
|
|
if (!wallet) return '';
|
|
if (wallet.adsForever) return t('wallet.noAdsForever');
|
|
if (wallet.adsPaidUntilMs > Date.now()) {
|
|
const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US');
|
|
return t('wallet.noAdsUntil', { date });
|
|
}
|
|
return t('wallet.adsOn');
|
|
}
|
|
|
|
function onBuy(p: CatalogProduct) {
|
|
if (needsWebSpendWarning(context, wallet?.segments ?? [], p.chips)) {
|
|
warnProduct = p;
|
|
return;
|
|
}
|
|
void doBuy(p);
|
|
}
|
|
|
|
async function doBuy(p: CatalogProduct) {
|
|
warnProduct = null;
|
|
if (busy) return;
|
|
busyId = p.productId;
|
|
try {
|
|
wallet = await gateway.walletBuy(p.productId);
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busyId = null;
|
|
}
|
|
}
|
|
|
|
// onOrder funds a chip pack with money: it opens a pending order and sends the player to the
|
|
// provider's hosted-payment page. The chips are credited later, by the verified server callback;
|
|
// paying accepts the public offer (linked below the packs).
|
|
async function onOrder(p: CatalogProduct) {
|
|
if (busy) return;
|
|
busyId = p.productId;
|
|
try {
|
|
const order = await gateway.walletOrder(p.productId);
|
|
if (context === 'vk') {
|
|
// VK settles the payment in-app via the bridge; the chips arrive on the server callback.
|
|
await vkShowOrderBox(order.orderId);
|
|
} else if (context === 'telegram') {
|
|
// Telegram Stars: the bot minted an invoice link (returned as redirectUrl); open it in-app.
|
|
// The chips are credited server-side by the forwarded payment and arrive on the live push;
|
|
// refresh on 'paid' as a fallback. Outside a Stars-capable client, fall back to the link.
|
|
const opened = telegramOpenInvoice(order.redirectUrl, (status) => {
|
|
if (status === 'paid') void load();
|
|
});
|
|
if (!opened) openExternalUrl(order.redirectUrl);
|
|
} else {
|
|
openExternalUrl(order.redirectUrl);
|
|
}
|
|
} catch (e) {
|
|
handleError(e);
|
|
} finally {
|
|
busyId = null;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="wallet" data-testid="wallet">
|
|
<section>
|
|
<h3>{t('wallet.balance')}</h3>
|
|
{#if wallet && wallet.segments.length > 0}
|
|
{#each wallet.segments as s (s.source)}
|
|
<div class="row">
|
|
<span class="name">{sourceLabel(s.source)}</span>
|
|
<span class="value"
|
|
>🪙 {s.chips}{#if !s.spendable} <span class="muted">({t('wallet.viewOnly')})</span>{/if}</span
|
|
>
|
|
</div>
|
|
{/each}
|
|
{:else if !loading}
|
|
<p class="empty">{t('wallet.noChips')}</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<section>
|
|
<h3>{t('wallet.benefits')}</h3>
|
|
<div class="row"><span class="name">{adsLine()}</span></div>
|
|
<div class="row"><span class="name">{t('wallet.hints', { n: wallet?.hints ?? 0 })}</span></div>
|
|
</section>
|
|
|
|
<section>
|
|
<h3>{t('wallet.store')}</h3>
|
|
{#if purchaseBlocked}
|
|
<!-- prettier-ignore -->
|
|
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
|
|
{/if}
|
|
{#if canReward}
|
|
<div class="row reward" data-testid="reward-row">
|
|
<span class="name">{t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })}</span>
|
|
<button class="buy" class:working={busyId === 'reward'} data-testid="watch-ad" disabled={busy} onclick={onWatchAd}
|
|
>{t('wallet.rewardCta')}</button
|
|
>
|
|
</div>
|
|
{/if}
|
|
{#each values as p (p.productId)}
|
|
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
|
<span class="name">{p.title}</span>
|
|
<span class="price">🪙 {p.chips}</span>
|
|
<button class="buy" class:working={busyId === p.productId} data-testid="buy" disabled={busy} onclick={() => onBuy(p)}
|
|
>{t('wallet.buy')}</button
|
|
>
|
|
</div>
|
|
{/each}
|
|
|
|
{#if gpBuild}
|
|
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
|
|
{:else}
|
|
{#each packs as p (p.productId)}
|
|
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
|
|
<span class="name">{p.title}</span>
|
|
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)}</span>
|
|
<button
|
|
class="buy"
|
|
class:blocked={purchaseBlocked}
|
|
class:working={busyId === p.productId}
|
|
data-testid="buy-pack"
|
|
disabled={busy}
|
|
onclick={() => {
|
|
if (purchaseBlocked) showToast(t('wallet.platformNoBuy'));
|
|
else void onOrder(p);
|
|
}}>{t('wallet.buy')}</button
|
|
>
|
|
</div>
|
|
{/each}
|
|
{#if packs.length > 0}
|
|
<p class="offer" data-testid="offer">
|
|
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
|
|
<p class="empty">{t('wallet.empty')}</p>
|
|
{/if}
|
|
</section>
|
|
</div>
|
|
|
|
{#if warnProduct}
|
|
<Modal title={t('wallet.warnTitle')} onclose={() => (warnProduct = null)}>
|
|
<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>
|
|
<div class="warn-actions">
|
|
<button class="cancel" onclick={() => (warnProduct = null)}>{t('wallet.warnCancel')}</button>
|
|
<button class="buy" data-testid="warn-confirm" onclick={() => warnProduct && doBuy(warnProduct)}
|
|
>{t('wallet.warnConfirm')}</button
|
|
>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|
|
|
|
<style>
|
|
.wallet {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 18px;
|
|
padding: var(--pad);
|
|
}
|
|
section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
h3 {
|
|
margin: 0;
|
|
font-size: 0.95rem;
|
|
color: var(--text-muted);
|
|
}
|
|
.row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface);
|
|
}
|
|
.name {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
}
|
|
.value,
|
|
.price {
|
|
flex: 0 0 auto;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
.muted {
|
|
color: var(--text-muted);
|
|
font-weight: 400;
|
|
}
|
|
.buy {
|
|
flex: 0 0 auto;
|
|
padding: 8px 14px;
|
|
border: 1px solid var(--accent);
|
|
background: var(--accent);
|
|
color: var(--accent-text);
|
|
border-radius: var(--radius-sm);
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.buy:disabled {
|
|
cursor: default;
|
|
}
|
|
/* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a
|
|
blocked pack on VK iOS — never every buy button at once when one purchase is busy. */
|
|
.buy.blocked,
|
|
.buy.working {
|
|
opacity: 0.5;
|
|
}
|
|
.ios-note {
|
|
margin: 0 0 4px;
|
|
padding: 8px 12px;
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
border: 1px dashed var(--border);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.ios-note a {
|
|
color: var(--accent);
|
|
}
|
|
.empty,
|
|
.stub {
|
|
margin: 0;
|
|
padding: 10px 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
.stub {
|
|
border: 1px dashed var(--border);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
.offer {
|
|
margin: 2px 0 0;
|
|
text-align: center;
|
|
font-size: 0.85rem;
|
|
}
|
|
.offer a {
|
|
color: var(--text-muted);
|
|
}
|
|
.warn-body {
|
|
margin: 0 0 14px;
|
|
}
|
|
.warn-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
}
|
|
.cancel {
|
|
padding: 8px 14px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
}
|
|
</style>
|