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:
parent
7ec7ae9200
commit
1c597ec471
3 changed files with 52 additions and 22 deletions
|
|
@ -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 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue