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