From eeaf09ea0eb7e702fa10f312c637e3571956f604 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 24 Jul 2026 18:18:35 +0000 Subject: [PATCH] app: zone-labels client + admin labeling UX (Phase B) + default map = downtown Sandpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - zoneLabels client (cached fetch wrapper) hitting bigbrainparking.mowden.top; adminStore keeps the admin password in secure-store. - Admin screen (verify + save password) reachable from Account → Admin. - MeterDetail shows a label badge and, for admins, 2h/3h/4h/Pay-now/Clear labeling controls that PUT/DELETE to the API. - app.json extra.zoneLabelsApiUrl. - Map default view is now downtown Sandpoint (48.2766,-116.5533 @ z14) instead of the continental-US fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app.json | 1 + app/src/api/adminStore.ts | 18 ++++ app/src/api/zoneLabels.ts | 142 ++++++++++++++++++++++++++ app/src/navigation/RootNavigator.tsx | 3 + app/src/screens/AccountScreen.tsx | 5 + app/src/screens/AdminScreen.tsx | 109 ++++++++++++++++++++ app/src/screens/MapScreen.tsx | 6 +- app/src/screens/MeterDetailScreen.tsx | 104 ++++++++++++++++++- 8 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 app/src/api/adminStore.ts create mode 100644 app/src/api/zoneLabels.ts create mode 100644 app/src/screens/AdminScreen.tsx diff --git a/app/app.json b/app/app.json index 4c83119..d6aed01 100644 --- a/app/app.json +++ b/app/app.json @@ -44,6 +44,7 @@ ], "extra": { "psEnvironment": "prodv2", + "zoneLabelsApiUrl": "https://bigbrainparking.mowden.top", "mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty", "mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", "unifiedPushDefaultDistributor": "io.heckel.ntfy", diff --git a/app/src/api/adminStore.ts b/app/src/api/adminStore.ts new file mode 100644 index 0000000..62b8619 --- /dev/null +++ b/app/src/api/adminStore.ts @@ -0,0 +1,18 @@ +import * as SecureStore from 'expo-secure-store'; + +/** + * The zone-labels admin password, kept in the OS keystore (never AsyncStorage). + * Its presence is what unlocks the labeling controls; it's sent as a Bearer token + * to the bigbrainparking.mowden.top API on writes. + */ +const ADMIN_KEY = 'ps_admin_token'; + +export function getAdminToken(): Promise { + return SecureStore.getItemAsync(ADMIN_KEY); +} + +export function setAdminToken(token: string | null): Promise { + return token + ? SecureStore.setItemAsync(ADMIN_KEY, token) + : SecureStore.deleteItemAsync(ADMIN_KEY); +} diff --git a/app/src/api/zoneLabels.ts b/app/src/api/zoneLabels.ts new file mode 100644 index 0000000..90bdbba --- /dev/null +++ b/app/src/api/zoneLabels.ts @@ -0,0 +1,142 @@ +import Constants from 'expo-constants'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { getAdminToken } from './adminStore'; + +/** + * Client for the BigBrainParking zone-labels API (bigbrainparking.mowden.top). + * Reads are public and cached locally so check-in works offline; writes require + * the admin token. This is a plain fetch wrapper — the ParkSmarter HttpClient is + * too domain-specific (its own auth headers / rolling tokens) to reuse here. + */ + +export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate'; + +export interface ZoneLabel { + zoneId: string; + customerId: string | null; + zoneName: string | null; + kind: LabelKind; + note: string | null; + updatedAt: number; + updatedBy: string | null; +} + +const CACHE_KEY = 'ps_zone_labels'; +const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, ''); + +export class ZoneLabelsError extends Error { + constructor( + message: string, + readonly status?: number, + ) { + super(message); + this.name = 'ZoneLabelsError'; + } +} + +/** Hours of free parking for a kind, or null for pay-immediate. */ +export function labelHours(kind: LabelKind): number | null { + return kind === 'free_2h' ? 2 : kind === 'free_3h' ? 3 : kind === 'free_4h' ? 4 : null; +} + +/** Short human label for a kind. */ +export function labelText(kind: LabelKind): string { + switch (kind) { + case 'free_2h': + return 'Free · 2h limit'; + case 'free_3h': + return 'Free · 3h limit'; + case 'free_4h': + return 'Free · 4h limit'; + case 'pay_immediate': + return 'Pay immediately'; + } +} + +let memCache: Record | null = null; + +async function loadCache(): Promise> { + if (memCache) return memCache; + const raw = await AsyncStorage.getItem(CACHE_KEY); + memCache = raw ? (JSON.parse(raw) as Record) : {}; + return memCache; +} + +async function saveCache(map: Record): Promise { + memCache = map; + await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(map)); +} + +function ensureConfigured(): void { + if (!BASE_URL) throw new ZoneLabelsError('zoneLabelsApiUrl is not configured'); +} + +async function authHeaders(): Promise> { + const token = await getAdminToken(); + if (!token) throw new ZoneLabelsError('not authenticated as admin'); + return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; +} + +/** Pull all labels and refresh the local cache. */ +export async function refreshLabels(): Promise { + ensureConfigured(); + const res = await fetch(`${BASE_URL}/api/labels`); + if (!res.ok) throw new ZoneLabelsError('labels fetch failed', res.status); + const body = (await res.json()) as { labels?: ZoneLabel[] }; + const map: Record = {}; + for (const l of body.labels ?? []) map[String(l.zoneId)] = l; + await saveCache(map); +} + +/** Cached label for a zone, or null. Never hits the network. */ +export async function getCachedLabel( + zoneId: number | string | null | undefined, +): Promise { + if (zoneId == null) return null; + const map = await loadCache(); + return map[String(zoneId)] ?? null; +} + +export async function setLabel( + zoneId: number | string, + kind: LabelKind, + meta: { zoneName?: string | null; customerId?: string | number | null } = {}, +): Promise { + ensureConfigured(); + const res = await fetch(`${BASE_URL}/api/labels/${encodeURIComponent(String(zoneId))}`, { + method: 'PUT', + headers: await authHeaders(), + body: JSON.stringify({ + kind, + zoneName: meta.zoneName ?? null, + customerId: meta.customerId ?? null, + }), + }); + if (!res.ok) throw new ZoneLabelsError('set label failed', res.status); + const label = (await res.json()) as ZoneLabel; + const map = await loadCache(); + map[String(zoneId)] = label; + await saveCache(map); + return label; +} + +export async function deleteLabel(zoneId: number | string): Promise { + ensureConfigured(); + const res = await fetch(`${BASE_URL}/api/labels/${encodeURIComponent(String(zoneId))}`, { + method: 'DELETE', + headers: await authHeaders(), + }); + if (!res.ok && res.status !== 404) throw new ZoneLabelsError('delete failed', res.status); + const map = await loadCache(); + delete map[String(zoneId)]; + await saveCache(map); +} + +/** Verify a candidate admin token against the server (for the settings "Test" button). */ +export async function verifyAdmin(token: string): Promise { + ensureConfigured(); + const res = await fetch(`${BASE_URL}/api/whoami`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return res.ok; +} diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx index bc78967..304cc9b 100644 --- a/app/src/navigation/RootNavigator.tsx +++ b/app/src/navigation/RootNavigator.tsx @@ -23,6 +23,7 @@ import { NotificationsScreen } from '@/screens/NotificationsScreen'; import { StartSessionScreen } from '@/screens/StartSessionScreen'; import { SessionDetailScreen } from '@/screens/SessionDetailScreen'; import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen'; +import { AdminScreen } from '@/screens/AdminScreen'; import { useTheme } from '@/theme/ThemeContext'; import { useSessionStatusSync } from '@/notifications/sessionStatus'; import type { ActiveSession, PastSession, Zone } from 'parksmarter-client'; @@ -38,6 +39,7 @@ export type RootStackParamList = { PaymentMethods: undefined; Notifications: undefined; Diagnostics: undefined; + Admin: undefined; }; export type TabParamList = { @@ -148,6 +150,7 @@ export function RootNavigator() { component={DiagnosticsScreen} options={{ title: 'Diagnostics' }} /> + ) : ( diff --git a/app/src/screens/AccountScreen.tsx b/app/src/screens/AccountScreen.tsx index dbcfd90..772c954 100644 --- a/app/src/screens/AccountScreen.tsx +++ b/app/src/screens/AccountScreen.tsx @@ -59,6 +59,11 @@ export function AccountScreen() { navigation.navigate('Diagnostics')} /> + navigation.navigate('Admin')} + /> { + void getAdminToken().then((t) => setAuthed(!!t)); + }, []); + + const saveAndTest = async () => { + const token = value.trim(); + if (!token) return; + setBusy(true); + try { + const ok = await verifyAdmin(token); + if (!ok) { + Alert.alert('Not accepted', 'That password was rejected by the server.'); + return; + } + await setAdminToken(token); + setAuthed(true); + setValue(''); + Alert.alert('Admin enabled', 'You can now label zones from the meter screen.'); + } catch (e: any) { + Alert.alert('Could not verify', e?.message ?? 'Check your connection and try again.'); + } finally { + setBusy(false); + } + }; + + const signOut = async () => { + await setAdminToken(null); + setAuthed(false); + Alert.alert('Signed out', 'Admin labeling is now disabled on this device.'); + }; + + return ( + + + Zone labeling {authed ? '· enabled' : '· disabled'} + + + + + The admin password lets you tag zones as free (2h/3h/4h) or pay-immediately. + It's stored securely on this device and sent only to + bigbrainparking.mowden.top. + + + + {busy ? ( + + ) : ( + Verify & save + )} + + + + {authed ? ( + + Sign out of admin + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + section: { marginBottom: 8, marginLeft: 4, fontSize: 13, fontWeight: '600' }, + card: { borderRadius: 12, padding: 16, gap: 12 }, + help: { fontSize: 13, lineHeight: 19 }, + input: { borderWidth: 1, borderRadius: 10, padding: 12, fontSize: 16 }, + button: { borderRadius: 10, padding: 14, alignItems: 'center' }, + buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 }, + signout: { marginTop: 24, borderWidth: 1.5, borderRadius: 12, padding: 14, alignItems: 'center' }, +}); diff --git a/app/src/screens/MapScreen.tsx b/app/src/screens/MapScreen.tsx index 8ef643d..b494ff6 100644 --- a/app/src/screens/MapScreen.tsx +++ b/app/src/screens/MapScreen.tsx @@ -24,9 +24,9 @@ const MAP_STYLE_DARK = (Constants.expoConfig?.extra?.mapStyleUrlDark as string) ?? 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; -// Fallback view when we have no GPS and no cached location (continental US). -const DEFAULT_CENTER: Coords = { latitude: 39.5, longitude: -98.35 }; -const DEFAULT_ZOOM = 4; +// Default view when we have no last-session lot: all of downtown Sandpoint, ID. +const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 }; +const DEFAULT_ZOOM = 14; type Nav = NativeStackNavigationProp; diff --git a/app/src/screens/MeterDetailScreen.tsx b/app/src/screens/MeterDetailScreen.tsx index 72a62fa..4c9075c 100644 --- a/app/src/screens/MeterDetailScreen.tsx +++ b/app/src/screens/MeterDetailScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import type { RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native'; @@ -7,6 +7,25 @@ import type { RootStackParamList } from '@/navigation/RootNavigator'; import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore'; import { useTheme } from '@/theme/ThemeContext'; import type { SpacePolicy } from 'parksmarter-client'; +import { getAdminToken } from '@/api/adminStore'; +import { + deleteLabel, + getCachedLabel, + labelText, + refreshLabels, + setLabel, + type LabelKind, + type ZoneLabel, +} from '@/api/zoneLabels'; + +/** Admin labeling buttons — kind, or 'clear' to remove. */ +const LABEL_CHOICES: Array<{ label: string; kind: LabelKind | 'clear' }> = [ + { label: '2h', kind: 'free_2h' }, + { label: '3h', kind: 'free_3h' }, + { label: '4h', kind: 'free_4h' }, + { label: 'Pay now', kind: 'pay_immediate' }, + { label: 'Clear', kind: 'clear' }, +]; type DetailRoute = RouteProp; @@ -69,6 +88,44 @@ export function MeterDetailScreen() { const { params } = useRoute(); const z = params.zone; const [saved, setSaved] = useState(false); + const [label, setLabelState] = useState(null); + const [isAdmin, setIsAdmin] = useState(false); + const [savingKind, setSavingKind] = useState(null); + + useEffect(() => { + let alive = true; + void getAdminToken().then((t) => alive && setIsAdmin(!!t)); + void getCachedLabel(z.ZoneId).then((l) => alive && setLabelState(l)); + // Refresh from the server, then re-read this zone's label. + void refreshLabels() + .then(() => getCachedLabel(z.ZoneId)) + .then((l) => alive && setLabelState(l)) + .catch(() => {}); + return () => { + alive = false; + }; + }, [z.ZoneId]); + + const applyLabel = async (kind: LabelKind | 'clear') => { + if (z.ZoneId == null) return; + setSavingKind(kind); + try { + if (kind === 'clear') { + await deleteLabel(z.ZoneId); + setLabelState(null); + } else { + const l = await setLabel(z.ZoneId, kind, { + zoneName: z.ZoneName ?? null, + customerId: z.CustomerId ?? null, + }); + setLabelState(l); + } + } catch (e: any) { + Alert.alert('Could not save label', e?.message ?? 'error'); + } finally { + setSavingKind(null); + } + }; const onSave = async () => { await saveKiosk(toSavedKiosk(z)); @@ -98,6 +155,17 @@ export function MeterDetailScreen() { {z.ZoneLocation} ) : null} + + + {label ? labelText(label.kind) : 'Unlabeled'} + + + @@ -136,6 +204,36 @@ export function MeterDetailScreen() { ) : null} + {isAdmin ? ( + + Label this zone (admin) + + {LABEL_CHOICES.map((c) => { + const active = label?.kind === c.kind; + return ( + applyLabel(c.kind)} + disabled={savingKind != null} + > + + {c.label} + + + ); + })} + + + ) : null} +