Scanner: parse processQR URLs, manual Zone ID entry, zone-name fallback

Kiosk QR decoded to a bare https://www.parksmarter.com/home/processQR (no zone) —
even the official app rejects code-less URL QRs. Now: parse processQR?code= URLs,
detect bare app-link QRs and prompt for the printed Zone ID, and add manual Zone ID
entry. Lookups try getMetersByScannerCode then getMetersByZoneName (DSB13 maps to
zone 113150). Fixes scanning real stickers, which previously passed the URL to the API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 12:38:38 -07:00
parent 1c597ec471
commit d1473f9776

View file

@ -1,5 +1,14 @@
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import {
ActivityIndicator,
Alert,
Modal,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { import {
Camera, Camera,
useCameraDevice, useCameraDevice,
@ -14,38 +23,98 @@ import type { RootStackParamList } from '@/navigation/RootNavigator';
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
/** /**
* Scan a kiosk QR code (its `ScannerCode`), look the meter up via the API, and * Interpret a scanned code. Kiosk QR codes come in two flavors:
* jump to the detail screen where it can be saved/shared. * - a raw scanner/zone code (e.g. "DSB13") -> use directly
* Uses on-device VisionCamera code scanning no Google Play Services. * - a ParkSmarter/ips-txt2pay URL. If it has a query with the code
* (/processQR?code=DSB13) we extract it; a bare app-link URL (this older
* sticker: /processQR) carries no zone, so we ask for the printed Zone ID.
*/ */
export function parseScannedCode(raw: string): { code?: string; isAppLink?: boolean } {
const v = raw.trim();
if (!/^https?:\/\//i.test(v)) return { code: v };
if (v.length > 255) return {};
const lower = v.toLowerCase();
if (!(lower.includes('parksmarter.com') || lower.includes('ips-txt2pay'))) {
return { isAppLink: true };
}
const q = v.split('?')[1];
if (!q) return { isAppLink: true };
for (const pair of q.split('&')) {
const [k, val] = pair.split('=');
if (val && /^(code|zone|zoneid|m|meter)$/i.test(k)) {
return { code: decodeURIComponent(val) };
}
}
const bare = q.split('&')[0];
if (bare && !bare.includes('=')) return { code: decodeURIComponent(bare) };
return { isAppLink: true };
}
export function ScanScreen() { export function ScanScreen() {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const { hasPermission, requestPermission } = useCameraPermission(); const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back'); const device = useCameraDevice('back');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [manualOpen, setManualOpen] = useState(false);
const [manual, setManual] = useState('');
const lock = useRef(false); const lock = useRef(false);
React.useEffect(() => { React.useEffect(() => {
if (!hasPermission) void requestPermission(); if (!hasPermission) void requestPermission();
}, [hasPermission, requestPermission]); }, [hasPermission, requestPermission]);
const onScanned = useCallback( const lookupCode = useCallback(
async (code: string) => { async (code: string) => {
if (lock.current) return;
lock.current = true;
setBusy(true); setBusy(true);
try { try {
const res = await ps.getMetersByScannerCode(code); let res = await ps.getMetersByScannerCode(code);
const zone = res.Zones?.[0]; let zone = res.Zones?.[0];
if (zone) navigation.navigate('MeterDetail', { zone }); if (!zone) {
res = await ps.getMetersByZoneName(code);
zone = res.Zones?.[0];
}
if (zone) {
setManualOpen(false);
navigation.navigate('MeterDetail', { zone });
} else {
Alert.alert('Not found', `No meter matches "${code}".`);
}
} catch (e: any) {
Alert.alert('Lookup failed', e?.serverMessage ?? e?.message ?? 'error');
} finally { } finally {
setBusy(false); setBusy(false);
setTimeout(() => (lock.current = false), 1500);
} }
}, },
[navigation], [navigation],
); );
const onScanned = useCallback(
async (raw: string) => {
if (lock.current) return;
lock.current = true;
try {
const { code, isAppLink } = parseScannedCode(raw);
if (code) {
await lookupCode(code);
} else if (isAppLink) {
Alert.alert(
'App-link QR',
'This QR just opens the ParkSmarter app — it has no meter info. Enter the Zone ID printed on the kiosk instead.',
[
{ text: 'Enter Zone ID', onPress: () => setManualOpen(true) },
{ text: 'Cancel', style: 'cancel' },
],
);
} else {
Alert.alert('Unrecognized code', 'That QR is not a parking meter code.');
}
} finally {
setTimeout(() => (lock.current = false), 1800);
}
},
[lookupCode],
);
const codeScanner = useCodeScanner({ const codeScanner = useCodeScanner({
codeTypes: ['qr', 'ean-13', 'code-128'], codeTypes: ['qr', 'ean-13', 'code-128'],
onCodeScanned: (codes) => { onCodeScanned: (codes) => {
@ -54,6 +123,37 @@ export function ScanScreen() {
}, },
}); });
const ManualEntry = () => (
<Modal visible={manualOpen} transparent animationType="slide" onRequestClose={() => setManualOpen(false)}>
<View style={styles.modalBackdrop}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Enter Zone ID</Text>
<Text style={styles.modalSub}>The code printed on the kiosk, e.g. DSB13.</Text>
<TextInput
style={styles.input}
placeholder="Zone ID"
autoCapitalize="characters"
autoFocus
value={manual}
onChangeText={setManual}
/>
<View style={styles.modalActions}>
<TouchableOpacity onPress={() => setManualOpen(false)} style={styles.action}>
<Text style={{ color: '#666', fontWeight: '600' }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.lookupBtn}
disabled={!manual.trim() || busy}
onPress={() => lookupCode(manual.trim())}
>
<Text style={styles.buttonText}>{busy ? 'Looking…' : 'Look up'}</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
if (!hasPermission) { if (!hasPermission) {
return ( return (
<View style={styles.center}> <View style={styles.center}>
@ -61,6 +161,10 @@ export function ScanScreen() {
<TouchableOpacity style={styles.button} onPress={requestPermission}> <TouchableOpacity style={styles.button} onPress={requestPermission}>
<Text style={styles.buttonText}>Grant camera access</Text> <Text style={styles.buttonText}>Grant camera access</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.button, styles.buttonAlt]} onPress={() => setManualOpen(true)}>
<Text style={styles.buttonText}>Enter Zone ID instead</Text>
</TouchableOpacity>
<ManualEntry />
</View> </View>
); );
} }
@ -68,6 +172,10 @@ export function ScanScreen() {
return ( return (
<View style={styles.center}> <View style={styles.center}>
<Text style={styles.msg}>No camera available.</Text> <Text style={styles.msg}>No camera available.</Text>
<TouchableOpacity style={styles.button} onPress={() => setManualOpen(true)}>
<Text style={styles.buttonText}>Enter Zone ID</Text>
</TouchableOpacity>
<ManualEntry />
</View> </View>
); );
} }
@ -79,35 +187,44 @@ export function ScanScreen() {
<View style={styles.reticle} /> <View style={styles.reticle} />
<Text style={styles.hint}>Point at the QR code on the parking kiosk</Text> <Text style={styles.hint}>Point at the QR code on the parking kiosk</Text>
</View> </View>
<TouchableOpacity style={styles.manualLink} onPress={() => setManualOpen(true)}>
<Text style={styles.manualText}>Enter Zone ID manually</Text>
</TouchableOpacity>
{busy ? ( {busy ? (
<View style={styles.busy}> <View style={styles.busy}>
<ActivityIndicator color="#fff" /> <ActivityIndicator color="#fff" />
<Text style={styles.busyText}>Looking up meter</Text> <Text style={styles.busyText}>Looking up meter</Text>
</View> </View>
) : null} ) : null}
<ManualEntry />
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#000' }, container: { flex: 1, backgroundColor: '#000' },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 12 },
msg: { fontSize: 16, textAlign: 'center', marginBottom: 16 }, msg: { fontSize: 16, textAlign: 'center', marginBottom: 8 },
overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' }, overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' },
reticle: { reticle: { width: 220, height: 220, borderWidth: 3, borderColor: '#fff', borderRadius: 16 },
width: 220,
height: 220,
borderWidth: 3,
borderColor: '#fff',
borderRadius: 16,
},
hint: { color: '#fff', marginTop: 16, fontSize: 14 }, hint: { color: '#fff', marginTop: 16, fontSize: 14 },
button: { backgroundColor: '#1e6f5c', padding: 14, borderRadius: 10 }, button: { backgroundColor: '#1e6f5c', padding: 14, borderRadius: 10 },
buttonAlt: { backgroundColor: '#555' },
buttonText: { color: '#fff', fontWeight: '600' }, buttonText: { color: '#fff', fontWeight: '600' },
busy: { manualLink: {
position: 'absolute', position: 'absolute',
bottom: 40, bottom: 40,
alignSelf: 'center', alignSelf: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
},
manualText: { color: '#fff', fontWeight: '600' },
busy: {
position: 'absolute',
top: 60,
alignSelf: 'center',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
@ -117,4 +234,12 @@ const styles = StyleSheet.create({
borderRadius: 20, borderRadius: 20,
}, },
busyText: { color: '#fff' }, busyText: { color: '#fff' },
modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
modalCard: { backgroundColor: '#fff', borderTopLeftRadius: 20, borderTopRightRadius: 20, padding: 20, gap: 10 },
modalTitle: { fontSize: 20, fontWeight: '700' },
modalSub: { color: '#666', marginBottom: 4 },
input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 10, padding: 14, fontSize: 18 },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 16, marginTop: 8 },
action: { paddingHorizontal: 8, paddingVertical: 6 },
lookupBtn: { backgroundColor: '#1e6f5c', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 10 },
}); });