From 4a5660e7e192e30aeeceec4107f8c9968e9319b9 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 3 Aug 2026 21:38:32 +0000 Subject: [PATCH] v0.4.0: Anonymous Mode + zone-location mirror; free check-in on any zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/app.json | 4 +- app/src/api/zoneMirror.ts | 34 +++++++++++++++ app/src/auth/AuthContext.tsx | 28 ++++++++++++- app/src/navigation/RootNavigator.tsx | 2 +- app/src/screens/AccountScreen.tsx | 55 +++++++++++++++---------- app/src/screens/LoginScreen.tsx | 28 ++++++++++++- app/src/screens/MapScreen.tsx | 59 ++++++++++++++++++--------- app/src/screens/MeterDetailScreen.tsx | 53 ++++++++++++++++-------- app/src/screens/ScanScreen.tsx | 28 ++++++++++++- app/src/screens/SessionsScreen.tsx | 25 +++++++++++- server/src/app.ts | 34 ++++++++++++++- server/src/db.ts | 37 +++++++++++++++++ server/test/labels.test.ts | 32 +++++++++++++++ 13 files changed, 351 insertions(+), 68 deletions(-) create mode 100644 app/src/api/zoneMirror.ts diff --git a/app/app.json b/app/app.json index 204b435..5215ecc 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.3.0", + "version": "0.4.0", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 17, + "versionCode": 18, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/api/zoneMirror.ts b/app/src/api/zoneMirror.ts new file mode 100644 index 0000000..9521565 --- /dev/null +++ b/app/src/api/zoneMirror.ts @@ -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 { + 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 { + 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 */ + } +} diff --git a/app/src/auth/AuthContext.tsx b/app/src/auth/AuthContext.tsx index 7f31272..1d94e82 100644 --- a/app/src/auth/AuthContext.tsx +++ b/app/src/auth/AuthContext.tsx @@ -9,13 +9,18 @@ import { ps } from '@/api/client'; import { authBus } from '@/auth/authBus'; import type { ApplicationValidityResponse } from 'parksmarter-client'; -type AuthStatus = 'loading' | 'signedOut' | 'signedIn'; +type AuthStatus = 'loading' | 'signedOut' | 'signedIn' | 'anonymous'; interface AuthState { status: AuthStatus; validity: ApplicationValidityResponse | null; login: (phoneNumber: string, password: string) => Promise; logout: () => Promise; + /** 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; } @@ -85,8 +90,27 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setStatus('signedOut'); }; + const enterAnonymous = () => { + setError(null); + setStatus('anonymous'); + }; + + const requireLogin = () => { + setError(null); + setStatus('signedOut'); + }; + const value = useMemo( - () => ({ status, validity, login, logout, error }), + () => ({ + status, + validity, + login, + logout, + enterAnonymous, + requireLogin, + isAnonymous: status === 'anonymous', + error, + }), [status, validity, error], ); diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx index 9ea996c..57da0bd 100644 --- a/app/src/navigation/RootNavigator.tsx +++ b/app/src/navigation/RootNavigator.tsx @@ -117,7 +117,7 @@ export function RootNavigator() { return ( - {status === 'signedIn' ? ( + {status === 'signedIn' || status === 'anonymous' ? ( ; export function AccountScreen() { const { colors, mode, toggle } = useTheme(); - const { logout } = useAuth(); + const { logout, isAnonymous, requireLogin } = useAuth(); const navigation = useNavigation