diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift index 3ca795d09..2a2d0526b 100644 --- a/client/ios/App/App/TrekShellPlugin.swift +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -3,6 +3,7 @@ import Capacitor import CoreLocation import CryptoKit import Security +import StoreKit import UserNotifications import WidgetKit #if canImport(ActivityKit) @@ -39,6 +40,10 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr CAPPluginMethod(name: "setWidgetSnapshot", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "syncTripDay", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "endTripDay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "storeProducts", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise), ] /// What this binary offers the server's UI, raised with every native feature @@ -46,7 +51,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr /// and a server can require a minimum (minNativeShellApi in /api/health/features). static let shellApi = 2 /// The native features by name, so the UI can turn each on by itself. - static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips"] + static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit"] // MARK: - Shared booking documents @@ -57,6 +62,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr TrekPush.shared.onOpen = { [weak self] url in DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) } } + TrekStore.watchUpdates() } // MARK: - Widgets and the travel-day Live Activity @@ -375,6 +381,134 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr /// Positions and errors for the UI, shaped like the Geolocation API's, with the /// When-In-Use permission asked the first time. +// MARK: - In-app purchase + +extension TrekShellPlugin { + /// Feature storeKit: the App Store's own price strings for the given products, + /// already in the buyer's currency and format. Products the store does not + /// know are left out, so the shell offers only what can really be bought. + @objc func storeProducts(_ call: CAPPluginCall) { + let ids = call.getArray("ids", String.self) ?? [] + Task { + guard AppStore.canMakePayments, let products = try? await Product.products(for: ids) else { + call.resolve(["products": []]) + return + } + call.resolve(["products": products.map { ["id": $0.id, "displayPrice": $0.displayPrice] }]) + } + } + + /// Feature storeKit: runs the App Store purchase sheet. Resolves with `kind` + /// bought (with the signed transaction the control plane verifies), cancelled, + /// pending (Ask to Buy, a payment the bank still has to approve) or failed. + /// The transaction stays unfinished until storeFinish, so a purchase whose + /// instance was never set up is delivered again rather than lost. + @objc func storePurchase(_ call: CAPPluginCall) { + guard let id = call.getString("productId") else { + call.reject("productId is required") + return + } + Task { + do { + guard let product = try await Product.products(for: [id]).first else { + call.resolve(["kind": "failed"]) + return + } + switch try await product.purchase() { + case .success(let verification): + guard case .verified(let transaction) = verification else { + call.resolve(["kind": "failed"]) + return + } + call.resolve([ + "kind": "bought", + "jws": verification.jwsRepresentation, + "transactionId": String(transaction.id), + ]) + case .userCancelled: + call.resolve(["kind": "cancelled"]) + case .pending: + call.resolve(["kind": "pending"]) + @unknown default: + call.resolve(["kind": "failed"]) + } + } catch { + call.resolve(["kind": "failed"]) + } + } + } + + /// Feature storeKit: marks a purchase as delivered, once its instance exists. + @objc func storeFinish(_ call: CAPPluginCall) { + guard let raw = call.getString("transactionId"), let id = UInt64(raw) else { + call.reject("transactionId is required") + return + } + Task { + await TrekStore.finish(id) + call.resolve() + } + } + + /// Feature storeKit: the newest active subscription on this Apple ID among + /// products starting with `prefix`, after syncing with the App Store. Resolves + /// without `jws` when there is none. + @objc func storeRestore(_ call: CAPPluginCall) { + let prefix = call.getString("prefix") ?? "cloud." + Task { + // Asks for the Apple ID password when needed; a cancel still leaves the local entitlements to check. + try? await AppStore.sync() + guard let found = await TrekStore.currentEntitlement(prefix: prefix) else { + call.resolve([:]) + return + } + call.resolve(["jws": found.jws, "transactionId": String(found.id)]) + } + } +} + +enum TrekStore { + private static var updates: Task? + + /// Listens for transactions that arrive outside a purchase sheet. Renewals are + /// finished at once: the control plane learns of them from Apple's server + /// notifications. A first purchase that lands here (Ask to Buy approved later, + /// a purchase interrupted by the app closing) stays unfinished for the shell + /// to redeem through a restore. + static func watchUpdates() { + guard updates == nil else { return } + updates = Task.detached { + for await result in Transaction.updates { + guard case .verified(let transaction) = result else { continue } + if transaction.originalID != transaction.id { + await transaction.finish() + } + } + } + } + + static func finish(_ id: UInt64) async { + for await result in Transaction.unfinished { + if case .verified(let transaction) = result, transaction.id == id { + await transaction.finish() + } + } + } + + static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64)? { + var newest: (date: Date, jws: String, id: UInt64)? + for await result in Transaction.currentEntitlements { + guard case .verified(let transaction) = result, + transaction.productID.hasPrefix(prefix), + transaction.revocationDate == nil else { continue } + if newest == nil || transaction.purchaseDate > newest!.date { + newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id) + } + } + return newest.map { ($0.jws, $0.id) } + } +} + final class TrekLocation: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() private let emit: ([String: Any]) -> Void diff --git a/client/native-shell/main.ts b/client/native-shell/main.ts index fa88fb567..0437550e3 100644 --- a/client/native-shell/main.ts +++ b/client/native-shell/main.ts @@ -5,8 +5,8 @@ import { StatusBar, Style } from '@capacitor/status-bar'; import logoUrl from '../public/icons/icon-white.svg'; import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds -import { getIapClient, productIdFor, type Purchase } from '../src/native/iap'; -import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links'; +import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap'; +import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links'; import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress'; import { TrekShell } from '../src/native/trekShell'; import { pickLocale, translator } from './i18n'; @@ -28,8 +28,12 @@ const root = document.getElementById('shell')!; const PROBE_TIMEOUT_MS = 8000; const iap = getIapClient(); -/** Whether this build can take a payment in-app; decided once at start. Until then, and in a store build without the purchase plugin, the plan picker finishes on the web. */ -let iapAvailable = false; +/** + * Whether this build can take a payment in-app, asked once at start. Without it + * the plan picker shows the plans but offers no way to buy: the store rules + * allow no link or button that sends a buyer to the website instead. + */ +const iapReady: Promise = iap.isAvailable().catch(() => false); /** The subdomain suffix a cloud instance sits on, from the cloud address: `liketrek.com` → `.liketrek.com`. */ function cloudSuffix(): string { @@ -359,34 +363,54 @@ async function fetchPlans(): Promise { return null; } -function subscribe(plan: CloudPlan, interval: 'month' | 'year'): void { - void tap(ImpactStyle.Medium); - // In-app purchase when this build can take one; otherwise finish on the web, so - // nothing half-wired faces a customer until the store products exist. - if (iapAvailable) { - showClaim(plan, interval); - } else { - window.open(`${CLOUD_URL}/order?plan=${encodeURIComponent(plan.id)}`, '_blank', 'noopener'); - } +/** + * What the plan picker can sell. With the store, a plan is offered at the price + * string the App Store gives for its product, in the buyer's currency, and only + * when the store knows that product. The fake (development, preview) sells + * every plan at the plan list's price. Without either, nothing is for sale. + */ +interface Offer { + canBuy: boolean; + storePrices: Map | null; } -function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean): HTMLElement { - const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents; +function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): { label: string; buyable: boolean } { + const cents = interval === 'year' ? plan.yearlyCents : plan.monthlyCents; + const storePrice = offer.storePrices?.get(productIdFor(plan.id, interval)); + if (offer.storePrices) return { label: storePrice ?? money(cents), buyable: offer.canBuy && !!storePrice }; + return { label: money(cents), buyable: offer.canBuy }; +} + +async function loadOffer(plans: CloudPlan[]): Promise { + const canBuy = await iapReady; + if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null }; + const ids = plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]); + return { canBuy, storePrices: await iap.prices(ids) }; +} + +function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement { + const { label, buyable } = offerFor(offer, plan, interval); const per = interval === 'year' ? t('native.plans.perYear') : t('native.plans.perMonth'); const feat = (text: string) => el('span', {}, icon(I.check, 16), text); // An absent feature (e.g. a plan with no AI documents): a muted dash, not a // green tick, so "none" never reads as a perk. const featOff = (text: string) => el('span', { className: 'off' }, icon('M5 12h14', 16), text); - const sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe')); - sub.addEventListener('click', () => subscribe(plan, interval)); + let sub: HTMLElement | null = null; + if (buyable) { + sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe')); + sub.addEventListener('click', () => { + void tap(ImpactStyle.Medium); + showClaim(plan, interval, label); + }); + } return el( 'div', - { className: featured ? 'plan featured' : 'plan' }, + { className: 'plan' }, el( 'div', { className: 'plan-top' }, el('span', { className: 'plan-name' }, plan.name), - el('span', { className: 'plan-price' }, money(price), el('small', {}, per)) + el('span', { className: 'plan-price' }, label, el('small', {}, per)) ), el( 'div', @@ -397,15 +421,65 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean ? feat(t('native.plans.aiDocs', { n: String(plan.aiDocuments) })) : featOff(t('native.plans.noAi')) ), - sub + ...(sub ? [sub] : []) ); } +/** + * Below the plans. With a store: how the subscription renews, the terms and the + * privacy policy, and a way to restore a subscription this Apple ID already + * holds (a reinstall, a new phone). Without one: only that buying in the app is + * not available, and no pointer elsewhere. + */ +function planFooter(offer: Offer): Node[] { + if (!offer.canBuy) return [el('p', { className: 'note' }, t('native.plans.soon'))]; + + const status = el('p', { className: 'hint', role: 'status' }); + const restore = el('button', { type: 'button', className: 'btn btn-ghost' }, t('native.plans.restore')); + restore.addEventListener('click', async () => { + void tap(); + restore.disabled = true; + status.className = 'hint'; + status.textContent = t('native.plans.restoring'); + const purchase = await iap.restore(); + if (!purchase) { + restore.disabled = false; + status.textContent = t('native.plans.restoreNone'); + return; + } + // A restore finds the instance the purchase was first redeemed for; address and email are not asked again. + const redeemed = await redeemPurchase('', '', purchase); + if (redeemed.ok && redeemed.instanceUrl) { + await iap.finish(purchase); + showProvisioning(redeemed.instanceUrl); + return; + } + void notify(NotificationType.Error); + restore.disabled = false; + status.className = 'hint bad'; + status.textContent = t('native.plans.restoreFailed'); + }); + + return [ + restore, + status, + el('p', { className: 'note' }, t('native.plans.legal')), + el( + 'div', + { className: 'foot-links' }, + externalLink(t('native.plans.terms'), STORE_TERMS_URL), + externalLink(t('native.privacy'), PRIVACY_URL) + ), + ]; +} + function showPlans(opts: { back?: boolean } = {}): void { let interval: 'month' | 'year' = 'month'; let plans: CloudPlan[] | null = null; + let offer: Offer = { canBuy: false, storePrices: null }; const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' })); + const footer = el('div', { className: 'plans-foot' }); const monthBtn = el('button', { type: 'button' }, t('native.plans.monthly')); const yearBtn = el('button', { type: 'button' }, t('native.plans.yearly')); @@ -419,7 +493,7 @@ function showPlans(opts: { back?: boolean } = {}): void { const list = plans; if (!list) return; content.className = 'plans'; - content.replaceChildren(...list.map((p) => planCard(p, interval, false))); + content.replaceChildren(...list.map((p) => planCard(p, interval, offer))); }; const choose = (next: 'month' | 'year') => { if (interval === next) return; @@ -433,9 +507,12 @@ function showPlans(opts: { back?: boolean } = {}): void { paintSeg(); const load = async () => { - plans = await fetchPlans(); - if (plans) { + const list = await fetchPlans(); + if (list) { + offer = await loadOffer(list); + plans = list; renderList(); + footer.replaceChildren(...planFooter(offer)); return; } content.className = ''; @@ -458,7 +535,7 @@ function showPlans(opts: { back?: boolean } = {}): void { el('p', { className: 'sub' }, t('native.plans.sub')), seg, content, - el('p', { className: 'note' }, t('native.plans.soon')) + footer ), opts ); @@ -507,8 +584,9 @@ async function redeemPurchase(slug: string, email: string, purchase: Purchase): } } -function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: boolean } = {}): void { - const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents; +function showClaim(plan: CloudPlan, interval: 'month' | 'year', price: string, opts: { back?: boolean } = {}): void { + /** A purchase that went through but whose redeem did not: a retry redeems it again instead of buying twice. */ + let bought: Purchase | null = null; const slugInput = el('input', { type: 'text', @@ -546,7 +624,7 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b const submit = el( 'button', { type: 'submit', className: 'btn' }, - t('native.claim.subscribe', { price: money(price) }) + t('native.claim.subscribe', { price }) ); const error = el('p', { className: 'error', role: 'alert' }); const form = el( @@ -617,24 +695,33 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking')); const restore = () => { lock(false); - submit.replaceChildren(t('native.claim.subscribe', { price: money(price) })); + submit.replaceChildren(t('native.claim.subscribe', { price })); }; - const outcome = await iap.purchase(productIdFor(plan.id, interval)); - if (outcome.kind === 'cancelled') { - restore(); - error.textContent = t('native.claim.cancelled'); - return; - } - if (outcome.kind === 'failed') { - void notify(NotificationType.Error); - restore(); - error.textContent = t('native.claim.failed'); - return; + if (!bought) { + const outcome = await iap.purchase(productIdFor(plan.id, interval)); + if (outcome.kind === 'cancelled') { + restore(); + error.textContent = t('native.claim.cancelled'); + return; + } + if (outcome.kind === 'pending') { + restore(); + error.textContent = t('native.claim.pending'); + return; + } + if (outcome.kind === 'failed') { + void notify(NotificationType.Error); + restore(); + error.textContent = t('native.claim.failed'); + return; + } + bought = outcome.purchase; } - const redeemed = await redeemPurchase(slug, email, outcome.purchase); + const redeemed = await redeemPurchase(slug, email, bought); if (redeemed.ok && redeemed.instanceUrl) { + await iap.finish(bought); showProvisioning(redeemed.instanceUrl); return; } @@ -808,9 +895,6 @@ async function paintStatusBar(): Promise { async function start(): Promise { void paintStatusBar(); - iap.isAvailable().then((ok) => { - iapAvailable = ok; - }); let url: string | null; try { ({ url } = await TrekShell.getServer()); diff --git a/client/native-shell/shell.css b/client/native-shell/shell.css index 55d889c6f..cff1c917c 100644 --- a/client/native-shell/shell.css +++ b/client/native-shell/shell.css @@ -881,6 +881,20 @@ a.linkbtn { margin: 2px 0 0; line-height: 1.45; } +/* Under the plans: restore, the renewal terms, the legal links. */ +.plans-foot { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; +} +.plans-foot .hint { + text-align: center; + margin: 0; +} +.plans-foot .hint:empty { + display: none; +} /* ── Claim: address + email, and the provisioning wait ───────────────────── */ /* One rounded field holding the address input and its fixed ".liketrek.com" diff --git a/client/src/native/iap.test.ts b/client/src/native/iap.test.ts index fd7a01643..a775bb65a 100644 --- a/client/src/native/iap.test.ts +++ b/client/src/native/iap.test.ts @@ -1,17 +1,36 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const platform = vi.hoisted(() => ({ ios: true })); +const shell = vi.hoisted(() => ({ + getCapabilities: vi.fn(), + storeProducts: vi.fn(), + storePurchase: vi.fn(), + storeFinish: vi.fn(), + storeRestore: vi.fn(), +})); + +vi.mock('./platform', () => ({ isNativeIos: () => platform.ios })); +vi.mock('./trekShell', () => ({ TrekShell: shell })); + import { fakeIapClient, nativeIapClient, productIdFor } from './iap'; +/** A signed transaction as far as the client reads it: only the payload matters here. */ +function jwsFor(productId: string): string { + const payload = btoa(JSON.stringify({ productId })).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return `header.${payload}.signature`; +} + +beforeEach(() => { + platform.ios = true; + for (const fn of Object.values(shell)) fn.mockReset(); +}); + describe('in-app purchase client', () => { it('names store products by the shared scheme', () => { expect(productIdFor('start', 'month')).toBe('cloud.start.monthly'); expect(productIdFor('pro', 'year')).toBe('cloud.pro.yearly'); }); - it('the native client is unavailable until the purchase plugin is wired', async () => { - expect(await nativeIapClient.isAvailable()).toBe(false); - expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'failed' }); - }); - it('the fake client yields a receipt for the product asked for', async () => { expect(await fakeIapClient.isAvailable()).toBe(true); const outcome = await fakeIapClient.purchase('cloud.plus.monthly'); @@ -22,3 +41,74 @@ describe('in-app purchase client', () => { expect(outcome.purchase.receipt).toContain('cloud.plus.monthly'); }); }); + +describe('native client', () => { + it('is available only on iOS with the storeKit feature', async () => { + shell.getCapabilities.mockResolvedValue({ features: ['storeKit'] }); + expect(await nativeIapClient.isAvailable()).toBe(true); + + shell.getCapabilities.mockResolvedValue({ features: ['widgets'] }); + expect(await nativeIapClient.isAvailable()).toBe(false); + + shell.getCapabilities.mockRejectedValue({ code: 'UNIMPLEMENTED' }); + expect(await nativeIapClient.isAvailable()).toBe(false); + + platform.ios = false; + shell.getCapabilities.mockResolvedValue({ features: ['storeKit'] }); + expect(await nativeIapClient.isAvailable()).toBe(false); + }); + + it('maps the store price strings by product', async () => { + shell.storeProducts.mockResolvedValue({ products: [{ id: 'cloud.plus.monthly', displayPrice: '9,99 €' }] }); + const prices = await nativeIapClient.prices(['cloud.plus.monthly', 'cloud.pro.monthly']); + expect(shell.storeProducts).toHaveBeenCalledWith({ ids: ['cloud.plus.monthly', 'cloud.pro.monthly'] }); + expect(prices.get('cloud.plus.monthly')).toBe('9,99 €'); + expect(prices.has('cloud.pro.monthly')).toBe(false); + + shell.storeProducts.mockRejectedValue(new Error('down')); + expect((await nativeIapClient.prices(['cloud.plus.monthly'])).size).toBe(0); + }); + + it('turns a bought transaction into a receipt', async () => { + shell.storePurchase.mockResolvedValue({ kind: 'bought', jws: 'signed', transactionId: '42' }); + expect(await nativeIapClient.purchase('cloud.plus.yearly')).toEqual({ + kind: 'bought', + purchase: { platform: 'apple', productId: 'cloud.plus.yearly', receipt: 'signed', transactionId: '42' }, + }); + }); + + it('passes cancel and pending through, and fails on anything else', async () => { + shell.storePurchase.mockResolvedValue({ kind: 'cancelled' }); + expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'cancelled' }); + shell.storePurchase.mockResolvedValue({ kind: 'pending' }); + expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'pending' }); + shell.storePurchase.mockResolvedValue({ kind: 'bought' }); + expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'failed' }); + shell.storePurchase.mockRejectedValue(new Error('boom')); + expect(await nativeIapClient.purchase('cloud.plus.monthly')).toEqual({ kind: 'failed' }); + }); + + it('finishes by transaction id and swallows a failure', async () => { + const purchase = { platform: 'apple' as const, productId: 'cloud.plus.monthly', receipt: 'x', transactionId: '7' }; + shell.storeFinish.mockResolvedValue(undefined); + await nativeIapClient.finish(purchase); + expect(shell.storeFinish).toHaveBeenCalledWith({ transactionId: '7' }); + shell.storeFinish.mockRejectedValue(new Error('gone')); + await expect(nativeIapClient.finish(purchase)).resolves.toBeUndefined(); + }); + + it('restores the subscription the Apple ID holds, with its product', async () => { + const jws = jwsFor('cloud.pro.yearly'); + shell.storeRestore.mockResolvedValue({ jws, transactionId: '9' }); + expect(await nativeIapClient.restore()).toEqual({ + platform: 'apple', + productId: 'cloud.pro.yearly', + receipt: jws, + transactionId: '9', + }); + expect(shell.storeRestore).toHaveBeenCalledWith({ prefix: 'cloud.' }); + + shell.storeRestore.mockResolvedValue({}); + expect(await nativeIapClient.restore()).toBeNull(); + }); +}); diff --git a/client/src/native/iap.ts b/client/src/native/iap.ts index 0707f7548..5e5046192 100644 --- a/client/src/native/iap.ts +++ b/client/src/native/iap.ts @@ -1,12 +1,15 @@ +import { isNativeIos } from './platform'; +import { TrekShell } from './trekShell'; + /** * In-app purchase, from the app's side. A seam, like {@link TrekShell}: the - * screens drive a plan to a receipt through this, and never touch the store - * plugin directly. Two implementations stand behind it — a fake one for - * development and preview, so the whole subscription flow can be walked without a - * store, and the native one that will call the platform plugin once the store - * products and accounts exist. Until then the native client reports itself - * unavailable and the plan picker falls back to finishing on the web, so nothing - * half-wired ever faces a customer. + * screens drive a plan to a receipt through this, and never touch StoreKit + * directly. Two implementations stand behind it: a fake one for development and + * preview, so the whole subscription flow can be walked without a store, and the + * native one that runs StoreKit 2 through TrekShell (feature storeKit). Where + * neither can take a payment (Android until Play Billing is wired, a binary from + * before storeKit) the client reports itself unavailable and the shell offers no + * purchase at all: the store rules leave no room for sending buyers to the web. */ type IapPlatform = 'apple' | 'google'; @@ -15,26 +18,50 @@ type IapPlatform = 'apple' | 'google'; export interface Purchase { platform: IapPlatform; productId: string; + /** Apple: the signed transaction (JWS) the control plane verifies. */ receipt: string; + /** The store's id for this transaction, handed back to {@link IapClient.finish}. */ + transactionId: string; } -/** What a purchase attempt came to. `cancelled` is the customer backing out, not an error. */ -type PurchaseOutcome = { kind: 'bought'; purchase: Purchase } | { kind: 'cancelled' } | { kind: 'failed' }; +/** + * What a purchase attempt came to. `cancelled` is the customer backing out, not + * an error; `pending` is a purchase waiting on someone else (Ask to Buy, a bank), + * which a restore picks up once it goes through. + */ +type PurchaseOutcome = + | { kind: 'bought'; purchase: Purchase } + | { kind: 'cancelled' } + | { kind: 'pending' } + | { kind: 'failed' }; export interface IapClient { - /** Whether this build can take a payment: the plugin is present and the store products exist. */ + /** Whether this build can take a payment. */ isAvailable(): Promise; + /** + * The store's own price strings by product id, in the buyer's currency. A + * product missing from the map cannot be bought here. Empty for the fake, whose + * prices come from the plan list. + */ + prices(productIds: string[]): Promise>; /** Runs the platform's purchase sheet for a product and resolves with what became of it. */ purchase(productId: string): Promise; + /** Tells the store a purchase was delivered, once its instance exists. */ + finish(purchase: Purchase): Promise; + /** The subscription this store account already holds, for a reinstall or a new device. */ + restore(): Promise; } +/** Every store product of the cloud starts with this. */ +const PRODUCT_PREFIX = 'cloud.'; + /** * The store product id for a plan and interval. Must match the identifiers created * in App Store Connect and the Play Console, and the control plane's catalog * (`cloud..`); kept here as the one place the app names them. */ export function productIdFor(planId: string, interval: 'month' | 'year'): string { - return `cloud.${planId}.${interval === 'year' ? 'yearly' : 'monthly'}`; + return `${PRODUCT_PREFIX}${planId}.${interval === 'year' ? 'yearly' : 'monthly'}`; } /** @@ -46,30 +73,94 @@ export const fakeIapClient: IapClient = { async isAvailable() { return true; }, + async prices() { + return new Map(); + }, async purchase(productId) { await new Promise((resolve) => setTimeout(resolve, 900)); + const nonce = String(Date.now()); return { kind: 'bought', - purchase: { platform: 'apple', productId, receipt: `fake-receipt:${productId}:${Date.now()}` }, + purchase: { platform: 'apple', productId, receipt: `fake-receipt:${productId}:${nonce}`, transactionId: nonce }, }; }, + async finish() {}, + async restore() { + return null; + }, }; /** - * The store client. A stub until the purchase plugin is a dependency and the - * products exist: it reports itself unavailable, so the plan picker finishes on - * the web instead of offering a payment it cannot take. When the plugin lands, - * this is where it is wired; the screens do not change. + * StoreKit 2 through TrekShell. Available on iOS when the binary has the + * storeKit feature; everywhere else (Android, the web, an older binary) it + * reports itself unavailable and every call comes to nothing. */ export const nativeIapClient: IapClient = { async isAvailable() { - return false; + if (!isNativeIos()) return false; + try { + const caps = await TrekShell.getCapabilities(); + return caps.features.includes('storeKit'); + } catch { + return false; + } }, - async purchase() { - return { kind: 'failed' }; + async prices(productIds) { + try { + const { products } = await TrekShell.storeProducts({ ids: productIds }); + return new Map(products.map((p) => [p.id, p.displayPrice])); + } catch { + return new Map(); + } + }, + async purchase(productId) { + try { + const result = await TrekShell.storePurchase({ productId }); + if (result.kind === 'bought' && result.jws && result.transactionId) { + return { + kind: 'bought', + purchase: { platform: 'apple', productId, receipt: result.jws, transactionId: result.transactionId }, + }; + } + if (result.kind === 'cancelled' || result.kind === 'pending') return { kind: result.kind }; + return { kind: 'failed' }; + } catch { + return { kind: 'failed' }; + } + }, + async finish(purchase) { + try { + await TrekShell.storeFinish({ transactionId: purchase.transactionId }); + } catch { + // Left unfinished, the store hands the transaction over again; a restore redeems it idempotently. + } + }, + async restore() { + try { + const { jws, transactionId } = await TrekShell.storeRestore({ prefix: PRODUCT_PREFIX }); + if (!jws || !transactionId) return null; + return { platform: 'apple', productId: productIdOf(jws), receipt: jws, transactionId }; + } catch { + return null; + } }, }; +/** + * The product id inside a signed transaction, read for display and routing only: + * the control plane verifies the signature and takes the product from there. + */ +function productIdOf(jws: string): string { + try { + const part = jws.split('.')[1] ?? ''; + const json = atob(part.replace(/-/g, '+').replace(/_/g, '/')); + const id = (JSON.parse(json) as { productId?: unknown }).productId; + return typeof id === 'string' ? id : ''; + } catch { + return ''; + } +} + /** * Whether to use the fake. On in a development build, and switchable on in a * preview build with `localStorage['trek-iap-fake'] = '1'`, so the flow can be diff --git a/client/src/native/links.ts b/client/src/native/links.ts index f367a8f9d..5cd54d1ca 100644 --- a/client/src/native/links.ts +++ b/client/src/native/links.ts @@ -14,3 +14,10 @@ export const DEMO_SERVER_URL = 'https://demo.liketrek.com'; /** TREK Cloud, for someone who wants a hosted TREK rather than running their own. */ export const CLOUD_URL = 'https://liketrek.com'; + +/** + * The terms an in-app subscription is sold under: Apple's standard licence + * agreement, which the App Store applies when an app names no own EULA. The + * store rules ask for it next to the purchase, together with the privacy policy. + */ +export const STORE_TERMS_URL = 'https://www.apple.com/legal/internet-services/itunes/dev/stdeula/'; diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts index 018a9b4ef..fdf567b3a 100644 --- a/client/src/native/trekShell.ts +++ b/client/src/native/trekShell.ts @@ -73,6 +73,16 @@ export interface TrekShellPlugin { total: number; }): Promise<{ running: boolean }>; endTripDay(): Promise; + /** iOS, feature storeKit: the App Store's price strings for the products it knows. */ + storeProducts(options: { ids: string[] }): Promise<{ products: { id: string; displayPrice: string }[] }>; + /** iOS, feature storeKit: the purchase sheet; `jws` is the signed transaction, set when bought. */ + storePurchase(options: { + productId: string; + }): Promise<{ kind: 'bought' | 'cancelled' | 'pending' | 'failed'; jws?: string; transactionId?: string }>; + /** iOS, feature storeKit: marks a purchase delivered once its instance exists. */ + storeFinish(options: { transactionId: string }): Promise; + /** iOS, feature storeKit: the newest active subscription under `prefix` on this Apple ID, if any. */ + storeRestore(options: { prefix: string }): Promise<{ jws?: string; transactionId?: string }>; addListener( event: 'pushOpened', listener: (payload: { url: string }) => void diff --git a/shared/src/i18n/ar/native.ts b/shared/src/i18n/ar/native.ts index 4add90ef6..a89101165 100644 --- a/shared/src/i18n/ar/native.ts +++ b/shared/src/i18n/ar/native.ts @@ -52,7 +52,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'اشترك', 'native.plans.loadError': 'تعذّر تحميل الخطط. تحقق من اتصالك وحاول مرة أخرى.', 'native.plans.retry': 'حاول مرة أخرى', - 'native.plans.soon': 'الاشتراكات داخل التطبيق قادمة قريبًا. أكمل الآن على الموقع.', + 'native.plans.soon': 'الاشتراك داخل التطبيق غير متاح بعد.', 'native.claim.title': 'اقتربنا من الانتهاء', 'native.claim.sub': 'اختر عنوانك. سنُعدّه على الفور.', 'native.claim.addressLabel': 'عنوانك', @@ -66,6 +66,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'تم إلغاء الشراء.', 'native.claim.failed': 'لم تتم عملية الشراء. حاول مرة أخرى.', 'native.claim.redeemFailed': 'تعذّر علينا إعداد TREK الخاص بك. حاول مرة أخرى.', + 'native.plans.restore': 'استعادة المشتريات', + 'native.plans.restoring': 'جارٍ التحقق من مشترياتك…', + 'native.plans.restoreNone': 'لم يُعثر على اشتراك نشط لمعرّف Apple هذا.', + 'native.plans.restoreFailed': 'تعذّرت استعادة TREK الخاص بك. حاول مرة أخرى.', + 'native.plans.legal': 'يُخصم المبلغ من معرّف Apple عند التأكيد. يتجدد الاشتراك تلقائيًا ما لم تلغه قبل 24 ساعة على الأقل من نهاية الفترة. يمكنك إدارته أو إلغاؤه من إعدادات حسابك في App Store.', + 'native.plans.terms': 'شروط الاستخدام', + 'native.claim.pending': 'عملية الشراء بانتظار الموافقة. عند اكتمالها، اضغط على استعادة المشتريات.', 'native.provisioning.title': 'جارٍ إعداد TREK الخاص بك', 'native.provisioning.sub': 'نُحضّر {host}. يستغرق هذا دقيقة واحدة فقط.', 'native.provisioning.failedTitle': 'شارف على الجهوزية', diff --git a/shared/src/i18n/az/native.ts b/shared/src/i18n/az/native.ts index 130e48b3d..1a08d8c5b 100644 --- a/shared/src/i18n/az/native.ts +++ b/shared/src/i18n/az/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Abunə ol', 'native.plans.loadError': 'Planları yükləmək mümkün olmadı. Bağlantınızı yoxlayın və yenidən cəhd edin.', 'native.plans.retry': 'Yenidən cəhd edin', - 'native.plans.soon': 'Tətbiqdaxili abunələr tezliklə gəlir. Hələlik veb saytda tamamlayın.', + 'native.plans.soon': 'Tətbiqdaxili abunə hələ mövcud deyil.', 'native.claim.title': 'Az qaldı', 'native.claim.sub': 'Ünvanınızı seçin. Dərhal hazırlayırıq.', 'native.claim.addressLabel': 'Ünvanınız', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Alış ləğv edildi.', 'native.claim.failed': 'Alış baş tutmadı. Zəhmət olmasa yenidən cəhd edin.', 'native.claim.redeemFailed': 'TREK-inizi hazırlaya bilmədik. Zəhmət olmasa yenidən cəhd edin.', + 'native.plans.restore': 'Alışları bərpa et', + 'native.plans.restoring': 'Alışlarınız yoxlanılır…', + 'native.plans.restoreNone': 'Bu Apple ID üçün aktiv abunə tapılmadı.', + 'native.plans.restoreFailed': 'TREK-inizi bərpa edə bilmədik. Zəhmət olmasa yenidən cəhd edin.', + 'native.plans.legal': 'Ödəniş təsdiq zamanı Apple ID-nizdən tutulur. Dövrün bitməsinə ən azı 24 saat qalmış ləğv etməsəniz, abunə avtomatik yenilənir. Onu App Store hesab ayarlarınızda idarə edə və ya ləğv edə bilərsiniz.', + 'native.plans.terms': 'İstifadə şərtləri', + 'native.claim.pending': 'Alışınız təsdiq gözləyir. Tamamlandıqdan sonra Alışları bərpa et düyməsinə toxunun.', 'native.provisioning.title': 'TREK-iniz hazırlanır', 'native.provisioning.sub': '{host} hazırlanır. Bu yalnız bir dəqiqə çəkir.', 'native.provisioning.failedTitle': 'Demək olar ki hazırdır', diff --git a/shared/src/i18n/br/native.ts b/shared/src/i18n/br/native.ts index 506543143..85be9cafc 100644 --- a/shared/src/i18n/br/native.ts +++ b/shared/src/i18n/br/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Assinar', 'native.plans.loadError': 'Não foi possível carregar os planos. Verifique sua conexão e tente novamente.', 'native.plans.retry': 'Tentar de novo', - 'native.plans.soon': 'As assinaturas no app estão chegando. Por enquanto, conclua no site.', + 'native.plans.soon': 'A assinatura no app ainda não está disponível.', 'native.claim.title': 'Quase lá', 'native.claim.sub': 'Escolha seu endereço. Preparamos tudo na hora.', 'native.claim.addressLabel': 'Seu endereço', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Compra cancelada.', 'native.claim.failed': 'A compra não foi concluída. Tente novamente.', 'native.claim.redeemFailed': 'Não conseguimos preparar seu TREK. Tente novamente.', + 'native.plans.restore': 'Restaurar compras', + 'native.plans.restoring': 'Verificando suas compras…', + 'native.plans.restoreNone': 'Nenhuma assinatura ativa encontrada neste ID Apple.', + 'native.plans.restoreFailed': 'Não conseguimos restaurar seu TREK. Tente novamente.', + 'native.plans.legal': 'O pagamento é cobrado no seu ID Apple ao confirmar. A assinatura é renovada automaticamente, a menos que seja cancelada pelo menos 24 horas antes do fim do período. Gerencie ou cancele nos ajustes da sua conta da App Store.', + 'native.plans.terms': 'Termos de uso', + 'native.claim.pending': 'Sua compra está aguardando aprovação. Quando for concluída, toque em Restaurar compras.', 'native.provisioning.title': 'Preparando seu TREK', 'native.provisioning.sub': 'Preparando {host}. Isso leva só um minuto.', 'native.provisioning.failedTitle': 'Quase pronto', diff --git a/shared/src/i18n/ca/native.ts b/shared/src/i18n/ca/native.ts index 702e42094..c0a336aee 100644 --- a/shared/src/i18n/ca/native.ts +++ b/shared/src/i18n/ca/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Subscriu-te', 'native.plans.loadError': 'No s’han pogut carregar els plans. Comprova la connexió i torna-ho a provar.', 'native.plans.retry': 'Torna-ho a provar', - 'native.plans.soon': 'Les subscripcions a l’app arribaran aviat. De moment, acaba al web.', + 'native.plans.soon': 'La subscripció a l’app encara no està disponible.', 'native.claim.title': 'Gairebé a punt', 'native.claim.sub': 'Tria la teva adreça. Ho preparem de seguida.', 'native.claim.addressLabel': 'La teva adreça', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Compra cancel·lada.', 'native.claim.failed': 'La compra no s’ha completat. Torna-ho a provar.', 'native.claim.redeemFailed': 'No hem pogut preparar el teu TREK. Torna-ho a provar.', + 'native.plans.restore': 'Restaura les compres', + 'native.plans.restoring': 'Comprovant les teves compres…', + 'native.plans.restoreNone': 'No s’ha trobat cap subscripció activa en aquest ID d’Apple.', + 'native.plans.restoreFailed': 'No hem pogut restaurar el teu TREK. Torna-ho a provar.', + 'native.plans.legal': 'El pagament es carrega al teu ID d’Apple en confirmar. La subscripció es renova automàticament si no la cancel·les com a mínim 24 hores abans que acabi el període. Gestiona-la o cancel·la-la a la configuració del teu compte de l’App Store.', + 'native.plans.terms': 'Condicions d’ús', + 'native.claim.pending': 'La teva compra està pendent d’aprovació. Quan es completi, toca Restaura les compres.', 'native.provisioning.title': 'Preparant el teu TREK', 'native.provisioning.sub': 'Preparant {host}. Només triga un minut.', 'native.provisioning.failedTitle': 'Gairebé a punt', diff --git a/shared/src/i18n/cs/native.ts b/shared/src/i18n/cs/native.ts index 502b4149f..d71c53b3f 100644 --- a/shared/src/i18n/cs/native.ts +++ b/shared/src/i18n/cs/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Předplatit', 'native.plans.loadError': 'Tarify se nepodařilo načíst. Zkontrolujte připojení a zkuste to znovu.', 'native.plans.retry': 'Zkusit znovu', - 'native.plans.soon': 'Předplatné v aplikaci brzy dorazí. Zatím to dokončete na webu.', + 'native.plans.soon': 'Předplatné v aplikaci zatím není k dispozici.', 'native.claim.title': 'Skoro hotovo', 'native.claim.sub': 'Vyberte si adresu. Hned ji připravíme.', 'native.claim.addressLabel': 'Vaše adresa', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Nákup zrušen.', 'native.claim.failed': 'Nákup se nezdařil. Zkuste to znovu.', 'native.claim.redeemFailed': 'Nepodařilo se připravit váš TREK. Zkuste to znovu.', + 'native.plans.restore': 'Obnovit nákupy', + 'native.plans.restoring': 'Kontrolujeme vaše nákupy…', + 'native.plans.restoreNone': 'Pro toto Apple ID nebylo nalezeno žádné aktivní předplatné.', + 'native.plans.restoreFailed': 'Váš TREK se nepodařilo obnovit. Zkuste to znovu.', + 'native.plans.legal': 'Platba se strhne z vašeho Apple ID při potvrzení. Předplatné se automaticky obnovuje, pokud jej nezrušíte alespoň 24 hodin před koncem období. Spravovat nebo zrušit jej můžete v nastavení účtu App Store.', + 'native.plans.terms': 'Podmínky použití', + 'native.claim.pending': 'Váš nákup čeká na schválení. Jakmile projde, klepněte na Obnovit nákupy.', 'native.provisioning.title': 'Připravujeme váš TREK', 'native.provisioning.sub': 'Připravujeme {host}. Zabere to jen minutu.', 'native.provisioning.failedTitle': 'Téměř hotovo', diff --git a/shared/src/i18n/de/native.ts b/shared/src/i18n/de/native.ts index fc0c20e3d..46863aaf4 100644 --- a/shared/src/i18n/de/native.ts +++ b/shared/src/i18n/de/native.ts @@ -54,7 +54,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Abonnieren', 'native.plans.loadError': 'Die Tarife konnten nicht geladen werden. Prüfe deine Verbindung und versuch es erneut.', 'native.plans.retry': 'Erneut versuchen', - 'native.plans.soon': 'In-App-Abos kommen bald. Vorerst auf der Website abschließen.', + 'native.plans.soon': 'Abschließen in der App ist noch nicht verfügbar.', 'native.claim.title': 'Fast geschafft', 'native.claim.sub': 'Wähle deine Adresse. Wir richten sie sofort ein.', 'native.claim.addressLabel': 'Deine Adresse', @@ -68,6 +68,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Kauf abgebrochen.', 'native.claim.failed': 'Der Kauf hat nicht geklappt. Bitte versuch es erneut.', 'native.claim.redeemFailed': 'Wir konnten deine TREK nicht einrichten. Bitte versuch es erneut.', + 'native.plans.restore': 'Käufe wiederherstellen', + 'native.plans.restoring': 'Deine Käufe werden geprüft…', + 'native.plans.restoreNone': 'Für diese Apple-ID wurde kein aktives Abo gefunden.', + 'native.plans.restoreFailed': 'Wir konnten deine TREK nicht wiederherstellen. Bitte versuch es erneut.', + 'native.plans.legal': 'Die Zahlung wird bei Bestätigung über deine Apple-ID abgerechnet. Das Abo verlängert sich automatisch, wenn du es nicht mindestens 24 Stunden vor Ablauf des Zeitraums kündigst. Verwalten und kündigen kannst du es in den Einstellungen deines App-Store-Accounts.', + 'native.plans.terms': 'Nutzungsbedingungen', + 'native.claim.pending': 'Dein Kauf wartet noch auf eine Freigabe. Sobald er durch ist, tippe auf Käufe wiederherstellen.', 'native.provisioning.title': 'Deine TREK wird eingerichtet', 'native.provisioning.sub': '{host} wird vorbereitet. Das dauert nur eine Minute.', 'native.provisioning.failedTitle': 'Gleich fertig', diff --git a/shared/src/i18n/en/native.ts b/shared/src/i18n/en/native.ts index 2e4bd3e2b..14bdcf68b 100644 --- a/shared/src/i18n/en/native.ts +++ b/shared/src/i18n/en/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Subscribe', 'native.plans.loadError': 'Could not load the plans. Check your connection and try again.', 'native.plans.retry': 'Try again', - 'native.plans.soon': 'In-app subscriptions are coming. For now, finish on the website.', + 'native.plans.soon': 'Subscribing in the app is not available yet.', 'native.claim.title': 'Almost there', 'native.claim.sub': 'Choose your address. We set it up right away.', 'native.claim.addressLabel': 'Your address', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Purchase cancelled.', 'native.claim.failed': 'The purchase did not go through. Please try again.', 'native.claim.redeemFailed': 'We could not set up your TREK. Please try again.', + 'native.plans.restore': 'Restore purchases', + 'native.plans.restoring': 'Checking your purchases…', + 'native.plans.restoreNone': 'No active subscription found on this Apple ID.', + 'native.plans.restoreFailed': 'We could not restore your TREK. Please try again.', + 'native.plans.legal': 'Payment is charged to your Apple ID when you confirm. The subscription renews automatically unless you cancel it at least 24 hours before the period ends. Manage or cancel it in your App Store account settings.', + 'native.plans.terms': 'Terms of use', + 'native.claim.pending': 'Your purchase is waiting for approval. Once it goes through, tap Restore purchases.', 'native.provisioning.title': 'Setting up your TREK', 'native.provisioning.sub': 'Getting {host} ready. This only takes a minute.', 'native.provisioning.failedTitle': 'Almost ready', diff --git a/shared/src/i18n/es/native.ts b/shared/src/i18n/es/native.ts index 93cfe30c3..780eb3bb6 100644 --- a/shared/src/i18n/es/native.ts +++ b/shared/src/i18n/es/native.ts @@ -54,7 +54,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Suscribirse', 'native.plans.loadError': 'No se han podido cargar los planes. Revisa tu conexión e inténtalo de nuevo.', 'native.plans.retry': 'Reintentar', - 'native.plans.soon': 'Las suscripciones dentro de la app llegarán pronto. Por ahora, termina en la web.', + 'native.plans.soon': 'La suscripción dentro de la app aún no está disponible.', 'native.claim.title': 'Casi listo', 'native.claim.sub': 'Elige tu dirección. La preparamos al instante.', 'native.claim.addressLabel': 'Tu dirección', @@ -68,6 +68,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Compra cancelada.', 'native.claim.failed': 'La compra no se completó. Inténtalo de nuevo.', 'native.claim.redeemFailed': 'No pudimos preparar tu TREK. Inténtalo de nuevo.', + 'native.plans.restore': 'Restaurar compras', + 'native.plans.restoring': 'Comprobando tus compras…', + 'native.plans.restoreNone': 'No se encontró ninguna suscripción activa en este ID de Apple.', + 'native.plans.restoreFailed': 'No pudimos restaurar tu TREK. Inténtalo de nuevo.', + 'native.plans.legal': 'El pago se cobra a tu ID de Apple al confirmar. La suscripción se renueva automáticamente salvo que la canceles al menos 24 horas antes de que termine el periodo. Gestiónala o cancélala en los ajustes de tu cuenta del App Store.', + 'native.plans.terms': 'Términos de uso', + 'native.claim.pending': 'Tu compra está pendiente de aprobación. Cuando se complete, toca Restaurar compras.', 'native.provisioning.title': 'Preparando tu TREK', 'native.provisioning.sub': 'Preparando {host}. Solo tarda un minuto.', 'native.provisioning.failedTitle': 'Casi listo', diff --git a/shared/src/i18n/et/native.ts b/shared/src/i18n/et/native.ts index f55eecdb5..c613f8c01 100644 --- a/shared/src/i18n/et/native.ts +++ b/shared/src/i18n/et/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Telli', 'native.plans.loadError': 'Pakette ei õnnestunud laadida. Kontrolli ühendust ja proovi uuesti.', 'native.plans.retry': 'Proovi uuesti', - 'native.plans.soon': 'Rakendusesisesed tellimused on tulekul. Praegu lõpeta veebis.', + 'native.plans.soon': 'Rakendusesisene tellimine pole veel saadaval.', 'native.claim.title': 'Peaaegu valmis', 'native.claim.sub': 'Vali oma aadress. Seadistame selle kohe.', 'native.claim.addressLabel': 'Sinu aadress', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Ost tühistatud.', 'native.claim.failed': 'Ost ei õnnestunud. Proovi uuesti.', 'native.claim.redeemFailed': 'Me ei saanud sinu TREK-i seadistada. Proovi uuesti.', + 'native.plans.restore': 'Taasta ostud', + 'native.plans.restoring': 'Kontrollime sinu oste…', + 'native.plans.restoreNone': 'Selle Apple ID jaoks ei leitud aktiivset tellimust.', + 'native.plans.restoreFailed': 'Me ei saanud sinu TREK-i taastada. Proovi uuesti.', + 'native.plans.legal': 'Makse võetakse kinnitamisel sinu Apple ID-lt. Tellimus pikeneb automaatselt, kui sa ei tühista seda vähemalt 24 tundi enne perioodi lõppu. Halda või tühista seda oma App Store’i konto seadetes.', + 'native.plans.terms': 'Kasutustingimused', + 'native.claim.pending': 'Sinu ost ootab kinnitust. Kui see on läbi, puuduta Taasta ostud.', 'native.provisioning.title': 'Seadistame sinu TREK-i', 'native.provisioning.sub': 'Valmistame ette aadressi {host}. See võtab vaid minuti.', 'native.provisioning.failedTitle': 'Peaaegu valmis', diff --git a/shared/src/i18n/fr/native.ts b/shared/src/i18n/fr/native.ts index a87a19fa8..5f8faa83a 100644 --- a/shared/src/i18n/fr/native.ts +++ b/shared/src/i18n/fr/native.ts @@ -54,7 +54,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'S’abonner', 'native.plans.loadError': 'Impossible de charger les forfaits. Vérifiez votre connexion et réessayez.', 'native.plans.retry': 'Réessayer', - 'native.plans.soon': 'Les abonnements dans l’app arrivent bientôt. Pour l’instant, terminez sur le site.', + 'native.plans.soon': 'L’abonnement dans l’app n’est pas encore disponible.', 'native.claim.title': 'Presque terminé', 'native.claim.sub': 'Choisissez votre adresse. Nous la préparons aussitôt.', 'native.claim.addressLabel': 'Votre adresse', @@ -68,6 +68,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Achat annulé.', 'native.claim.failed': 'L’achat n’a pas abouti. Réessayez.', 'native.claim.redeemFailed': 'Nous n’avons pas pu préparer votre TREK. Réessayez.', + 'native.plans.restore': 'Restaurer les achats', + 'native.plans.restoring': 'Vérification de vos achats…', + 'native.plans.restoreNone': 'Aucun abonnement actif trouvé pour cet identifiant Apple.', + 'native.plans.restoreFailed': 'Nous n’avons pas pu restaurer votre TREK. Réessayez.', + 'native.plans.legal': 'Le paiement est débité de votre identifiant Apple lors de la confirmation. L’abonnement se renouvelle automatiquement, sauf résiliation au moins 24 heures avant la fin de la période. Gérez-le ou résiliez-le dans les réglages de votre compte App Store.', + 'native.plans.terms': 'Conditions d’utilisation', + 'native.claim.pending': 'Votre achat attend une approbation. Une fois validé, touchez Restaurer les achats.', 'native.provisioning.title': 'Préparation de votre TREK', 'native.provisioning.sub': 'Préparation de {host}. Cela ne prend qu’une minute.', 'native.provisioning.failedTitle': 'Presque prêt', diff --git a/shared/src/i18n/gr/native.ts b/shared/src/i18n/gr/native.ts index f0a0ed4fd..144ee6f7e 100644 --- a/shared/src/i18n/gr/native.ts +++ b/shared/src/i18n/gr/native.ts @@ -56,7 +56,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Εγγραφή', 'native.plans.loadError': 'Δεν ήταν δυνατή η φόρτωση των πλάνων. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.', 'native.plans.retry': 'Δοκιμάστε ξανά', - 'native.plans.soon': 'Οι συνδρομές εντός εφαρμογής έρχονται. Προς το παρόν, ολοκληρώστε στον ιστότοπο.', + 'native.plans.soon': 'Η συνδρομή μέσα από την εφαρμογή δεν είναι ακόμη διαθέσιμη.', 'native.claim.title': 'Σχεδόν έτοιμο', 'native.claim.sub': 'Επιλέξτε τη διεύθυνσή σας. Το ετοιμάζουμε αμέσως.', 'native.claim.addressLabel': 'Η διεύθυνσή σας', @@ -70,6 +70,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Η αγορά ακυρώθηκε.', 'native.claim.failed': 'Η αγορά δεν ολοκληρώθηκε. Δοκιμάστε ξανά.', 'native.claim.redeemFailed': 'Δεν μπορέσαμε να ετοιμάσουμε το TREK σας. Δοκιμάστε ξανά.', + 'native.plans.restore': 'Επαναφορά αγορών', + 'native.plans.restoring': 'Έλεγχος των αγορών σας…', + 'native.plans.restoreNone': 'Δεν βρέθηκε ενεργή συνδρομή για αυτό το Apple ID.', + 'native.plans.restoreFailed': 'Δεν μπορέσαμε να επαναφέρουμε το TREK σας. Δοκιμάστε ξανά.', + 'native.plans.legal': 'Η πληρωμή χρεώνεται στο Apple ID σας κατά την επιβεβαίωση. Η συνδρομή ανανεώνεται αυτόματα, εκτός αν την ακυρώσετε τουλάχιστον 24 ώρες πριν από το τέλος της περιόδου. Διαχειριστείτε ή ακυρώστε τη στις ρυθμίσεις του λογαριασμού σας στο App Store.', + 'native.plans.terms': 'Όροι χρήσης', + 'native.claim.pending': 'Η αγορά σας περιμένει έγκριση. Μόλις ολοκληρωθεί, πατήστε Επαναφορά αγορών.', 'native.provisioning.title': 'Ετοιμάζουμε το TREK σας', 'native.provisioning.sub': 'Ετοιμάζουμε το {host}. Χρειάζεται μόνο ένα λεπτό.', 'native.provisioning.failedTitle': 'Σχεδόν έτοιμο', diff --git a/shared/src/i18n/he/native.ts b/shared/src/i18n/he/native.ts index 08d9ea761..2768f4b8a 100644 --- a/shared/src/i18n/he/native.ts +++ b/shared/src/i18n/he/native.ts @@ -52,7 +52,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'הרשמה', 'native.plans.loadError': 'לא ניתן לטעון את התוכניות. בדקו את החיבור ונסו שוב.', 'native.plans.retry': 'נסו שוב', - 'native.plans.soon': 'הרשמות בתוך האפליקציה בקרוב. בינתיים, השלימו באתר.', + 'native.plans.soon': 'הרשמה בתוך האפליקציה עדיין לא זמינה.', 'native.claim.title': 'כמעט סיימנו', 'native.claim.sub': 'בחרו את הכתובת שלכם. אנחנו מכינים אותה מיד.', 'native.claim.addressLabel': 'הכתובת שלכם', @@ -66,6 +66,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'הרכישה בוטלה.', 'native.claim.failed': 'הרכישה לא הושלמה. אנא נסו שוב.', 'native.claim.redeemFailed': 'לא הצלחנו להכין את ה-TREK שלכם. אנא נסו שוב.', + 'native.plans.restore': 'שחזור רכישות', + 'native.plans.restoring': 'בודקים את הרכישות שלך…', + 'native.plans.restoreNone': 'לא נמצא מינוי פעיל עבור Apple ID זה.', + 'native.plans.restoreFailed': 'לא הצלחנו לשחזר את ה-TREK שלך. אנא נסו שוב.', + 'native.plans.legal': 'התשלום מחויב ב-Apple ID שלך בעת האישור. המינוי מתחדש אוטומטית אלא אם מבטלים אותו לפחות 24 שעות לפני סוף התקופה. אפשר לנהל או לבטל אותו בהגדרות חשבון ה-App Store.', + 'native.plans.terms': 'תנאי שימוש', + 'native.claim.pending': 'הרכישה שלך ממתינה לאישור. כשהיא תושלם, הקישו על שחזור רכישות.', 'native.provisioning.title': 'מכינים את ה-TREK שלכם', 'native.provisioning.sub': 'מכינים את {host}. זה לוקח רק דקה.', 'native.provisioning.failedTitle': 'כמעט מוכן', diff --git a/shared/src/i18n/hu/native.ts b/shared/src/i18n/hu/native.ts index 9d6ba2c60..514f9ae24 100644 --- a/shared/src/i18n/hu/native.ts +++ b/shared/src/i18n/hu/native.ts @@ -54,7 +54,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Előfizetés', 'native.plans.loadError': 'A csomagokat nem sikerült betölteni. Ellenőrizd a kapcsolatot, és próbáld újra.', 'native.plans.retry': 'Újra', - 'native.plans.soon': 'Az appon belüli előfizetés hamarosan érkezik. Addig fejezd be a weboldalon.', + 'native.plans.soon': 'Az appon belüli előfizetés még nem érhető el.', 'native.claim.title': 'Mindjárt kész', 'native.claim.sub': 'Válaszd ki a címed. Azonnal beállítjuk.', 'native.claim.addressLabel': 'A címed', @@ -68,6 +68,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Vásárlás megszakítva.', 'native.claim.failed': 'A vásárlás nem sikerült. Próbáld újra.', 'native.claim.redeemFailed': 'Nem sikerült beállítani a TREK-edet. Próbáld újra.', + 'native.plans.restore': 'Vásárlások visszaállítása', + 'native.plans.restoring': 'Vásárlásaid ellenőrzése…', + 'native.plans.restoreNone': 'Ehhez az Apple ID-hoz nem található aktív előfizetés.', + 'native.plans.restoreFailed': 'Nem sikerült visszaállítani a TREK-edet. Próbáld újra.', + 'native.plans.legal': 'A fizetést megerősítéskor az Apple ID-d terheli. Az előfizetés automatikusan megújul, hacsak nem mondod le legalább 24 órával az időszak vége előtt. Kezelni vagy lemondani az App Store-fiókod beállításaiban tudod.', + 'native.plans.terms': 'Felhasználási feltételek', + 'native.claim.pending': 'A vásárlásod jóváhagyásra vár. Ha teljesült, koppints a Vásárlások visszaállítása gombra.', 'native.provisioning.title': 'A TREK-edet állítjuk be', 'native.provisioning.sub': 'A(z) {host} előkészítése. Csak egy percet vesz igénybe.', 'native.provisioning.failedTitle': 'Majdnem kész', diff --git a/shared/src/i18n/id/native.ts b/shared/src/i18n/id/native.ts index 451fb869a..2f4cf82ec 100644 --- a/shared/src/i18n/id/native.ts +++ b/shared/src/i18n/id/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Berlangganan', 'native.plans.loadError': 'Tidak dapat memuat paket. Periksa koneksi Anda dan coba lagi.', 'native.plans.retry': 'Coba lagi', - 'native.plans.soon': 'Langganan dalam aplikasi akan hadir. Untuk saat ini, selesaikan di situs web.', + 'native.plans.soon': 'Berlangganan di aplikasi belum tersedia.', 'native.claim.title': 'Hampir selesai', 'native.claim.sub': 'Pilih alamat Anda. Kami langsung menyiapkannya.', 'native.claim.addressLabel': 'Alamat Anda', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Pembelian dibatalkan.', 'native.claim.failed': 'Pembelian tidak berhasil. Silakan coba lagi.', 'native.claim.redeemFailed': 'Kami tidak dapat menyiapkan TREK Anda. Silakan coba lagi.', + 'native.plans.restore': 'Pulihkan pembelian', + 'native.plans.restoring': 'Memeriksa pembelian Anda…', + 'native.plans.restoreNone': 'Tidak ada langganan aktif untuk Apple ID ini.', + 'native.plans.restoreFailed': 'Kami tidak dapat memulihkan TREK Anda. Silakan coba lagi.', + 'native.plans.legal': 'Pembayaran ditagihkan ke Apple ID Anda saat konfirmasi. Langganan diperpanjang otomatis kecuali dibatalkan setidaknya 24 jam sebelum periode berakhir. Kelola atau batalkan di pengaturan akun App Store Anda.', + 'native.plans.terms': 'Ketentuan penggunaan', + 'native.claim.pending': 'Pembelian Anda menunggu persetujuan. Setelah selesai, ketuk Pulihkan pembelian.', 'native.provisioning.title': 'Menyiapkan TREK Anda', 'native.provisioning.sub': 'Menyiapkan {host}. Ini hanya perlu satu menit.', 'native.provisioning.failedTitle': 'Hampir siap', diff --git a/shared/src/i18n/it/native.ts b/shared/src/i18n/it/native.ts index c1e03e734..065bb14d2 100644 --- a/shared/src/i18n/it/native.ts +++ b/shared/src/i18n/it/native.ts @@ -54,7 +54,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Abbonati', 'native.plans.loadError': 'Impossibile caricare i piani. Controlla la connessione e riprova.', 'native.plans.retry': 'Riprova', - 'native.plans.soon': 'Gli abbonamenti nell’app arrivano presto. Per ora, completa sul sito.', + 'native.plans.soon': 'L’abbonamento nell’app non è ancora disponibile.', 'native.claim.title': 'Quasi fatto', 'native.claim.sub': 'Scegli il tuo indirizzo. Lo prepariamo subito.', 'native.claim.addressLabel': 'Il tuo indirizzo', @@ -68,6 +68,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Acquisto annullato.', 'native.claim.failed': 'L’acquisto non è andato a buon fine. Riprova.', 'native.claim.redeemFailed': 'Non siamo riusciti a preparare il tuo TREK. Riprova.', + 'native.plans.restore': 'Ripristina acquisti', + 'native.plans.restoring': 'Controllo dei tuoi acquisti…', + 'native.plans.restoreNone': 'Nessun abbonamento attivo trovato per questo ID Apple.', + 'native.plans.restoreFailed': 'Non siamo riusciti a ripristinare il tuo TREK. Riprova.', + 'native.plans.legal': 'Il pagamento viene addebitato sul tuo ID Apple alla conferma. L’abbonamento si rinnova automaticamente se non lo annulli almeno 24 ore prima della fine del periodo. Gestiscilo o annullalo nelle impostazioni del tuo account App Store.', + 'native.plans.terms': 'Condizioni d’uso', + 'native.claim.pending': 'Il tuo acquisto è in attesa di approvazione. Quando va a buon fine, tocca Ripristina acquisti.', 'native.provisioning.title': 'Preparazione del tuo TREK', 'native.provisioning.sub': 'Stiamo preparando {host}. Ci vuole solo un minuto.', 'native.provisioning.failedTitle': 'Quasi pronto', diff --git a/shared/src/i18n/ja/native.ts b/shared/src/i18n/ja/native.ts index 3816c75c5..b8278ab80 100644 --- a/shared/src/i18n/ja/native.ts +++ b/shared/src/i18n/ja/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': '登録', 'native.plans.loadError': 'プランを読み込めませんでした。接続を確認してもう一度お試しください。', 'native.plans.retry': 'もう一度試す', - 'native.plans.soon': 'アプリ内サブスクリプションは近日対応。今はウェブで完了してください。', + 'native.plans.soon': 'アプリ内での登録はまだご利用いただけません。', 'native.claim.title': 'もう少しです', 'native.claim.sub': 'アドレスを選んでください。すぐに用意します。', 'native.claim.addressLabel': 'あなたのアドレス', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': '購入をキャンセルしました。', 'native.claim.failed': '購入できませんでした。もう一度お試しください。', 'native.claim.redeemFailed': 'TREKを用意できませんでした。もう一度お試しください。', + 'native.plans.restore': '購入を復元', + 'native.plans.restoring': '購入を確認しています…', + 'native.plans.restoreNone': 'このApple IDに有効なサブスクリプションは見つかりませんでした。', + 'native.plans.restoreFailed': 'TREKを復元できませんでした。もう一度お試しください。', + 'native.plans.legal': 'お支払いは確認時にApple IDに請求されます。期間終了の24時間前までに解約しない限り、サブスクリプションは自動更新されます。管理や解約はApp Storeのアカウント設定から行えます。', + 'native.plans.terms': '利用規約', + 'native.claim.pending': '購入は承認待ちです。完了したら「購入を復元」をタップしてください。', 'native.provisioning.title': 'TREKを準備中', 'native.provisioning.sub': '{host} を準備しています。1分ほどで完了します。', 'native.provisioning.failedTitle': 'もうすぐ完了', diff --git a/shared/src/i18n/ko/native.ts b/shared/src/i18n/ko/native.ts index c936e8ea8..9fc544d49 100644 --- a/shared/src/i18n/ko/native.ts +++ b/shared/src/i18n/ko/native.ts @@ -52,7 +52,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': '구독', 'native.plans.loadError': '플랜을 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.', 'native.plans.retry': '다시 시도', - 'native.plans.soon': '앱 내 구독이 곧 제공됩니다. 지금은 웹에서 완료하세요.', + 'native.plans.soon': '앱 내 구독은 아직 이용할 수 없습니다.', 'native.claim.title': '거의 다 됐어요', 'native.claim.sub': '주소를 선택하세요. 바로 준비해 드립니다.', 'native.claim.addressLabel': '내 주소', @@ -66,6 +66,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': '구매가 취소되었습니다.', 'native.claim.failed': '구매가 완료되지 않았습니다. 다시 시도하세요.', 'native.claim.redeemFailed': 'TREK을 준비하지 못했습니다. 다시 시도하세요.', + 'native.plans.restore': '구입 내역 복원', + 'native.plans.restoring': '구입 내역을 확인하는 중…', + 'native.plans.restoreNone': '이 Apple ID에서 활성 구독을 찾을 수 없습니다.', + 'native.plans.restoreFailed': 'TREK을 복원하지 못했습니다. 다시 시도하세요.', + 'native.plans.legal': '결제는 확인 시 Apple ID로 청구됩니다. 기간 종료 최소 24시간 전에 해지하지 않으면 구독이 자동으로 갱신됩니다. App Store 계정 설정에서 관리하거나 해지할 수 있습니다.', + 'native.plans.terms': '이용 약관', + 'native.claim.pending': '구입이 승인을 기다리고 있습니다. 완료되면 구입 내역 복원을 탭하세요.', 'native.provisioning.title': 'TREK 준비 중', 'native.provisioning.sub': '{host}을(를) 준비하고 있습니다. 1분이면 됩니다.', 'native.provisioning.failedTitle': '거의 준비됐어요', diff --git a/shared/src/i18n/nl/native.ts b/shared/src/i18n/nl/native.ts index c433bff66..1c8474e7f 100644 --- a/shared/src/i18n/nl/native.ts +++ b/shared/src/i18n/nl/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Abonneren', 'native.plans.loadError': 'Kan de abonnementen niet laden. Controleer je verbinding en probeer het opnieuw.', 'native.plans.retry': 'Opnieuw proberen', - 'native.plans.soon': 'Abonneren in de app komt eraan. Rond het voorlopig af op de website.', + 'native.plans.soon': 'Abonneren in de app is nog niet beschikbaar.', 'native.claim.title': 'Bijna klaar', 'native.claim.sub': 'Kies je adres. We zetten het meteen klaar.', 'native.claim.addressLabel': 'Je adres', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Aankoop geannuleerd.', 'native.claim.failed': 'De aankoop is niet gelukt. Probeer het opnieuw.', 'native.claim.redeemFailed': 'We konden je TREK niet klaarzetten. Probeer het opnieuw.', + 'native.plans.restore': 'Aankopen herstellen', + 'native.plans.restoring': 'Je aankopen worden gecontroleerd…', + 'native.plans.restoreNone': 'Geen actief abonnement gevonden voor deze Apple ID.', + 'native.plans.restoreFailed': 'We konden je TREK niet herstellen. Probeer het opnieuw.', + 'native.plans.legal': 'De betaling wordt bij bevestiging via je Apple ID afgeschreven. Het abonnement wordt automatisch verlengd, tenzij je het minstens 24 uur voor het einde van de periode opzegt. Beheer of annuleer het in de instellingen van je App Store-account.', + 'native.plans.terms': 'Gebruiksvoorwaarden', + 'native.claim.pending': 'Je aankoop wacht op goedkeuring. Zodra die rond is, tik je op Aankopen herstellen.', 'native.provisioning.title': 'Je TREK wordt klaargezet', 'native.provisioning.sub': '{host} wordt klaargemaakt. Dit duurt maar een minuut.', 'native.provisioning.failedTitle': 'Bijna klaar', diff --git a/shared/src/i18n/pl/native.ts b/shared/src/i18n/pl/native.ts index 9109d8a26..1adb007cb 100644 --- a/shared/src/i18n/pl/native.ts +++ b/shared/src/i18n/pl/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Subskrybuj', 'native.plans.loadError': 'Nie udało się wczytać planów. Sprawdź połączenie i spróbuj ponownie.', 'native.plans.retry': 'Spróbuj ponownie', - 'native.plans.soon': 'Subskrypcje w aplikacji już wkrótce. Na razie dokończ na stronie.', + 'native.plans.soon': 'Subskrypcja w aplikacji nie jest jeszcze dostępna.', 'native.claim.title': 'Prawie gotowe', 'native.claim.sub': 'Wybierz swój adres. Przygotujemy go od razu.', 'native.claim.addressLabel': 'Twój adres', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Zakup anulowany.', 'native.claim.failed': 'Zakup się nie powiódł. Spróbuj ponownie.', 'native.claim.redeemFailed': 'Nie udało się przygotować Twojego TREK. Spróbuj ponownie.', + 'native.plans.restore': 'Przywróć zakupy', + 'native.plans.restoring': 'Sprawdzanie zakupów…', + 'native.plans.restoreNone': 'Nie znaleziono aktywnej subskrypcji dla tego Apple ID.', + 'native.plans.restoreFailed': 'Nie udało się przywrócić Twojego TREK. Spróbuj ponownie.', + 'native.plans.legal': 'Płatność zostanie pobrana z Twojego Apple ID po potwierdzeniu. Subskrypcja odnawia się automatycznie, chyba że anulujesz ją co najmniej 24 godziny przed końcem okresu. Zarządzaj nią lub anuluj w ustawieniach konta App Store.', + 'native.plans.terms': 'Warunki korzystania', + 'native.claim.pending': 'Twój zakup czeka na zatwierdzenie. Gdy zostanie zrealizowany, stuknij Przywróć zakupy.', 'native.provisioning.title': 'Przygotowujemy Twój TREK', 'native.provisioning.sub': 'Przygotowujemy {host}. To zajmie tylko minutę.', 'native.provisioning.failedTitle': 'Już prawie', diff --git a/shared/src/i18n/ru/native.ts b/shared/src/i18n/ru/native.ts index 2f286ef86..944ec8a2e 100644 --- a/shared/src/i18n/ru/native.ts +++ b/shared/src/i18n/ru/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Подписаться', 'native.plans.loadError': 'Не удалось загрузить тарифы. Проверьте подключение и повторите попытку.', 'native.plans.retry': 'Повторить', - 'native.plans.soon': 'Подписки в приложении скоро появятся. Пока оформите на сайте.', + 'native.plans.soon': 'Оформление подписки в приложении пока недоступно.', 'native.claim.title': 'Почти готово', 'native.claim.sub': 'Выберите адрес. Мы сразу всё настроим.', 'native.claim.addressLabel': 'Ваш адрес', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Покупка отменена.', 'native.claim.failed': 'Покупка не прошла. Повторите попытку.', 'native.claim.redeemFailed': 'Не удалось настроить ваш TREK. Повторите попытку.', + 'native.plans.restore': 'Восстановить покупки', + 'native.plans.restoring': 'Проверяем ваши покупки…', + 'native.plans.restoreNone': 'Для этого Apple ID не найдено активной подписки.', + 'native.plans.restoreFailed': 'Не удалось восстановить ваш TREK. Повторите попытку.', + 'native.plans.legal': 'Оплата списывается с вашего Apple ID при подтверждении. Подписка продлевается автоматически, если не отменить её минимум за 24 часа до конца периода. Управлять подпиской или отменить её можно в настройках учётной записи App Store.', + 'native.plans.terms': 'Условия использования', + 'native.claim.pending': 'Ваша покупка ожидает подтверждения. Когда она пройдёт, нажмите «Восстановить покупки».', 'native.provisioning.title': 'Настраиваем ваш TREK', 'native.provisioning.sub': 'Готовим {host}. Это займёт всего минуту.', 'native.provisioning.failedTitle': 'Почти готово', diff --git a/shared/src/i18n/sk/native.ts b/shared/src/i18n/sk/native.ts index fb88d9e33..7163d8795 100644 --- a/shared/src/i18n/sk/native.ts +++ b/shared/src/i18n/sk/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Predplatiť', 'native.plans.loadError': 'Tarify sa nepodarilo načítať. Skontrolujte pripojenie a skúste to znova.', 'native.plans.retry': 'Skúsiť znova', - 'native.plans.soon': 'Predplatné v aplikácii čoskoro príde. Zatiaľ to dokončite na webe.', + 'native.plans.soon': 'Predplatné v aplikácii zatiaľ nie je dostupné.', 'native.claim.title': 'Takmer hotovo', 'native.claim.sub': 'Vyberte si adresu. Hneď ju pripravíme.', 'native.claim.addressLabel': 'Vaša adresa', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Nákup zrušený.', 'native.claim.failed': 'Nákup sa nepodaril. Skúste to znova.', 'native.claim.redeemFailed': 'Nepodarilo sa pripraviť váš TREK. Skúste to znova.', + 'native.plans.restore': 'Obnoviť nákupy', + 'native.plans.restoring': 'Kontrolujeme vaše nákupy…', + 'native.plans.restoreNone': 'Pre toto Apple ID sa nenašlo žiadne aktívne predplatné.', + 'native.plans.restoreFailed': 'Váš TREK sa nepodarilo obnoviť. Skúste to znova.', + 'native.plans.legal': 'Platba sa strhne z vášho Apple ID pri potvrdení. Predplatné sa automaticky obnovuje, ak ho nezrušíte aspoň 24 hodín pred koncom obdobia. Spravovať alebo zrušiť ho môžete v nastaveniach účtu App Store.', + 'native.plans.terms': 'Podmienky používania', + 'native.claim.pending': 'Váš nákup čaká na schválenie. Keď prejde, ťuknite na Obnoviť nákupy.', 'native.provisioning.title': 'Pripravujeme váš TREK', 'native.provisioning.sub': 'Pripravujeme {host}. Zaberie to len minútu.', 'native.provisioning.failedTitle': 'Takmer hotovo', diff --git a/shared/src/i18n/sv/native.ts b/shared/src/i18n/sv/native.ts index cd8cea9fd..8c55aa91f 100644 --- a/shared/src/i18n/sv/native.ts +++ b/shared/src/i18n/sv/native.ts @@ -54,7 +54,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Prenumerera', 'native.plans.loadError': 'Det gick inte att läsa in abonnemangen. Kontrollera anslutningen och försök igen.', 'native.plans.retry': 'Försök igen', - 'native.plans.soon': 'Prenumerationer i appen är på väg. Slutför på webben tills vidare.', + 'native.plans.soon': 'Prenumeration i appen är inte tillgänglig ännu.', 'native.claim.title': 'Nästan klart', 'native.claim.sub': 'Välj din adress. Vi gör i ordning den direkt.', 'native.claim.addressLabel': 'Din adress', @@ -68,6 +68,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Köpet avbröts.', 'native.claim.failed': 'Köpet gick inte igenom. Försök igen.', 'native.claim.redeemFailed': 'Vi kunde inte göra i ordning din TREK. Försök igen.', + 'native.plans.restore': 'Återställ köp', + 'native.plans.restoring': 'Kontrollerar dina köp…', + 'native.plans.restoreNone': 'Ingen aktiv prenumeration hittades för detta Apple-ID.', + 'native.plans.restoreFailed': 'Vi kunde inte återställa din TREK. Försök igen.', + 'native.plans.legal': 'Betalningen debiteras ditt Apple-ID när du bekräftar. Prenumerationen förnyas automatiskt om du inte avslutar den minst 24 timmar före periodens slut. Hantera eller avsluta den i inställningarna för ditt App Store-konto.', + 'native.plans.terms': 'Användarvillkor', + 'native.claim.pending': 'Ditt köp väntar på godkännande. När det har gått igenom trycker du på Återställ köp.', 'native.provisioning.title': 'Gör i ordning din TREK', 'native.provisioning.sub': 'Gör i ordning {host}. Det tar bara en minut.', 'native.provisioning.failedTitle': 'Snart klart', diff --git a/shared/src/i18n/th/native.ts b/shared/src/i18n/th/native.ts index 5f8be69bf..6493f5bf3 100644 --- a/shared/src/i18n/th/native.ts +++ b/shared/src/i18n/th/native.ts @@ -52,7 +52,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'สมัครสมาชิก', 'native.plans.loadError': 'โหลดแพ็กเกจไม่สำเร็จ ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง', 'native.plans.retry': 'ลองอีกครั้ง', - 'native.plans.soon': 'การสมัครสมาชิกในแอปกำลังจะมา ตอนนี้ทำให้เสร็จบนเว็บไซต์ก่อน', + 'native.plans.soon': 'ยังไม่สามารถสมัครสมาชิกในแอปได้', 'native.claim.title': 'เกือบเสร็จแล้ว', 'native.claim.sub': 'เลือกที่อยู่ของคุณ เราจะตั้งค่าให้ทันที', 'native.claim.addressLabel': 'ที่อยู่ของคุณ', @@ -66,6 +66,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'ยกเลิกการซื้อแล้ว', 'native.claim.failed': 'การซื้อไม่สำเร็จ โปรดลองอีกครั้ง', 'native.claim.redeemFailed': 'เราตั้งค่า TREK ของคุณไม่สำเร็จ โปรดลองอีกครั้ง', + 'native.plans.restore': 'กู้คืนการซื้อ', + 'native.plans.restoring': 'กำลังตรวจสอบการซื้อของคุณ…', + 'native.plans.restoreNone': 'ไม่พบการสมัครสมาชิกที่ใช้งานอยู่สำหรับ Apple ID นี้', + 'native.plans.restoreFailed': 'เรากู้คืน TREK ของคุณไม่สำเร็จ โปรดลองอีกครั้ง', + 'native.plans.legal': 'ระบบจะเรียกเก็บเงินจาก Apple ID ของคุณเมื่อยืนยัน การสมัครสมาชิกจะต่ออายุอัตโนมัติ เว้นแต่คุณจะยกเลิกอย่างน้อย 24 ชั่วโมงก่อนสิ้นสุดรอบ จัดการหรือยกเลิกได้ในการตั้งค่าบัญชี App Store', + 'native.plans.terms': 'ข้อกำหนดการใช้งาน', + 'native.claim.pending': 'การซื้อของคุณกำลังรอการอนุมัติ เมื่อเสร็จสิ้นแล้ว ให้แตะกู้คืนการซื้อ', 'native.provisioning.title': 'กำลังตั้งค่า TREK ของคุณ', 'native.provisioning.sub': 'กำลังเตรียม {host} ใช้เวลาเพียงหนึ่งนาที', 'native.provisioning.failedTitle': 'เกือบเสร็จแล้ว', diff --git a/shared/src/i18n/tr/native.ts b/shared/src/i18n/tr/native.ts index 761d62395..4f7ec5b2e 100644 --- a/shared/src/i18n/tr/native.ts +++ b/shared/src/i18n/tr/native.ts @@ -54,7 +54,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Abone ol', 'native.plans.loadError': 'Planlar yüklenemedi. Bağlantınızı kontrol edip tekrar deneyin.', 'native.plans.retry': 'Tekrar dene', - 'native.plans.soon': 'Uygulama içi abonelikler yakında. Şimdilik web sitesinde tamamlayın.', + 'native.plans.soon': 'Uygulama içi abonelik henüz kullanılamıyor.', 'native.claim.title': 'Neredeyse hazır', 'native.claim.sub': 'Adresinizi seçin. Hemen hazırlıyoruz.', 'native.claim.addressLabel': 'Adresiniz', @@ -68,6 +68,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Satın alma iptal edildi.', 'native.claim.failed': 'Satın alma tamamlanamadı. Lütfen tekrar deneyin.', 'native.claim.redeemFailed': 'TREK’inizi hazırlayamadık. Lütfen tekrar deneyin.', + 'native.plans.restore': 'Satın alımları geri yükle', + 'native.plans.restoring': 'Satın alımlarınız kontrol ediliyor…', + 'native.plans.restoreNone': 'Bu Apple Kimliği için etkin abonelik bulunamadı.', + 'native.plans.restoreFailed': 'TREK’inizi geri yükleyemedik. Lütfen tekrar deneyin.', + 'native.plans.legal': 'Ödeme, onayladığınızda Apple Kimliğinizden alınır. Abonelik, dönem bitmeden en az 24 saat önce iptal edilmezse otomatik olarak yenilenir. Aboneliği App Store hesap ayarlarınızdan yönetebilir veya iptal edebilirsiniz.', + 'native.plans.terms': 'Kullanım koşulları', + 'native.claim.pending': 'Satın alımınız onay bekliyor. Tamamlandığında Satın alımları geri yükle’ye dokunun.', 'native.provisioning.title': 'TREK’iniz hazırlanıyor', 'native.provisioning.sub': '{host} hazırlanıyor. Bu yalnızca bir dakika sürer.', 'native.provisioning.failedTitle': 'Neredeyse hazır', diff --git a/shared/src/i18n/uk/native.ts b/shared/src/i18n/uk/native.ts index b9e61936c..e3e637f6a 100644 --- a/shared/src/i18n/uk/native.ts +++ b/shared/src/i18n/uk/native.ts @@ -53,7 +53,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Підписатися', 'native.plans.loadError': 'Не вдалося завантажити тарифи. Перевірте з’єднання та повторіть спробу.', 'native.plans.retry': 'Повторити', - 'native.plans.soon': 'Підписки в застосунку вже скоро. Поки що завершіть на сайті.', + 'native.plans.soon': 'Оформлення підписки в застосунку поки недоступне.', 'native.claim.title': 'Майже готово', 'native.claim.sub': 'Виберіть адресу. Ми одразу все налаштуємо.', 'native.claim.addressLabel': 'Ваша адреса', @@ -67,6 +67,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Покупку скасовано.', 'native.claim.failed': 'Покупка не пройшла. Повторіть спробу.', 'native.claim.redeemFailed': 'Не вдалося налаштувати ваш TREK. Повторіть спробу.', + 'native.plans.restore': 'Відновити покупки', + 'native.plans.restoring': 'Перевіряємо ваші покупки…', + 'native.plans.restoreNone': 'Для цього Apple ID не знайдено активної підписки.', + 'native.plans.restoreFailed': 'Не вдалося відновити ваш TREK. Повторіть спробу.', + 'native.plans.legal': 'Оплата списується з вашого Apple ID під час підтвердження. Підписка поновлюється автоматично, якщо не скасувати її щонайменше за 24 години до кінця періоду. Керувати нею чи скасувати її можна в налаштуваннях облікового запису App Store.', + 'native.plans.terms': 'Умови використання', + 'native.claim.pending': 'Ваша покупка очікує схвалення. Коли вона пройде, торкніться «Відновити покупки».', 'native.provisioning.title': 'Налаштовуємо ваш TREK', 'native.provisioning.sub': 'Готуємо {host}. Це займе лише хвилину.', 'native.provisioning.failedTitle': 'Майже готово', diff --git a/shared/src/i18n/vi/native.ts b/shared/src/i18n/vi/native.ts index 13e89e0c2..f7cf0cd5e 100644 --- a/shared/src/i18n/vi/native.ts +++ b/shared/src/i18n/vi/native.ts @@ -54,7 +54,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': 'Đăng ký', 'native.plans.loadError': 'Không thể tải các gói. Hãy kiểm tra kết nối và thử lại.', 'native.plans.retry': 'Thử lại', - 'native.plans.soon': 'Đăng ký trong ứng dụng sắp ra mắt. Hiện tại, hãy hoàn tất trên trang web.', + 'native.plans.soon': 'Chưa thể đăng ký trong ứng dụng.', 'native.claim.title': 'Sắp xong rồi', 'native.claim.sub': 'Chọn địa chỉ của bạn. Chúng tôi thiết lập ngay.', 'native.claim.addressLabel': 'Địa chỉ của bạn', @@ -68,6 +68,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': 'Đã hủy mua.', 'native.claim.failed': 'Giao dịch mua không thành công. Vui lòng thử lại.', 'native.claim.redeemFailed': 'Chúng tôi không thể thiết lập TREK của bạn. Vui lòng thử lại.', + 'native.plans.restore': 'Khôi phục giao dịch mua', + 'native.plans.restoring': 'Đang kiểm tra giao dịch mua của bạn…', + 'native.plans.restoreNone': 'Không tìm thấy gói đăng ký đang hoạt động cho Apple ID này.', + 'native.plans.restoreFailed': 'Chúng tôi không thể khôi phục TREK của bạn. Vui lòng thử lại.', + 'native.plans.legal': 'Khoản thanh toán được tính vào Apple ID của bạn khi xác nhận. Gói đăng ký tự động gia hạn trừ khi bạn hủy ít nhất 24 giờ trước khi kỳ hạn kết thúc. Quản lý hoặc hủy trong cài đặt tài khoản App Store của bạn.', + 'native.plans.terms': 'Điều khoản sử dụng', + 'native.claim.pending': 'Giao dịch mua của bạn đang chờ phê duyệt. Khi hoàn tất, hãy chạm Khôi phục giao dịch mua.', 'native.provisioning.title': 'Đang thiết lập TREK của bạn', 'native.provisioning.sub': 'Đang chuẩn bị {host}. Chỉ mất một phút.', 'native.provisioning.failedTitle': 'Gần xong', diff --git a/shared/src/i18n/zh-TW/native.ts b/shared/src/i18n/zh-TW/native.ts index 9f2cb0a1f..e8720097d 100644 --- a/shared/src/i18n/zh-TW/native.ts +++ b/shared/src/i18n/zh-TW/native.ts @@ -52,7 +52,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': '訂閱', 'native.plans.loadError': '無法載入方案。請檢查網路連線後重試。', 'native.plans.retry': '重試', - 'native.plans.soon': '應用程式內訂閱即將推出。目前請在網站上完成。', + 'native.plans.soon': '目前尚不支援在 App 內訂閱。', 'native.claim.title': '快完成了', 'native.claim.sub': '選擇你的網址。我們會立即為你準備好。', 'native.claim.addressLabel': '你的網址', @@ -66,6 +66,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': '已取消購買。', 'native.claim.failed': '購買未成功。請重試。', 'native.claim.redeemFailed': '我們無法為你設定 TREK。請重試。', + 'native.plans.restore': '恢復購買項目', + 'native.plans.restoring': '正在檢查你的購買項目…', + 'native.plans.restoreNone': '此 Apple ID 沒有找到有效的訂閱。', + 'native.plans.restoreFailed': '無法恢復你的 TREK。請重試。', + 'native.plans.legal': '確認後將透過你的 Apple ID 付款。除非在目前週期結束前至少 24 小時取消,否則訂閱會自動續訂。可在 App Store 帳號設定中管理或取消。', + 'native.plans.terms': '使用條款', + 'native.claim.pending': '你的購買正在等待核准。完成後,請點一下「恢復購買項目」。', 'native.provisioning.title': '正在設定你的 TREK', 'native.provisioning.sub': '正在準備 {host}。只需一分鐘。', 'native.provisioning.failedTitle': '即將就緒', diff --git a/shared/src/i18n/zh/native.ts b/shared/src/i18n/zh/native.ts index 22fe852e1..5a3401896 100644 --- a/shared/src/i18n/zh/native.ts +++ b/shared/src/i18n/zh/native.ts @@ -52,7 +52,7 @@ const native: TranslationStrings = { 'native.plans.subscribe': '订阅', 'native.plans.loadError': '无法加载方案。请检查网络连接后重试。', 'native.plans.retry': '重试', - 'native.plans.soon': '应用内订阅即将推出。目前请在网站上完成。', + 'native.plans.soon': '暂不支持在应用内订阅。', 'native.claim.title': '就快好了', 'native.claim.sub': '选择你的地址。我们会立即为你准备好。', 'native.claim.addressLabel': '你的地址', @@ -66,6 +66,13 @@ const native: TranslationStrings = { 'native.claim.cancelled': '购买已取消。', 'native.claim.failed': '购买未成功。请重试。', 'native.claim.redeemFailed': '我们无法为你设置 TREK。请重试。', + 'native.plans.restore': '恢复购买', + 'native.plans.restoring': '正在检查你的购买…', + 'native.plans.restoreNone': '此 Apple ID 下没有找到有效订阅。', + 'native.plans.restoreFailed': '无法恢复你的 TREK。请重试。', + 'native.plans.legal': '确认后将通过你的 Apple ID 付款。除非在当前周期结束前至少 24 小时取消,否则订阅会自动续订。可在 App Store 账户设置中管理或取消。', + 'native.plans.terms': '使用条款', + 'native.claim.pending': '你的购买正在等待批准。完成后,请点按“恢复购买”。', 'native.provisioning.title': '正在设置你的 TREK', 'native.provisioning.sub': '正在准备 {host}。只需一分钟。', 'native.provisioning.failedTitle': '即将就绪',