v0.4.0: Anonymous Mode + zone-location mirror; free check-in on any zone
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:
Erik 2026-08-03 21:38:32 +00:00
parent 463facbe5a
commit 4a5660e7e1
13 changed files with 351 additions and 68 deletions

View file

@ -14,6 +14,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '@/theme/ThemeContext';
import { ps } from '@/api/client';
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 { Zone } from 'parksmarter-client';
@ -48,6 +50,7 @@ export function MapScreen() {
const { mode } = useTheme();
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
const { coords, refresh } = useLocation();
const { isAnonymous } = useAuth();
const [zones, setZones] = useState<Zone[]>([]);
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
const [loading, setLoading] = useState(false);
@ -107,25 +110,43 @@ export function MapScreen() {
}
}, [mapReady, initialCenter]);
const searchAt = useCallback(async (c: Coords, label: string) => {
setLoading(true);
setStatus(`Searching ${label}`);
try {
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
setZones(found);
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
} catch (e: any) {
setZones([]);
setStatus(
e?.status === 401
? 'Session expired — sign in again.'
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
);
} finally {
setLoading(false);
}
}, []);
const searchAt = useCallback(
async (c: Coords, label: string) => {
setLoading(true);
setStatus(`Searching ${label}`);
try {
if (isAnonymous) {
// No ParkSmarter login — read areas from our mirror (all of them; the
// covered area is small). The device GPS is never sent.
const all = await getMirrorZones();
const found = all.filter((z) => z.Lat != null && z.Long != null);
setZones(found);
setStatus(found.length ? `${found.length} areas` : 'No areas mirrored yet — sign in to load them.');
return;
}
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
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
// map's center point — never the device GPS. (If you want to search your own