feat(android): resolve gateway origin on native, skip SW, hide MVP purchases
Make the web SPA behave correctly inside the Capacitor native shell, where the bundle loads from a local origin: - New lib/origin.ts gatewayOrigin(): absolute URLs resolve to VITE_GATEWAY_URL on native (the finished-game export/share URL in Game.svelte and the Wallet site-root link), falling back to the page origin on web. transport.ts already resolved via VITE_GATEWAY_URL and is left as-is. - Skip the PWA service worker on the native channel (the assets are already local; a worker would risk serving stale content across store updates). - Hide the money-purchase UI in the MVP: new distribution.purchasesHidden() folds VITE_PAYMENTS_DISABLED and the Google Play flag. The Wallet "buy" tab shows a neutral, pointer-free note (wallet.purchasesSoon) for the RuStore MVP and keeps the RuStore stub Google-Play-only. A ?nopay mock force mirrors ?gp for the e2e. - Native env types in vite-env.d.ts. Client-only, contour-safe: no wire/proto/schema change; web/VK/Telegram unchanged. Tests: origin + purchasesHidden unit tests, a ?nopay wallet e2e. svelte-check clean, vitest green, web + native vite build clean.
This commit is contained in:
@@ -114,6 +114,10 @@ The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui`
|
|||||||
eyeball per-variant tiles.
|
eyeball per-variant tiles.
|
||||||
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
|
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
|
||||||
plugin-config gotcha in wiring them).
|
plugin-config gotcha in wiring them).
|
||||||
|
- **The `playwright test` runner can't fetch browsers in this sandbox** — `playwright install` dies
|
||||||
|
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
|
||||||
|
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
|
||||||
|
browser against a local `vite --mode mock` server for visual verification.
|
||||||
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
|
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
|
||||||
Use **testcontainers** for container-backed tests.
|
Use **testcontainers** for container-backed tests.
|
||||||
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
|
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
|
||||||
|
|||||||
+19
-4
@@ -71,7 +71,17 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
|
|||||||
**Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э»
|
**Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э»
|
||||||
brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive
|
brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive
|
||||||
icons + splashes. Superseded by the icon rebrand below.
|
icons + splashes. Superseded by the icon rebrand below.
|
||||||
- **B–G — pending.**
|
- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts`
|
||||||
|
`gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL,
|
||||||
|
`screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left
|
||||||
|
as-is. Service worker skipped on the native channel (`lib/pwa.svelte.ts`). Payments hidden in the MVP
|
||||||
|
via new `purchasesHidden()` (`lib/distribution.ts`: folds `VITE_PAYMENTS_DISABLED` + the GP flag, plus
|
||||||
|
a `?nopay` mock force); the `Wallet.svelte` buy tab shows a neutral pointer-free note
|
||||||
|
(`wallet.purchasesSoon`) for the RuStore MVP, RuStore stub kept GP-only. Native env types in
|
||||||
|
`vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean;
|
||||||
|
`?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch
|
||||||
|
browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs).
|
||||||
|
- **C–G — pending.**
|
||||||
|
|
||||||
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
|
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
|
||||||
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
|
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
|
||||||
@@ -232,7 +242,7 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
|
|||||||
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
|
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
|
||||||
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
|
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
|
||||||
|
|
||||||
### B. Native web-build correctness (file:// origin)
|
### B. Native web-build correctness (file:// origin) — ✅ DONE
|
||||||
|
|
||||||
1. **One origin helper** — new `ui/src/lib/origin.ts`:
|
1. **One origin helper** — new `ui/src/lib/origin.ts`:
|
||||||
```ts
|
```ts
|
||||||
@@ -264,8 +274,13 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
|
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
|
||||||
hide the buy actions (grep `isGooglePlayBuild` under `ui/src`). Keep the "go to RuStore" stub
|
hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub
|
||||||
`isGooglePlayBuild()`-only. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
|
`isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() &&
|
||||||
|
!isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key
|
||||||
|
`wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the
|
||||||
|
same note can replace the RuStore stub in the later Google Play anti-steering variant.
|
||||||
|
`purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the
|
||||||
|
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
|
||||||
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
|
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
|
||||||
4. **Native env matrix** — see Build & env.
|
4. **Native env matrix** — see Build & env.
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,22 @@ test('wallet: the Google Play build hides the money purchases behind the RuStore
|
|||||||
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
|
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('wallet: the native MVP hides the money purchases behind a neutral note (no store pointer)', async ({ page }) => {
|
||||||
|
await page.goto('/?nopay');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
await expect(page.getByText('Your turn')).toBeVisible();
|
||||||
|
await openWallet(page);
|
||||||
|
|
||||||
|
// Buy chips: the chip pack is gone; a neutral note shows and — unlike the Google Play build — it
|
||||||
|
// carries no RuStore (or any store) pointer.
|
||||||
|
await expect(page.getByTestId('purchases-hidden')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('gp-stub')).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();
|
||||||
|
});
|
||||||
|
|
||||||
test('wallet: a web spend that would draw store chips confirms with a warning, 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);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||||
import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
|
import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
|
||||||
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
||||||
|
import { gatewayOrigin } from '../lib/origin';
|
||||||
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
||||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||||
import { patchLobbyGame } from '../lib/lobbycache';
|
import { patchLobbyGame } from '../lib/lobbycache';
|
||||||
@@ -1211,7 +1212,7 @@
|
|||||||
const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : '';
|
const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : '';
|
||||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
|
||||||
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
|
const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone);
|
||||||
const url = new URL(path, location.origin).href;
|
const url = new URL(path, gatewayOrigin()).href;
|
||||||
const mime = kind === 'png' ? 'image/png' : 'text/plain';
|
const mime = kind === 'png' ? 'image/png' : 'text/plain';
|
||||||
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
|
if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return;
|
||||||
if (insideVK()) {
|
if (insideVK()) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||||
import { isGooglePlayBuild } from './distribution';
|
import { isGooglePlayBuild, purchasesHidden } from './distribution';
|
||||||
|
|
||||||
describe('isGooglePlayBuild', () => {
|
describe('isGooglePlayBuild', () => {
|
||||||
it('is false by default (no VITE_GP_BUILD flag, not the mock ?gp force)', () => {
|
it('is false by default (no VITE_GP_BUILD flag, not the mock ?gp force)', () => {
|
||||||
@@ -8,3 +8,21 @@ describe('isGooglePlayBuild', () => {
|
|||||||
expect(isGooglePlayBuild()).toBe(false);
|
expect(isGooglePlayBuild()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('purchasesHidden', () => {
|
||||||
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
it('is false by default (the build sells normally)', () => {
|
||||||
|
expect(purchasesHidden()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true for the native MVP that defers billing (VITE_PAYMENTS_DISABLED)', () => {
|
||||||
|
vi.stubEnv('VITE_PAYMENTS_DISABLED', '1');
|
||||||
|
expect(purchasesHidden()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true for the Google Play build (folds isGooglePlayBuild)', () => {
|
||||||
|
vi.stubEnv('VITE_GP_BUILD', '1');
|
||||||
|
expect(purchasesHidden()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Distribution channel of the native build. Google Play forbids selling in-app currency through
|
// Distribution flags for the native builds. Two independent build-time flags hide the in-app-currency
|
||||||
// an external gate, so the Google Play build hides the purchase actions and shows a stub pointing
|
// purchase actions: VITE_GP_BUILD marks the Google Play build (Google forbids selling in-app currency
|
||||||
// the user to the RuStore build; every other build (web, VK, Telegram, RuStore native) sells
|
// through an external gate — it hides the actions and shows a stub pointing at the RuStore build), and
|
||||||
// normally. The channel is a build-time flag the Google Play build sets, not a runtime guess.
|
// VITE_PAYMENTS_DISABLED marks the thin native MVP that defers store billing (it hides the actions with
|
||||||
|
// no store pointer). Every other build (web, VK, Telegram, a billing-enabled RuStore native build) sells
|
||||||
|
// normally. purchasesHidden() folds both; isGooglePlayBuild() stays separate for the RuStore-pointer stub.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* isGooglePlayBuild reports whether this is the Google Play native build, where in-app-currency
|
* isGooglePlayBuild reports whether this is the Google Play native build, where in-app-currency
|
||||||
@@ -19,3 +21,22 @@ export function isGooglePlayBuild(): boolean {
|
|||||||
}
|
}
|
||||||
return (import.meta.env as Record<string, string | undefined>).VITE_GP_BUILD === '1';
|
return (import.meta.env as Record<string, string | undefined>).VITE_GP_BUILD === '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* purchasesHidden reports whether this build hides the in-app-currency purchase actions. It is true
|
||||||
|
* for the Google Play build (external-payment policy — see isGooglePlayBuild) and for the thin native
|
||||||
|
* MVP that defers store billing (the build-time flag VITE_PAYMENTS_DISABLED set to "1"). Every other
|
||||||
|
* build sells normally. Under the mock e2e it can be forced on with a `?nopay` query parameter so the
|
||||||
|
* hidden-purchases state is exercisable without a separate build.
|
||||||
|
*/
|
||||||
|
export function purchasesHidden(): boolean {
|
||||||
|
if (isGooglePlayBuild()) return true;
|
||||||
|
if (
|
||||||
|
import.meta.env.MODE === 'mock' &&
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
new URLSearchParams(window.location.search).has('nopay')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (import.meta.env as Record<string, string | undefined>).VITE_PAYMENTS_DISABLED === '1';
|
||||||
|
}
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export const en = {
|
|||||||
'wallet.iosBlockedLink': 'other version',
|
'wallet.iosBlockedLink': 'other version',
|
||||||
'wallet.iosBlockedPost': ' of the game.',
|
'wallet.iosBlockedPost': ' of the game.',
|
||||||
'wallet.gpStub': 'To buy chips, install the RuStore build.',
|
'wallet.gpStub': 'To buy chips, install the RuStore build.',
|
||||||
|
'wallet.purchasesSoon': 'Purchases will be available in a future update.',
|
||||||
'wallet.cur.RUB': '₽',
|
'wallet.cur.RUB': '₽',
|
||||||
'wallet.cur.VOTE': 'votes',
|
'wallet.cur.VOTE': 'votes',
|
||||||
'wallet.cur.XTR': '⭐',
|
'wallet.cur.XTR': '⭐',
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'wallet.iosBlockedLink': 'другой версией',
|
'wallet.iosBlockedLink': 'другой версией',
|
||||||
'wallet.iosBlockedPost': ' игры.',
|
'wallet.iosBlockedPost': ' игры.',
|
||||||
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
|
||||||
|
'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.',
|
||||||
'wallet.cur.RUB': '₽',
|
'wallet.cur.RUB': '₽',
|
||||||
'wallet.cur.VOTE': 'голосов',
|
'wallet.cur.VOTE': 'голосов',
|
||||||
'wallet.cur.XTR': '⭐',
|
'wallet.cur.XTR': '⭐',
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { gatewayOrigin } from './origin';
|
||||||
|
|
||||||
|
describe('gatewayOrigin', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the configured VITE_GATEWAY_URL (the packaged native build)', () => {
|
||||||
|
vi.stubEnv('VITE_GATEWAY_URL', 'https://erudit-game.ru');
|
||||||
|
// Even with a page origin present, the configured gateway wins (native loads from a local origin).
|
||||||
|
vi.stubGlobal('location', { origin: 'https://localhost' });
|
||||||
|
expect(gatewayOrigin()).toBe('https://erudit-game.ru');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the page origin when VITE_GATEWAY_URL is unset (web/mock)', () => {
|
||||||
|
vi.stubEnv('VITE_GATEWAY_URL', '');
|
||||||
|
vi.stubGlobal('location', { origin: 'https://erudit-game.ru' });
|
||||||
|
expect(gatewayOrigin()).toBe('https://erudit-game.ru');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string with neither a configured gateway nor a location (server-side)', () => {
|
||||||
|
vi.stubEnv('VITE_GATEWAY_URL', '');
|
||||||
|
// No location stub — location is undefined in the node test env, so the path degrades to relative.
|
||||||
|
expect(gatewayOrigin()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// The absolute origin the client talks to. A packaged native build (Capacitor) loads the SPA from
|
||||||
|
// its own local origin (https://localhost / file://), so an absolute URL built from location.origin
|
||||||
|
// would point at the app itself, not the gateway. The native build therefore sets VITE_GATEWAY_URL
|
||||||
|
// to the real gateway; web/mock leave it empty and fall back to the page origin. Centralised so every
|
||||||
|
// absolute-URL construction resolves identically. transport.ts computes the same fallback inline for
|
||||||
|
// the Connect base URL and is intentionally left untouched.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gatewayOrigin returns the absolute origin every client build talks to: the configured
|
||||||
|
* VITE_GATEWAY_URL when set (the native build), otherwise the current page origin. It returns an
|
||||||
|
* empty string when there is no location (server-side), so callers appending a path degrade to a
|
||||||
|
* relative URL.
|
||||||
|
*/
|
||||||
|
export function gatewayOrigin(): string {
|
||||||
|
const configured = import.meta.env.VITE_GATEWAY_URL ?? '';
|
||||||
|
return configured || (typeof location !== 'undefined' ? location.origin : '');
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa';
|
import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa';
|
||||||
import { insideTelegram } from './telegram';
|
import { insideTelegram } from './telegram';
|
||||||
import { insideVK } from './vk';
|
import { insideVK } from './vk';
|
||||||
|
import { clientChannel } from './channel';
|
||||||
|
|
||||||
/** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web
|
/** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web
|
||||||
* install makes no sense (the platform manages its own launcher). Kept here, out of the pure
|
* install makes no sense (the platform manages its own launcher). Kept here, out of the pure
|
||||||
@@ -79,12 +80,16 @@ export async function promptInstall(): Promise<void> {
|
|||||||
* registerServiceWorker registers the app-shell service worker (built from ui/src/sw.ts to
|
* registerServiceWorker registers the app-shell service worker (built from ui/src/sw.ts to
|
||||||
* dist/sw.js by vite-plugin-pwa): it satisfies Chromium's installability requirement and precaches
|
* dist/sw.js by vite-plugin-pwa): it satisfies Chromium's installability requirement and precaches
|
||||||
* the shell + assets so an installed PWA cold-launches offline. Web-only: skipped in the mock build
|
* the shell + assets so an installed PWA cold-launches offline. Web-only: skipped in the mock build
|
||||||
* (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini App. Best-effort —
|
* (a real worker would perturb the Playwright run), inside a Telegram/VK Mini App, and inside the
|
||||||
* a failed registration only means the app is not installable and cannot launch offline.
|
* Capacitor native shell (the bundle is already local, and a worker would risk serving stale assets
|
||||||
|
* across store updates). Best-effort — a failed registration only means the app is not installable
|
||||||
|
* and cannot launch offline.
|
||||||
*/
|
*/
|
||||||
export function registerServiceWorker(): void {
|
export function registerServiceWorker(): void {
|
||||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
if (import.meta.env.MODE === 'mock') return;
|
if (import.meta.env.MODE === 'mock') return;
|
||||||
if (inMiniApp()) return;
|
if (inMiniApp()) return;
|
||||||
|
const ch = clientChannel();
|
||||||
|
if (ch === 'android' || ch === 'ios') return;
|
||||||
void navigator.serviceWorker.register('sw.js').catch(() => {});
|
void navigator.serviceWorker.register('sw.js').catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
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, spendableChips, type SpendContext } from '../lib/wallet';
|
import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
|
||||||
import { isGooglePlayBuild } from '../lib/distribution';
|
import { isGooglePlayBuild, purchasesHidden } from '../lib/distribution';
|
||||||
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
||||||
|
import { gatewayOrigin } from '../lib/origin';
|
||||||
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
||||||
import { telegramOpenInvoice } from '../lib/telegram';
|
import { telegramOpenInvoice } from '../lib/telegram';
|
||||||
import { rewardedReady, showRewarded } from '../lib/ads';
|
import { rewardedReady, showRewarded } from '../lib/ads';
|
||||||
@@ -16,8 +17,9 @@
|
|||||||
|
|
||||||
// The Wallet section: a compact chip balance, the active benefits, and the storefront. The store
|
// The Wallet section: a compact chip balance, the active benefits, and the storefront. The store
|
||||||
// splits into "buy chips" (chip packs, funded with money — a provider redirect) and "spend chips"
|
// splits into "buy chips" (chip packs, funded with money — a provider redirect) and "spend chips"
|
||||||
// (values bought with chips — an instant exchange). On the Google Play build the money purchases
|
// (values bought with chips — an instant exchange). When purchases are hidden — the Google Play
|
||||||
// are hidden behind a RuStore stub (store policy), while spending already-earned chips still works.
|
// build (behind a RuStore stub, store policy) or the native MVP that defers billing (a neutral
|
||||||
|
// note) — the money purchases disappear while 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);
|
||||||
@@ -38,13 +40,16 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const gpBuild = isGooglePlayBuild();
|
const gpBuild = isGooglePlayBuild();
|
||||||
|
// Money purchases are hidden in the Google Play build (policy) and in the native MVP that defers
|
||||||
|
// billing; the RuStore-pointer stub shows only for the former (gpBuild).
|
||||||
|
const noPurchases = purchasesHidden();
|
||||||
// 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
|
||||||
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
|
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
|
||||||
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
|
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
|
||||||
// The "other version" link points at this deployment's own site root (the landing lists every
|
// 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.
|
// platform), so it follows prod vs the contour without a hardcoded host.
|
||||||
const siteRoot = typeof location !== 'undefined' ? `${location.origin}/` : '/';
|
const siteRoot = `${gatewayOrigin()}/`;
|
||||||
|
|
||||||
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') ?? []);
|
||||||
@@ -237,8 +242,12 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if noPurchases}
|
||||||
{#if gpBuild}
|
{#if gpBuild}
|
||||||
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
|
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="stub" data-testid="purchases-hidden">{t('wallet.purchasesSoon')}</p>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{#each packs as p (p.productId)}
|
{#each packs 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="pack" data-pid={p.productId}>
|
||||||
|
|||||||
Vendored
+8
-1
@@ -2,8 +2,15 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
/** Base URL of the gateway Connect endpoint. Empty in dev (same-origin proxy). */
|
/** Base URL of the gateway Connect endpoint. Empty in dev (same-origin proxy); set to the real
|
||||||
|
* gateway origin in a packaged native build. */
|
||||||
readonly VITE_GATEWAY_URL?: string;
|
readonly VITE_GATEWAY_URL?: string;
|
||||||
|
/** "1" hides the in-app-currency purchase actions in the thin native MVP that defers store billing. */
|
||||||
|
readonly VITE_PAYMENTS_DISABLED?: string;
|
||||||
|
/** Store listing URL the native update overlay opens for its "update" action (e.g. the RuStore page). */
|
||||||
|
readonly VITE_STORE_URL?: string;
|
||||||
|
/** Bundled-dictionary version the native offline path requests; matches the packaged DAWG files. */
|
||||||
|
readonly VITE_DICT_VERSION?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user