Compare commits

...

18 Commits

Author SHA1 Message Date
Maurice 792e55b2ca style(native): format the in-app purchase files and strings 2026-11-07 11:25:17 +01:00
Maurice 5462bce9c3 feat(native): TREK Cloud subscriptions through the App Store
The plan picker sent buyers to the website, which the App Store does not allow.
It now sells through StoreKit 2 in the TrekShell plugin (feature storeKit):
App Store price strings in the buyer's currency, the purchase sheet, the signed
transaction redeemed at the control plane, and the transaction finished only
once the instance exists. A purchase whose redeem failed is redeemed again on
retry, never bought twice.

Restore purchases, the auto-renewal terms and the links to the terms of use and
the privacy policy sit under the plans. Where no store purchase is possible
(Android, an older binary) the picker shows the plans with no way to buy and no
pointer to the web.
2026-11-07 11:25:17 +01:00
Maurice ede54d10cd test(managed): the mock factory brings its own imports
vi.mock is hoisted above the file's imports, so the managed-tab mock could not
use the icons and JSX imported at the top (ReferenceError before initialization).
2026-11-07 11:25:10 +01:00
Maurice e7a99db03b fix(privacy): the PDF print view uses the bundled Poppins, not Google Fonts
The trip PDF's print view linked Poppins from fonts.googleapis.com, so printing
a trip sent the reader's IP address to Google, the case German courts treated as
a GDPR breach (LG München I, 3 O 17493/20). It now declares the Poppins files
TREK already ships under /fonts, and the content security policy no longer
allows fonts.googleapis.com or fonts.gstatic.com, so no font can be fetched
from Google again by accident.
2026-11-07 11:22:53 +01:00
Maurice e5a119b6bd feat(managed): an install's own admin tabs on the phone too
The mobile admin never read managedAdminTabs, so a tab an install attaches
there (managed/index.tsx) existed on the desktop only. The phone's section
picker now lists them after the built-in sections, as the desktop sidebar does,
and shows `mobileElement` when a tab brings a phone layout of its own, else its
desktop `element`. TREK itself attaches nothing; the seam stays empty.
2026-11-07 10:47:59 +01:00
Maurice 315db3fa5a style(demo): format the phone welcome 2026-11-07 10:41:25 +01:00
Maurice 19ef38a377 fix(demo): the phone welcome scales with the text size and is paired with the desktop one
MDemoWelcome used raw rem sizes, which ignore the text size setting and broke
theme:lint, and was missing from feature-pairs.json, which broke lint:pairs.
2026-11-07 10:05:23 +01:00
Maurice 7797a9a562 fix(demo): the phone welcome lives with the phone dashboard, not under components
components/ may not import from mobile/ (lint:layers). The phone dashboard
mounts the sheet itself, as it already mounted the welcome, and the desktop
dialog no longer asks which shell it is in.
2026-11-07 00:33:16 +01:00
Maurice 55e4bfa11e fix(demo): the phone welcome sheet is opaque, so the dashboard does not show through its list 2026-11-07 00:22:04 +01:00
Maurice ad0fa41bb5 Revert "fix(demo): the phone welcome sheet is opaque, so the dashboard does not show through its list"
This reverts commit 7c91a609da.
2026-11-07 00:21:29 +01:00
Maurice 7c91a609da fix(demo): the phone welcome sheet is opaque, so the dashboard does not show through its list 2026-11-07 00:20:06 +01:00
Maurice 545eb650ca fix(demo): the welcome is a sheet on a phone, not the desktop dialog made narrow
On a phone the head took half the screen, the list scrolled in a small window
and the start button broke over two lines next to the links. The phone now gets
a bottom sheet of the phone shell: a small head with the countdown, the same
content as a list, a full-width start button with the two links under it. The
photo credits open in its place and back, as on the desktop.
2026-11-07 00:15:14 +01:00
Maurice 43f86f4559 test(booking-import): the per-leg seat tests pass the type check of the tests
An object literal with airplaneSeat inside the legs() call failed the excess
property check of tsconfig.tests.json; the nodes are built as constants first,
as the other seat tests do.
2026-11-07 00:12:56 +01:00
Maurice 25f2cd8192 fix(booking-import): every leg of a connecting flight keeps its own seat
The grouped flight wrote a seat only on the booking itself, from the first
segment. Each leg in metadata.legs now carries the seat of its own segment,
which the planner and the PDF already show per leg.
2026-11-06 23:50:53 +01:00
aaronspruit 7ae9198a43 fix(booking-import): read the seat that KItinerary gives
KItinerary writes a flight seat as airplaneSeat and a ticket seat as
reservedTicket.ticketedSeat.seatNumber, the schema.org fields, and it
removes seat and seatNumber from a script result. segmentDetail read only
seat and seatNumber, so an imported booking lost its seat.

