From 4cb54787372bbba4bcc6d8a656b9357596784620 Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 13 Jul 2026 18:38:39 -0700 Subject: [PATCH] v0.1.8: tap a session for full detail + receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sessions list items are now tappable -> SessionDetailScreen. - Past sessions fetch the full receipt (GET /api/ParkingReceipt): vehicle, start/end, time purchased, parking/fee/total, payment (MASTERCARD ••1234), auth code, meter, city, transaction id. Adds an "Email me this receipt" action. - Active sessions show zone/space/start/ends/time-remaining. - Confirm PastSession + ParkingReceipt types from a live DL-zone session (were UNCONFIRMED); fix past-session card to show Zone/Description, not blank. Co-Authored-By: Claude Fable 5 --- app/app.json | 4 +- app/src/navigation/RootNavigator.tsx | 9 +- app/src/screens/SessionDetailScreen.tsx | 159 ++++++++++++++++++++++++ app/src/screens/SessionsScreen.tsx | 35 ++++-- parksmarter-client/src/types.ts | 61 +++++++-- 5 files changed, 243 insertions(+), 25 deletions(-) create mode 100644 app/src/screens/SessionDetailScreen.tsx diff --git a/app/app.json b/app/app.json index 4c94380..1ac0da5 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.1.7", + "version": "0.1.8", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 7, + "versionCode": 8, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx index 21df729..b0295a5 100644 --- a/app/src/navigation/RootNavigator.tsx +++ b/app/src/navigation/RootNavigator.tsx @@ -21,14 +21,16 @@ import { VehiclesScreen } from '@/screens/VehiclesScreen'; import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen'; import { NotificationsScreen } from '@/screens/NotificationsScreen'; import { StartSessionScreen } from '@/screens/StartSessionScreen'; +import { SessionDetailScreen } from '@/screens/SessionDetailScreen'; import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen'; import { useTheme } from '@/theme/ThemeContext'; -import type { Zone } from 'parksmarter-client'; +import type { ActiveSession, PastSession, Zone } from 'parksmarter-client'; export type RootStackParamList = { Tabs: undefined; MeterDetail: { zone: Zone }; StartSession: { zone: Zone }; + SessionDetail: { session: PastSession | ActiveSession; kind: 'active' | 'past' }; About: undefined; Profile: undefined; Vehicles: undefined; @@ -120,6 +122,11 @@ export function RootNavigator() { component={StartSessionScreen} options={{ title: 'Start session' }} /> + diff --git a/app/src/screens/SessionDetailScreen.tsx b/app/src/screens/SessionDetailScreen.tsx new file mode 100644 index 0000000..eb1e770 --- /dev/null +++ b/app/src/screens/SessionDetailScreen.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import type { RouteProp } from '@react-navigation/native'; +import { useRoute } from '@react-navigation/native'; +import { ps } from '@/api/client'; +import { useTheme } from '@/theme/ThemeContext'; +import { logLine } from '@/features/diagnostics/fileLogger'; +import type { RootStackParamList } from '@/navigation/RootNavigator'; +import type { ParkingReceipt } from 'parksmarter-client'; + +type DetailRoute = RouteProp; + +const show = (v: unknown) => (v == null || v === '' ? '—' : String(v)); +const dollars = (v: unknown) => (v == null || v === '' ? undefined : `$${v}`); + +export function SessionDetailScreen() { + const { colors } = useTheme(); + const { session, kind } = useRoute().params; + const s = session as Record; + const tid = s.TransactionID; + + const [receipt, setReceipt] = useState(null); + const [loading, setLoading] = useState(kind === 'past' && tid != null); + const [emailing, setEmailing] = useState(false); + + // Past sessions have a full receipt (auth code, payment, amounts) — fetch it. + useEffect(() => { + if (kind !== 'past' || tid == null) return; + (async () => { + try { + const r = await ps.getParkingReceipt(tid); + if (r?.ParkingReceipt && (r as any).Response?.Status !== 'Error') { + setReceipt(r.ParkingReceipt); + } + } catch (e: any) { + logLine(`[RECEIPT] fetch failed tid=${tid}: ${e?.serverMessage ?? e?.message ?? e}`); + } finally { + setLoading(false); + } + })(); + }, [kind, tid]); + + const emailReceipt = async () => { + if (tid == null) return; + setEmailing(true); + try { + await ps.emailParkingReceipt(tid); + Alert.alert('Receipt sent', 'Emailed to your account address.'); + } catch (e: any) { + Alert.alert('Couldn’t email receipt', e?.serverMessage ?? e?.message ?? 'Please try again.'); + } finally { + setEmailing(false); + } + }; + + const r = receipt; + const rows: Array<[string, string | undefined]> = + kind === 'past' + ? [ + ['Vehicle', r?.Vehicle ?? s.VehicleNumber], + ['Zone', s.Description ?? s.Zone ?? r?.MeterNumber], + ['Space', r?.Space ?? s.Space], + ['Started', r?.StartTime ?? s.StartTime], + ['Ended', r?.EndTime ?? s.EndTime], + ['Time purchased', s.TimePurchased ? `${s.TimePurchased} min` : undefined], + ['Parking', r?.Amount ?? dollars(s.Amount)], + ['Fee', r?.TransactionFee ?? dollars(s.TransactionFee)], + ['Total', r?.Total], + [ + 'Payment', + [r?.PaymentDisplay ?? s.PaymentDisplay, r?.CC ?? (s.CardLastFour ? `••${s.CardLastFour}` : '')] + .filter(Boolean) + .join(' ') || undefined, + ], + ['Auth code', r?.AuthCode], + ['City', s.City ?? r?.CustomerName], + ['Transaction', tid != null ? String(tid) : undefined], + ] + : [ + ['Vehicle', s.VehiclePlate ?? s.VehicleNumber], + ['Zone', s.ZoneName ?? s.Zone], + ['Space', s.SpaceName ?? s.Space], + ['Started', s.StartTimeDisplay ?? s.StartTime], + ['Ends', s.EndTimeDisplay ?? s.EndTime], + ['Time remaining', s.TimeRemaining != null ? `${s.TimeRemaining} min` : undefined], + ['Amount', dollars(s.Amount)], + ]; + + if (loading) { + return ( + + + + ); + } + + const title = s.Description ?? s.ZoneName ?? s.Zone ?? r?.MeterNumber ?? 'Session'; + const visible = rows.filter(([, v]) => v != null && v !== ''); + + return ( + + {title} + {kind === 'active' ? ( + + Active session + + ) : null} + + + {visible.map(([label, value], i) => ( + + {label} + {show(value)} + + ))} + + + {kind === 'past' && tid != null ? ( + + + {emailing ? 'Sending…' : 'Email me this receipt'} + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + title: { fontSize: 22, fontWeight: '700', marginBottom: 12 }, + badge: { alignSelf: 'flex-start', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, marginBottom: 12 }, + card: { borderRadius: 12, padding: 4, paddingHorizontal: 14 }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#8883', + }, + value: { fontWeight: '600', flexShrink: 1, textAlign: 'right', marginLeft: 12 }, + btn: { marginTop: 16, borderWidth: 1.5, borderRadius: 12, padding: 14, alignItems: 'center' }, +}); diff --git a/app/src/screens/SessionsScreen.tsx b/app/src/screens/SessionsScreen.tsx index 6d23cf9..33d8ed0 100644 --- a/app/src/screens/SessionsScreen.tsx +++ b/app/src/screens/SessionsScreen.tsx @@ -1,12 +1,17 @@ import React, { useCallback, useState } from 'react'; -import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; -import { useFocusEffect } from '@react-navigation/native'; +import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { ps } from '@/api/client'; import { useTheme } from '@/theme/ThemeContext'; +import type { RootStackParamList } from '@/navigation/RootNavigator'; import type { ActiveSession, PastSession } from 'parksmarter-client'; +type Nav = NativeStackNavigationProp; + export function SessionsScreen() { const { colors } = useTheme(); + const navigation = useNavigation