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 { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import {
ActivityIndicator,
Alert,
Modal,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import {
Camera,
useCameraDevice,
@ -14,38 +23,98 @@ import type { RootStackParamList } from '@/navigation/RootNavigator';
type Nav = NativeStackNavigationProp<RootStackParamList>;
/**
* Scan a kiosk QR code (its `ScannerCode`), look the meter up via the API, and
* jump to the detail screen where it can be saved/shared.
* Uses on-device VisionCamera code scanning no Google Play Services.
* Interpret a scanned code. Kiosk QR codes come in two flavors:
* - a raw scanner/zone code (e.g. "DSB13") -> use directly
* - 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() {
const navigation = useNavigation<Nav>();
const { hasPermission, requestPermission } = useCameraPermission();
const device = useCameraDevice('back');
const [busy, setBusy] = useState(false);
const [manualOpen, setManualOpen] = useState(false);
const [manual, setManual] = useState('');
const lock = useRef(false);
React.useEffect(() => {
if (!hasPermission) void requestPermission();
}, [hasPermission, requestPermission]);
const onScanned = useCallback(
const lookupCode = useCallback(
async (code: string) => {
if (lock.current) return;
lock.current = true;
setBusy(true);
try {
const res = await ps.getMetersByScannerCode(code);
const zone = res.Zones?.[0];
if (zone) navigation.navigate('MeterDetail', { zone });
let res = await ps.getMetersByScannerCode(code);
let zone = res.Zones?.[0];
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 {
setBusy(false);
setTimeout(() => (lock.current = false), 1500);
}
},
[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({
codeTypes: ['qr', 'ean-13', 'code-128'],
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) {
return (
<View style={styles.center}>
@ -61,6 +161,10 @@ export function ScanScreen() {
<TouchableOpacity style={styles.button} onPress={requestPermission}>
<Text style={styles.buttonText}>Grant camera access</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.button, styles.buttonAlt]} onPress={() => setManualOpen(true)}>
<Text style={styles.buttonText}>Enter Zone ID instead</Text>
</TouchableOpacity>
<ManualEntry />
</View>
);
}
@ -68,6 +172,10 @@ export function ScanScreen() {
return (
<View style={styles.center}>
<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>
);
}
@ -79,35 +187,44 @@ export function ScanScreen() {
<View style={styles.reticle} />
<Text style={styles.hint}>Point at the QR code on the parking kiosk</Text>
</View>
<TouchableOpacity style={styles.manualLink} onPress={() => setManualOpen(true)}>
<Text style={styles.manualText}>Enter Zone ID manually</Text>
</TouchableOpacity>
{busy ? (
<View style={styles.busy}>
<ActivityIndicator color="#fff" />
<Text style={styles.busyText}>Looking up meter</Text>
</View>
) : null}
<ManualEntry />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#000' },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
msg: { fontSize: 16, textAlign: 'center', marginBottom: 16 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 12 },
msg: { fontSize: 16, textAlign: 'center', marginBottom: 8 },
overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' },
reticle: {
width: 220,
height: 220,
borderWidth: 3,
borderColor: '#fff',
borderRadius: 16,
},
reticle: { width: 220, height: 220, borderWidth: 3, borderColor: '#fff', borderRadius: 16 },
hint: { color: '#fff', marginTop: 16, fontSize: 14 },
button: { backgroundColor: '#1e6f5c', padding: 14, borderRadius: 10 },
buttonAlt: { backgroundColor: '#555' },
buttonText: { color: '#fff', fontWeight: '600' },
busy: {
manualLink: {
position: 'absolute',
bottom: 40,
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',
alignItems: 'center',
gap: 8,
@ -117,4 +234,12 @@ const styles = StyleSheet.create({
borderRadius: 20,
},
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 },
});