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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue