From 37a4e84360139293baad453b6079add192af6a96 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 7 Nov 2026 10:40:41 +0100 Subject: [PATCH] feat: Apple Wallet passes for bookings "Add to Apple Wallet" on a booking in the iOS app turns it into a TREK pass: a boarding pass from and to for flights, trains, buses and boats, check-in and check-out for a stay, pickup and drop-off for a car, the time for anything else, with the confirmation code as a QR code. Wallet brings it up on the lock screen around its time and place: times carry the offset of the endpoint's zone (looked up from the coordinates where the endpoint has none), stays and restaurants their place's coordinates. The labels come from the booking forms' own strings, so nothing new to translate on the pass. Only liketrek holds the Pass Type ID certificate, so the server sends the pass's texts to the TREK services' signer (TREK_WALLET_PASS_URL, push.liketrek.com) once per tap; it stores nothing, and TREK_SERVICES=off turns it off with the other services. Texts are cleaned of links and shortened here, the answer must be a .pkpass within 512 KB, and a user gets 20 passes a minute. - New wallet-pass domain: GET /api/trips/:tripId/reservations/:id/wallet-pass behind the trip guard, the booking checked to belong to the trip; trip title and place come from the reservations domain (findPassContext) - MWalletButton in the transport and booking sheets, only where the app binary has the wallet feature; undated bookings and failures are told apart - TREK_WALLET_PASS_URL in .env.example, compose, Helm and the wiki --- charts/trek/templates/configmap.yaml | 3 + charts/trek/values.yaml | 4 + client/scripts/theme-disable-baseline.json | 1 + .../mobile/components/MWalletButton.test.tsx | 59 +++ .../src/mobile/components/MWalletButton.tsx | 55 +++ .../screens/trip/sheets/MReservationSheet.tsx | 9 +- .../screens/trip/sheets/MTransportSheet.tsx | 2 + client/src/native/wallet.test.ts | 36 +- client/src/native/wallet.ts | 27 + docker-compose.yml | 1 + server/.env.example | 1 + server/src/app-config/boot-derive.ts | 3 + server/src/app-config/env.schema.ts | 1 + .../repositories/Reservations.repository.ts | 467 +++++++++++++----- server/src/nest/app.module.ts | 2 + .../reservations/reservations-read.service.ts | 7 +- .../nest/wallet-pass/wallet-pass.builder.ts | 302 +++++++++++ .../wallet-pass/wallet-pass.controller.ts | 35 ++ .../nest/wallet-pass/wallet-pass.module.ts | 15 + .../nest/wallet-pass/wallet-pass.service.ts | 153 ++++++ .../src/nest/wallet-pass/wallet-pass.words.ts | 56 +++ server/tests/e2e/wallet-pass.e2e.test.ts | 168 +++++++ .../Reservations.repository.test.ts | 37 ++ .../wallet-pass/wallet-pass.builder.test.ts | 240 +++++++++ .../wallet-pass.controller.test.ts | 56 +++ .../wallet-pass/wallet-pass.service.test.ts | 155 ++++++ server/vitest.config.ts | 3 + shared/src/i18n/ar/reservations.ts | 3 + shared/src/i18n/az/reservations.ts | 3 + shared/src/i18n/br/reservations.ts | 3 + shared/src/i18n/ca/reservations.ts | 4 + shared/src/i18n/cs/reservations.ts | 3 + shared/src/i18n/de/reservations.ts | 3 + shared/src/i18n/en/reservations.ts | 3 + shared/src/i18n/es/reservations.ts | 3 + shared/src/i18n/et/reservations.ts | 3 + shared/src/i18n/fr/reservations.ts | 4 + shared/src/i18n/gr/reservations.ts | 4 + shared/src/i18n/he/reservations.ts | 3 + shared/src/i18n/hu/reservations.ts | 3 + shared/src/i18n/id/reservations.ts | 3 + shared/src/i18n/it/reservations.ts | 3 + shared/src/i18n/ja/reservations.ts | 3 + shared/src/i18n/ko/reservations.ts | 3 + shared/src/i18n/nl/reservations.ts | 3 + shared/src/i18n/pl/reservations.ts | 3 + shared/src/i18n/ru/reservations.ts | 3 + shared/src/i18n/sk/reservations.ts | 3 + shared/src/i18n/sv/reservations.ts | 3 + shared/src/i18n/th/reservations.ts | 3 + shared/src/i18n/tr/reservations.ts | 3 + shared/src/i18n/uk/reservations.ts | 3 + shared/src/i18n/vi/reservations.ts | 3 + shared/src/i18n/zh-TW/reservations.ts | 3 + shared/src/i18n/zh/reservations.ts | 3 + wiki/Environment-Variables.md | 1 + 56 files changed, 1873 insertions(+), 113 deletions(-) create mode 100644 client/src/mobile/components/MWalletButton.test.tsx create mode 100644 client/src/mobile/components/MWalletButton.tsx create mode 100644 server/src/nest/wallet-pass/wallet-pass.builder.ts create mode 100644 server/src/nest/wallet-pass/wallet-pass.controller.ts create mode 100644 server/src/nest/wallet-pass/wallet-pass.module.ts create mode 100644 server/src/nest/wallet-pass/wallet-pass.service.ts create mode 100644 server/src/nest/wallet-pass/wallet-pass.words.ts create mode 100644 server/tests/e2e/wallet-pass.e2e.test.ts create mode 100644 server/tests/unit/nest/wallet-pass/wallet-pass.builder.test.ts create mode 100644 server/tests/unit/nest/wallet-pass/wallet-pass.controller.test.ts create mode 100644 server/tests/unit/nest/wallet-pass/wallet-pass.service.test.ts diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index fd6478837..4dbcb2b8c 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -127,6 +127,9 @@ data: {{- if .Values.env.TREK_VALHALLA_URL }} TREK_VALHALLA_URL: {{ .Values.env.TREK_VALHALLA_URL | quote }} {{- end }} + {{- if .Values.env.TREK_WALLET_PASS_URL }} + TREK_WALLET_PASS_URL: {{ .Values.env.TREK_WALLET_PASS_URL | quote }} + {{- end }} {{- if .Values.env.GOOGLE_LIST_DATA_BASE }} GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 278cd05d7..2ee2e06aa 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -159,6 +159,10 @@ env: # TREK_VALHALLA_URL: "" # The Valhalla of the TREK services, which this server relays routing to unless the admin picks another # routing service. The browser never talks to it. Defaults to https://valhalla.liketrek.com. + # TREK_WALLET_PASS_URL: "" + # Signs the Apple Wallet pass of a booking when someone taps "Add to Apple Wallet" in the iOS app. + # Only the pass's texts go there, once per tap. Never asked with TREK_SERVICES=off. + # Defaults to https://push.liketrek.com/v1/wallet/pass. # AMAP_API_BASE: "" # Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of # restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into diff --git a/client/scripts/theme-disable-baseline.json b/client/scripts/theme-disable-baseline.json index 8fae04402..f73c86f24 100644 --- a/client/scripts/theme-disable-baseline.json +++ b/client/scripts/theme-disable-baseline.json @@ -54,6 +54,7 @@ "components/shared/DialogShell.tsx": 2, "components/shared/PasswordChecklist.tsx": 2, "mobile/components/MSheet.tsx": 1, + "mobile/components/MWalletButton.tsx": 1, "mobile/screens/atlas/MAtlas.tsx": 1, "mobile/screens/atlas/MAtlasCountryPopup.tsx": 2, "mobile/screens/atlas/MAtlasSearch.tsx": 1, diff --git a/client/src/mobile/components/MWalletButton.test.tsx b/client/src/mobile/components/MWalletButton.test.tsx new file mode 100644 index 000000000..b6625e881 --- /dev/null +++ b/client/src/mobile/components/MWalletButton.test.tsx @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; +import MWalletButton from './MWalletButton'; + +const wallet = vi.hoisted(() => ({ + can: true, + add: vi.fn(), +})); + +vi.mock('../../native/wallet', () => ({ + canAddToWallet: () => wallet.can, + addReservationToWallet: wallet.add, +})); +vi.mock('../../native/capabilities', () => ({ useNativeCapabilities: () => ({ caps: {}, updateRequired: false }) })); + +beforeEach(() => { + wallet.can = true; + wallet.add.mockReset(); +}); + +describe('MWalletButton', () => { + it('is not there for a booking that is not saved yet', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('sits in a wrapper when the sheet places it', () => { + const { container } = render(); + expect(container.querySelector('div.mt-2 > button')).not.toBeNull(); + }); + + it('is not there where the app cannot add a pass', () => { + wallet.can = false; + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('adds the booking and says nothing when Wallet took it', async () => { + wallet.add.mockResolvedValue('added'); + const onError = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(wallet.add).toHaveBeenCalledWith(42, 7, expect.any(String))); + await waitFor(() => expect(screen.getByRole('button')).not.toBeDisabled()); + expect(onError).not.toHaveBeenCalled(); + }); + + it('explains an undated booking and a failure apart', async () => { + const onError = vi.fn(); + render(); + wallet.add.mockResolvedValueOnce('undated'); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(onError).toHaveBeenCalledWith('reservations.wallet.undated')); + await waitFor(() => expect(screen.getByRole('button')).not.toBeDisabled()); + wallet.add.mockResolvedValueOnce('failed'); + fireEvent.click(screen.getByRole('button')); + await waitFor(() => expect(onError).toHaveBeenLastCalledWith('reservations.wallet.failed')); + }); +}); diff --git a/client/src/mobile/components/MWalletButton.tsx b/client/src/mobile/components/MWalletButton.tsx new file mode 100644 index 000000000..32ea37107 --- /dev/null +++ b/client/src/mobile/components/MWalletButton.tsx @@ -0,0 +1,55 @@ +import { Loader2, Wallet } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from '../../i18n'; +import { useNativeCapabilities } from '../../native/capabilities'; +import { addReservationToWallet, canAddToWallet } from '../../native/wallet'; + +interface MWalletButtonProps { + tripId: number; + /** None yet (a booking being created): no button. */ + reservationId: number | undefined; + /** Placement in the hosting sheet, on a wrapper around the button. */ + className?: string; + /** Shown when the pass could not be made (a toast in the sheet that hosts the button). */ + onError: (message: string) => void; +} + +/** + * "Add to Apple Wallet" on a booking, in the iOS app only (feature wallet): the + * booking becomes a TREK pass that shows up on the lock screen around its time + * and place. Black like Apple's own badge, so it reads as Wallet at a glance. + */ +export default function MWalletButton({ tripId, reservationId, onError, className }: MWalletButtonProps) { + const { t, language } = useTranslation(); + // Re-renders once the app binary has said what it can do. + useNativeCapabilities(); + const [busy, setBusy] = useState(false); + if (reservationId === undefined || !canAddToWallet()) return null; + const id = reservationId; + + const add = async () => { + if (busy) return; + setBusy(true); + try { + const outcome = await addReservationToWallet(tripId, id, language); + if (outcome === 'undated') onError(t('reservations.wallet.undated')); + else if (outcome === 'failed') onError(t('reservations.wallet.failed')); + } finally { + setBusy(false); + } + }; + + const button = ( + + ); + return className ?
{button}
: button; +} diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index 782677f96..d630cb328 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { Link2, Ticket } from 'lucide-react' import MSheet from '../../../components/MSheet' +import MWalletButton from '../../../components/MWalletButton' import { MBookingCodeStatus, MBookingNotes, MBookingTravelers } from './MBookingFields' import { useAddonStore } from '../../../../store/addonStore' import { useTranslation } from '../../../../i18n' @@ -36,6 +37,9 @@ const EMPTY = { hotel_address: '', } +/** A booking that is saved already has an id; one being created has none. */ +const savedId = (res: { id: number } | null | undefined): number | undefined => res?.id + /** * Add/edit booking sheet — the mobile counterpart of the desktop ReservationModal, * driven by the planner's own editor flags (showReservationModal / editingReservation / @@ -187,6 +191,9 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati />
+ {/* A saved booking goes into Apple Wallet (iOS app only). */} + + {/* BOOKING TYPE */} {t('reservations.bookingType')}
@@ -398,7 +405,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati {/* FILES + COSTS */} {t('mobileTrip.onMap')} + {resFiles.map(f => (