Detect declined sessions (200 + Status:Error); theme Sessions screen

- startParkingSession: server returns HTTP 200 even on a declined charge — now
  detect Response.Status:"Error" and throw the gateway reason (e.g. "DECLINED")
  so the app shows the failure instead of a false "Parked!"
- Confirmed the session request format is correct (server processed it); expand
  StartParkingSessionResponse with the real fields (OriginalErrorMessage, etc.)
- Theme SessionsScreen (was black-on-black in dark mode)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 12:25:14 -07:00
parent 7ec7ae9200
commit 1c597ec471
3 changed files with 52 additions and 22 deletions

View file

@ -2,9 +2,11 @@ import React, { useCallback, useState } from 'react';
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { ps } from '@/api/client'; import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
import type { ActiveSession, PastSession } from 'parksmarter-client'; import type { ActiveSession, PastSession } from 'parksmarter-client';
export function SessionsScreen() { export function SessionsScreen() {
const { colors } = useTheme();
const [active, setActive] = useState<ActiveSession[]>([]); const [active, setActive] = useState<ActiveSession[]>([]);
const [past, setPast] = useState<PastSession[]>([]); const [past, setPast] = useState<PastSession[]>([]);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@ -31,31 +33,32 @@ export function SessionsScreen() {
return ( return (
<ScrollView <ScrollView
style={{ backgroundColor: colors.bg }}
contentContainerStyle={{ padding: 16 }} contentContainerStyle={{ padding: 16 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
> >
<Text style={styles.header}>Active</Text> <Text style={[styles.header, { color: colors.text }]}>Active</Text>
{active.length === 0 ? ( {active.length === 0 ? (
<Text style={styles.empty}>No active sessions.</Text> <Text style={[styles.empty, { color: colors.subtext }]}>No active sessions.</Text>
) : ( ) : (
active.map((s, i) => ( active.map((s, i) => (
<View key={i} style={[styles.card, styles.activeCard]}> <View key={i} style={[styles.card, { backgroundColor: colors.primary + '22' }]}>
<Text style={styles.zone}>{s.ZoneName ?? 'Session'}</Text> <Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
<Text style={styles.meta}> <Text style={[styles.meta, { color: colors.subtext }]}>
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''} {s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''}
</Text> </Text>
</View> </View>
)) ))
)} )}
<Text style={[styles.header, { marginTop: 20 }]}>History</Text> <Text style={[styles.header, { color: colors.text, marginTop: 20 }]}>History</Text>
{past.length === 0 ? ( {past.length === 0 ? (
<Text style={styles.empty}>No past sessions.</Text> <Text style={[styles.empty, { color: colors.subtext }]}>No past sessions.</Text>
) : ( ) : (
past.map((s, i) => ( past.map((s, i) => (
<View key={i} style={styles.card}> <View key={i} style={[styles.card, { backgroundColor: colors.card }]}>
<Text style={styles.zone}>{s.ZoneName ?? 'Session'}</Text> <Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
<Text style={styles.meta}> <Text style={[styles.meta, { color: colors.subtext }]}>
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''} {s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''}
</Text> </Text>
</View> </View>
@ -67,9 +70,8 @@ export function SessionsScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 }, header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
empty: { color: '#888', marginBottom: 8 }, empty: { marginBottom: 8 },
card: { backgroundColor: '#f4f4f4', borderRadius: 10, padding: 14, marginBottom: 10 }, card: { borderRadius: 10, padding: 14, marginBottom: 10 },
activeCard: { backgroundColor: '#e8f5e9' },
zone: { fontSize: 16, fontWeight: '600' }, zone: { fontSize: 16, fontWeight: '600' },
meta: { color: '#777', fontSize: 13, marginTop: 2 }, meta: { fontSize: 13, marginTop: 2 },
}); });

View file

@ -598,14 +598,21 @@ export class ParkSmarterClient {
MeterTypeId: params.meterTypeId, MeterTypeId: params.meterTypeId,
}; };
if (params.bleEncBytes) body.BleEncBytes = params.bleEncBytes; if (params.bleEncBytes) body.BleEncBytes = params.bleEncBytes;
return this.unwrap( return this.http
this.http.request<T.StartParkingSessionResponse>({ .request<T.StartParkingSessionResponse>({ method: 'POST', path: '/api/Session', body })
method: 'POST', .then((res) => {
path: '/api/Session', // The server returns HTTP 200 even when the charge is declined, signalling
body, // failure via Response.Status:"Error" (+ OriginalErrorMessage like "DECLINED").
}), const env = res.data?.Response as { Status?: string; Message?: string } | undefined;
if (env?.Status === 'Error') {
throw new Error(
(env.Message || 'Payment failed.') +
(res.data?.OriginalErrorMessage ? ` (${res.data.OriginalErrorMessage})` : ''),
); );
} }
return res.data;
});
}
/** GET /api/ParkingSession — currently active sessions. */ /** GET /api/ParkingSession — currently active sessions. */
getActiveParkingSessions(): Promise<T.ActiveSessionsResponse> { getActiveParkingSessions(): Promise<T.ActiveSessionsResponse> {

View file

@ -452,8 +452,29 @@ export interface StartParkingSessionParams {
bleEncBytes?: string; bleEncBytes?: string;
} }
/**
* POST /api/Session. CONFIRMED via live capture. Returns HTTP 200 even on a
* declined charge check `Response.Status === 'Error'` (the client does this and
* throws). `OriginalErrorMessage` carries the gateway reason (e.g. "DECLINED").
*/
export interface StartParkingSessionResponse { export interface StartParkingSessionResponse {
BleEncBytes?: string; Response?: ResponseEnvelope;
BleEncBytes?: string | null;
PBPParkingSessionId?: number;
OriginalErrorMessage?: string;
StartTime?: string | null;
EndTime?: string | null;
TimePurchased?: string | null;
ZoneID?: number | string | null;
Zone?: string | null;
SpaceID?: number | string | null;
Space?: string | null;
VehicleNumber?: string | null;
Lat?: number | null;
Long?: number | null;
TransactionFee?: string | number | null;
Amount?: string | number | null;
AmountCharged?: string | number | null;
[key: string]: unknown; [key: string]: unknown;
} }