v0.4.0: Anonymous Mode + zone-location mirror; free check-in on any zone
Some checks failed
build-apk / build (push) Failing after 1h56m52s
Some checks failed
build-apk / build (push) Failing after 1h56m52s
Anonymous Mode — "Park without signing in" on the login screen (with a popup of what works vs needs a login). Anonymous users browse parking areas from our mirror, see labels, and start free check-in timers; paying, sessions, and account screens prompt to sign in. AuthContext gains an 'anonymous' status + enterAnonymous/requireLogin. Zone mirror — server gains a `zones` table + public GET /api/zones and admin POST /api/zones/sync. Signed-in admins push the zones they pull (authed) from ParkSmarter after each map search, so anonymous users can read areas without a ParkSmarter login. Map/Scan read the mirror when anonymous. Also: the free "Check in" button is now always available with a 2h/3h/4h picker (no longer gated on a prior label) — fixes "couldn't start a timer on a free zone". CORS probe confirmed ParkSmarter allows any origin but only Content-Type, so a future PWA can't auth to it — the mirror is what makes anonymous browsing (and a PWA) possible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
463facbe5a
commit
4a5660e7e1
13 changed files with 351 additions and 68 deletions
|
|
@ -3,14 +3,14 @@
|
||||||
"name": "BigBrainParking",
|
"name": "BigBrainParking",
|
||||||
"slug": "bigbrainparking",
|
"slug": "bigbrainparking",
|
||||||
"scheme": "bigbrainparking",
|
"scheme": "bigbrainparking",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"android": {
|
"android": {
|
||||||
"package": "top.mowden.bigbrainparking",
|
"package": "top.mowden.bigbrainparking",
|
||||||
"versionCode": 17,
|
"versionCode": 18,
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
|
|
||||||
34
app/src/api/zoneMirror.ts
Normal file
34
app/src/api/zoneMirror.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import type { Zone } from 'parksmarter-client';
|
||||||
|
import { getAdminToken } from './adminStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone-location mirror on bigbrainparking.mowden.top. Anonymous users read
|
||||||
|
* areas from here (no ParkSmarter login). Admins push the zones they pull
|
||||||
|
* (authed) from ParkSmarter so the mirror stays populated.
|
||||||
|
*/
|
||||||
|
const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
export async function getMirrorZones(): Promise<Zone[]> {
|
||||||
|
if (!BASE_URL) return [];
|
||||||
|
const res = await fetch(`${BASE_URL}/api/zones`);
|
||||||
|
if (!res.ok) throw new Error(`zones fetch failed ${res.status}`);
|
||||||
|
const body = (await res.json()) as { zones?: Zone[] };
|
||||||
|
return body.zones ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push authed-pulled zones to the mirror. No-ops unless an admin token is set. */
|
||||||
|
export async function syncZones(zones: Zone[]): Promise<void> {
|
||||||
|
if (!BASE_URL || zones.length === 0) return;
|
||||||
|
const token = await getAdminToken();
|
||||||
|
if (!token) return; // only admins populate the mirror
|
||||||
|
try {
|
||||||
|
await fetch(`${BASE_URL}/api/zones/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ zones }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best-effort — never block the UI on a mirror push */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,13 +9,18 @@ import { ps } from '@/api/client';
|
||||||
import { authBus } from '@/auth/authBus';
|
import { authBus } from '@/auth/authBus';
|
||||||
import type { ApplicationValidityResponse } from 'parksmarter-client';
|
import type { ApplicationValidityResponse } from 'parksmarter-client';
|
||||||
|
|
||||||
type AuthStatus = 'loading' | 'signedOut' | 'signedIn';
|
type AuthStatus = 'loading' | 'signedOut' | 'signedIn' | 'anonymous';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
status: AuthStatus;
|
status: AuthStatus;
|
||||||
validity: ApplicationValidityResponse | null;
|
validity: ApplicationValidityResponse | null;
|
||||||
login: (phoneNumber: string, password: string) => Promise<void>;
|
login: (phoneNumber: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
/** Enter the app without a ParkSmarter login (browse + free check-in only). */
|
||||||
|
enterAnonymous: () => void;
|
||||||
|
/** Leave anonymous mode and show the sign-in screen (e.g. to pay). */
|
||||||
|
requireLogin: () => void;
|
||||||
|
isAnonymous: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,8 +90,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
setStatus('signedOut');
|
setStatus('signedOut');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const enterAnonymous = () => {
|
||||||
|
setError(null);
|
||||||
|
setStatus('anonymous');
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireLogin = () => {
|
||||||
|
setError(null);
|
||||||
|
setStatus('signedOut');
|
||||||
|
};
|
||||||
|
|
||||||
const value = useMemo<AuthState>(
|
const value = useMemo<AuthState>(
|
||||||
() => ({ status, validity, login, logout, error }),
|
() => ({
|
||||||
|
status,
|
||||||
|
validity,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
enterAnonymous,
|
||||||
|
requireLogin,
|
||||||
|
isAnonymous: status === 'anonymous',
|
||||||
|
error,
|
||||||
|
}),
|
||||||
[status, validity, error],
|
[status, validity, error],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export function RootNavigator() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContainer theme={navTheme}>
|
<NavigationContainer theme={navTheme}>
|
||||||
{status === 'signedIn' ? (
|
{status === 'signedIn' || status === 'anonymous' ? (
|
||||||
<Stack.Navigator>
|
<Stack.Navigator>
|
||||||
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
|
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
export function AccountScreen() {
|
export function AccountScreen() {
|
||||||
const { colors, mode, toggle } = useTheme();
|
const { colors, mode, toggle } = useTheme();
|
||||||
const { logout } = useAuth();
|
const { logout, isAnonymous, requireLogin } = useAuth();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
|
|
||||||
const Item = ({
|
const Item = ({
|
||||||
|
|
@ -35,21 +35,33 @@ export function AccountScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
|
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
{isAnonymous ? (
|
||||||
<Item icon="person" label="Profile" onPress={() => navigation.navigate('Profile')} />
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
<Item icon="car-sport" label="Vehicles" onPress={() => navigation.navigate('Vehicles')} />
|
<Item icon="log-in" label="Sign in to pay & sync" onPress={requireLogin} />
|
||||||
<Item
|
<Item
|
||||||
icon="card"
|
icon="notifications"
|
||||||
label="Payment methods"
|
label="Notifications"
|
||||||
onPress={() => navigation.navigate('PaymentMethods')}
|
onPress={() => navigation.navigate('Notifications')}
|
||||||
/>
|
/>
|
||||||
<Item
|
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
|
||||||
icon="notifications"
|
</View>
|
||||||
label="Notifications"
|
) : (
|
||||||
onPress={() => navigation.navigate('Notifications')}
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
/>
|
<Item icon="person" label="Profile" onPress={() => navigation.navigate('Profile')} />
|
||||||
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
|
<Item icon="car-sport" label="Vehicles" onPress={() => navigation.navigate('Vehicles')} />
|
||||||
</View>
|
<Item
|
||||||
|
icon="card"
|
||||||
|
label="Payment methods"
|
||||||
|
onPress={() => navigation.navigate('PaymentMethods')}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
icon="notifications"
|
||||||
|
label="Notifications"
|
||||||
|
onPress={() => navigation.navigate('Notifications')}
|
||||||
|
/>
|
||||||
|
<Item icon="information-circle" label="About" onPress={() => navigation.navigate('About')} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text style={[styles.section, { color: colors.subtext }]}>Settings</Text>
|
<Text style={[styles.section, { color: colors.subtext }]}>Settings</Text>
|
||||||
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
|
|
@ -66,12 +78,11 @@ export function AccountScreen() {
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
{isAnonymous ? null : (
|
||||||
style={[styles.logout, { borderColor: colors.danger }]}
|
<TouchableOpacity style={[styles.logout, { borderColor: colors.danger }]} onPress={logout}>
|
||||||
onPress={logout}
|
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out</Text>
|
||||||
>
|
</TouchableOpacity>
|
||||||
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out</Text>
|
)}
|
||||||
</TouchableOpacity>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
Image,
|
Image,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
|
|
@ -13,7 +14,7 @@ import { useAuth } from '@/auth/AuthContext';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
|
|
||||||
export function LoginScreen() {
|
export function LoginScreen() {
|
||||||
const { login, error } = useAuth();
|
const { login, error, enterAnonymous } = useAuth();
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
@ -30,6 +31,25 @@ export function LoginScreen() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAnonymous = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'Park without signing in',
|
||||||
|
'Works without a login:\n' +
|
||||||
|
' • Browse parking areas on the map\n' +
|
||||||
|
' • See free (2h/3h/4h) vs pay-immediately zones\n' +
|
||||||
|
' • Start free-parking check-in timers with reminders\n\n' +
|
||||||
|
'Needs a ParkSmarter login:\n' +
|
||||||
|
' • Paying for parking\n' +
|
||||||
|
' • Your active & past sessions\n' +
|
||||||
|
' • Saved vehicles & payment methods\n\n' +
|
||||||
|
'You can sign in anytime from the Account tab.',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Continue', onPress: enterAnonymous },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={[styles.container, { backgroundColor: colors.bg }]}
|
style={[styles.container, { backgroundColor: colors.bg }]}
|
||||||
|
|
@ -77,6 +97,10 @@ export function LoginScreen() {
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.secondary} onPress={onAnonymous} disabled={busy}>
|
||||||
|
<Text style={[styles.secondaryText, { color: colors.primary }]}>Park without signing in</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<Text style={[styles.hint, { color: colors.subtext }]}>
|
<Text style={[styles.hint, { color: colors.subtext }]}>
|
||||||
Forgot your password? Reset it via the official app — this build reuses the same
|
Forgot your password? Reset it via the official app — this build reuses the same
|
||||||
account.
|
account.
|
||||||
|
|
@ -101,5 +125,7 @@ const styles = StyleSheet.create({
|
||||||
button: { borderRadius: 10, padding: 16, alignItems: 'center' },
|
button: { borderRadius: 10, padding: 16, alignItems: 'center' },
|
||||||
buttonDisabled: { opacity: 0.6 },
|
buttonDisabled: { opacity: 0.6 },
|
||||||
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||||
|
secondary: { padding: 14, alignItems: 'center', marginTop: 4 },
|
||||||
|
secondaryText: { fontWeight: '700', fontSize: 15 },
|
||||||
hint: { fontSize: 12, textAlign: 'center', marginTop: 16 },
|
hint: { fontSize: 12, textAlign: 'center', marginTop: 16 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
import { useLocation, type Coords } from '@/features/location/useLocation';
|
import { useLocation, type Coords } from '@/features/location/useLocation';
|
||||||
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
|
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import type { Zone } from 'parksmarter-client';
|
import type { Zone } from 'parksmarter-client';
|
||||||
|
|
||||||
|
|
@ -48,6 +50,7 @@ export function MapScreen() {
|
||||||
const { mode } = useTheme();
|
const { mode } = useTheme();
|
||||||
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
||||||
const { coords, refresh } = useLocation();
|
const { coords, refresh } = useLocation();
|
||||||
|
const { isAnonymous } = useAuth();
|
||||||
const [zones, setZones] = useState<Zone[]>([]);
|
const [zones, setZones] = useState<Zone[]>([]);
|
||||||
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
|
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -107,25 +110,43 @@ export function MapScreen() {
|
||||||
}
|
}
|
||||||
}, [mapReady, initialCenter]);
|
}, [mapReady, initialCenter]);
|
||||||
|
|
||||||
const searchAt = useCallback(async (c: Coords, label: string) => {
|
const searchAt = useCallback(
|
||||||
setLoading(true);
|
async (c: Coords, label: string) => {
|
||||||
setStatus(`Searching ${label}…`);
|
setLoading(true);
|
||||||
try {
|
setStatus(`Searching ${label}…`);
|
||||||
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
|
try {
|
||||||
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
|
if (isAnonymous) {
|
||||||
setZones(found);
|
// No ParkSmarter login — read areas from our mirror (all of them; the
|
||||||
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
|
// covered area is small). The device GPS is never sent.
|
||||||
} catch (e: any) {
|
const all = await getMirrorZones();
|
||||||
setZones([]);
|
const found = all.filter((z) => z.Lat != null && z.Long != null);
|
||||||
setStatus(
|
setZones(found);
|
||||||
e?.status === 401
|
setStatus(found.length ? `${found.length} areas` : 'No areas mirrored yet — sign in to load them.');
|
||||||
? 'Session expired — sign in again.'
|
return;
|
||||||
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
|
}
|
||||||
);
|
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
|
||||||
} finally {
|
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
|
||||||
setLoading(false);
|
setZones(found);
|
||||||
}
|
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
|
||||||
}, []);
|
void syncZones(found); // admin-only; no-ops otherwise
|
||||||
|
} catch (e: any) {
|
||||||
|
setZones([]);
|
||||||
|
setStatus(
|
||||||
|
e?.status === 401
|
||||||
|
? 'Session expired — sign in again.'
|
||||||
|
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isAnonymous],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anonymous: load the mirrored areas once on open (no ParkSmarter needed).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAnonymous) void searchAt(DEFAULT_CENTER, 'Sandpoint');
|
||||||
|
}, [isAnonymous, searchAt]);
|
||||||
|
|
||||||
// Search whatever the map is currently centered on. This only ever sends the
|
// Search whatever the map is currently centered on. This only ever sends the
|
||||||
// map's center point — never the device GPS. (If you want to search your own
|
// map's center point — never the device GPS. (If you want to search your own
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import type { SpacePolicy } from 'parksmarter-client';
|
import type { SpacePolicy } from 'parksmarter-client';
|
||||||
import { getAdminToken } from '@/api/adminStore';
|
import { getAdminToken } from '@/api/adminStore';
|
||||||
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import {
|
import {
|
||||||
deleteLabel,
|
deleteLabel,
|
||||||
getCachedLabel,
|
getCachedLabel,
|
||||||
|
|
@ -87,6 +88,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
export function MeterDetailScreen() {
|
export function MeterDetailScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { isAnonymous, requireLogin } = useAuth();
|
||||||
const { params } = useRoute<DetailRoute>();
|
const { params } = useRoute<DetailRoute>();
|
||||||
const z = params.zone;
|
const z = params.zone;
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
@ -135,18 +137,38 @@ export function MeterDetailScreen() {
|
||||||
Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`);
|
Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const freeHours = label ? labelHours(label.kind) : null;
|
const labeledHours = label ? labelHours(label.kind) : null;
|
||||||
|
|
||||||
const onCheckin = async () => {
|
const doCheckin = async (hours: number) => {
|
||||||
if (!freeHours) return;
|
await startCheckin(z, hours);
|
||||||
await startCheckin(z, freeHours);
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Checked in',
|
'Checked in',
|
||||||
`Free parking for ${freeHours}h. You'll get a heads-up before it ends — with buttons to pay or end.`,
|
`Free timer set for ${hours}h. You'll get a heads-up before it ends — with buttons to pay or end.`,
|
||||||
[{ text: 'OK', onPress: () => navigation.navigate('Tabs') }],
|
[{ text: 'OK', onPress: () => navigation.navigate('Tabs') }],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPay = () => {
|
||||||
|
if (isAnonymous) {
|
||||||
|
Alert.alert('Sign in to pay', 'Paying for parking needs a ParkSmarter login.', [
|
||||||
|
{ text: 'Not now', style: 'cancel' },
|
||||||
|
{ text: 'Sign in', onPress: requireLogin },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigation.navigate('StartSession', { zone: z });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always available — pick a duration (defaults to the labeled limit if any).
|
||||||
|
const onCheckin = () => {
|
||||||
|
Alert.alert('Free check-in', 'Start a local timer for the free limit here:', [
|
||||||
|
{ text: labeledHours === 2 ? '2 hours ✓' : '2 hours', onPress: () => doCheckin(2) },
|
||||||
|
{ text: labeledHours === 3 ? '3 hours ✓' : '3 hours', onPress: () => doCheckin(3) },
|
||||||
|
{ text: labeledHours === 4 ? '4 hours ✓' : '4 hours', onPress: () => doCheckin(4) },
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
const firstSpace = z.Spaces?.[0];
|
const firstSpace = z.Spaces?.[0];
|
||||||
const currentPolicy = firstSpace?.Policies?.find((p) => p.CurrentSlot);
|
const currentPolicy = firstSpace?.Policies?.find((p) => p.CurrentSlot);
|
||||||
|
|
||||||
|
|
@ -258,24 +280,19 @@ export function MeterDetailScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{freeHours ? (
|
<TouchableOpacity style={[styles.button, { backgroundColor: '#1b5e20' }]} onPress={onCheckin}>
|
||||||
<TouchableOpacity style={[styles.button, { backgroundColor: '#1b5e20' }]} onPress={onCheckin}>
|
<Text style={[styles.buttonText, { color: '#fff' }]}>
|
||||||
<Text style={[styles.buttonText, { color: '#fff' }]}>
|
{labeledHours ? `Check in (free · ${labeledHours}h)` : 'Check in (free timer)'}
|
||||||
Check in (free · {freeHours}h)
|
</Text>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{label?.kind === 'pay_immediate' ? (
|
{label?.kind === 'pay_immediate' ? (
|
||||||
<Text style={[styles.note, { color: colors.subtext }]}>Pay immediately — no free window here.</Text>
|
<Text style={[styles.note, { color: colors.subtext }]}>Pay immediately — no free window here.</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity style={[styles.button, { backgroundColor: colors.card }]} onPress={onPay}>
|
||||||
style={[styles.button, { backgroundColor: freeHours ? colors.card : colors.primary }]}
|
<Text style={[styles.buttonText, { color: colors.text }]}>
|
||||||
onPress={() => navigation.navigate('StartSession', { zone: z })}
|
{isAnonymous ? 'Sign in to pay' : 'Start parking session (pay)'}
|
||||||
>
|
|
||||||
<Text style={[styles.buttonText, { color: freeHours ? colors.text : '#fff' }]}>
|
|
||||||
Start parking session
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
|
import { getMirrorZones } from '@/api/zoneMirror';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
@ -52,6 +54,7 @@ export function parseScannedCode(raw: string): { code?: string; isAppLink?: bool
|
||||||
|
|
||||||
export function ScanScreen() {
|
export function ScanScreen() {
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { isAnonymous } = useAuth();
|
||||||
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);
|
||||||
|
|
@ -66,6 +69,29 @@ export function ScanScreen() {
|
||||||
const lookupCode = useCallback(
|
const lookupCode = useCallback(
|
||||||
async (code: string) => {
|
async (code: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
// Anonymous: no ParkSmarter API — match against the mirrored areas.
|
||||||
|
if (isAnonymous) {
|
||||||
|
try {
|
||||||
|
const lc = code.toLowerCase();
|
||||||
|
const zone = (await getMirrorZones()).find(
|
||||||
|
(z) =>
|
||||||
|
String(z.ScannerCode ?? '').toLowerCase() === lc ||
|
||||||
|
String(z.ZoneName ?? '').toLowerCase() === lc ||
|
||||||
|
String(z.TerminalSerNo ?? '') === code,
|
||||||
|
);
|
||||||
|
if (zone) {
|
||||||
|
setManualOpen(false);
|
||||||
|
navigation.navigate('MeterDetail', { zone });
|
||||||
|
} else {
|
||||||
|
Alert.alert('Not found', `No mirrored area matches "${code}". Sign in to search live.`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Lookup failed', e?.message ?? 'Could not reach the area mirror.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// A scanned/typed code can resolve by terminal serial, scanner code, or
|
// A scanned/typed code can resolve by terminal serial, scanner code, or
|
||||||
// zone name. Unmatched lookups return an error envelope (which the client
|
// zone name. Unmatched lookups return an error envelope (which the client
|
||||||
// throws), and ZoneName is case-sensitive server-side — the official app
|
// throws), and ZoneName is case-sensitive server-side — the official app
|
||||||
|
|
@ -103,7 +129,7 @@ export function ScanScreen() {
|
||||||
Alert.alert('Lookup failed', 'Could not reach ParkSmarter. Check your connection.');
|
Alert.alert('Lookup failed', 'Could not reach ParkSmarter. Check your connection.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigation],
|
[navigation, isAnonymous],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onScanned = useCallback(
|
const onScanned = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View }
|
||||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
||||||
|
|
@ -12,11 +13,13 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
export function SessionsScreen() {
|
export function SessionsScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
|
const { isAnonymous, requireLogin } = useAuth();
|
||||||
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);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
if (isAnonymous) return;
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const [a, p] = await Promise.all([
|
const [a, p] = await Promise.all([
|
||||||
|
|
@ -28,7 +31,7 @@ export function SessionsScreen() {
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isAnonymous]);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
|
@ -36,6 +39,25 @@ export function SessionsScreen() {
|
||||||
}, [load]),
|
}, [load]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isAnonymous) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.center, { backgroundColor: colors.bg }]}>
|
||||||
|
<Text style={[styles.zone, { color: colors.text, marginBottom: 8 }]}>
|
||||||
|
Sign in to see your sessions
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.empty, { color: colors.subtext, textAlign: 'center', marginBottom: 16 }]}>
|
||||||
|
Your active and past parking sessions live in your ParkSmarter account.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.card, { backgroundColor: colors.primary, paddingHorizontal: 28 }]}
|
||||||
|
onPress={requireLogin}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ backgroundColor: colors.bg }}
|
style={{ backgroundColor: colors.bg }}
|
||||||
|
|
@ -84,6 +106,7 @@ export function SessionsScreen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
|
||||||
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||||
empty: { marginBottom: 8 },
|
empty: { marginBottom: 8 },
|
||||||
card: { borderRadius: 10, padding: 14, marginBottom: 10 },
|
card: { borderRadius: 10, padding: 14, marginBottom: 10 },
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@ export async function buildApp(opts: BuildOptions) {
|
||||||
opts.blockCooldownMs ?? 60 * 60 * 1000,
|
opts.blockCooldownMs ?? 60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
const app = Fastify({ trustProxy: opts.trustProxy ?? true, logger: false, bodyLimit: 16 * 1024 });
|
// 1 MB: zone-sync batches carry full Zone objects (policies, logos, …).
|
||||||
|
const app = Fastify({ trustProxy: opts.trustProxy ?? true, logger: false, bodyLimit: 1024 * 1024 });
|
||||||
|
|
||||||
await app.register(rateLimit, {
|
await app.register(rateLimit, {
|
||||||
max: opts.rateLimitMax ?? 120,
|
max: opts.rateLimitMax ?? 120,
|
||||||
|
|
@ -111,6 +112,37 @@ export async function buildApp(opts: BuildOptions) {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- Zone mirror (for anonymous browsing) --------------------------------
|
||||||
|
const coord = (v: unknown): number | null => {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) && n !== 0 ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/api/zones', async () => ({ zones: db.allZones(), count: db.zoneCount() }));
|
||||||
|
|
||||||
|
// The app pushes the Zone list it just pulled (authed) from ParkSmarter so
|
||||||
|
// anonymous users can read areas without a ParkSmarter login.
|
||||||
|
app.post(
|
||||||
|
'/api/zones/sync',
|
||||||
|
{ preHandler: requireAdmin, ...writeLimit },
|
||||||
|
async (req: FastifyRequest<{ Body: { zones?: unknown[] } }>, reply) => {
|
||||||
|
const zones = Array.isArray(req.body?.zones) ? req.body!.zones : null;
|
||||||
|
if (!zones) return reply.code(400).send({ error: 'zones_required' });
|
||||||
|
const rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }> = [];
|
||||||
|
for (const z of zones as Array<Record<string, unknown>>) {
|
||||||
|
if (z == null || z.ZoneId == null) continue;
|
||||||
|
rows.push({
|
||||||
|
zoneId: String(z.ZoneId),
|
||||||
|
zoneName: z.ZoneName != null ? String(z.ZoneName) : null,
|
||||||
|
lat: coord(z.Lat),
|
||||||
|
long: coord(z.Long),
|
||||||
|
data: JSON.stringify(z),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { synced: db.upsertZones(rows), total: db.zoneCount() };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.addHook('onClose', async () => db.close());
|
app.addHook('onClose', async () => db.close());
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,46 @@ export class LabelDb {
|
||||||
reason TEXT,
|
reason TEXT,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS zones (
|
||||||
|
zone_id TEXT PRIMARY KEY,
|
||||||
|
zone_name TEXT,
|
||||||
|
lat REAL,
|
||||||
|
long REAL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Upsert mirrored parking areas (full Zone JSON in `data`). Returns count. */
|
||||||
|
upsertZones(
|
||||||
|
rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }>,
|
||||||
|
): number {
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO zones (zone_id, zone_name, lat, long, data, updated_at)
|
||||||
|
VALUES (@zoneId, @zoneName, @lat, @long, @data, @updatedAt)
|
||||||
|
ON CONFLICT(zone_id) DO UPDATE SET
|
||||||
|
zone_name = excluded.zone_name,
|
||||||
|
lat = excluded.lat, long = excluded.long,
|
||||||
|
data = excluded.data, updated_at = excluded.updated_at`,
|
||||||
|
);
|
||||||
|
const now = Date.now();
|
||||||
|
this.db.transaction((items: typeof rows) => {
|
||||||
|
for (const it of items) stmt.run({ ...it, updatedAt: now });
|
||||||
|
})(rows);
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All mirrored zones as their original Zone objects. */
|
||||||
|
allZones(): unknown[] {
|
||||||
|
const rows = this.db.prepare('SELECT data FROM zones').all() as { data: string }[];
|
||||||
|
return rows.map((r) => JSON.parse(r.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneCount(): number {
|
||||||
|
return (this.db.prepare('SELECT COUNT(*) AS n FROM zones').get() as { n: number }).n;
|
||||||
|
}
|
||||||
|
|
||||||
all(): ZoneLabel[] {
|
all(): ZoneLabel[] {
|
||||||
return (this.db.prepare('SELECT * FROM zone_labels ORDER BY zone_id').all() as Row[]).map(toLabel);
|
return (this.db.prepare('SELECT * FROM zone_labels ORDER BY zone_id').all() as Row[]).map(toLabel);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,38 @@ test('rate limit returns 429 past the threshold', async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('zone mirror: admin sync then public read', async () => {
|
||||||
|
const app = await make();
|
||||||
|
// sync requires auth
|
||||||
|
let r = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/zones/sync',
|
||||||
|
payload: { zones: [{ ZoneId: 113165, ZoneName: 'DL', Lat: 48.27, Long: -116.55 }] },
|
||||||
|
});
|
||||||
|
assert.equal(r.statusCode, 401);
|
||||||
|
// authed sync (one zone lacks ZoneId → skipped)
|
||||||
|
r = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/zones/sync',
|
||||||
|
headers: auth,
|
||||||
|
payload: {
|
||||||
|
zones: [
|
||||||
|
{ ZoneId: 113165, ZoneName: 'DL', Lat: 48.27, Long: -116.55, Spaces: [{ SpaceId: 1 }] },
|
||||||
|
{ ZoneName: 'no-id' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(r.statusCode, 200);
|
||||||
|
assert.equal(r.json().synced, 1);
|
||||||
|
// public read returns full Zone objects
|
||||||
|
r = await app.inject({ method: 'GET', url: '/api/zones' });
|
||||||
|
assert.equal(r.statusCode, 200);
|
||||||
|
assert.equal(r.json().count, 1);
|
||||||
|
assert.equal(r.json().zones[0].ZoneName, 'DL');
|
||||||
|
assert.equal(r.json().zones[0].Spaces[0].SpaceId, 1);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('seeded IP denylist blocks with 403', async () => {
|
test('seeded IP denylist blocks with 403', async () => {
|
||||||
// app.inject uses 127.0.0.1 as the client IP
|
// app.inject uses 127.0.0.1 as the client IP
|
||||||
const app = await make({ seedBlockedIps: ['127.0.0.1'] });
|
const app = await make({ seedBlockedIps: ['127.0.0.1'] });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue