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 => (