Merge pull request 'Wallet redesign: split buy/spend, compact balance, exchange confirm + insufficient-chips fix' (#242) from feature/wallet-redesign into development
CI / changes (push) Successful in 2s
CI / deploy (push) Successful in 1m56s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m12s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / deploy (push) Successful in 1m56s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m12s
CI / deploy (pull_request) Has been skipped
This commit was merged in pull request #242.
This commit is contained in:
+20
-12
@@ -534,21 +534,29 @@ shown defensively (text escaped, attachments downloaded rather than rendered).
|
|||||||
### Wallet
|
### Wallet
|
||||||
|
|
||||||
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
|
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
|
||||||
none. It shows their **chips** (the in-game currency, split by where they were funded — VK, Telegram
|
none. A compact **balance** leads the screen: the running context's own **chips** (the in-game
|
||||||
or the web), their **active benefits** (ads off until a date or forever, and the available hint
|
currency) first, then any linked other-platform chips behind that platform's logo — VK, Telegram or
|
||||||
count), and a **store**. What is visible and spendable depends on **where the app runs**, by the
|
the web, split by where they were funded. What is visible and spendable depends on **where the app
|
||||||
store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that store's chips are
|
runs**, by the store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that
|
||||||
usable; on the open web all attached chips are, drawn web → VK → Telegram; on VK-iOS the balance is
|
store's chips are usable; on the open web all attached chips are, drawn web → VK → Telegram; on
|
||||||
shown but frozen for spending.
|
VK-iOS the balance is shown but frozen for spending.
|
||||||
|
|
||||||
|
Below the balance an **Active** line lists what the player currently holds — the remaining hint count
|
||||||
|
and, if bought, the ad-free end date (or "forever"); it is hidden when nothing is active. The
|
||||||
|
**store** then splits, by a two-way toggle, into **Buy chips** and **Spend chips**:
|
||||||
|
|
||||||
|
- **Buy chips** — the **chip packs** bought with real money, priced in the running context's currency
|
||||||
|
(VK Votes, Telegram Stars, or roubles on the web), plus, at the top, a **watch-an-ad-for-chips**
|
||||||
|
option where it is offered. Paying opens the provider's payment page (accepting the public offer).
|
||||||
|
- **Spend chips** — the **values** bought with chips (extra hints, days without ads) at a fixed chip
|
||||||
|
price. Their action reads **Exchange**, and a value the balance cannot cover is disabled. Tapping
|
||||||
|
Exchange asks the player to **confirm** (the spend is instant, with no provider window to stand in
|
||||||
|
for a confirmation); that same dialog also carries the store-compliance **warning** when a web
|
||||||
|
spend would draw VK/Telegram chips — the benefit then works only on the web and in the app.
|
||||||
|
|
||||||
The **store** lists **values** bought with chips (extra hints, days without ads) at a fixed chip
|
|
||||||
price shown in every context, and **chip packs** bought with real money, priced in the running
|
|
||||||
context's currency (VK Votes, Telegram Stars, or roubles on the web). Buying a value applies its
|
|
||||||
benefit at once. Before a web purchase that would draw VK/Telegram chips, the app **warns** that the
|
|
||||||
benefit will then work only on the web and in the app — a store rule — and asks the player to confirm.
|
|
||||||
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
|
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
|
||||||
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
|
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
|
||||||
**no purchase history** — only the current balances, benefits and store.
|
**no purchase history** — only the current balances, active benefits and store.
|
||||||
|
|
||||||
### Advertising banner
|
### Advertising banner
|
||||||
|
|
||||||
|
|||||||
+23
-14
@@ -548,21 +548,30 @@ high-rate флага. С карточки пользователя операт
|
|||||||
### Кошелёк
|
### Кошелёк
|
||||||
|
|
||||||
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
|
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
|
||||||
«О программе»; у гостей его нет. Он показывает **фишки** (внутриигровую валюту, разделённую по тому,
|
«О программе»; у гостей его нет. Экран открывает компактный **баланс**: сначала собственные **фишки**
|
||||||
где она была пополнена — VK, Telegram или веб), **активные блага** (реклама выключена до даты или
|
(внутриигровая валюта) текущего контекста, затем привязанные фишки других платформ за их логотипом —
|
||||||
навсегда, и число доступных подсказок) и **магазин**. Что видно и что можно потратить, зависит от
|
VK, Telegram или веб, разделённые по тому, где они были пополнены. Что видно и что можно потратить,
|
||||||
того, **где запущено приложение**, по правилам соответствия магазинам из [`PAYMENTS.md`](PAYMENTS.md):
|
зависит от того, **где запущено приложение**, по правилам соответствия магазинам из
|
||||||
внутри магазина доступны только его фишки; в открытом вебе — все привязанные, списываются в порядке
|
[`PAYMENTS.md`](PAYMENTS.md): внутри магазина доступны только его фишки; в открытом вебе — все
|
||||||
веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
|
привязанные, списываются в порядке веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
|
||||||
|
|
||||||
**Магазин** перечисляет **ценности**, покупаемые за фишки (дополнительные подсказки, дни без рекламы),
|
Под балансом строка **«Активные»** перечисляет, что у игрока есть сейчас — остаток подсказок и, если
|
||||||
по фиксированной цене в фишках, одинаковой во всех контекстах, и **наборы фишек**, покупаемые за
|
куплено, дату окончания «без рекламы» (или «навсегда»); она скрыта, когда активного ничего нет. Далее
|
||||||
настоящие деньги, в валюте текущего контекста (голоса VK, звёзды Telegram или рубли в вебе). Покупка
|
**магазин** двухпозиционным переключателем делится на **«Купить фишки»** и **«Потратить фишки»**:
|
||||||
ценности сразу применяет её благо. Перед покупкой в вебе, которая списала бы фишки VK/Telegram,
|
|
||||||
приложение **предупреждает**, что благо будет работать только в вебе и в приложении — это правило
|
- **Купить фишки** — **наборы фишек** за настоящие деньги, в валюте текущего контекста (голоса VK,
|
||||||
магазинов — и просит подтверждения. В сборке для **Google Play** покупки за деньги скрыты за подсказкой
|
звёзды Telegram или рубли в вебе), а сверху — вариант **«ролик за фишки»**, где он предлагается.
|
||||||
установить сборку из RuStore (правило Google о внутриигровой валюте); трата уже заработанных фишек
|
Оплата открывает страницу провайдера (с принятием публичной оферты).
|
||||||
по-прежнему работает. Кошелёк **не хранит историю покупок** — только текущие балансы, блага и магазин.
|
- **Потратить фишки** — **ценности** за фишки (дополнительные подсказки, дни без рекламы) по
|
||||||
|
фиксированной цене в фишках. Их действие называется **«Обмен»**, а ценность, на которую не хватает
|
||||||
|
баланса, недоступна. Тап по «Обмену» просит **подтверждения** (трата мгновенна, окна провайдера,
|
||||||
|
которое сошло бы за подтверждение, нет); это же окно несёт и **предупреждение** соответствия
|
||||||
|
магазинам, когда трата в вебе списала бы фишки VK/Telegram — благо тогда работает только в вебе и в
|
||||||
|
приложении.
|
||||||
|
|
||||||
|
В сборке для **Google Play** покупки за деньги скрыты за подсказкой установить сборку из RuStore
|
||||||
|
(правило Google о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк
|
||||||
|
**не хранит историю покупок** — только текущие балансы, активные блага и магазин.
|
||||||
|
|
||||||
### Рекламный баннер
|
### Рекламный баннер
|
||||||
|
|
||||||
|
|||||||
+41
-24
@@ -1,9 +1,10 @@
|
|||||||
import { expect, test, type Page } from './fixtures';
|
import { expect, test, type Page } from './fixtures';
|
||||||
|
|
||||||
// The Wallet section against the mock transport (no backend). The mock seeds a web/native (direct)
|
// The Wallet section against the mock transport (no backend). The mock seeds a web/native (direct)
|
||||||
// account holding a direct + vk chip segment and a small catalog (two chip-priced values and one
|
// account holding a direct (120) + vk (400) chip segment and a small catalog (two chip-priced values
|
||||||
// rouble-priced chip pack — see lib/mock/client.ts), so the storefront, the store-compliance
|
// and one rouble-priced chip pack — see lib/mock/client.ts), so the compact balance, the split
|
||||||
// warning and the Google Play stub are all exercisable without a backend.
|
// storefront, the exchange-confirm dialog (with its store-compliance warning) and the Google Play
|
||||||
|
// stub are all exercisable without a backend.
|
||||||
|
|
||||||
async function loginLobby(page: Page): Promise<void> {
|
async function loginLobby(page: Page): Promise<void> {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -22,24 +23,33 @@ async function openWallet(page: Page): Promise<void> {
|
|||||||
await expect(page.getByTestId('wallet')).toBeVisible();
|
await expect(page.getByTestId('wallet')).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
test('wallet: balances, benefits and the storefront render for a durable account', async ({ page }) => {
|
test('wallet: balance, active benefits and the split storefront render for a durable account', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openWallet(page);
|
await openWallet(page);
|
||||||
|
|
||||||
// Balances: the seeded direct ("Web") and vk segments.
|
// The compact balance leads with the context's own (direct/"Web") chips, then the linked vk
|
||||||
await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible();
|
// segment behind its logo.
|
||||||
await expect(page.getByText('Web', { exact: true })).toBeVisible();
|
const balance = page.getByTestId('balance');
|
||||||
await expect(page.getByText('VK', { exact: true })).toBeVisible();
|
await expect(balance).toContainText('120');
|
||||||
|
await expect(balance).toContainText('400');
|
||||||
|
await expect(balance.locator('.plogo')).toHaveCount(1); // the one linked (vk) platform
|
||||||
|
|
||||||
// Benefits + the storefront: two values priced in chips, one pack priced in roubles.
|
// The active benefits (the seeded 5 hints) sit above the store.
|
||||||
await expect(page.getByRole('heading', { name: 'Benefits' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Active' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('active')).toContainText('Hints: 5');
|
||||||
await expect(page.getByRole('heading', { name: 'Store' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Store' })).toBeVisible();
|
||||||
await expect(page.getByTestId('product')).toHaveCount(3);
|
|
||||||
const pack = page.locator('[data-kind="pack"]');
|
// Default tab — Buy chips: one rouble-priced pack + the public offer, and no values.
|
||||||
await expect(pack).toContainText('₽');
|
await expect(page.locator('[data-kind="pack"]')).toHaveCount(1);
|
||||||
// The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link.
|
await expect(page.locator('[data-kind="pack"]')).toContainText('₽');
|
||||||
await expect(pack.getByTestId('buy-pack')).toBeEnabled();
|
await expect(page.locator('[data-kind="pack"]').getByTestId('buy-pack')).toBeEnabled();
|
||||||
await expect(page.getByTestId('offer')).toContainText('Public offer');
|
await expect(page.getByTestId('offer')).toContainText('Public offer');
|
||||||
|
await expect(page.locator('[data-kind="value"]')).toHaveCount(0);
|
||||||
|
|
||||||
|
// Spend chips tab: the two chip-priced values, exchanged (not bought) with the "Exchange" button.
|
||||||
|
await page.getByTestId('tab-spend').click();
|
||||||
|
await expect(page.locator('[data-kind="value"]')).toHaveCount(2);
|
||||||
|
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toHaveText('Exchange');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
|
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
|
||||||
@@ -87,32 +97,39 @@ test('wallet: the Google Play build hides the money purchases behind the RuStore
|
|||||||
await expect(page.getByText('Your turn')).toBeVisible();
|
await expect(page.getByText('Your turn')).toBeVisible();
|
||||||
await openWallet(page);
|
await openWallet(page);
|
||||||
|
|
||||||
// The chip pack is gone; the stub points at the RuStore build. Spending earned chips still works.
|
// Buy chips: the chip pack is gone; the stub points at the RuStore build.
|
||||||
await expect(page.getByTestId('gp-stub')).toBeVisible();
|
await expect(page.getByTestId('gp-stub')).toBeVisible();
|
||||||
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
|
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
|
||||||
|
// Spending earned chips still works — the values live in the Spend tab.
|
||||||
|
await page.getByTestId('tab-spend').click();
|
||||||
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
|
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wallet: a web spend that would draw store chips warns first, then completes', async ({ page }) => {
|
test('wallet: a web spend that would draw store chips confirms with a warning, then completes', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openWallet(page);
|
await openWallet(page);
|
||||||
|
await page.getByTestId('tab-spend').click();
|
||||||
|
|
||||||
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → warn.
|
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → the confirm
|
||||||
|
// dialog carries the cross-platform warning.
|
||||||
await page.locator('[data-pid="val-noads-30"]').getByTestId('buy').click();
|
await page.locator('[data-pid="val-noads-30"]').getByTestId('buy').click();
|
||||||
await expect(page.getByTestId('warn')).toBeVisible();
|
await expect(page.getByTestId('warn')).toBeVisible();
|
||||||
await page.getByTestId('warn-confirm').click();
|
await page.getByTestId('spend-confirm').click();
|
||||||
await expect(page.getByTestId('warn')).toBeHidden();
|
// The spend applied: the no-ads benefit now shows an end date in the Active section.
|
||||||
// The spend applied: the no-ads benefit now shows an end date.
|
|
||||||
await expect(page.getByTestId('wallet')).toContainText('No ads until');
|
await expect(page.getByTestId('wallet')).toContainText('No ads until');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wallet: a spend the direct segment covers alone does not warn', async ({ page }) => {
|
test('wallet: a spend the direct segment covers alone confirms without a warning', async ({ page }) => {
|
||||||
await loginLobby(page);
|
await loginLobby(page);
|
||||||
await openWallet(page);
|
await openWallet(page);
|
||||||
|
await page.getByTestId('tab-spend').click();
|
||||||
|
|
||||||
// The 100-chip value is covered by the direct segment (120) alone → no store draw, no warning.
|
// The 100-chip value is covered by the direct segment (120) alone → the confirm dialog shows, but
|
||||||
|
// with no cross-platform warning.
|
||||||
await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click();
|
await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click();
|
||||||
|
await expect(page.getByTestId('spend-body')).toBeVisible();
|
||||||
await expect(page.getByTestId('warn')).toBeHidden();
|
await expect(page.getByTestId('warn')).toBeHidden();
|
||||||
|
await page.getByTestId('spend-confirm').click();
|
||||||
// The hints benefit rose from 5 to 55.
|
// The hints benefit rose from 5 to 55.
|
||||||
await expect(page.getByTestId('wallet')).toContainText('Hints 55');
|
await expect(page.getByTestId('wallet')).toContainText('Hints: 55');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ const DIST = 'dist';
|
|||||||
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
|
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
|
||||||
// always-loaded Wallet screen, then to 126 for the in-game board-feedback UX — the staged-play
|
// always-loaded Wallet screen, then to 126 for the in-game board-feedback UX — the staged-play
|
||||||
// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
|
// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
|
||||||
// wiring live in the always-loaded Game / Board / Rack screens. The heavy parts — the dict loader,
|
// wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront
|
||||||
// the move generator and the preload orchestration — still stay in lazy chunks. Scoped CSS lands in
|
// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same
|
||||||
// the CSS chunk, not this JS budget.
|
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
|
||||||
const BUDGET = { app: 126, shared: 31, landing: 5 };
|
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||||
|
const BUDGET = { app: 127, shared: 31, landing: 5 };
|
||||||
|
|
||||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||||
|
|||||||
+10
-8
@@ -228,20 +228,19 @@ export const en = {
|
|||||||
// --- wallet ---
|
// --- wallet ---
|
||||||
'wallet.title': 'Wallet',
|
'wallet.title': 'Wallet',
|
||||||
'wallet.tab': 'Wallet',
|
'wallet.tab': 'Wallet',
|
||||||
'wallet.balance': 'Balance',
|
|
||||||
'wallet.noChips': 'No chips yet',
|
|
||||||
'wallet.source.vk': 'VK',
|
'wallet.source.vk': 'VK',
|
||||||
'wallet.source.telegram': 'Telegram',
|
'wallet.source.telegram': 'Telegram',
|
||||||
'wallet.source.direct': 'Web',
|
'wallet.source.direct': 'Web',
|
||||||
'wallet.viewOnly': 'view only',
|
'wallet.active': 'Active',
|
||||||
'wallet.benefits': 'Benefits',
|
'wallet.activeHints': 'Hints: {n}',
|
||||||
'wallet.noAdsUntil': 'No ads until {date}',
|
'wallet.noAdsUntil': 'No ads until {date}',
|
||||||
'wallet.noAdsForever': 'No ads forever',
|
'wallet.noAdsForever': 'No ads forever',
|
||||||
'wallet.adsOn': 'Ads are on',
|
|
||||||
'wallet.hints': 'Hints {n}',
|
|
||||||
'wallet.store': 'Store',
|
'wallet.store': 'Store',
|
||||||
|
'wallet.tabBuy': 'Buy chips',
|
||||||
|
'wallet.tabSpend': 'Spend chips',
|
||||||
'wallet.empty': 'The store is empty for now',
|
'wallet.empty': 'The store is empty for now',
|
||||||
'wallet.buy': 'Buy',
|
'wallet.buy': 'Buy',
|
||||||
|
'wallet.spend': 'Exchange',
|
||||||
'wallet.rewardWatch': 'Watch an ad for {n} 🪙',
|
'wallet.rewardWatch': 'Watch an ad for {n} 🪙',
|
||||||
'wallet.rewardCta': 'Watch',
|
'wallet.rewardCta': 'Watch',
|
||||||
'wallet.rewardCredited': '+{n} 🪙 for watching',
|
'wallet.rewardCredited': '+{n} 🪙 for watching',
|
||||||
@@ -256,10 +255,11 @@ export const en = {
|
|||||||
'wallet.cur.RUB': '₽',
|
'wallet.cur.RUB': '₽',
|
||||||
'wallet.cur.VOTE': 'votes',
|
'wallet.cur.VOTE': 'votes',
|
||||||
'wallet.cur.XTR': '⭐',
|
'wallet.cur.XTR': '⭐',
|
||||||
'wallet.warnTitle': 'Web-only purchase',
|
'wallet.spendTitle': 'Confirm exchange',
|
||||||
|
'wallet.spendBody': 'Exchange {n} 🪙 for “{title}”?',
|
||||||
|
'wallet.spendConfirm': 'Exchange',
|
||||||
'wallet.warnBody':
|
'wallet.warnBody':
|
||||||
'This spends your VK/Telegram chips, and the benefit will work on the web and in the app only — because of store rules.',
|
'This spends your VK/Telegram chips, and the benefit will work on the web and in the app only — because of store rules.',
|
||||||
'wallet.warnConfirm': 'Continue',
|
|
||||||
'wallet.warnCancel': 'Cancel',
|
'wallet.warnCancel': 'Cancel',
|
||||||
'about.title': 'About',
|
'about.title': 'About',
|
||||||
'about.tab': 'Info',
|
'about.tab': 'Info',
|
||||||
@@ -313,6 +313,8 @@ export const en = {
|
|||||||
'error.invalid_email': 'Enter a valid email address.',
|
'error.invalid_email': 'Enter a valid email address.',
|
||||||
'error.invalid_config': 'Invalid game settings.',
|
'error.invalid_config': 'Invalid game settings.',
|
||||||
'error.not_found': 'Not found.',
|
'error.not_found': 'Not found.',
|
||||||
|
'error.insufficient_chips': 'Not enough chips.',
|
||||||
|
'error.product_not_found': 'This item is no longer available.',
|
||||||
'error.session_invalid': 'Your session expired. Please sign in again.',
|
'error.session_invalid': 'Your session expired. Please sign in again.',
|
||||||
'error.unauthenticated': 'Please sign in.',
|
'error.unauthenticated': 'Please sign in.',
|
||||||
'error.rate_limited': 'Too many requests, slow down.',
|
'error.rate_limited': 'Too many requests, slow down.',
|
||||||
|
|||||||
+10
-8
@@ -228,20 +228,19 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
// --- wallet ---
|
// --- wallet ---
|
||||||
'wallet.title': 'Кошелёк',
|
'wallet.title': 'Кошелёк',
|
||||||
'wallet.tab': 'Кошелёк',
|
'wallet.tab': 'Кошелёк',
|
||||||
'wallet.balance': 'Баланс',
|
|
||||||
'wallet.noChips': 'Пока нет фишек',
|
|
||||||
'wallet.source.vk': 'VK',
|
'wallet.source.vk': 'VK',
|
||||||
'wallet.source.telegram': 'Telegram',
|
'wallet.source.telegram': 'Telegram',
|
||||||
'wallet.source.direct': 'Веб',
|
'wallet.source.direct': 'Веб',
|
||||||
'wallet.viewOnly': 'просмотр',
|
'wallet.active': 'Активные',
|
||||||
'wallet.benefits': 'Блага',
|
'wallet.activeHints': 'Подсказки: {n}',
|
||||||
'wallet.noAdsUntil': 'Без рекламы до {date}',
|
'wallet.noAdsUntil': 'Без рекламы до {date}',
|
||||||
'wallet.noAdsForever': 'Без рекламы навсегда',
|
'wallet.noAdsForever': 'Без рекламы навсегда',
|
||||||
'wallet.adsOn': 'Реклама включена',
|
|
||||||
'wallet.hints': 'Подсказки {n}',
|
|
||||||
'wallet.store': 'Магазин',
|
'wallet.store': 'Магазин',
|
||||||
|
'wallet.tabBuy': 'Купить фишки',
|
||||||
|
'wallet.tabSpend': 'Потратить фишки',
|
||||||
'wallet.empty': 'Магазин пока пуст',
|
'wallet.empty': 'Магазин пока пуст',
|
||||||
'wallet.buy': 'Купить',
|
'wallet.buy': 'Купить',
|
||||||
|
'wallet.spend': 'Обмен',
|
||||||
'wallet.rewardWatch': 'Ролик за {n} 🪙',
|
'wallet.rewardWatch': 'Ролик за {n} 🪙',
|
||||||
'wallet.rewardCta': 'Смотреть',
|
'wallet.rewardCta': 'Смотреть',
|
||||||
'wallet.rewardCredited': '+{n} 🪙 за просмотр',
|
'wallet.rewardCredited': '+{n} 🪙 за просмотр',
|
||||||
@@ -256,10 +255,11 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'wallet.cur.RUB': '₽',
|
'wallet.cur.RUB': '₽',
|
||||||
'wallet.cur.VOTE': 'голосов',
|
'wallet.cur.VOTE': 'голосов',
|
||||||
'wallet.cur.XTR': '⭐',
|
'wallet.cur.XTR': '⭐',
|
||||||
'wallet.warnTitle': 'Покупка только для веба',
|
'wallet.spendTitle': 'Подтверждение обмена',
|
||||||
|
'wallet.spendBody': 'Обменять {n} 🪙 на «{title}»?',
|
||||||
|
'wallet.spendConfirm': 'Обменять',
|
||||||
'wallet.warnBody':
|
'wallet.warnBody':
|
||||||
'Оплата спишет фишки VK/Telegram, и благо будет работать только в вебе и приложении — из-за правил магазинов.',
|
'Оплата спишет фишки VK/Telegram, и благо будет работать только в вебе и приложении — из-за правил магазинов.',
|
||||||
'wallet.warnConfirm': 'Продолжить',
|
|
||||||
'wallet.warnCancel': 'Отмена',
|
'wallet.warnCancel': 'Отмена',
|
||||||
'about.title': 'О программе',
|
'about.title': 'О программе',
|
||||||
'about.tab': 'Инфо',
|
'about.tab': 'Инфо',
|
||||||
@@ -313,6 +313,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'error.invalid_email': 'Введите корректный адрес почты.',
|
'error.invalid_email': 'Введите корректный адрес почты.',
|
||||||
'error.invalid_config': 'Неверные настройки игры.',
|
'error.invalid_config': 'Неверные настройки игры.',
|
||||||
'error.not_found': 'Не найдено.',
|
'error.not_found': 'Не найдено.',
|
||||||
|
'error.insufficient_chips': 'Не хватает фишек.',
|
||||||
|
'error.product_not_found': 'Этого товара больше нет.',
|
||||||
'error.session_invalid': 'Сессия истекла. Войдите снова.',
|
'error.session_invalid': 'Сессия истекла. Войдите снова.',
|
||||||
'error.unauthenticated': 'Пожалуйста, войдите.',
|
'error.unauthenticated': 'Пожалуйста, войдите.',
|
||||||
'error.rate_limited': 'Слишком много запросов, помедленнее.',
|
'error.rate_limited': 'Слишком много запросов, помедленнее.',
|
||||||
|
|||||||
+165
-100
@@ -5,7 +5,7 @@
|
|||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { normalizeSubtype } from '../lib/platform';
|
import { normalizeSubtype } from '../lib/platform';
|
||||||
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
|
import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
|
||||||
import { isGooglePlayBuild } from '../lib/distribution';
|
import { isGooglePlayBuild } from '../lib/distribution';
|
||||||
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
||||||
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
||||||
@@ -14,11 +14,10 @@
|
|||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
||||||
|
|
||||||
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
// The Wallet section: a compact chip balance, the active benefits, and the storefront. The store
|
||||||
// Values are bought with chips (they work once the player has any); chip packs are bought with
|
// splits into "buy chips" (chip packs, funded with money — a provider redirect) and "spend chips"
|
||||||
// money (the purchase itself arrives with payment intake — here they are shown priced only). On
|
// (values bought with chips — an instant exchange). On the Google Play build the money purchases
|
||||||
// the Google Play build the money purchases are hidden behind a RuStore stub (store policy), while
|
// are hidden behind a RuStore stub (store policy), while spending already-earned chips still works.
|
||||||
// spending already-earned chips still works.
|
|
||||||
|
|
||||||
let wallet = $state<Wallet | null>(null);
|
let wallet = $state<Wallet | null>(null);
|
||||||
let catalog = $state<Catalog | null>(null);
|
let catalog = $state<Catalog | null>(null);
|
||||||
@@ -27,10 +26,17 @@
|
|||||||
// (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button.
|
// (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button.
|
||||||
let busyId = $state<string | null>(null);
|
let busyId = $state<string | null>(null);
|
||||||
const busy = $derived(busyId !== 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 context: SpendContext = executionContext();
|
||||||
|
// The storefront half in view: buying chips for money, or spending chips on values.
|
||||||
|
let tab = $state<'buy' | 'spend'>('buy');
|
||||||
|
// The value awaiting an exchange confirmation, or null. Every chip-spend confirms (it is instant —
|
||||||
|
// no provider redirect to stand in as a confirmation), and the dialog folds in the cross-platform
|
||||||
|
// warning when the spend would draw vk/tg chips.
|
||||||
|
let spendProduct = $state<CatalogProduct | null>(null);
|
||||||
|
const spendWarn = $derived(
|
||||||
|
!!spendProduct && needsWebSpendWarning(context, wallet?.segments ?? [], spendProduct.chips),
|
||||||
|
);
|
||||||
|
|
||||||
const gpBuild = isGooglePlayBuild();
|
const gpBuild = isGooglePlayBuild();
|
||||||
// Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown
|
// 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
|
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
|
||||||
@@ -42,6 +48,29 @@
|
|||||||
|
|
||||||
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
|
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
|
||||||
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
|
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
|
||||||
|
// The chips the player can actually spend in this context — a value costing more is out of reach,
|
||||||
|
// so its exchange button is disabled (the server stays authoritative and also rejects it).
|
||||||
|
const spendable = $derived(wallet ? spendableChips(wallet) : 0);
|
||||||
|
|
||||||
|
// The balance reads as the context's own segment first (🪙 N), with any linked other-platform
|
||||||
|
// segments appended behind their logo — on the web all linked segments show and spend together;
|
||||||
|
// inside a VK/Telegram store only that store's own segment is present, so it stands alone.
|
||||||
|
const mainSeg = $derived(wallet?.segments.find((s) => s.source === context));
|
||||||
|
const linkedSegs = $derived((wallet?.segments ?? []).filter((s) => s.source !== context));
|
||||||
|
|
||||||
|
// The active benefits, one short phrase each, only for what the player actually holds — nothing
|
||||||
|
// to show (ad-free lapsed and no hints) hides the whole section.
|
||||||
|
const activeItems = $derived.by(() => {
|
||||||
|
const items: string[] = [];
|
||||||
|
if (!wallet) return items;
|
||||||
|
if (wallet.hints > 0) items.push(t('wallet.activeHints', { n: wallet.hints }));
|
||||||
|
if (wallet.adsForever) items.push(t('wallet.noAdsForever'));
|
||||||
|
else if (wallet.adsPaidUntilMs > Date.now()) {
|
||||||
|
const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US');
|
||||||
|
items.push(t('wallet.noAdsUntil', { date }));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
// Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in
|
// 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.
|
// this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show.
|
||||||
@@ -121,26 +150,17 @@
|
|||||||
return t(`wallet.cur.${currency}` as MessageKey);
|
return t(`wallet.cur.${currency}` as MessageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adsLine(): string {
|
function platformLogo(source: string): string {
|
||||||
if (!wallet) return '';
|
return source === 'vk' ? 'vk-logo.svg' : 'telegram-logo.svg';
|
||||||
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) {
|
// onSpend opens the exchange confirmation for a chip-priced value; doBuy runs the actual spend.
|
||||||
if (needsWebSpendWarning(context, wallet?.segments ?? [], p.chips)) {
|
function onSpend(p: CatalogProduct) {
|
||||||
warnProduct = p;
|
spendProduct = p;
|
||||||
return;
|
|
||||||
}
|
|
||||||
void doBuy(p);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doBuy(p: CatalogProduct) {
|
async function doBuy(p: CatalogProduct) {
|
||||||
warnProduct = null;
|
spendProduct = null;
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busyId = p.productId;
|
busyId = p.productId;
|
||||||
try {
|
try {
|
||||||
@@ -183,92 +203,93 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wallet" data-testid="wallet">
|
<div class="wallet" data-testid="wallet">
|
||||||
<section>
|
<div class="balance" data-testid="balance">
|
||||||
<h3>{t('wallet.balance')}</h3>
|
<span class="coin" aria-hidden="true">🪙</span><span class="num" class:dim={mainSeg && !mainSeg.spendable}>{mainSeg?.chips ?? 0}</span>
|
||||||
{#if wallet && wallet.segments.length > 0}
|
{#each linkedSegs as s (s.source)}
|
||||||
{#each wallet.segments as s (s.source)}
|
<span class="plus" aria-hidden="true">+</span><img class="plogo" src={platformLogo(s.source)} width="16" height="16" alt={sourceLabel(s.source)} /><span class="num" class:dim={!s.spendable}>{s.chips}</span>
|
||||||
<div class="row">
|
{/each}
|
||||||
<span class="name">{sourceLabel(s.source)}</span>
|
</div>
|
||||||
<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>
|
{#if activeItems.length > 0}
|
||||||
<h3>{t('wallet.benefits')}</h3>
|
<section data-testid="active">
|
||||||
<div class="row"><span class="name">{adsLine()}</span></div>
|
<h3>{t('wallet.active')}</h3>
|
||||||
<div class="row"><span class="name">{t('wallet.hints', { n: wallet?.hints ?? 0 })}</span></div>
|
<div class="row"><span class="name">{activeItems.join('. ')}</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>{t('wallet.store')}</h3>
|
<h3>{t('wallet.store')}</h3>
|
||||||
{#if purchaseBlocked}
|
<div class="seg">
|
||||||
<!-- prettier-ignore -->
|
<button class="opt" class:active={tab === 'buy'} data-testid="tab-buy" onclick={() => (tab = 'buy')}>{t('wallet.tabBuy')}</button>
|
||||||
<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>
|
<button class="opt" class:active={tab === 'spend'} data-testid="tab-spend" onclick={() => (tab = 'spend')}>{t('wallet.tabSpend')}</button>
|
||||||
{/if}
|
</div>
|
||||||
{#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}
|
{#if tab === 'buy'}
|
||||||
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
|
{#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}
|
||||||
|
{#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>
|
||||||
|
{:else if !loading}
|
||||||
|
<p class="empty">{t('wallet.empty')}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{#each packs as p (p.productId)}
|
{#each values as p (p.productId)}
|
||||||
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
|
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
||||||
<span class="name">{p.title}</span>
|
<span class="name">{p.title}</span>
|
||||||
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)}</span>
|
<span class="price">🪙 {p.chips}</span>
|
||||||
<button
|
<button class="buy" class:working={busyId === p.productId} class:cantafford={spendable < p.chips} data-testid="buy" disabled={busy || spendable < p.chips} onclick={() => onSpend(p)}
|
||||||
class="buy"
|
>{t('wallet.spend')}</button
|
||||||
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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if packs.length > 0}
|
{#if !loading && values.length === 0}
|
||||||
<p class="offer" data-testid="offer">
|
<p class="empty">{t('wallet.empty')}</p>
|
||||||
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
|
|
||||||
<p class="empty">{t('wallet.empty')}</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if warnProduct}
|
{#if spendProduct}
|
||||||
<Modal title={t('wallet.warnTitle')} onclose={() => (warnProduct = null)}>
|
<Modal title={t('wallet.spendTitle')} onclose={() => (spendProduct = null)}>
|
||||||
<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>
|
<p class="spend-body" data-testid="spend-body">{t('wallet.spendBody', { n: spendProduct.chips, title: spendProduct.title })}</p>
|
||||||
|
{#if spendWarn}<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>{/if}
|
||||||
<div class="warn-actions">
|
<div class="warn-actions">
|
||||||
<button class="cancel" onclick={() => (warnProduct = null)}>{t('wallet.warnCancel')}</button>
|
<button class="cancel" onclick={() => (spendProduct = null)}>{t('wallet.warnCancel')}</button>
|
||||||
<button class="buy" data-testid="warn-confirm" onclick={() => warnProduct && doBuy(warnProduct)}
|
<button class="buy" data-testid="spend-confirm" onclick={() => spendProduct && doBuy(spendProduct)}
|
||||||
>{t('wallet.warnConfirm')}</button
|
>{t('wallet.spendConfirm')}</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -281,6 +302,29 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
padding: var(--pad);
|
padding: var(--pad);
|
||||||
}
|
}
|
||||||
|
/* The compact balance: the context's own chips (🪙 N) then any linked other-platform segments
|
||||||
|
behind their logo, digits monospaced so they align; a non-spendable (view-only) segment dims. */
|
||||||
|
.balance {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.balance .num {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.balance .num.dim {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.balance .plus {
|
||||||
|
margin: 0 3px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.balance .plogo {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
section {
|
section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -291,6 +335,26 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
/* The buy/spend segmented control, matching the New Game type selector. */
|
||||||
|
.seg {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.opt {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 64px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.opt.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -304,16 +368,11 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.value,
|
|
||||||
.price {
|
.price {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.muted {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
.buy {
|
.buy {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
@@ -330,7 +389,8 @@
|
|||||||
/* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a
|
/* 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. */
|
blocked pack on VK iOS — never every buy button at once when one purchase is busy. */
|
||||||
.buy.blocked,
|
.buy.blocked,
|
||||||
.buy.working {
|
.buy.working,
|
||||||
|
.buy.cantafford {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.ios-note {
|
.ios-note {
|
||||||
@@ -362,8 +422,13 @@
|
|||||||
.offer a {
|
.offer a {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
.spend-body {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
.warn-body {
|
.warn-body {
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.warn-actions {
|
.warn-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user