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