segmentDetail now also reads airplaneSeat, and then the ticket seat when
no other key has a value. seat and seatNumber stay first, so a booking
that already has a seat gets the same result.
2026-11-06 23:49:31 +01:00
Maurice 8f3a8bd670 fix(ci): the carousel tests at the 90 ms settle, the search type, the hook back under its line limit
- The carousel and timeline tests step by the new 90 ms settle
- The journey search keeps its suggestion type through the fallback
- nearestCardIndex leaves useJourneyCarouselSync, which had grown past its
  line limit; WidgetBag is not exported; the test baseline drops by the
  non-null assertions a test no longer needs
2026-11-06 22:51:15 +01:00
Maurice e0c3642874 fix(journey): the location search suggests as you type, like the planner
The journal searched the full text only, which for a hotel in Osaka listed
the Japanese names the index holds. It now asks the planner's typed-ahead
search first (usePlaceSuggestions, the user's language, one billed session
per search), shows each place with where it is, and takes its point from the
details once picked; the full search still answers when there are no
suggestions. (#3003)
2026-11-06 22:28:37 +01:00
Maurice 25091dbd60 fix(mobile): switches keep their knob inside the track at every text size
The track was w-10, which is rem and followed the text size setting, while
the knob and its travel are px; with a smaller text size the knob hung over
the end of the track. The track is 40 px now.
2026-11-06 21:57:10 +01:00
55 changed files with 1268 additions and 146 deletions
+135 -1
View File
@@ -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<Void, Never>?
/// 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
+127 -43
View File
@@ -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<boolean> = 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<CloudPlan[] | null> {
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<string, string> | 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<Offer> {
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<void> {
async function start(): Promise<void> {
void paintStatusBar();
iap.isAvailable().then((ok) => {
iapAvailable = ok;
});
let url: string | null;
try {
({ url } = await TrekShell.getServer());
+14
View File
@@ -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"
+1 -1
View File
@@ -35,7 +35,7 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1913,
"@typescript-eslint/no-non-null-assertion": 1912,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
+6
View File
@@ -669,6 +669,12 @@
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
},
{
"feature": "Demo welcome",
"hooks": ["src/components/Demo/useDemoWelcome.ts"],
"desktop": ["src/components/Demo/DemoWelcome.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDemoWelcome.tsx"]
},
{
"feature": "Settings / Demo account locks",
"hooks": ["src/components/Demo/useDemoLock.ts"],
@@ -88,8 +88,8 @@ describe('JourneyEntryCover', () => {
rerender(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={() => {}} />)
// Drawn larger, not laid out larger: the box stays, so nothing beside it moves.
expect(container.querySelector('button')!.className).toContain('scale-[1.18]')
expect(container.querySelector('button')!.className).toContain('w-[136px]')
expect(container.querySelector('button')?.className).toContain('scale-[1.18]')
expect(container.querySelector('button')?.className).toContain('w-[136px]')
})
it('FE-JRN-COVER-007: a shared journey serves its photos through the share token', () => {
@@ -285,13 +285,14 @@ describe('MobileMapTimeline', () => {
carousel.getBoundingClientRect = () => fakeRect(0, 300)
cards.forEach((c, i) => { c.getBoundingClientRect = () => fakeRect(i === 2 ? 30 : -400, 240) })
// The strip counts as landed 90 ms after its last scroll event.
fireEvent.scroll(carousel)
act(() => { vi.advanceTimersByTime(100) })
act(() => { vi.advanceTimersByTime(60) })
fireEvent.scroll(carousel)
act(() => { vi.advanceTimersByTime(100) })
act(() => { vi.advanceTimersByTime(60) })
expect(lastMapProps.current.activeMarkerId).toBe('1')
act(() => { vi.advanceTimersByTime(100) })
act(() => { vi.advanceTimersByTime(40) })
expect(lastMapProps.current.activeMarkerId).toBe('3')
} finally {
vi.useRealTimers()
@@ -127,7 +127,7 @@ describe('useJourneyCarouselSync', () => {
});
carousel.dispatchEvent(new Event('scroll'));
act(() => vi.advanceTimersByTime(100));
act(() => vi.advanceTimersByTime(60));
carousel.dispatchEvent(new Event('scroll'));
act(() => vi.advanceTimersByTime(89));
expect(hook.result.current.activeIndex).toBe(0);
@@ -20,11 +20,34 @@ export interface JourneyCarouselSyncOptions<E extends CarouselEntry> {
}
/**
* The card carousel over the journey map, behind both the phone journey screen and the
* narrow desktop and public timeline: the active card is the one nearest the centre
* once scrolling settles, and the map follows it; a marker, a day on the scrubber or a
* tap brings its card to the centre.
* The card closest to the strip's horizontal centre. More stable than
* IntersectionObserver thresholds when the active card can drift toward the
* viewport edge with proximity snapping.
*/
function nearestCardIndex(el: HTMLDivElement | null, cards: Map<number, HTMLDivElement>): number | null {
if (!el) return null;
const center = el.getBoundingClientRect().left + el.clientWidth / 2;
let bestIdx = 0;
let bestDist = Infinity;
cards.forEach((node, idx) => {
const r = node.getBoundingClientRect();
const d = Math.abs(r.left + r.width / 2 - center);
if (d < bestDist) {
bestDist = d;
bestIdx = idx;
}
});
// The first and the last card can never reach the centre, so a carousel scrolled
// as far as it goes means the card at that end: without this, opening on the
// latest day settled on the one before it (#2851). Measured from the start in
// either direction: in a right-to-left layout scrollLeft runs negative.
const travel = el.scrollWidth - el.clientWidth;
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cards.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
return bestIdx;
}
/**
* How long the strip has to be still before the swipe counts as landed: long
* enough to see past the gaps between scroll events of a momentum swipe, short
@@ -32,6 +55,12 @@ export interface JourneyCarouselSyncOptions<E extends CarouselEntry> {
*/
const SETTLE_MS = 90;
/**
* The card carousel over the journey map, behind both the phone journey screen and the
* narrow desktop and public timeline: the active card is the one nearest the centre
* once scrolling settles, and the map follows it; a marker, a day on the scrubber or a
* tap brings its card to the centre.
*/
export function useJourneyCarouselSync<E extends CarouselEntry>({
entries,
mapEntries,
@@ -65,33 +94,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
[entries, mapEntries]
);
// Pick the card closest to the horizontal centre. More stable than
// IntersectionObserver thresholds when the active card can drift toward the
// viewport edge with proximity snapping.
const nearestCard = useCallback((): number | null => {
const el = carouselRef.current;
if (!el) return null;
const center = el.getBoundingClientRect().left + el.clientWidth / 2;
let bestIdx = 0;
let bestDist = Infinity;
cardRefs.current.forEach((node, idx) => {
const r = node.getBoundingClientRect();
const d = Math.abs(r.left + r.width / 2 - center);
if (d < bestDist) {
bestDist = d;
bestIdx = idx;
}
});
// The first and the last card can never reach the centre, so a carousel scrolled
// as far as it goes means the card at that end: without this, opening on the
// latest day settled on the one before it (#2851). Measured from the start in
// either direction: in a right-to-left layout scrollLeft runs negative.
const travel = el.scrollWidth - el.clientWidth;
const scrolled = Math.abs(el.scrollLeft);
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
return bestIdx;
}, []);
const nearestCard = useCallback(() => nearestCardIndex(carouselRef.current, cardRefs.current), []);
const pickNearestCard = useCallback(() => {
const bestIdx = nearestCard();
@@ -281,6 +281,8 @@ describe('useJourneyEntryForm', () => {
vi.useFakeTimers();
const places = Array.from({ length: 8 }, (_, i) => ({ name: `P${i}`, lat: String(i), lng: '1' }));
const search = vi.spyOn(mapsApi, 'search').mockResolvedValue({ places } as never);
// No suggestions: the full search answers.
vi.spyOn(mapsApi, 'autocomplete').mockResolvedValue({ suggestions: [], source: 'none' } as never);
const { result } = setup();
act(() => result.current.searchLocation('R'));
expect(result.current.showLocationResults).toBe(true);
@@ -306,6 +308,35 @@ describe('useJourneyEntryForm', () => {
expect(result.current.showLocationResults).toBe(false);
});
it('FE-JRN-ENTRYFORM-013b: suggestions come first, as in the planner, and a pick fetches its point (#3003)', async () => {
vi.useFakeTimers();
const suggest = vi.spyOn(mapsApi, 'autocomplete').mockResolvedValue({
suggestions: [{ placeId: 'g1', mainText: 'Hotel Keihan Temmabashi', secondaryText: 'Osaka, Japan' }],
source: 'google',
} as never);
const search = vi.spyOn(mapsApi, 'search');
const details = vi.spyOn(mapsApi, 'details').mockResolvedValue({ place: { lat: 34.69, lng: 135.51 } } as never);
const { result } = setup();
act(() => result.current.searchLocation('Hotel Keihan'));
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
});
expect(suggest).toHaveBeenCalledWith('Hotel Keihan', 'en', undefined, expect.any(AbortSignal), expect.any(String));
expect(search).not.toHaveBeenCalled();
expect(result.current.locationResults).toEqual([
{ name: 'Hotel Keihan Temmabashi', address: 'Osaka, Japan', lat: null, lng: null, placeId: 'g1' },
]);
await act(async () => {
result.current.pickLocation(result.current.locationResults[0]);
await vi.advanceTimersByTimeAsync(0);
});
expect(details).toHaveBeenCalledWith('g1', 'en', expect.any(String));
expect(result.current.locationName).toBe('Hotel Keihan Temmabashi');
expect(result.current.locationLat).toBe(34.69);
expect(result.current.locationLng).toBe(135.51);
});
it('FE-JRN-ENTRYFORM-014: "use my location" fills the point, then the place name', async () => {
locate.mockResolvedValue({ lat: 1.234567, lng: 2.345678 });
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Here' } as never);
@@ -1,14 +1,17 @@
import type { MapsAutocompleteResult } from '@trek/shared';
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { journeyApi, mapsApi, weatherApi } from '../../api/client';
import { getCurrentPositionOnce } from '../../hooks/useGeolocation';
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions';
import { useTranslation } from '../../i18n';
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore';
import { getApiErrorMessage } from '../../types';
import { normalizeImageFiles } from '../../utils/convertHeic';
import { localIsoDate } from '../../utils/localDate';
import { PlacesSession } from '../../utils/placesSession';
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue';
import { useToast } from '../shared/Toast';
import type { ProviderPhotoGroup } from './JourneyDetailPageProviderPicker';
@@ -22,8 +25,10 @@ export type PendingProviderGroup = ProviderPhotoGroup & { provider: string };
export interface LocationResult {
name: string;
address?: string;
lat: number;
lng: number;
/** Null for a suggestion while typing: its point comes with the details when it is picked. */
lat: number | null;
lng: number | null;
placeId?: string;
}
export interface JourneyEntryFormOptions {
@@ -104,6 +109,11 @@ export function useJourneyEntryForm({
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Counts location lookups, so an answer that comes back after the field moved on is dropped.
const locationSearchSeqRef = useRef(0);
// The planner's typed-ahead search (usePlaceSuggestions): names and their place in the
// user's language as the provider has them, billed as one session per search (#3003).
const { autocomplete } = usePlaceSuggestions();
const placesSessionRef = useRef(new PlacesSession());
const suggestAbortRef = useRef<AbortController | null>(null);
const [mood, setMood] = useState(entry.mood || '');
const [weather, setWeather] = useState(entry.weather || '');
// The icon the form filled in by itself (see the weather effect below). It is a
@@ -456,7 +466,33 @@ export function useJourneyEntryForm({
}
locationTimerRef.current = setTimeout(async () => {
setLocationSearching(true);
suggestAbortRef.current?.abort();
const controller = new AbortController();
suggestAbortRef.current = controller;
try {
// Suggestions first, as the planner does; the full search when they bring nothing.
const suggested = await autocomplete(
query,
placeLang,
undefined,
controller.signal,
placesSessionRef.current.current()
)
.then((r): MapsAutocompleteResult['suggestions'] => r.suggestions ?? [])
.catch((): MapsAutocompleteResult['suggestions'] => []);
if (seq !== locationSearchSeqRef.current) return;
if (suggested.length > 0) {
setLocationResults(
suggested.slice(0, 6).map((sg) => ({
name: sg.mainText,
address: sg.secondaryText || undefined,
lat: sg.lat ?? null,
lng: sg.lng ?? null,
placeId: sg.placeId,
}))
);
return;
}
const res = await mapsApi.search(query, placeLang);
if (seq !== locationSearchSeqRef.current) return;
setLocationResults(
@@ -478,7 +514,7 @@ export function useJourneyEntryForm({
};
const pickLocation = (r: LocationResult) => {
locationSearchSeqRef.current++;
const seq = ++locationSearchSeqRef.current;
setLocationSearching(false);
setLocationName(r.name);
setLocationLat(r.lat);
@@ -486,6 +522,26 @@ export function useJourneyEntryForm({
setLocationQuery('');
setShowLocationResults(false);
setLocationResults([]);
if (r.lat != null && r.lng != null) {
placesSessionRef.current.end();
return;
}
// A suggestion names the place; its point comes with the details, which close the session.
if (!r.placeId) return;
void mapsApi
.details(r.placeId, placeLang, placesSessionRef.current.peek())
.then((res) => {
if (seq !== locationSearchSeqRef.current) return;
const place = res.place;
if (place?.lat == null || place?.lng == null) return;
setLocationLat(Number(place.lat));
setLocationLng(Number(place.lng));
})
.catch(() => {
// The name stays without a point, as a typed one would; the map simply has no pin for it.
if (seq === locationSearchSeqRef.current) setLocationLat(null);
})
.finally(() => placesSessionRef.current.end());
};
const handleUseCurrentLocation = async () => {
+6 -1
View File
@@ -610,8 +610,13 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
<meta charset="UTF-8">
<base href="${window.location.origin}/">
<title>${escHtml(trip?.title || tr('pdf.travelPlan'))}</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
/* Poppins from this server (client/public/fonts), never from Google: the print view
would otherwise send every reader's IP to Google's font servers. */
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/Poppins-Regular.ttf') format('truetype'); }
@font-face { font-family: 'Poppins'; font-style: italic; font-weight: 400; font-display: swap; src: url('/fonts/Poppins-Italic.ttf') format('truetype'); }
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/Poppins-Medium.ttf') format('truetype'); }
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 600 700; font-display: swap; src: url('/fonts/Poppins-Bold.ttf') format('truetype'); }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+6
View File
@@ -78,6 +78,12 @@ export interface ManagedAdminTab {
group?: 'users' | 'config' | 'integration' | 'maintenance'
/** Rendered in the tab body. /admin already requires an admin to get here. */
element: ReactElement
/**
* The same tab laid out for a phone, in the mobile admin's section picker.
* Without it the phone shows `element`, which then has to hold up on a narrow
* screen by itself.
*/
mobileElement?: ReactElement
}
/** Extra protected routes. Spread into the router next to the built-in ones. */
+3 -1
View File
@@ -20,7 +20,9 @@ export default function MToggle({ checked, onChange, ariaLabel, disabled = false
selectionHaptic()
onChange(!checked)
}}
className={`box-border h-[22px] w-10 flex-none rounded-full p-[2px] transition-colors duration-[180ms] disabled:opacity-50 ${
// Width in px like the knob and its travel: w-10 is rem and followed the text size,
// so a smaller text setting left the knob hanging over the end of the track.
className={`box-border h-[22px] w-[40px] flex-none rounded-full p-[2px] transition-colors duration-[180ms] disabled:opacity-50 ${
checked ? 'bg-m-act' : 'bg-[color:var(--m-trackoff)]'
} ${className}`}
>
@@ -44,6 +44,7 @@ import MAdminSheets from './MAdminSheets'
import MAdminStoragePanel from './MAdminStoragePanel'
import MAdminUsersSection from './MAdminUsersSection'
import { MAdminButton } from './MAdminUi'
import { managedAdminTabs } from '../../../managed'
// Stat card of the 2×2 grid (design §6.3): 34px icon tile, 19px/800 number,
// small tracking label. Count-up animation matches the desktop stat cards.
@@ -98,6 +99,8 @@ export default function MAdmin() {
...(managed ? [] : [{ id: 'backup', label: t('admin.tabs.backup'), icon: Database }]),
{ id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText },
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug }] : []),
// The install's own tabs (managed/index.tsx), after the built-in ones as on the desktop.
...managedAdminTabs.map(tab => ({ id: tab.id, label: tab.label, icon: tab.Icon })),
]
const activeSection = sections.find((s) => s.id === activeTab) ?? sections[0]
@@ -227,6 +230,9 @@ export default function MAdmin() {
{activeTab === 'backup' && <MAdminBackupPanel />}
{activeTab === 'audit' && <MAdminAuditLogPanel serverTimezone={serverTimezone} />}
{activeTab === 'dev-notifications' && <MAdminDevNotificationsPanel />}
{managedAdminTabs.map(tab =>
activeTab === tab.id ? <div key={tab.id}>{tab.mobileElement ?? tab.element}</div> : null,
)}
<MAdminSheets admin={admin} t={t} />
</div>
@@ -14,7 +14,7 @@ import { useInAppNotificationStore } from '../../../store/inAppNotificationStore
import { useDashboardPlugins } from '../../../components/Dashboard/useDashboardPlugins'
import { useAssistantHomeLauncher } from '../../../components/Assistant/useAssistantHome'
import type { TripCardBadge } from '../../../api/client'
import DemoWelcome from '../../../components/Demo/DemoWelcome'
import MDemoWelcome from './MDemoWelcome'
import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
import PluginWidgets from '../../../components/Plugins/PluginWidgets'
import { entityGradient } from '../../../utils/gradients'
@@ -253,7 +253,7 @@ export default function MDashboard(): React.ReactElement {
<MUserMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
{demoMode && <DemoWelcome />}
{demoMode && <MDemoWelcome />}
<MPullIndicator state={pull} label={t('common.loading')} />
<div ref={contentRef} className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
@@ -0,0 +1,37 @@
import { waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useAuthStore } from '../../../store/authStore';
import MDemoWelcome from './MDemoWelcome';
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { serverTimezone: 'UTC' });
});
describe('MDemoWelcome', () => {
it('FE-COMP-DEMOWELCOME-030: on a phone it is a sheet with the same content and a full-width start', async () => {
render(<MDemoWelcome />);
const sheet = screen.getByRole('dialog', { name: 'Live demo' });
expect(sheet).toHaveTextContent('Welcome to TREK');
expect(within(sheet).getByTestId('demo-countdown')).toBeInTheDocument();
expect(within(sheet).getByText('Everything resets every hour')).toBeInTheDocument();
expect(within(sheet).getByText('Trip planner')).toBeInTheDocument();
expect(within(sheet).getByText('Automatic backups')).toBeInTheDocument();
expect(within(sheet).getByRole('link', { name: /source code/i })).toHaveAttribute('target', '_blank');
await userEvent.click(within(sheet).getByRole('button', { name: /start exploring/i }));
await waitFor(() => expect(screen.queryByText('Trip planner')).not.toBeInTheDocument());
});
it('FE-COMP-DEMOWELCOME-031: the photo credits open in its place and lead back', async () => {
render(<MDemoWelcome />);
const sheet = screen.getByRole('dialog', { name: 'Live demo' });
await userEvent.click(within(sheet).getByRole('button', { name: /photo credits/i }));
const back = await within(sheet).findByRole('button', { name: /back/i });
expect(within(sheet).queryByText('Trip planner')).not.toBeInTheDocument();
await userEvent.click(back);
expect(within(sheet).getByText('Trip planner')).toBeInTheDocument();
});
});
@@ -0,0 +1,177 @@
import { ArrowLeft, ArrowRight, Clock, ExternalLink, Images, RotateCcw } from 'lucide-react';
import { lazy, Suspense } from 'react';
import {
DEMO_FEATURES,
DEMO_FULL_VERSION,
DEMO_LIMITS,
TREK_REPO_URL,
type DemoLine,
} from '../../../components/Demo/demoWelcomeModel';
import { useDemoWelcome } from '../../../components/Demo/useDemoWelcome';
import ErrorBoundary from '../../../components/shared/ErrorBoundary';
import TrekIcon from '../../../components/shared/TrekIcon';
import { useTranslation } from '../../../i18n';
import MSheet from '../../components/MSheet';
import { Eyebrow, SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi';
import { FormSheetHeader } from '../trip/sheets/PlSheetChrome';
const DemoCredits = lazy(() => import('../../../components/Demo/DemoCredits'));
type Translate = ReturnType<typeof useTranslation>['t'];
const CARD = 'rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]';
function Lines({ lines, accent, t }: { lines: readonly DemoLine[]; accent?: boolean; t: Translate }) {
return (
<ul className={`${CARD} m-0 flex list-none flex-col gap-2 p-3`}>
{lines.map(({ icon: Icon, labelKey }) => (
<li
key={labelKey}
className="flex items-center gap-2.5 text-[length:calc(12.5px*var(--fs-scale-body,1))] text-m-ink"
>
<Icon
size={15}
strokeWidth={2}
className={`flex-none ${accent ? 'text-success' : 'text-m-faint'}`}
aria-hidden
/>
<span>{t(labelKey)}</span>
</li>
))}
</ul>
);
}
function Welcome({ t }: { t: Translate }) {
return (
<div className="flex flex-col gap-4">
<p className="m-0 text-[length:calc(13px*var(--fs-scale-body,1))] leading-snug text-m-muted">
{t('demo.welcome.tagline')}
</p>
<div className="flex items-start gap-2.5 rounded-[18px] bg-warning-soft px-3.5 py-3">
<RotateCcw size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" aria-hidden />
<div className="min-w-0">
<div className="text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
{t('demo.welcome.resetTitle')}
</div>
<p className="m-0 mt-0.5 text-[length:calc(12px*var(--fs-scale-caption,1))] leading-snug text-m-muted">
{t('demo.welcome.resetBody')}
</p>
</div>
</div>
<section className="flex flex-col gap-2">
<Eyebrow>{t('demo.welcome.tryTitle')}</Eyebrow>
<ul className={`${CARD} m-0 flex list-none flex-col divide-y divide-[color:var(--m-rowbr)] p-0`}>
{DEMO_FEATURES.map(({ id, icon: Icon, titleKey, bodyKey }) => (
<li key={id} className="flex gap-3 px-3 py-2.5">
<span className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-[color:var(--m-sheetop)] text-m-ink">
<Icon size={15} strokeWidth={2} aria-hidden />
</span>
<div className="min-w-0">
<div className="text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
{t(titleKey)}
</div>
<p className="m-0 text-[length:calc(11.5px*var(--fs-scale-caption,1))] leading-snug text-m-muted">
{t(bodyKey)}
</p>
</div>
</li>
))}
</ul>
</section>
<section className="flex flex-col gap-2">
<Eyebrow>{t('demo.welcome.limitsTitle')}</Eyebrow>
<Lines lines={DEMO_LIMITS} t={t} />
</section>
<section className="flex flex-col gap-2">
<Eyebrow>{t('demo.welcome.fullTitle')}</Eyebrow>
<Lines lines={DEMO_FULL_VERSION} accent t={t} />
</section>
</div>
);
}
/**
* The demo welcome on a phone: a sheet from the bottom like every other form
* in the phone shell, instead of the desktop dialog squeezed onto a narrow
* screen. The head stays small so the list gets the room, the start button
* runs the full width, and the two links sit under it.
*/
export default function MDemoWelcome() {
const { t } = useTranslation();
const { open, close, countdown, creditsOpen, openCredits, closeCredits } = useDemoWelcome();
const title = t(creditsOpen ? 'demo.credits.title' : 'demo.welcome.title');
return (
<MSheet open={open} onClose={close} variant="bottom" material="opaque" ariaLabel={t('demo.welcome.eyebrow')}>
<div className="flex max-h-[88dvh] min-h-0 flex-col">
<FormSheetHeader
leading={
<span className="grid h-10 w-10 flex-none place-items-center rounded-[13px] bg-[color:var(--m-ic)]">
<TrekIcon className="h-6 w-6 text-m-ink" />
</span>
}
title={title}
subtitle={
<span className="inline-flex items-center gap-1">
<Clock size={11} strokeWidth={2.2} aria-hidden />
{t('demo.welcome.resetIn')}
<span className="tabular-nums" data-testid="demo-countdown">
{countdown}
</span>
</span>
}
onClose={close}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-1">
{creditsOpen ? (
<ErrorBoundary boundaryId="demo-credits">
<Suspense fallback={null}>
<DemoCredits />
</Suspense>
</ErrorBoundary>
) : (
<Welcome t={t} />
)}
</div>
<div className="flex flex-none flex-col gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
{creditsOpen ? (
<button type="button" onClick={closeCredits} className={`${SHEET_SECONDARY} w-full`}>
<ArrowLeft size={15} strokeWidth={2.2} className="rtl:-scale-x-100" aria-hidden />
{t('demo.credits.back')}
</button>
) : (
<>
<button type="button" onClick={close} className={`${SHEET_PRIMARY} w-full justify-center`}>
{t('demo.welcome.start')}
<ArrowRight size={15} strokeWidth={2.2} className="rtl:-scale-x-100" aria-hidden />
</button>
<div className="flex items-center justify-center gap-5 text-[length:calc(12px*var(--fs-scale-caption,1))] font-medium text-m-muted">
<a
href={TREK_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-m-muted no-underline"
>
<ExternalLink size={13} strokeWidth={2} aria-hidden />
{t('demo.welcome.repo')}
</a>
<button type="button" onClick={openCredits} className="inline-flex items-center gap-1.5 text-m-muted">
<Images size={13} strokeWidth={2} aria-hidden />
{t('demo.welcome.credits')}
</button>
</div>
</>
)}
</div>
</div>
</MSheet>
);
}
+96 -6
View File
@@ -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();
});
});
+107 -19
View File
@@ -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,47 @@ 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<boolean>;
/**
* 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<Map<string, string>>;
/** Runs the platform's purchase sheet for a product and resolves with what became of it. */
purchase(productId: string): Promise<PurchaseOutcome>;
/** Tells the store a purchase was delivered, once its instance exists. */
finish(purchase: Purchase): Promise<void>;
/** The subscription this store account already holds, for a reinstall or a new device. */
restore(): Promise<Purchase | null>;
}
/** 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.<plan>.<monthly|yearly>`); 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 +70,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
+7
View File
@@ -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/';
+10
View File
@@ -73,6 +73,16 @@ export interface TrekShellPlugin {
total: number;
}): Promise<{ running: boolean }>;
endTripDay(): Promise<void>;
/** 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<void>;
/** 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
+1 -1
View File
@@ -9,7 +9,7 @@ import type { PackingItem } from '../types';
* client/ios/App/TREKWidgets/WidgetSnapshot.swift reads the shapes.
*/
export interface WidgetBag {
interface WidgetBag {
name: string;
color: string;
grams: number;
@@ -0,0 +1,83 @@
import { http, HttpResponse } from 'msw';
import { Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MAdmin from '../../../../src/mobile/screens/admin/MAdmin';
import { useAuthStore } from '../../../../src/store/authStore';
import { buildAdmin } from '../../../helpers/factories';
import { server } from '../../../helpers/msw/server';
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
import { resetAllStores, seedStore } from '../../../helpers/store';
// An install that fills the admin-tab attachment point (managed/index.tsx): one tab
// with a phone layout of its own, one without.
vi.mock('../../../../src/managed', async () => {
// vi.mock is hoisted above the imports, so the factory brings its own.
const { createElement } = await import('react');
const { CreditCard, LifeBuoy } = await import('lucide-react');
return {
managedRoutes: [],
managedNavItems: [],
visibleManagedNavItems: () => [],
managedAdminTabs: [
{
id: 'subscription',
label: 'Subscription',
Icon: CreditCard,
group: 'maintenance',
element: createElement('div', { 'data-testid': 'desktop-subscription' }),
mobileElement: createElement('div', { 'data-testid': 'phone-subscription' }),
},
{
id: 'support',
label: 'Help and support',
Icon: LifeBuoy,
group: 'maintenance',
element: createElement('div', { 'data-testid': 'desktop-support' }),
},
],
};
});
function renderAdmin() {
return render(
<Routes>
<Route path="/admin" element={<MAdmin />} />
</Routes>,
{ initialEntries: ['/admin'] }
);
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
server.use(
http.get('/api/admin/collab-features', () =>
HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true })
),
http.get('/api/admin/places-photos', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/places-autocomplete', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/places-details', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/invites/trips', () => HttpResponse.json({ trips: [] }))
);
});
afterEach(() => {
server.resetHandlers();
});
describe('MAdmin with an install’s own tabs', () => {
it('FE-MOB-ADMIN-MANAGED-001: lists them in the section picker and shows their phone layout', async () => {
renderAdmin();
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
fireEvent.click(screen.getByRole('button', { name: 'Subscription' }));
await waitFor(() => expect(screen.getByTestId('phone-subscription')).toBeInTheDocument());
expect(screen.queryByTestId('desktop-subscription')).not.toBeInTheDocument();
});
it('FE-MOB-ADMIN-MANAGED-002: a tab without a phone layout shows its desktop element', async () => {
renderAdmin();
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
fireEvent.click(screen.getByRole('button', { name: 'Help and support' }));
await waitFor(() => expect(screen.getByTestId('desktop-support')).toBeInTheDocument());
});
});
+3 -2
View File
@@ -278,7 +278,8 @@ export function applyGlobalMiddleware(
// verifying a real .heic upload in a browser, not just deleting the
// string here.
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://unpkg.com'],
// No Google Fonts: every font ships with TREK (client/public/fonts), so no reader's IP goes to Google.
styleSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: [
"'self'",
@@ -372,7 +373,7 @@ export function applyGlobalMiddleware(
// thumbnail answered 404 (#2341). Invisible in dev, where Vite serves the
// document without this header.
mediaSrc: ["'self'", 'blob:'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
fontSrc: ["'self'", 'data:'],
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
objectSrc: ["'self'"],
@@ -304,11 +304,13 @@ function mapFlightGroup(legs: KiReservation[], source: ParsedBookingItem['source
const arrEp = makeEndpoint(f.arrivalAirport, isLast ? 'to' : 'stop', onward.time, onward.date);
if (arrEp) endpoints.push(arrEp);
const airline = f.airline?.name ?? f.airline?.iataCode ?? '';
const { seat } = segmentDetail(legs[i]);
metaLegs.push({
from: iataOf(f.departureAirport),
to: iataOf(f.arrivalAirport),
...(airline ? { airline } : {}),
...(f.flightNumber ? { flight_number: f.flightNumber } : {}),
...(seat != null ? { seat } : {}),
dep_time: splitIso(f.departureTime).time,
arr_time: arr.time,
});
@@ -709,7 +711,12 @@ function segmentDetail(r: KiReservation): {
}
return undefined;
};
const seat = pick('seat', 'seatNumber');
// KItinerary writes a flight seat as airplaneSeat and a rail seat as
// reservedTicket.ticketedSeat.seatNumber, and drops any other seat key.
const ticket = r.reservedTicket as { ticketedSeat?: { seatNumber?: unknown } } | undefined;
const ticketSeat = ticket?.ticketedSeat?.seatNumber;
const seat =
pick('seat', 'seatNumber', 'airplaneSeat') ?? (ticketSeat != null && ticketSeat !== '' ? ticketSeat : undefined);
const cls = pick('class', 'bookingClass', 'fareClass', 'serviceClass', 'seatingType');
const platform = pick('platform', 'departurePlatform');
const price = pick('price', 'priceAmount', 'totalPrice', 'total');
@@ -634,3 +634,51 @@ describe('kitinerary mapper: a prompt-shaped AI lodging (#2477)', () => {
});
});
});
describe('kitinerary mapper: the seat as KItinerary writes it', () => {
it('reads a flight seat from airplaneSeat', () => {
const node = {
...flight('CE9P8V', SFO, JFK, '2026-06-11T10:00:00', '2026-06-11T18:30:00', '450'),
airplaneSeat: '4D',
};
const { items } = mapReservations(legs(node), 'test.json');
expect(items[0].metadata).toMatchObject({ seat: '4D' });
});
it('reads a seat from reservedTicket.ticketedSeat.seatNumber', () => {
const node = {
...flight('CE9P8V', SFO, JFK, '2026-06-11T10:00:00', '2026-06-11T18:30:00', '450'),
reservedTicket: { ticketedSeat: { seatNumber: '23A' } },
};
const { items } = mapReservations(legs(node), 'test.json');
expect(items[0].metadata).toMatchObject({ seat: '23A' });
});
});
describe('kitinerary mapper: the seat on each leg of a connecting flight', () => {
it('keeps every leg its own seat', () => {
const first = {
...flight('ABC123', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 100'),
airplaneSeat: '12C',
};
const second = {
...flight('ABC123', BER, HND, '2026-06-11T14:30:00', '2026-06-11T23:30:00', 'LH 200'),
airplaneSeat: '40A',
};
const { items } = mapReservations(legs(first, second), 'test.json');
const legsMeta = (items[0].metadata as { legs: { seat?: string }[] }).legs;
expect(legsMeta.map((leg) => leg.seat)).toEqual(['12C', '40A']);
});
it('leaves the seat off a leg that has none', () => {
const first = flight('ABC123', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 100');
const second = {
...flight('ABC123', BER, HND, '2026-06-11T14:30:00', '2026-06-11T23:30:00', 'LH 200'),
airplaneSeat: '40A',
};
const { items } = mapReservations(legs(first, second), 'test.json');
const legsMeta = (items[0].metadata as { legs: Record<string, unknown>[] }).legs;
expect(legsMeta[0]).not.toHaveProperty('seat');
expect(legsMeta[1]).toMatchObject({ seat: '40A' });
});
});
+9 -1
View File
@@ -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,14 @@ 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': 'شارف على الجهوزية',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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',
+10 -1
View File
@@ -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,15 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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': 'Σχεδόν έτοιμο',
+9 -1
View File
@@ -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,14 @@ 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': 'כמעט מוכן',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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',
+10 -1
View File
@@ -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,15 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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': 'もうすぐ完了',
+9 -1
View File
@@ -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,14 @@ 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': '거의 준비됐어요',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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': 'Почти готово',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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': 'เกือบเสร็จแล้ว',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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': 'Майже готово',
+9 -1
View File
@@ -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,14 @@ 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',
+9 -1
View File
@@ -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,14 @@ 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': '即將就緒',
+9 -1
View File
@@ -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,14 @@ 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': '即将就绪',