Initial commit: parksmarter-client + BigBrainParking app
Reverse-engineered ParkSmarter API client (TypeScript, live-verified) plus a de-Googled Expo/React Native app for GrapheneOS: MapLibre meter map with GPS, VisionCamera QR kiosk scanning with save/share, local session-expiry reminders, UnifiedPush wiring, and Gitea CI to publish signed APKs for Obtainium. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
1dede50995
39 changed files with 3671 additions and 0 deletions
62
.gitea/workflows/build-apk.yml
Normal file
62
.gitea/workflows/build-apk.yml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Build a signed release APK and publish it as a Gitea/Forgejo release.
|
||||
# Obtainium then tracks this repo's releases and offers updates.
|
||||
#
|
||||
# Triggers on any tag like v0.1.0. Requires a self-hosted Actions runner that has
|
||||
# (or a container image that provides) the Android SDK + JDK 17 + Node 20.
|
||||
#
|
||||
# Required repo secrets (Settings -> Actions -> Secrets):
|
||||
# ANDROID_KEYSTORE_B64 base64 of your release keystore (keep this key forever —
|
||||
# Obtainium/F-Droid pin the signer; a new key = users must reinstall)
|
||||
# ANDROID_KEYSTORE_PASSWORD
|
||||
# ANDROID_KEY_ALIAS
|
||||
# ANDROID_KEY_PASSWORD
|
||||
#
|
||||
# Generate the keystore once, locally:
|
||||
# keytool -genkeypair -v -keystore bigbrainparking.keystore \
|
||||
# -alias bigbrainparking -keyalg RSA -keysize 2048 -validity 10000
|
||||
# base64 -w0 bigbrainparking.keystore # -> ANDROID_KEYSTORE_B64
|
||||
|
||||
name: build-apk
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# A container with Android SDK + Node preinstalled keeps the runner simple:
|
||||
container:
|
||||
image: reactnativecommunity/react-native-android:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build the API client
|
||||
run: npm run build --workspace parksmarter-client
|
||||
|
||||
- name: Restore signing keystore
|
||||
run: |
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > "$PWD/app/release.keystore"
|
||||
|
||||
- name: Expo prebuild (generate android/)
|
||||
working-directory: app
|
||||
run: npx expo prebuild --platform android --no-install
|
||||
|
||||
- name: Assemble signed release
|
||||
working-directory: app/android
|
||||
env:
|
||||
BBP_UPLOAD_STORE_FILE: ../release.keystore
|
||||
BBP_UPLOAD_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
BBP_UPLOAD_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
BBP_UPLOAD_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: ./gradlew assembleRelease --no-daemon
|
||||
|
||||
- name: Publish release with APK
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
files: app/android/app/build/outputs/apk/release/*.apk
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Builds
|
||||
dist/
|
||||
app/android/
|
||||
app/ios/
|
||||
app/.expo/
|
||||
*.apk
|
||||
*.aab
|
||||
|
||||
# Secrets / local capture
|
||||
**/.creds.json
|
||||
**/capture/
|
||||
*.keystore
|
||||
*.jks
|
||||
|
||||
# The original app artifact (local only, not source)
|
||||
*.xapk
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
*.log
|
||||
66
README.md
Normal file
66
README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# BigBrainParking
|
||||
|
||||
An unofficial, de-Googled client for the ParkSmarter (IPS Group) parking system,
|
||||
built to run on **GrapheneOS** with **UnifiedPush** notifications and distributed
|
||||
via **Obtainium** — no Google Play Services, no Firebase, no app store.
|
||||
|
||||
## Monorepo layout
|
||||
|
||||
```
|
||||
bigbrainparking/
|
||||
├── parksmarter-client/ # Zero-dep TypeScript API client (reverse-engineered, live-verified)
|
||||
├── app/ # Expo / React Native app (BigBrainParking)
|
||||
└── .gitea/workflows/ # CI: build signed APK -> publish release for Obtainium
|
||||
```
|
||||
|
||||
- **`parksmarter-client`** — the API layer. All ~40 endpoints, the custom-header auth
|
||||
model, and response models (most verified against production). Runs anywhere; the app
|
||||
imports it directly. See its own README for the API details.
|
||||
- **`app`** — the phone app. React Native (Expo prebuild), MapLibre maps, VisionCamera QR
|
||||
scanning, expo-secure-store token storage, local session-expiry reminders, and
|
||||
UnifiedPush wiring.
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| Phone + password login | ✅ wired | tokens in OS keystore |
|
||||
| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) |
|
||||
| "Near my last location" proximity search | ✅ wired | caches last GPS fix |
|
||||
| QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera |
|
||||
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
|
||||
| Active / past sessions | ✅ wired | list views |
|
||||
| Start a paid session | 🟡 gated | flow wired to `postStartParkingSession`, disabled pending review (real charge) |
|
||||
| Session-expiry reminders | ✅ wired | **local** notifications — no FCM needed |
|
||||
| UnifiedPush (ntfy) | 🟡 partial | endpoint registration done; server→ntfy bridge still needed (see below) |
|
||||
|
||||
## Build & run (dev)
|
||||
|
||||
Requires Node 20, JDK 17, Android SDK, and a GrapheneOS device (or any Android device)
|
||||
with USB debugging.
|
||||
|
||||
```bash
|
||||
npm install # installs both workspaces
|
||||
npm run build --workspace parksmarter-client # compile the client
|
||||
cd app
|
||||
npx expo prebuild --platform android # generate native project
|
||||
npx expo run:android # build + install a dev client
|
||||
```
|
||||
|
||||
The app talks to prod (`apiv2.parksmarter.com`) by default — change `extra.psEnvironment`
|
||||
in `app/app.json` to point elsewhere.
|
||||
|
||||
## Notifications on GrapheneOS
|
||||
|
||||
Session-expiry reminders are scheduled **locally** from each session's end time, so they
|
||||
need no push infrastructure and work fully offline of Google. `app/src/notifications/`
|
||||
also wires **UnifiedPush** (distributor: ntfy) for any genuinely server-initiated push —
|
||||
but note the ParkSmarter backend only pushes via **FCM**, so server push requires a small
|
||||
**FCM→ntfy bridge** (a service holding an FCM token that forwards to your ntfy topic, then
|
||||
registered via `PUT /api/Device`). Until that exists, local reminders cover the main case.
|
||||
|
||||
## Distribution via Obtainium (self-hosted)
|
||||
|
||||
Tag a release and CI builds a signed APK and publishes it to this repo's releases; your
|
||||
phone's Obtainium tracks the repo and offers updates. See
|
||||
[`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) for the full server + CI + keystore setup.
|
||||
16
app/App.tsx
Normal file
16
app/App.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { AuthProvider } from '@/auth/AuthContext';
|
||||
import { RootNavigator } from '@/navigation/RootNavigator';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<AuthProvider>
|
||||
<RootNavigator />
|
||||
<StatusBar style="auto" />
|
||||
</AuthProvider>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
49
app/app.json
Normal file
49
app/app.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "BigBrainParking",
|
||||
"slug": "bigbrainparking",
|
||||
"scheme": "bigbrainparking",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"android": {
|
||||
"package": "top.mowden.bigbrainparking",
|
||||
"permissions": [
|
||||
"ACCESS_COARSE_LOCATION",
|
||||
"ACCESS_FINE_LOCATION",
|
||||
"CAMERA",
|
||||
"POST_NOTIFICATIONS",
|
||||
"INTERNET",
|
||||
"ACCESS_NETWORK_STATE"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationWhenInUsePermission": "Show nearby parking meters on the map."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/notification-icon.png"
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-vision-camera",
|
||||
{
|
||||
"cameraPermissionText": "Scan the QR code on a parking kiosk."
|
||||
}
|
||||
],
|
||||
"./plugins/withReleaseSigning"
|
||||
],
|
||||
"extra": {
|
||||
"psEnvironment": "prodv2",
|
||||
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
|
||||
"unifiedPushDefaultDistributor": "io.heckel.ntfy"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
app/babel.config.js
Normal file
14
app/babel.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
alias: { '@': './src' },
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
};
|
||||
4
app/index.ts
Normal file
4
app/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { registerRootComponent } from 'expo';
|
||||
import App from './App';
|
||||
|
||||
registerRootComponent(App);
|
||||
20
app/metro.config.js
Normal file
20
app/metro.config.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Metro config for an Expo app inside an npm-workspaces monorepo.
|
||||
// Lets Metro resolve and watch the sibling `parksmarter-client` package.
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const workspaceRoot = path.resolve(projectRoot, '..');
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// Watch the whole monorepo so edits to parksmarter-client hot-reload.
|
||||
config.watchFolders = [workspaceRoot];
|
||||
|
||||
// Resolve modules from both the app and the workspace root.
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
module.exports = config;
|
||||
38
app/package.json
Normal file
38
app/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "bigbrainparking-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start --dev-client",
|
||||
"android": "expo run:android",
|
||||
"prebuild": "expo prebuild --platform android --clean",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"parksmarter-client": "*",
|
||||
"expo": "~53.0.0",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
"expo-location": "~18.0.0",
|
||||
"expo-notifications": "~0.29.0",
|
||||
"expo-constants": "~17.0.0",
|
||||
"expo-localization": "~16.0.0",
|
||||
"expo-linking": "~7.0.0",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"@react-navigation/native-stack": "^7.0.0",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@maplibre/maplibre-react-native": "^10.0.0",
|
||||
"react-native-vision-camera": "^4.6.0",
|
||||
"react-native-unifiedpush": "^2.0.0",
|
||||
"react": "19.0.0",
|
||||
"react-native": "0.79.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.0.0",
|
||||
"typescript": "~5.4.0"
|
||||
}
|
||||
}
|
||||
46
app/plugins/withReleaseSigning.js
Normal file
46
app/plugins/withReleaseSigning.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Expo config plugin: inject a release signingConfig into android/app/build.gradle
|
||||
* that reads credentials from environment variables (set by CI). This survives
|
||||
* `expo prebuild --clean`, so signed builds are reproducible without committing
|
||||
* the generated android/ directory.
|
||||
*
|
||||
* Env vars (see .gitea/workflows/build-apk.yml):
|
||||
* BBP_UPLOAD_STORE_FILE, BBP_UPLOAD_STORE_PASSWORD,
|
||||
* BBP_UPLOAD_KEY_ALIAS, BBP_UPLOAD_KEY_PASSWORD
|
||||
*
|
||||
* If the env vars are absent (local dev), the build falls back to the debug key.
|
||||
*/
|
||||
const { withAppBuildGradle } = require('@expo/config-plugins');
|
||||
|
||||
const SIGNING_BLOCK = `
|
||||
release {
|
||||
def storeFilePath = System.getenv("BBP_UPLOAD_STORE_FILE")
|
||||
if (storeFilePath != null) {
|
||||
storeFile file(storeFilePath)
|
||||
storePassword System.getenv("BBP_UPLOAD_STORE_PASSWORD")
|
||||
keyAlias System.getenv("BBP_UPLOAD_KEY_ALIAS")
|
||||
keyPassword System.getenv("BBP_UPLOAD_KEY_PASSWORD")
|
||||
}
|
||||
}`;
|
||||
|
||||
module.exports = function withReleaseSigning(config) {
|
||||
return withAppBuildGradle(config, (cfg) => {
|
||||
let gradle = cfg.modResults.contents;
|
||||
|
||||
// 1) Add a `release` signingConfig next to the default `debug` one.
|
||||
if (!gradle.includes('BBP_UPLOAD_STORE_FILE')) {
|
||||
gradle = gradle.replace(
|
||||
/signingConfigs \{/,
|
||||
`signingConfigs {${SIGNING_BLOCK}`,
|
||||
);
|
||||
// 2) Point the release buildType at it *only* when the keystore env is set.
|
||||
gradle = gradle.replace(
|
||||
/(buildTypes \{[\s\S]*?release \{)/,
|
||||
`$1\n if (System.getenv("BBP_UPLOAD_STORE_FILE") != null) { signingConfig signingConfigs.release }`,
|
||||
);
|
||||
}
|
||||
|
||||
cfg.modResults.contents = gradle;
|
||||
return cfg;
|
||||
});
|
||||
};
|
||||
17
app/src/api/client.ts
Normal file
17
app/src/api/client.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import Constants from 'expo-constants';
|
||||
import * as Localization from 'expo-localization';
|
||||
import { ParkSmarterClient, type EnvironmentName } from 'parksmarter-client';
|
||||
import { secureTokenStore } from './secureTokenStore';
|
||||
|
||||
const env =
|
||||
(Constants.expoConfig?.extra?.psEnvironment as EnvironmentName) ?? 'prodv2';
|
||||
|
||||
/**
|
||||
* The single app-wide API client. React Native ships a global `fetch`, so no
|
||||
* fetchImpl override is needed. Tokens persist in the OS keystore.
|
||||
*/
|
||||
export const ps = new ParkSmarterClient({
|
||||
environment: env,
|
||||
tokens: secureTokenStore,
|
||||
localeCode: (Localization.getLocales()[0]?.languageCode ?? 'en').toLowerCase(),
|
||||
});
|
||||
24
app/src/api/secureTokenStore.ts
Normal file
24
app/src/api/secureTokenStore.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as SecureStore from 'expo-secure-store';
|
||||
import type { TokenStore } from 'parksmarter-client';
|
||||
|
||||
/**
|
||||
* A TokenStore backed by the OS keystore (expo-secure-store).
|
||||
*
|
||||
* On GrapheneOS/Android this uses the hardware-backed Keystore, so the user's
|
||||
* Auth_Token and SessionId are encrypted at rest — never plain AsyncStorage.
|
||||
*/
|
||||
const AUTH_KEY = 'ps_auth_token';
|
||||
const SESSION_KEY = 'ps_session_id';
|
||||
|
||||
export const secureTokenStore: TokenStore = {
|
||||
getAuthToken: () => SecureStore.getItemAsync(AUTH_KEY),
|
||||
setAuthToken: (token) =>
|
||||
token
|
||||
? SecureStore.setItemAsync(AUTH_KEY, token)
|
||||
: SecureStore.deleteItemAsync(AUTH_KEY),
|
||||
getSessionId: () => SecureStore.getItemAsync(SESSION_KEY),
|
||||
setSessionId: (id) =>
|
||||
id
|
||||
? SecureStore.setItemAsync(SESSION_KEY, id)
|
||||
: SecureStore.deleteItemAsync(SESSION_KEY),
|
||||
};
|
||||
78
app/src/auth/AuthContext.tsx
Normal file
78
app/src/auth/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ps } from '@/api/client';
|
||||
import type { ApplicationValidityResponse } from 'parksmarter-client';
|
||||
|
||||
type AuthStatus = 'loading' | 'signedOut' | 'signedIn';
|
||||
|
||||
interface AuthState {
|
||||
status: AuthStatus;
|
||||
validity: ApplicationValidityResponse | null;
|
||||
login: (phoneNumber: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>('loading');
|
||||
const [validity, setValidity] = useState<ApplicationValidityResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// On launch: bootstrap (seeds SessionId + feature flags) and probe for an
|
||||
// existing token by attempting an authenticated read.
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const v = await ps.getApplicationValidity();
|
||||
setValidity(v);
|
||||
const existing = await ps.tokens.getAuthToken();
|
||||
if (existing) {
|
||||
await ps.getUserDetail(); // 401 throws -> treated as signed out
|
||||
setStatus('signedIn');
|
||||
} else {
|
||||
setStatus('signedOut');
|
||||
}
|
||||
} catch {
|
||||
setStatus('signedOut');
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const login = async (phoneNumber: string, password: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await ps.loginWithPhone({ phoneNumber, password });
|
||||
setStatus('signedIn');
|
||||
} catch (e: any) {
|
||||
// A 201 from /api/Auth means the account needs SMS verification first.
|
||||
if (e?.status === 201) setError('Account needs verification. Check your texts.');
|
||||
else setError(e?.serverMessage ?? e?.message ?? 'Login failed.');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await ps.logoutLocal();
|
||||
setStatus('signedOut');
|
||||
};
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
() => ({ status, validity, login, logout, error }),
|
||||
[status, validity, error],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
79
app/src/features/favorites/favoritesStore.ts
Normal file
79
app/src/features/favorites/favoritesStore.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Share } from 'react-native';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
|
||||
/**
|
||||
* Saved kiosks live entirely on-device. The ParkSmarter server exposes no
|
||||
* favorites API (the `FavoriteID` field never appears in live responses), so
|
||||
* "save" and "share" are client-side features we own.
|
||||
*/
|
||||
export interface SavedKiosk {
|
||||
/** Stable local id — the scanner code or serial identifies the kiosk. */
|
||||
key: string;
|
||||
zoneName?: string;
|
||||
scannerCode?: string;
|
||||
terminalSerNo?: string;
|
||||
zoneId?: number;
|
||||
customerId?: number;
|
||||
lat?: number;
|
||||
long?: number;
|
||||
savedAt: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const KEY = 'ps_saved_kiosks';
|
||||
|
||||
export function kioskKey(z: Pick<Zone, 'ScannerCode' | 'TerminalSerNo' | 'ZoneId'>): string {
|
||||
return String(z.ScannerCode ?? z.TerminalSerNo ?? z.ZoneId ?? 'unknown');
|
||||
}
|
||||
|
||||
export function toSavedKiosk(z: Zone, note?: string): SavedKiosk {
|
||||
return {
|
||||
key: kioskKey(z),
|
||||
zoneName: z.ZoneName,
|
||||
scannerCode: z.ScannerCode,
|
||||
terminalSerNo: z.TerminalSerNo,
|
||||
zoneId: z.ZoneId,
|
||||
customerId: z.CustomerId,
|
||||
lat: z.Lat,
|
||||
long: z.Long,
|
||||
savedAt: Date.now(),
|
||||
note,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listKiosks(): Promise<SavedKiosk[]> {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as SavedKiosk[]) : [];
|
||||
}
|
||||
|
||||
export async function saveKiosk(k: SavedKiosk): Promise<SavedKiosk[]> {
|
||||
const all = await listKiosks();
|
||||
const next = [k, ...all.filter((x) => x.key !== k.key)];
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function removeKiosk(key: string): Promise<SavedKiosk[]> {
|
||||
const next = (await listKiosks()).filter((x) => x.key !== key);
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a saved kiosk. Uses a `parksmarteralt://` deep link so another install
|
||||
* of this app can open straight to the kiosk; falls back to human-readable text.
|
||||
*/
|
||||
export async function shareKiosk(k: SavedKiosk): Promise<void> {
|
||||
const code = k.scannerCode ?? k.terminalSerNo ?? '';
|
||||
const deepLink = `bigbrainparking://kiosk?code=${encodeURIComponent(code)}`;
|
||||
const geo =
|
||||
k.lat != null && k.long != null ? `\nMap: geo:${k.lat},${k.long}` : '';
|
||||
await Share.share({
|
||||
message:
|
||||
`Parking kiosk${k.zoneName ? `: ${k.zoneName}` : ''}` +
|
||||
(code ? `\nCode: ${code}` : '') +
|
||||
geo +
|
||||
`\nOpen in ParkSmarter: ${deepLink}`,
|
||||
});
|
||||
}
|
||||
63
app/src/features/location/useLocation.ts
Normal file
63
app/src/features/location/useLocation.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as Location from 'expo-location';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export interface Coords {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
const LAST_LOC_KEY = 'ps_last_location';
|
||||
|
||||
/** Persist the most recent fix so the "near my last location" button works cold. */
|
||||
async function saveLastLocation(c: Coords) {
|
||||
await AsyncStorage.setItem(LAST_LOC_KEY, JSON.stringify(c));
|
||||
}
|
||||
export async function getLastKnownSavedLocation(): Promise<Coords | null> {
|
||||
const raw = await AsyncStorage.getItem(LAST_LOC_KEY);
|
||||
return raw ? (JSON.parse(raw) as Coords) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground location. On GrapheneOS this uses the OS location provider directly
|
||||
* (no Google Play Services). We prefer a fast last-known fix, then refine.
|
||||
*/
|
||||
export function useLocation() {
|
||||
const [coords, setCoords] = useState<Coords | null>(null);
|
||||
const [granted, setGranted] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
const ok = status === 'granted';
|
||||
setGranted(ok);
|
||||
if (!ok) {
|
||||
setError('Location permission denied.');
|
||||
return null;
|
||||
}
|
||||
const last = await Location.getLastKnownPositionAsync();
|
||||
if (last) {
|
||||
const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
|
||||
setCoords(c);
|
||||
void saveLastLocation(c);
|
||||
}
|
||||
const cur = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
});
|
||||
const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude };
|
||||
setCoords(c);
|
||||
void saveLastLocation(c);
|
||||
return c;
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Failed to get location.');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { coords, granted, error, refresh };
|
||||
}
|
||||
68
app/src/navigation/RootNavigator.tsx
Normal file
68
app/src/navigation/RootNavigator.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import React from 'react';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { LoginScreen } from '@/screens/LoginScreen';
|
||||
import { MapScreen } from '@/screens/MapScreen';
|
||||
import { ScanScreen } from '@/screens/ScanScreen';
|
||||
import { FavoritesScreen } from '@/screens/FavoritesScreen';
|
||||
import { SessionsScreen } from '@/screens/SessionsScreen';
|
||||
import { MeterDetailScreen } from '@/screens/MeterDetailScreen';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Tabs: undefined;
|
||||
MeterDetail: { zone: Zone };
|
||||
};
|
||||
|
||||
export type TabParamList = {
|
||||
Map: undefined;
|
||||
Scan: undefined;
|
||||
Favorites: undefined;
|
||||
Sessions: undefined;
|
||||
};
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<TabParamList>();
|
||||
|
||||
function Tabs() {
|
||||
return (
|
||||
<Tab.Navigator screenOptions={{ headerShown: true }}>
|
||||
<Tab.Screen name="Map" component={MapScreen} />
|
||||
<Tab.Screen name="Scan" component={ScanScreen} />
|
||||
<Tab.Screen name="Favorites" component={FavoritesScreen} />
|
||||
<Tab.Screen name="Sessions" component={SessionsScreen} />
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
export function RootNavigator() {
|
||||
const { status } = useAuth();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
{status === 'signedIn' ? (
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="MeterDetail"
|
||||
component={MeterDetailScreen}
|
||||
options={{ title: 'Meter' }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
) : (
|
||||
<LoginScreen />
|
||||
)}
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
58
app/src/notifications/localReminders.ts
Normal file
58
app/src/notifications/localReminders.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
/**
|
||||
* Session-expiry reminders as LOCAL scheduled notifications.
|
||||
*
|
||||
* This is the key GrapheneOS win: because the app knows a session's end time,
|
||||
* we schedule the reminder on-device — no server push, no FCM, no Play Services.
|
||||
* UnifiedPush (see unifiedPush.ts) is reserved for genuinely server-initiated
|
||||
* events that we can't predict locally.
|
||||
*/
|
||||
|
||||
export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
const settings = await Notifications.getPermissionsAsync();
|
||||
if (settings.granted) return true;
|
||||
const req = await Notifications.requestPermissionsAsync();
|
||||
return req.granted;
|
||||
}
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export interface ScheduleReminderArgs {
|
||||
transactionId: string | number;
|
||||
zoneName: string;
|
||||
/** When the parking session ends. */
|
||||
endTime: Date;
|
||||
/** Fire this many minutes before end (default 10). */
|
||||
leadMinutes?: number;
|
||||
}
|
||||
|
||||
/** Schedule an expiry reminder. Returns the notification id (for later cancel). */
|
||||
export async function scheduleExpiryReminder(
|
||||
args: ScheduleReminderArgs,
|
||||
): Promise<string | null> {
|
||||
const lead = (args.leadMinutes ?? 10) * 60 * 1000;
|
||||
const fireAt = new Date(args.endTime.getTime() - lead);
|
||||
if (fireAt.getTime() <= Date.now()) return null; // already too late
|
||||
|
||||
return Notifications.scheduleNotificationAsync({
|
||||
identifier: `session-${args.transactionId}`,
|
||||
content: {
|
||||
title: 'Parking expiring soon',
|
||||
body: `${args.zoneName} ends at ${args.endTime.toLocaleTimeString()}. Extend if you need more time.`,
|
||||
data: { transactionId: String(args.transactionId) },
|
||||
},
|
||||
trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: fireAt },
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelExpiryReminder(transactionId: string | number): Promise<void> {
|
||||
await Notifications.cancelScheduledNotificationAsync(`session-${transactionId}`);
|
||||
}
|
||||
62
app/src/notifications/unifiedPush.ts
Normal file
62
app/src/notifications/unifiedPush.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { ps } from '@/api/client';
|
||||
|
||||
/**
|
||||
* UnifiedPush integration (distributor: ntfy).
|
||||
*
|
||||
* IMPORTANT ARCHITECTURE NOTE
|
||||
* ---------------------------
|
||||
* The ParkSmarter backend only delivers push via FCM, keyed on the device token
|
||||
* registered through `PUT /api/Device`. A UnifiedPush endpoint (an ntfy URL)
|
||||
* cannot receive FCM directly, so server-initiated push requires a BRIDGE:
|
||||
*
|
||||
* ParkSmarter server --FCM--> [bridge holding an FCM token] --HTTP--> ntfy topic
|
||||
* |
|
||||
* UnifiedPush distributor (ntfy app)
|
||||
* v
|
||||
* this app
|
||||
*
|
||||
* Until that bridge exists, session-expiry reminders are handled entirely by
|
||||
* on-device local notifications (see localReminders.ts), which covers the main
|
||||
* use case without any server push. This module wires the UnifiedPush side so
|
||||
* the app is ready to be a push target once the bridge (or a self-hosted relay
|
||||
* that we register with `PUT /api/Device`) is in place.
|
||||
*
|
||||
* The `react-native-unifiedpush` API surface varies by version; treat the calls
|
||||
* below as the integration point to confirm against the installed version.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const UnifiedPush = require('react-native-unifiedpush');
|
||||
|
||||
const ENDPOINT_KEY = 'ps_unifiedpush_endpoint';
|
||||
const INSTANCE = 'default';
|
||||
|
||||
/** Kick off distributor discovery + registration (call after login). */
|
||||
export async function registerUnifiedPush(): Promise<void> {
|
||||
const distributors: string[] = await UnifiedPush.getDistributors();
|
||||
if (!distributors.length) {
|
||||
// No UnifiedPush distributor installed (e.g. ntfy). Local reminders still work.
|
||||
return;
|
||||
}
|
||||
const saved = await UnifiedPush.getSavedDistributor?.();
|
||||
const distributor = saved ?? distributors[0];
|
||||
await UnifiedPush.saveDistributor(distributor);
|
||||
await UnifiedPush.registerDevice(INSTANCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the endpoint the distributor hands back (wire this to the library's
|
||||
* `onNewEndpoint` event in the app root). We persist it and, once a bridge is
|
||||
* available, register it with the backend so the server can reach us.
|
||||
*/
|
||||
export async function onNewEndpoint(endpoint: string): Promise<void> {
|
||||
await AsyncStorage.setItem(ENDPOINT_KEY, endpoint);
|
||||
// When the FCM->ntfy bridge is live, register the bridge-issued token here:
|
||||
// await ps.updateDeviceToken({ pushNotificationsToken: bridgeToken, deviceType: '1' });
|
||||
void ps; // referenced so the intended integration is explicit
|
||||
}
|
||||
|
||||
export async function getSavedEndpoint(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(ENDPOINT_KEY);
|
||||
}
|
||||
84
app/src/screens/FavoritesScreen.tsx
Normal file
84
app/src/screens/FavoritesScreen.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import {
|
||||
listKiosks,
|
||||
removeKiosk,
|
||||
shareKiosk,
|
||||
type SavedKiosk,
|
||||
} from '@/features/favorites/favoritesStore';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export function FavoritesScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const [items, setItems] = useState<SavedKiosk[]>([]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void listKiosks().then(setItems);
|
||||
}, []),
|
||||
);
|
||||
|
||||
const open = async (k: SavedKiosk) => {
|
||||
// Re-fetch the live zone (rates/occupancy change) before showing detail.
|
||||
const res = k.scannerCode
|
||||
? await ps.getMetersByScannerCode(k.scannerCode)
|
||||
: k.terminalSerNo
|
||||
? await ps.getMetersBySerialNumber(k.terminalSerNo)
|
||||
: null;
|
||||
const zone = res?.Zones?.[0];
|
||||
if (zone) navigation.navigate('MeterDetail', { zone });
|
||||
};
|
||||
|
||||
const remove = async (k: SavedKiosk) => setItems(await removeKiosk(k.key));
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
data={items}
|
||||
keyExtractor={(k) => k.key}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.empty}>
|
||||
No saved kiosks yet. Scan a kiosk QR and tap “Save kiosk”.
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.card}>
|
||||
<TouchableOpacity style={{ flex: 1 }} onPress={() => open(item)}>
|
||||
<Text style={styles.name}>{item.zoneName ?? item.key}</Text>
|
||||
<Text style={styles.meta}>
|
||||
{item.scannerCode ? `Code ${item.scannerCode}` : `Serial ${item.terminalSerNo}`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.action} onPress={() => shareKiosk(item)}>
|
||||
<Text style={styles.actionText}>Share</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.action} onPress={() => remove(item)}>
|
||||
<Text style={[styles.actionText, { color: '#c0392b' }]}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
empty: { color: '#888', textAlign: 'center', marginTop: 48 },
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f4f4f4',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
gap: 10,
|
||||
},
|
||||
name: { fontSize: 16, fontWeight: '600' },
|
||||
meta: { color: '#777', fontSize: 12, marginTop: 2 },
|
||||
action: { paddingHorizontal: 6, paddingVertical: 4 },
|
||||
actionText: { color: '#1e6f5c', fontWeight: '600' },
|
||||
});
|
||||
100
app/src/screens/LoginScreen.tsx
Normal file
100
app/src/screens/LoginScreen.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
|
||||
export function LoginScreen() {
|
||||
const { login, error } = useAuth();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(phone.replace(/\D/g, ''), password);
|
||||
} catch {
|
||||
// error surfaced via context
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<Text style={styles.title}>BigBrainParking</Text>
|
||||
<Text style={styles.subtitle}>Sign in with your phone number</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Phone number"
|
||||
keyboardType="phone-pad"
|
||||
autoComplete="tel"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, busy && styles.buttonDisabled]}
|
||||
disabled={busy}
|
||||
onPress={onSubmit}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ height: 12 }} />
|
||||
<Text style={styles.hint}>
|
||||
Forgot your password? Use the official app or a reset SMS — this build reuses
|
||||
the same account.
|
||||
</Text>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 24, justifyContent: 'center' },
|
||||
title: { fontSize: 32, fontWeight: '700', textAlign: 'center' },
|
||||
subtitle: { fontSize: 15, color: '#666', textAlign: 'center', marginBottom: 24 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
error: { color: '#c0392b', marginBottom: 12 },
|
||||
button: {
|
||||
backgroundColor: '#1e6f5c',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonDisabled: { opacity: 0.6 },
|
||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
hint: { color: '#888', fontSize: 12, textAlign: 'center' },
|
||||
});
|
||||
155
app/src/screens/MapScreen.tsx
Normal file
155
app/src/screens/MapScreen.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import {
|
||||
MapView,
|
||||
Camera,
|
||||
MarkerView,
|
||||
UserLocation,
|
||||
} from '@maplibre/maplibre-react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import { useLocation, getLastKnownSavedLocation, type Coords } from '@/features/location/useLocation';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
|
||||
const MAP_STYLE =
|
||||
(Constants.expoConfig?.extra?.mapStyleUrl as string) ??
|
||||
'https://tiles.openfreemap.org/styles/liberty';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export function MapScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { coords, refresh } = useLocation();
|
||||
const [zones, setZones] = useState<Zone[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [center, setCenter] = useState<Coords | null>(null);
|
||||
|
||||
const loadMeters = useCallback(async (c: Coords) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
|
||||
setZones((res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null));
|
||||
setCenter(c);
|
||||
} catch {
|
||||
setZones([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-load meters once we have a current fix.
|
||||
useEffect(() => {
|
||||
if (coords && !center) void loadMeters(coords);
|
||||
}, [coords, center, loadMeters]);
|
||||
|
||||
const searchHere = async () => {
|
||||
const c = coords ?? (await refresh());
|
||||
if (c) await loadMeters(c);
|
||||
};
|
||||
|
||||
const searchLastKnown = async () => {
|
||||
const last = (await getLastKnownSavedLocation()) ?? coords;
|
||||
if (last) await loadMeters(last);
|
||||
};
|
||||
|
||||
const initial = center ?? coords;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* mapStyle / props are version-sensitive in @maplibre/maplibre-react-native */}
|
||||
<MapView style={styles.map} mapStyle={MAP_STYLE}>
|
||||
{initial ? (
|
||||
<Camera
|
||||
zoomLevel={15}
|
||||
centerCoordinate={[initial.longitude, initial.latitude]}
|
||||
animationDuration={0}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<UserLocation visible renderMode="native" />
|
||||
|
||||
{zones.map((z) => (
|
||||
<MarkerView
|
||||
key={String(z.ZoneId ?? z.ScannerCode ?? z.TerminalSerNo)}
|
||||
coordinate={[z.Long as number, z.Lat as number]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('MeterDetail', { zone: z })}
|
||||
style={[
|
||||
styles.marker,
|
||||
{
|
||||
backgroundColor: z.BackgroundColor ?? '#1e6f5c',
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.markerText} numberOfLines={1}>
|
||||
{z.ZoneName ?? 'Meter'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</MarkerView>
|
||||
))}
|
||||
</MapView>
|
||||
|
||||
<View style={styles.controls}>
|
||||
<TouchableOpacity style={styles.pillButton} onPress={searchHere}>
|
||||
<Text style={styles.pillText}>Search here</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.pillButton} onPress={searchLastKnown}>
|
||||
<Text style={styles.pillText}>Near last location</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.countBadge}>
|
||||
<Text style={styles.countText}>{zones.length} meters</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
map: { flex: 1 },
|
||||
marker: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
borderWidth: 2,
|
||||
maxWidth: 140,
|
||||
},
|
||||
markerText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
pillButton: {
|
||||
backgroundColor: '#1e6f5c',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
},
|
||||
pillText: { color: '#fff', fontWeight: '600' },
|
||||
loading: { position: 'absolute', top: 16, right: 16 },
|
||||
countBadge: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
countText: { color: '#fff', fontSize: 12 },
|
||||
});
|
||||
112
app/src/screens/MeterDetailScreen.tsx
Normal file
112
app/src/screens/MeterDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { useRoute } from '@react-navigation/native';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
|
||||
|
||||
type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>;
|
||||
|
||||
export function MeterDetailScreen() {
|
||||
const { params } = useRoute<DetailRoute>();
|
||||
const z = params.zone;
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const onSave = async () => {
|
||||
await saveKiosk(toSavedKiosk(z));
|
||||
setSaved(true);
|
||||
Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`);
|
||||
};
|
||||
|
||||
const firstSpace = z.Spaces?.[0];
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={{ padding: 16 }}>
|
||||
<Text style={styles.title}>{z.ZoneName ?? 'Parking meter'}</Text>
|
||||
{z.ZoneLocation ? <Text style={styles.sub}>{z.ZoneLocation}</Text> : null}
|
||||
|
||||
<View style={styles.row}>
|
||||
<Field label="Scanner code" value={z.ScannerCode} />
|
||||
<Field label="Serial" value={z.TerminalSerNo} />
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Field label="Rate" value={z.Rate != null ? `$${z.Rate}` : undefined} />
|
||||
<Field
|
||||
label="Max time"
|
||||
value={z.MaxTime != null ? `${z.MaxTime} min` : undefined}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Field
|
||||
label="Occupancy"
|
||||
value={z.PercentageFull != null ? `${z.PercentageFull}% full` : undefined}
|
||||
/>
|
||||
<Field label="Spaces" value={z.Spaces ? String(z.Spaces.length) : undefined} />
|
||||
</View>
|
||||
|
||||
{firstSpace?.Policies?.length ? (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Rate policies</Text>
|
||||
{firstSpace.Policies.slice(0, 6).map((p, i) => (
|
||||
<Text key={i} style={styles.policy}>
|
||||
• {p.DisplayString ?? p.RateType ?? 'Policy'}
|
||||
{p.Rate != null ? ` — $${p.Rate}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, saved && styles.buttonSaved]}
|
||||
onPress={onSave}
|
||||
disabled={saved}
|
||||
>
|
||||
<Text style={styles.buttonText}>{saved ? 'Saved ✓' : 'Save kiosk'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.buttonPrimary]}
|
||||
onPress={() =>
|
||||
Alert.alert(
|
||||
'Start session',
|
||||
'Starting a paid session is a real charge — this flow (vehicle + duration + card selection) is wired to postStartParkingSession and will be enabled after review.',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text style={styles.buttonText}>Start parking session</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string }) {
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<Text style={styles.fieldValue}>{value ?? '—'}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
title: { fontSize: 24, fontWeight: '700' },
|
||||
sub: { color: '#666', marginBottom: 12 },
|
||||
row: { flexDirection: 'row', gap: 12, marginTop: 8 },
|
||||
field: { flex: 1, backgroundColor: '#f2f2f2', borderRadius: 10, padding: 12 },
|
||||
fieldLabel: { fontSize: 12, color: '#888' },
|
||||
fieldValue: { fontSize: 16, fontWeight: '600', marginTop: 2 },
|
||||
card: { backgroundColor: '#f7f7f7', borderRadius: 10, padding: 14, marginTop: 16 },
|
||||
cardTitle: { fontWeight: '700', marginBottom: 6 },
|
||||
policy: { color: '#444', marginBottom: 2 },
|
||||
button: {
|
||||
marginTop: 16,
|
||||
backgroundColor: '#555',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonPrimary: { backgroundColor: '#1e6f5c' },
|
||||
buttonSaved: { backgroundColor: '#2e7d32' },
|
||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
});
|
||||
120
app/src/screens/ScanScreen.tsx
Normal file
120
app/src/screens/ScanScreen.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import {
|
||||
Camera,
|
||||
useCameraDevice,
|
||||
useCameraPermission,
|
||||
useCodeScanner,
|
||||
} from 'react-native-vision-camera';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
/**
|
||||
* Scan a kiosk QR code (its `ScannerCode`), look the meter up via the API, and
|
||||
* jump to the detail screen where it can be saved/shared.
|
||||
* Uses on-device VisionCamera code scanning — no Google Play Services.
|
||||
*/
|
||||
export function ScanScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { hasPermission, requestPermission } = useCameraPermission();
|
||||
const device = useCameraDevice('back');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const lock = useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasPermission) void requestPermission();
|
||||
}, [hasPermission, requestPermission]);
|
||||
|
||||
const onScanned = useCallback(
|
||||
async (code: string) => {
|
||||
if (lock.current) return;
|
||||
lock.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await ps.getMetersByScannerCode(code);
|
||||
const zone = res.Zones?.[0];
|
||||
if (zone) navigation.navigate('MeterDetail', { zone });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setTimeout(() => (lock.current = false), 1500);
|
||||
}
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const codeScanner = useCodeScanner({
|
||||
codeTypes: ['qr', 'ean-13', 'code-128'],
|
||||
onCodeScanned: (codes) => {
|
||||
const value = codes[0]?.value;
|
||||
if (value) void onScanned(value);
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.msg}>Camera permission is needed to scan kiosks.</Text>
|
||||
<TouchableOpacity style={styles.button} onPress={requestPermission}>
|
||||
<Text style={styles.buttonText}>Grant camera access</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!device) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.msg}>No camera available.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Camera style={StyleSheet.absoluteFill} device={device} isActive codeScanner={codeScanner} />
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.reticle} />
|
||||
<Text style={styles.hint}>Point at the QR code on the parking kiosk</Text>
|
||||
</View>
|
||||
{busy ? (
|
||||
<View style={styles.busy}>
|
||||
<ActivityIndicator color="#fff" />
|
||||
<Text style={styles.busyText}>Looking up meter…</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000' },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
|
||||
msg: { fontSize: 16, textAlign: 'center', marginBottom: 16 },
|
||||
overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' },
|
||||
reticle: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
borderWidth: 3,
|
||||
borderColor: '#fff',
|
||||
borderRadius: 16,
|
||||
},
|
||||
hint: { color: '#fff', marginTop: 16, fontSize: 14 },
|
||||
button: { backgroundColor: '#1e6f5c', padding: 14, borderRadius: 10 },
|
||||
buttonText: { color: '#fff', fontWeight: '600' },
|
||||
busy: {
|
||||
position: 'absolute',
|
||||
bottom: 40,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
},
|
||||
busyText: { color: '#fff' },
|
||||
});
|
||||
75
app/src/screens/SessionsScreen.tsx
Normal file
75
app/src/screens/SessionsScreen.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { ps } from '@/api/client';
|
||||
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
||||
|
||||
export function SessionsScreen() {
|
||||
const [active, setActive] = useState<ActiveSession[]>([]);
|
||||
const [past, setPast] = useState<PastSession[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [a, p] = await Promise.all([
|
||||
ps.getActiveParkingSessions(),
|
||||
ps.getPastParkingSessions({ currentPage: 1, pageSize: 20 }),
|
||||
]);
|
||||
setActive(a.ParkingSession ?? []);
|
||||
setPast(p.Session ?? []);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void load();
|
||||
}, [load]),
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
|
||||
>
|
||||
<Text style={styles.header}>Active</Text>
|
||||
{active.length === 0 ? (
|
||||
<Text style={styles.empty}>No active sessions.</Text>
|
||||
) : (
|
||||
active.map((s, i) => (
|
||||
<View key={i} style={[styles.card, styles.activeCard]}>
|
||||
<Text style={styles.zone}>{s.ZoneName ?? 'Session'}</Text>
|
||||
<Text style={styles.meta}>
|
||||
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
<Text style={[styles.header, { marginTop: 20 }]}>History</Text>
|
||||
{past.length === 0 ? (
|
||||
<Text style={styles.empty}>No past sessions.</Text>
|
||||
) : (
|
||||
past.map((s, i) => (
|
||||
<View key={i} style={styles.card}>
|
||||
<Text style={styles.zone}>{s.ZoneName ?? 'Session'}</Text>
|
||||
<Text style={styles.meta}>
|
||||
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||
empty: { color: '#888', marginBottom: 8 },
|
||||
card: { backgroundColor: '#f4f4f4', borderRadius: 10, padding: 14, marginBottom: 10 },
|
||||
activeCard: { backgroundColor: '#e8f5e9' },
|
||||
zone: { fontSize: 16, fontWeight: '600' },
|
||||
meta: { color: '#777', fontSize: 13, marginTop: 2 },
|
||||
});
|
||||
12
app/tsconfig.json
Normal file
12
app/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "Bundler",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
98
docs/DISTRIBUTION.md
Normal file
98
docs/DISTRIBUTION.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Distribution: self-hosted CI → Obtainium
|
||||
|
||||
Goal: push a git tag, have your server build a **signed APK**, publish it as a release
|
||||
on `git.mowden.top`, and have **Obtainium** on your GrapheneOS phone auto-offer updates.
|
||||
|
||||
Repo: `ssh://git@git.mowden.top:222/hank/BigBrainParking.git`
|
||||
(web: `https://git.mowden.top/hank/BigBrainParking`)
|
||||
|
||||
---
|
||||
|
||||
## 1. One-time: create the signing key (do this locally)
|
||||
|
||||
The signer is **permanent** — Obtainium (and F-Droid) pin it, so if you ever rebuild with
|
||||
a different key, users must uninstall + reinstall. Keep this keystore backed up and secret.
|
||||
|
||||
```bash
|
||||
keytool -genkeypair -v -keystore bigbrainparking.keystore \
|
||||
-alias bigbrainparking -keyalg RSA -keysize 2048 -validity 10000
|
||||
base64 -w0 bigbrainparking.keystore # copy this string for the CI secret
|
||||
```
|
||||
|
||||
## 2. One-time: add CI secrets
|
||||
|
||||
On `git.mowden.top` → the repo → **Settings → Actions → Secrets**, add:
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `ANDROID_KEYSTORE_B64` | the base64 string from step 1 |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | keystore password |
|
||||
| `ANDROID_KEY_ALIAS` | `bigbrainparking` |
|
||||
| `ANDROID_KEY_PASSWORD` | key password |
|
||||
|
||||
## 3. One-time: an Actions runner that can build Android
|
||||
|
||||
You need a Gitea/Forgejo **Actions runner** registered to this repo/instance. The provided
|
||||
workflow (`.gitea/workflows/build-apk.yml`) runs inside a container image that already has
|
||||
the Android SDK + Node, so the host runner just needs Docker:
|
||||
|
||||
```bash
|
||||
# on your server: install act_runner (Gitea) or forgejo-runner (Forgejo), then:
|
||||
./act_runner register --instance https://git.mowden.top --token <RUNNER_TOKEN>
|
||||
./act_runner daemon # (or a systemd unit)
|
||||
```
|
||||
|
||||
Make sure the runner labels include `ubuntu-latest` (or edit `runs-on:` in the workflow).
|
||||
|
||||
> If `git.mowden.top` is **Forgejo**, move the workflow to `.forgejo/workflows/build-apk.yml`
|
||||
> (Forgejo also reads `.gitea/workflows`, so it usually works as-is).
|
||||
|
||||
## 4. Release flow (every update)
|
||||
|
||||
```bash
|
||||
# bump version in app/app.json ("version") first, then:
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
CI builds `app/android/app/build/outputs/apk/release/*.apk`, signs it, and attaches it to a
|
||||
new release `v0.1.0`. Done.
|
||||
|
||||
## 5. Add the app in Obtainium (on the phone)
|
||||
|
||||
Obtainium supports Gitea/Forgejo release sources. Add app → paste:
|
||||
|
||||
```
|
||||
https://git.mowden.top/hank/BigBrainParking
|
||||
```
|
||||
|
||||
Pick the **Gitea** (or **Forgejo**) source type if prompted, and Obtainium will track
|
||||
releases and offer each new signed APK. Install ntfy + set it as your UnifiedPush
|
||||
distributor for push later.
|
||||
|
||||
### Fallback if your Obtainium build lacks Gitea support
|
||||
|
||||
Serve the APK statically and use Obtainium's **"HTML"** source:
|
||||
|
||||
1. CI (or a hook) copies the APK to a web dir, e.g. `/var/www/bbp/BigBrainParking-<version>.apk`.
|
||||
2. nginx with autoindex on that dir:
|
||||
```nginx
|
||||
location /bbp/ { root /var/www; autoindex on; }
|
||||
```
|
||||
3. In Obtainium, add an **HTML** app pointing at `https://<yourserver>/bbp/` with an APK
|
||||
link filter like `BigBrainParking-.*\.apk` and version extraction from the filename.
|
||||
|
||||
This path skips Gitea releases entirely — it's just a directory of signed APKs — but you
|
||||
lose changelogs and per-release metadata.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Reproducibility / F-Droid later:** if you ever want a real F-Droid repo (signed index,
|
||||
usable by the F-Droid client too), run `fdroidserver` on the server pointed at the same
|
||||
APK output dir. Obtainium consumes F-Droid repos as well. Overkill for one app, but an
|
||||
option.
|
||||
- **Version code:** Expo derives Android `versionCode` from `app.json`. Bump
|
||||
`expo.version` (and optionally set `expo.android.versionCode`) each release so Obtainium
|
||||
sees an increase.
|
||||
8
package.json
Normal file
8
package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "bigbrainparking",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"parksmarter-client",
|
||||
"app"
|
||||
]
|
||||
}
|
||||
7
parksmarter-client/.creds.example.json
Normal file
7
parksmarter-client/.creds.example.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"phoneNumber": "5551234567",
|
||||
"password": "your-parksmarter-password",
|
||||
"environment": "prodv2",
|
||||
"lat": 40.4406,
|
||||
"lng": -79.9959
|
||||
}
|
||||
4
parksmarter-client/.gitignore
vendored
Normal file
4
parksmarter-client/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.creds.json
|
||||
capture/
|
||||
195
parksmarter-client/README.md
Normal file
195
parksmarter-client/README.md
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
# parksmarter-client
|
||||
|
||||
An unofficial, typed **TypeScript** client for the ParkSmarter (IPS Group) parking
|
||||
API. It was reverse-engineered from the official Android app
|
||||
`com.ipsgroupinc.parksmarter` 4.4.0 — a React Native / Hermes build — by decompiling
|
||||
the JS bundle and recovering every endpoint path, header, app token, and
|
||||
request/response field name.
|
||||
|
||||
TypeScript was chosen because the target is "an alternative Android app or web app":
|
||||
the same package runs unchanged in a browser, in React Native (Expo/bare), and in
|
||||
Node 18+. It has **zero runtime dependencies** (uses `fetch`). If you specifically
|
||||
want a native-Kotlin client instead, the `environments.ts` + endpoint table below map
|
||||
directly onto Retrofit/OkHttp — ask and it can be ported.
|
||||
|
||||
## Install / build
|
||||
|
||||
```bash
|
||||
cd parksmarter-client
|
||||
npm install
|
||||
npm run build # emits dist/
|
||||
```
|
||||
|
||||
Import from source (`src/index.ts`) or the built `dist/`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ParkSmarterClient } from './src';
|
||||
|
||||
const ps = new ParkSmarterClient({ environment: 'prodv2' }); // default env
|
||||
|
||||
// 1) Bootstrap — seeds a ParkSmarter_SessionId and returns feature flags.
|
||||
const validity = await ps.getApplicationValidity();
|
||||
if (validity.Config?.IsInMaintenanceMode) throw new Error('maintenance');
|
||||
|
||||
// 2) Log in — Auth_Token + SessionId are stored automatically.
|
||||
await ps.loginWithPhone({ phoneNumber: '5551234567', password: 'hunter2' });
|
||||
|
||||
// 3) Use authenticated endpoints.
|
||||
const me = await ps.getUserDetail();
|
||||
const nearby = await ps.getMetersByLocation({ latitude: 40.44, longitude: -79.99 });
|
||||
|
||||
// 4) Start a session.
|
||||
await ps.startParkingSession({
|
||||
creditCardId: me.CreditCardDetails![0].CCID!,
|
||||
vehicleId: me.VehicleDetails![0].VehicleID!,
|
||||
zoneId: nearby.Zones![0].ZoneId!,
|
||||
spaceId: nearby.Zones![0].Spaces![0].SpaceID!,
|
||||
customerId: nearby.Zones![0].CustomerID!,
|
||||
meterTypeId: nearby.Zones![0].MeterTypeId!,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date(Date.now() + 3600_000).toISOString(),
|
||||
minutesToPurchase: 60,
|
||||
parkingCost: 2.0,
|
||||
transactionFee: 0.35,
|
||||
});
|
||||
```
|
||||
|
||||
### React Native / Node < 18
|
||||
|
||||
Pass a `fetchImpl` if the global `fetch` isn't present, and use persistent storage
|
||||
for tokens:
|
||||
|
||||
```ts
|
||||
import { ParkSmarterClient, TokenStore } from './src';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const tokens: TokenStore = {
|
||||
getAuthToken: () => SecureStore.getItemAsync('ps_auth'),
|
||||
setAuthToken: (t) =>
|
||||
t ? SecureStore.setItemAsync('ps_auth', t) : SecureStore.deleteItemAsync('ps_auth'),
|
||||
getSessionId: () => SecureStore.getItemAsync('ps_session'),
|
||||
setSessionId: (s) =>
|
||||
s ? SecureStore.setItemAsync('ps_session', s) : SecureStore.deleteItemAsync('ps_session'),
|
||||
};
|
||||
|
||||
const ps = new ParkSmarterClient({ tokens /*, fetchImpl: fetch */ });
|
||||
```
|
||||
|
||||
## Authentication model
|
||||
|
||||
This API does **not** use OAuth/Bearer. Auth is carried in custom headers:
|
||||
|
||||
| Header | Meaning | Source |
|
||||
| --- | --- | --- |
|
||||
| `Application_Token` | Identifies the app build. Required on **every** request. | Per-environment constant (baked in). |
|
||||
| `X-Request-Id` | Fresh UUID per request. | Generated by the client. |
|
||||
| `Auth_Token` | The logged-in user's token. | Returned in the **body** of `POST /api/Auth` as `Auth_Token`; sent as a header on authenticated calls. |
|
||||
| `ParkSmarter_SessionId` | Server session id. | Seeded by `GET /api/ApplicationValidity` and login; echoed on some responses. |
|
||||
| `Content-Type: application/json` | On POST/PUT only. | — |
|
||||
|
||||
The client persists `Auth_Token` and `SessionId` into the `TokenStore` automatically.
|
||||
Every request also carries a `localeCode` query param (default `en`).
|
||||
|
||||
> Note: a `POST /api/Auth` returning HTTP **201** is treated by the app as a
|
||||
> "needs verification / not a success" case, not a normal login. Handle 201 distinctly
|
||||
> if you see it.
|
||||
|
||||
## Environments
|
||||
|
||||
| Name | Base URL | Notes |
|
||||
| --- | --- | --- |
|
||||
| `dev` | `https://dev-parksmarter-api.ipsmeters.com` | |
|
||||
| `stage` | `https://staging-parksmarter-api.ipsmeters.com` | |
|
||||
| `test` | `https://testing-parksmarter-api.ipsmeters.com` | |
|
||||
| `prodv1` | `https://api.parksmarter.com` | |
|
||||
| `prodv2` | `https://apiv2.parksmarter.com` | **default** |
|
||||
| `prodv3` | `https://apiv3.parksmarter.com` | |
|
||||
|
||||
Application tokens for each are in `src/environments.ts`.
|
||||
|
||||
## Endpoint reference
|
||||
|
||||
Every method maps to one endpoint. Method is inferred from the app's naming.
|
||||
|
||||
| Client method | HTTP | Path |
|
||||
| --- | --- | --- |
|
||||
| `getApplicationValidity()` | GET | `/api/ApplicationValidity` |
|
||||
| `loginWithPhone()` / `loginWithApple()` | POST | `/api/Auth` |
|
||||
| `logoutAllDevices()` | POST | `/api/Auth/Logout` |
|
||||
| `signUp()` | POST | `/api/User` |
|
||||
| `getUserDetail()` | GET | `/api/User` |
|
||||
| `updateProfile()` | PUT | `/api/User` |
|
||||
| `requestDeleteUser()` | DELETE | `/api/User` |
|
||||
| `isEmailRegistered()` / `isPhoneRegistered()` | GET | `/api/User` |
|
||||
| `requestResetPassword()` | POST | `/api/Password` |
|
||||
| `updatePassword()` | PUT | `/api/Password` |
|
||||
| `requestVerifyUser()` | POST | `/api/UserVerification` |
|
||||
| `verifyUser()` | GET | `/api/UserVerification` |
|
||||
| `updateDeviceToken()` | PUT | `/api/Device` |
|
||||
| `addVehicle()` | POST | `/api/Vehicle` |
|
||||
| `updateVehicle()` | PUT | `/api/Vehicle` |
|
||||
| `deleteVehicle()` | DELETE | `/api/Vehicle` |
|
||||
| `addCard()` / `updateCard()` | POST | `/api/Card` |
|
||||
| `setCardDefault()` | PUT | `/api/Card` |
|
||||
| `deleteCard()` | DELETE | `/api/Card` |
|
||||
| `getMetersByLocation()` | GET | `/api/Meter` (`Lat`,`Long`) |
|
||||
| `getLimitedMetersByLocation()` | GET | `/api/MeterList` (`Lat`,`Long`) |
|
||||
| `getMetersByZoneName()` / `searchMetersByZoneOrSpace()` | GET | `/api/Meter` (`ZoneName`/`Query`) |
|
||||
| `getMetersBySerialNumber()` / `getMetersByScannerCode()` | GET | `/api/Meter` (`TerminalSerNo`/`ScannerCode`) |
|
||||
| `getParkingLots()` | GET | `/api/ParkingLogix` |
|
||||
| `getParkingEstimateMulti()` | GET | `/api/ParkingEstimateMulti` |
|
||||
| `getParkingEstimateSingle()` | GET | `/api/ParkingEstimate` |
|
||||
| `getParkingEstimateItems()` | GET | `/api/ParkingEstimateItems` |
|
||||
| `startParkingSession()` | POST | `/api/Session` |
|
||||
| `getActiveParkingSessions()` | GET | `/api/ParkingSession` |
|
||||
| `getPastParkingSessions()` | GET | `/api/Session` |
|
||||
| `getParkingReceipt()` | GET | `/api/ParkingReceipt` |
|
||||
| `emailParkingReceipt()` | POST | `/api/ParkingReceipt` |
|
||||
| `getNotificationSettings()` | GET | `/api/Setting` |
|
||||
| `setNotificationSettings()` | POST | `/api/Setting` |
|
||||
| `getStates()` | GET | `/api/State` |
|
||||
| `getAbout()` / `getFAQ()` / `getPrivacyPolicy()` / `getTerms()` | GET | `/api/ParkSmarter*` |
|
||||
|
||||
## Field-name conventions
|
||||
|
||||
The server uses PascalCase ("PSJSON"). This client accepts friendly camelCase inputs
|
||||
and maps them to the wire format for you; **responses are returned as the raw server
|
||||
PascalCase JSON** and typed accordingly in `src/types.ts`. Response interfaces include
|
||||
an index signature because not every optional field is guaranteed on every call — the
|
||||
well-known fields are typed explicitly.
|
||||
|
||||
## Accuracy & verification status
|
||||
|
||||
Request shapes were recovered from the app code and are exact. Response models were then
|
||||
**verified against production** by logging in and calling every read-only endpoint (see
|
||||
`sweep.mjs`, which records field-names+types only — no PII). Each interface in
|
||||
`src/types.ts` is annotated CONFIRMED or UNCONFIRMED.
|
||||
|
||||
- **CONFIRMED via live capture:** login (`AuthResponse`), `UserDetail`, `VehicleDetail`,
|
||||
`CreditCardDetail`, `Zone`/`Space`/`SpacePolicy`, `ParkingLot`/`ParkingLotDetail`, all
|
||||
three estimate responses (`ParkingDetail` price ladder), notification-settings envelope,
|
||||
states wrapper, the password-reset flow, and the shared `Response` envelope.
|
||||
- **UNCONFIRMED (no session history on the test account):** `ActiveSession`,
|
||||
`PastSession`, `ParkingReceipt`. Field names are from static analysis; capture from an
|
||||
account with at least one past/active session to confirm.
|
||||
|
||||
Two behaviors worth knowing (both confirmed live):
|
||||
|
||||
1. **Meter search requires auth.** `/api/Meter` and `/api/MeterList` return **401** without a
|
||||
valid `Auth_Token`, despite being data lookups.
|
||||
2. **`Response` envelope + token refresh.** Most authenticated responses embed
|
||||
`Response: { Auth_Token, Message, Status }`. When `Response.Auth_Token` (or a top-level
|
||||
`Auth_Token`) is non-empty, it's a rolling refresh of your user token — the client stores
|
||||
it automatically.
|
||||
|
||||
To capture the remaining UNCONFIRMED models, re-run `sweep.mjs` on an account that has a
|
||||
saved card and session history. There's **no TLS pinning**, so a proxy capture on a rooted
|
||||
device/emulator is also an option if you'd rather see the app's own traffic.
|
||||
|
||||
## Legal
|
||||
|
||||
For interoperability/research with your own account. Not affiliated with or endorsed by
|
||||
IPS Group / ParkSmarter. Respect their Terms of Service and applicable law.
|
||||
```
|
||||
30
parksmarter-client/package-lock.json
generated
Normal file
30
parksmarter-client/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "parksmarter-client",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parksmarter-client",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
parksmarter-client/package.json
Normal file
25
parksmarter-client/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "parksmarter-client",
|
||||
"version": "0.1.0",
|
||||
"description": "Unofficial TypeScript client for the ParkSmarter (IPS Group) parking API. Reverse-engineered from the official Android app 4.4.0.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist", "src", "README.md"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"keywords": ["parksmarter", "parking", "ips", "api-client"],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
713
parksmarter-client/src/client.ts
Normal file
713
parksmarter-client/src/client.ts
Normal file
|
|
@ -0,0 +1,713 @@
|
|||
/**
|
||||
* ParkSmarterClient — a typed wrapper over the ParkSmarter (IPS Group) REST API.
|
||||
*
|
||||
* Reverse-engineered from the official Android app (com.ipsgroupinc.parksmarter 4.4.0,
|
||||
* a React Native / Hermes build). All endpoint paths, header names, the app-token
|
||||
* scheme, and request/response field names were recovered from the app bundle.
|
||||
*
|
||||
* Auth model (NOT OAuth bearer):
|
||||
* - `Application_Token` : identifies the app build; required on every call.
|
||||
* - `Auth_Token` : the logged-in user's token; returned by POST /api/Auth
|
||||
* (login) in the body field `Auth_Token`, then sent as a
|
||||
* header on authenticated calls.
|
||||
* - `ParkSmarter_SessionId` : a server session id; seeded by
|
||||
* GET /api/ApplicationValidity and login, echoed on some
|
||||
* responses. Sent as a header when present.
|
||||
* - `X-Request-Id` : a fresh UUID per request.
|
||||
*
|
||||
* The client persists Auth_Token and SessionId into the provided TokenStore
|
||||
* automatically after login / bootstrap.
|
||||
*/
|
||||
import {
|
||||
DEFAULT_ENVIRONMENT,
|
||||
ENVIRONMENTS,
|
||||
Environment,
|
||||
EnvironmentName,
|
||||
} from './environments.js';
|
||||
import {
|
||||
HttpClient,
|
||||
MemoryTokenStore,
|
||||
ParkSmarterResponse,
|
||||
TokenStore,
|
||||
} from './http.js';
|
||||
import * as T from './types.js';
|
||||
|
||||
export interface ParkSmarterClientOptions {
|
||||
/** Named environment (default 'prodv2') or a fully custom Environment. */
|
||||
environment?: EnvironmentName | Environment;
|
||||
/** Token persistence. Defaults to in-memory. Use secure storage in real apps. */
|
||||
tokens?: TokenStore;
|
||||
/** Locale sent as the `localeCode` query param (default 'en'). */
|
||||
localeCode?: string;
|
||||
timeoutMs?: number;
|
||||
/** Provide a fetch impl for React Native / Node < 18 / tests. */
|
||||
fetchImpl?: typeof fetch;
|
||||
uuid?: () => string;
|
||||
}
|
||||
|
||||
function resolveEnvironment(
|
||||
env: EnvironmentName | Environment | undefined,
|
||||
): Environment {
|
||||
if (!env) return ENVIRONMENTS[DEFAULT_ENVIRONMENT];
|
||||
if (typeof env === 'string') return ENVIRONMENTS[env];
|
||||
return env;
|
||||
}
|
||||
|
||||
export class ParkSmarterClient {
|
||||
readonly http: HttpClient;
|
||||
readonly tokens: TokenStore;
|
||||
|
||||
constructor(options: ParkSmarterClientOptions = {}) {
|
||||
this.tokens = options.tokens ?? new MemoryTokenStore();
|
||||
this.http = new HttpClient({
|
||||
environment: resolveEnvironment(options.environment),
|
||||
tokens: this.tokens,
|
||||
localeCode: options.localeCode,
|
||||
timeoutMs: options.timeoutMs,
|
||||
fetchImpl: options.fetchImpl,
|
||||
uuid: options.uuid,
|
||||
});
|
||||
}
|
||||
|
||||
/** Set the locale used for the `localeCode` query param on subsequent calls. */
|
||||
setLocale(localeCode: string): void {
|
||||
this.http.setLocaleCode(localeCode);
|
||||
}
|
||||
|
||||
private unwrap<D>(p: Promise<ParkSmarterResponse<D>>): Promise<D> {
|
||||
return p.then((r) => r.data);
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Bootstrap / app validity */
|
||||
/* ============================================================== */
|
||||
|
||||
/**
|
||||
* GET /api/ApplicationValidity — call this first. Returns feature flags,
|
||||
* maintenance/upgrade status, and a SessionId that is stored automatically.
|
||||
* Public (no Auth_Token required).
|
||||
*/
|
||||
async getApplicationValidity(): Promise<T.ApplicationValidityResponse> {
|
||||
const res = await this.http.request<T.ApplicationValidityResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ApplicationValidity',
|
||||
query: { ApplicationToken: this.http.environment.appToken },
|
||||
includeAuthToken: false,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Auth */
|
||||
/* ============================================================== */
|
||||
|
||||
/** POST /api/Auth — phone + password login. Persists Auth_Token & SessionId. */
|
||||
async loginWithPhone(params: T.LoginWithPhoneParams): Promise<T.AuthResponse> {
|
||||
const res = await this.http.request<T.AuthResponse>({
|
||||
method: 'POST',
|
||||
path: '/api/Auth',
|
||||
body: { UserName: params.phoneNumber, Password: params.password },
|
||||
includeAuthToken: false,
|
||||
});
|
||||
if (res.data?.Auth_Token) await this.tokens.setAuthToken(res.data.Auth_Token);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** POST /api/Auth — Sign in with Apple. Persists Auth_Token & SessionId. */
|
||||
async loginWithApple(params: T.LoginWithAppleParams): Promise<T.AuthResponse> {
|
||||
const res = await this.http.request<T.AuthResponse>({
|
||||
method: 'POST',
|
||||
path: '/api/Auth',
|
||||
body: {
|
||||
EmailAddress: params.emailAddress,
|
||||
AppleUserId: params.appleId,
|
||||
ProviderAuth: params.providerAuth,
|
||||
ProviderIdentity: params.providerIdentity,
|
||||
},
|
||||
includeAuthToken: false,
|
||||
});
|
||||
if (res.data?.Auth_Token) await this.tokens.setAuthToken(res.data.Auth_Token);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** Re-authenticate using a cached Auth_Token (sets it, then callers can bootstrap). */
|
||||
async loginWithCachedToken(authToken: string): Promise<void> {
|
||||
await this.tokens.setAuthToken(authToken);
|
||||
}
|
||||
|
||||
/** POST /api/Auth/Logout — invalidate the token on all devices. */
|
||||
async logoutAllDevices(): Promise<void> {
|
||||
await this.http.request({ method: 'POST', path: '/api/Auth/Logout' });
|
||||
}
|
||||
|
||||
/** Clear local tokens (client-side sign out). */
|
||||
async logoutLocal(): Promise<void> {
|
||||
await this.tokens.setAuthToken(null);
|
||||
await this.tokens.setSessionId(null);
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Sign up, password, verification */
|
||||
/* ============================================================== */
|
||||
|
||||
/** POST /api/User — create an account. Public. */
|
||||
async signUp(params: T.SignUpParams): Promise<boolean> {
|
||||
await this.http.request({
|
||||
method: 'POST',
|
||||
path: '/api/User',
|
||||
body: {
|
||||
PersonalEmailAddress: params.emailAddress,
|
||||
PersonalPhone: params.mobilePhone,
|
||||
Password: params.password,
|
||||
},
|
||||
includeAuthToken: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** POST /api/Password — request a password reset. Public. */
|
||||
async requestResetPassword(
|
||||
params: T.RequestResetPasswordParams,
|
||||
): Promise<void> {
|
||||
await this.http.request({
|
||||
method: 'POST',
|
||||
path: '/api/Password',
|
||||
body: {
|
||||
ResetPasswordType: params.reqType,
|
||||
PersonalEmailAddress: '',
|
||||
PersonalPhone: params.phoneNumber,
|
||||
},
|
||||
includeAuthToken: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** PUT /api/Password — change password (authenticated). */
|
||||
async updatePassword(params: T.UpdatePasswordParams): Promise<void> {
|
||||
await this.http.request({
|
||||
method: 'PUT',
|
||||
path: '/api/Password',
|
||||
body: { OldPassword: params.oldPassword, NewPassword: params.newPassword },
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/UserVerification — request an SMS verification code. */
|
||||
async requestVerifyUser(params: T.RequestVerifyUserParams): Promise<void> {
|
||||
await this.http.request({
|
||||
method: 'POST',
|
||||
path: '/api/UserVerification',
|
||||
body: { PhoneNumber: '1' + params.phoneNumber },
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/UserVerification — confirm an SMS code. */
|
||||
async verifyUser(params: T.VerifyUserParams): Promise<void> {
|
||||
await this.http.request({
|
||||
method: 'GET',
|
||||
path: '/api/UserVerification',
|
||||
query: {
|
||||
PhoneNumber: '1' + params.phoneNumber,
|
||||
ConfirmationCode: params.code,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/User?EmailAddress=… — is this email already registered? */
|
||||
async isEmailRegistered(emailAddress: string): Promise<boolean> {
|
||||
const res = await this.http.request<T.UserDetail>({
|
||||
method: 'GET',
|
||||
path: '/api/User',
|
||||
query: { EmailAddress: emailAddress },
|
||||
includeAuthToken: false,
|
||||
});
|
||||
return Boolean(res.data?.PersonalEmailAddress);
|
||||
}
|
||||
|
||||
/** GET /api/User?PhoneNumber=… — is this phone already registered? */
|
||||
async isPhoneRegistered(phoneNumber: string): Promise<boolean> {
|
||||
const res = await this.http.request<T.UserDetail>({
|
||||
method: 'GET',
|
||||
path: '/api/User',
|
||||
query: { PhoneNumber: phoneNumber },
|
||||
includeAuthToken: false,
|
||||
});
|
||||
return Boolean(res.data?.PersonalPhone);
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* User profile */
|
||||
/* ============================================================== */
|
||||
|
||||
/** GET /api/User — full profile incl. vehicles & cards (authenticated). */
|
||||
getUserDetail(): Promise<T.UserDetail> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.UserDetail>({
|
||||
method: 'GET',
|
||||
path: '/api/User',
|
||||
query: { EmailAddress: '', PhoneNumber: '' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** PUT /api/User — update email/phone (authenticated). */
|
||||
async updateProfile(params: T.UpdateProfileParams): Promise<void> {
|
||||
await this.http.request({
|
||||
method: 'PUT',
|
||||
path: '/api/User',
|
||||
body: {
|
||||
PersonalEmailAddress: params.emailAddress,
|
||||
PersonalPhone: params.phoneNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** DELETE /api/User — request account deletion (authenticated). */
|
||||
async requestDeleteUser(): Promise<void> {
|
||||
await this.http.request({
|
||||
method: 'DELETE',
|
||||
path: '/api/User',
|
||||
query: { DeviceType: 'Android' },
|
||||
});
|
||||
}
|
||||
|
||||
/** PUT /api/Device — register/update the push device token. */
|
||||
async updateDeviceToken(
|
||||
params: T.UpdateDeviceTokenParams,
|
||||
): Promise<T.UpdateDeviceTokenResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.UpdateDeviceTokenResponse>({
|
||||
method: 'PUT',
|
||||
path: '/api/Device',
|
||||
body: {
|
||||
DeviceID: params.pushNotificationsToken,
|
||||
IMEINumber: params.imeiNumber,
|
||||
DeviceType: params.deviceType ?? '1',
|
||||
Language: params.language,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Vehicles */
|
||||
/* ============================================================== */
|
||||
|
||||
/** POST /api/Vehicle — add a vehicle. */
|
||||
async addVehicle(params: T.AddVehicleParams): Promise<T.VehicleMutationResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.VehicleMutationResponse>({
|
||||
method: 'POST',
|
||||
path: '/api/Vehicle',
|
||||
body: {
|
||||
VehiclePlate: params.plate,
|
||||
VehicleState: params.state,
|
||||
VehicleAlias: params.vehicleAlias,
|
||||
IsDefault: params.isDefaultVehicle ?? false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** PUT /api/Vehicle — update a vehicle. Success when `Status === 'Success'`. */
|
||||
async updateVehicle(
|
||||
params: T.UpdateVehicleParams,
|
||||
): Promise<T.VehicleMutationResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.VehicleMutationResponse>({
|
||||
method: 'PUT',
|
||||
path: '/api/Vehicle',
|
||||
body: {
|
||||
VehiclePlate: params.plate,
|
||||
VehicleState: params.state,
|
||||
VehicleID: params.id,
|
||||
VehicleAlias: params.vehicleAlias,
|
||||
IsDefault: params.isDefaultVehicle ?? false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /api/Vehicle?VehicleID=… */
|
||||
async deleteVehicle(vehicleId: number | string): Promise<void> {
|
||||
await this.http.request({
|
||||
method: 'DELETE',
|
||||
path: '/api/Vehicle',
|
||||
query: { VehicleID: vehicleId },
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Credit cards */
|
||||
/* ============================================================== */
|
||||
|
||||
/** POST /api/Card — add a card. */
|
||||
async addCard(params: T.AddCardParams): Promise<unknown> {
|
||||
return this.unwrap(
|
||||
this.http.request({
|
||||
method: 'POST',
|
||||
path: '/api/Card',
|
||||
body: this.buildCardBody(params),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /api/Card — update a card (full replace). */
|
||||
async updateCard(params: T.AddCardParams): Promise<unknown> {
|
||||
return this.unwrap(
|
||||
this.http.request({
|
||||
method: 'POST',
|
||||
path: '/api/Card',
|
||||
body: this.buildCardBody(params),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** PUT /api/Card — set (or unset) a card as default. */
|
||||
async setCardDefault(params: T.SetDefaultCardParams): Promise<unknown> {
|
||||
return this.unwrap(
|
||||
this.http.request({
|
||||
method: 'PUT',
|
||||
path: '/api/Card',
|
||||
body: { CCID: params.id, CCDefault: params.isDefaultCard },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /api/Card?CCID=… */
|
||||
async deleteCard(cardId: number | string): Promise<void> {
|
||||
await this.http.request({
|
||||
method: 'DELETE',
|
||||
path: '/api/Card',
|
||||
query: { CCID: cardId },
|
||||
});
|
||||
}
|
||||
|
||||
private buildCardBody(p: T.AddCardParams) {
|
||||
return {
|
||||
CCNumber: p.cardNumber,
|
||||
CCAlias: p.alias,
|
||||
CCExpDate: p.expDate,
|
||||
CCZip: p.zipCode,
|
||||
CCDefault: p.isDefaultCard ?? false,
|
||||
ParentCCID: p.id,
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Meters / zones */
|
||||
/* ============================================================== */
|
||||
|
||||
/** GET /api/Meter?Lat=&Long= — meters near a coordinate. */
|
||||
getMetersByLocation(loc: T.LatLng): Promise<T.MetersResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.MetersResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/Meter',
|
||||
query: { Lat: loc.latitude, Long: loc.longitude },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/MeterList?Lat=&Long= — limited meter list near a coordinate. */
|
||||
getLimitedMetersByLocation(loc: T.LatLng): Promise<T.MetersResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.MetersResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/MeterList',
|
||||
query: { Lat: loc.latitude, Long: loc.longitude },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/Meter?ZoneName=… */
|
||||
getMetersByZoneName(zoneName: string): Promise<T.MetersResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.MetersResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/Meter',
|
||||
query: { ZoneName: zoneName },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/Meter?Query=… — zone or space name search. */
|
||||
searchMetersByZoneOrSpace(query: string): Promise<T.MetersResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.MetersResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/Meter',
|
||||
query: { Query: query },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/Meter?TerminalSerNo=… */
|
||||
getMetersBySerialNumber(serialNumber: string): Promise<T.MetersResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.MetersResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/Meter',
|
||||
query: { TerminalSerNo: serialNumber },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/Meter?ScannerCode=… (e.g. from a scanned QR/barcode). */
|
||||
getMetersByScannerCode(scannerCode: string): Promise<T.MetersResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.MetersResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/Meter',
|
||||
query: { ScannerCode: scannerCode },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/ParkingLogix — nearby parking lots with occupancy. */
|
||||
getParkingLots(): Promise<T.ParkingLotsResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.ParkingLotsResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ParkingLogix',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Estimates */
|
||||
/* ============================================================== */
|
||||
|
||||
/** GET /api/ParkingEstimateMulti — multi-duration price ladder. */
|
||||
getParkingEstimateMulti(
|
||||
params: T.MultiEstimateParams,
|
||||
): Promise<T.MultiEstimateResponse> {
|
||||
const query: Record<string, unknown> = {
|
||||
ZoneID: params.zoneId,
|
||||
SpaceID: params.spaceId,
|
||||
CustomerID: params.customerId,
|
||||
MinCreditAmount: params.minCreditAmount,
|
||||
VehicleID: params.vehicleId,
|
||||
};
|
||||
if (params.bleEncBytes) query.BleEncBytesString = params.bleEncBytes;
|
||||
return this.unwrap(
|
||||
this.http.request<T.MultiEstimateResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ParkingEstimateMulti',
|
||||
query,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/ParkingEstimate — single-duration price. */
|
||||
getParkingEstimateSingle(
|
||||
params: T.SingleEstimateParams,
|
||||
): Promise<T.SingleEstimateResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.SingleEstimateResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ParkingEstimate',
|
||||
query: {
|
||||
ZoneID: params.zoneId,
|
||||
SpaceID: params.spaceId,
|
||||
CustomerID: params.customerId,
|
||||
ParkingDuration: params.durationInMinutes,
|
||||
VehicleID: params.vehicleId,
|
||||
CCID: params.creditCardId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/ParkingEstimateItems — item-based price options. */
|
||||
getParkingEstimateItems(
|
||||
params: T.ItemsEstimateParams,
|
||||
): Promise<T.ItemsEstimateResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.ItemsEstimateResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ParkingEstimateItems',
|
||||
query: {
|
||||
ZoneID: params.zoneId,
|
||||
SpaceID: params.spaceId,
|
||||
CustomerID: params.customerId,
|
||||
VehicleID: params.vehicleId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Parking sessions */
|
||||
/* ============================================================== */
|
||||
|
||||
/** POST /api/Session — start/pay for a parking session. */
|
||||
startParkingSession(
|
||||
params: T.StartParkingSessionParams,
|
||||
): Promise<T.StartParkingSessionResponse> {
|
||||
const fee = params.transactionFee ?? 0;
|
||||
const body: Record<string, unknown> = {
|
||||
CCID: String(params.creditCardId),
|
||||
Amount: (params.parkingCost + fee).toFixed(2),
|
||||
SpaceID: params.spaceId,
|
||||
StartTime: params.startTime,
|
||||
EndTime: params.endTime,
|
||||
CustomerID: params.customerId,
|
||||
VehicleID: String(params.vehicleId),
|
||||
TimePurchased: String(params.minutesToPurchase),
|
||||
ParkingCost: params.parkingCost.toFixed(2),
|
||||
TransactionFee: fee.toFixed(2),
|
||||
ZoneID: params.zoneId,
|
||||
MinCreditAmount:
|
||||
params.minCreditAmount != null
|
||||
? params.minCreditAmount.toFixed(2)
|
||||
: undefined,
|
||||
MeterTypeId: params.meterTypeId,
|
||||
};
|
||||
if (params.bleEncBytes) body.BleEncBytes = params.bleEncBytes;
|
||||
return this.unwrap(
|
||||
this.http.request<T.StartParkingSessionResponse>({
|
||||
method: 'POST',
|
||||
path: '/api/Session',
|
||||
body,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/ParkingSession — currently active sessions. */
|
||||
getActiveParkingSessions(): Promise<T.ActiveSessionsResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.ActiveSessionsResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ParkingSession',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/Session — past sessions (paged). */
|
||||
getPastParkingSessions(
|
||||
params: T.PastSessionsParams = {},
|
||||
): Promise<T.PastSessionsResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.PastSessionsResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/Session',
|
||||
query: {
|
||||
CurrentPage: params.currentPage ?? '',
|
||||
PageSize: params.pageSize ?? '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Receipts */
|
||||
/* ============================================================== */
|
||||
|
||||
/** GET /api/ParkingReceipt?TransactionID=… */
|
||||
getParkingReceipt(transactionId: number | string): Promise<T.ReceiptResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.ReceiptResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ParkingReceipt',
|
||||
query: { TransactionID: transactionId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /api/ParkingReceipt?Id=… — email a receipt to the account holder. */
|
||||
async emailParkingReceipt(receiptId: number | string): Promise<void> {
|
||||
await this.http.request({
|
||||
method: 'POST',
|
||||
path: '/api/ParkingReceipt',
|
||||
query: { Id: receiptId },
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Notification settings */
|
||||
/* ============================================================== */
|
||||
|
||||
/** GET /api/Setting — raw notification settings bitmask. */
|
||||
getNotificationSettings(): Promise<T.RawSettingsResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.RawSettingsResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/Setting',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/Setting — update notification settings.
|
||||
* `userSettings` is the numeric bitmask (as a string) the app computes via reduceSettings.
|
||||
*/
|
||||
async setNotificationSettings(userSettings: string | number): Promise<void> {
|
||||
await this.http.request({
|
||||
method: 'POST',
|
||||
path: '/api/Setting',
|
||||
body: { UserSettings: String(userSettings) },
|
||||
});
|
||||
}
|
||||
|
||||
/* ============================================================== */
|
||||
/* Static content */
|
||||
/* ============================================================== */
|
||||
|
||||
/**
|
||||
* GET /api/State?ID=… — states/regions under a parent id.
|
||||
* The `ID` param is required — the route 404s without it (confirmed against prod).
|
||||
*/
|
||||
getStates(id: number | string): Promise<T.StatesResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.StatesResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/State',
|
||||
query: { ID: id },
|
||||
includeAuthToken: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/ParkSmarterAbout — `{ Value }`. */
|
||||
getAbout(): Promise<T.ValueResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.ValueResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ParkSmarterAbout',
|
||||
includeAuthToken: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/ParkSmarterFAQ — `{ FAQs: [...] }`. */
|
||||
getFAQ(): Promise<T.FAQResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.FAQResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ParkSmarterFAQ',
|
||||
includeAuthToken: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/ParkSmarterPrivacyPolicies — `{ Value }`. */
|
||||
getPrivacyPolicy(): Promise<T.ValueResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.ValueResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ParkSmarterPrivacyPolicies',
|
||||
includeAuthToken: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/ParkSmarterTerms — `{ Value }`. */
|
||||
getTerms(): Promise<T.ValueResponse> {
|
||||
return this.unwrap(
|
||||
this.http.request<T.ValueResponse>({
|
||||
method: 'GET',
|
||||
path: '/api/ParkSmarterTerms',
|
||||
includeAuthToken: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
parksmarter-client/src/environments.ts
Normal file
58
parksmarter-client/src/environments.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* ParkSmarter backend environments.
|
||||
*
|
||||
* Recovered from the official app (com.ipsgroupinc.parksmarter 4.4.0).
|
||||
* Each environment pairs a base URL with an application token that the server
|
||||
* requires on every request via the `Application_Token` header.
|
||||
*
|
||||
* `prodv2` is the environment the shipping app defaults to.
|
||||
*/
|
||||
export type EnvironmentName =
|
||||
| 'dev'
|
||||
| 'stage'
|
||||
| 'test'
|
||||
| 'prodv1'
|
||||
| 'prodv2'
|
||||
| 'prodv3';
|
||||
|
||||
export interface Environment {
|
||||
name: EnvironmentName;
|
||||
baseUrl: string;
|
||||
appToken: string;
|
||||
}
|
||||
|
||||
export const ENVIRONMENTS: Record<EnvironmentName, Environment> = {
|
||||
dev: {
|
||||
name: 'dev',
|
||||
baseUrl: 'https://dev-parksmarter-api.ipsmeters.com',
|
||||
appToken: 'ED09B2F6-BE30-4C92-9204-7D02026CAAE7',
|
||||
},
|
||||
stage: {
|
||||
name: 'stage',
|
||||
baseUrl: 'https://staging-parksmarter-api.ipsmeters.com',
|
||||
appToken: 'AC657B81-121E-42E8-B6BF-5C2C668B4E00',
|
||||
},
|
||||
test: {
|
||||
name: 'test',
|
||||
baseUrl: 'https://testing-parksmarter-api.ipsmeters.com',
|
||||
appToken: 'AC657B81-121E-42E8-B6BF-5C2C668B4E00',
|
||||
},
|
||||
prodv1: {
|
||||
name: 'prodv1',
|
||||
baseUrl: 'https://api.parksmarter.com',
|
||||
appToken: '98774898-E21E-4548-B513-FA7211ABA442',
|
||||
},
|
||||
prodv2: {
|
||||
name: 'prodv2',
|
||||
baseUrl: 'https://apiv2.parksmarter.com',
|
||||
appToken: 'B66EEDDA-B618-4926-B8A0-F5B58397EEBA',
|
||||
},
|
||||
prodv3: {
|
||||
name: 'prodv3',
|
||||
baseUrl: 'https://apiv3.parksmarter.com',
|
||||
appToken: '98774898-E21E-4548-B513-FA7211ABA442',
|
||||
},
|
||||
};
|
||||
|
||||
/** The environment the official app ships pointed at. */
|
||||
export const DEFAULT_ENVIRONMENT: EnvironmentName = 'prodv2';
|
||||
252
parksmarter-client/src/http.ts
Normal file
252
parksmarter-client/src/http.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/**
|
||||
* Transport layer for the ParkSmarter API.
|
||||
*
|
||||
* This mirrors the request pipeline used by the official app:
|
||||
* - Base URL + query string are concatenated onto the endpoint path.
|
||||
* - Query params are serialized as `?k=encodeURIComponent(v)&...`.
|
||||
* - Headers:
|
||||
* Application_Token always (identifies the app build)
|
||||
* X-Request-Id always (a fresh UUID per request)
|
||||
* Content-Type: application/json on POST/PUT
|
||||
* Auth_Token when the endpoint requires an authenticated user
|
||||
* ParkSmarter_SessionId when a server session id is available
|
||||
* - The user auth token and session id are NOT HTTP bearer tokens; they are
|
||||
* custom headers named exactly `Auth_Token` and `ParkSmarter_SessionId`.
|
||||
*/
|
||||
import { Environment } from './environments.js';
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
|
||||
export interface TokenStore {
|
||||
/** Current user auth token, or null if signed out. */
|
||||
getAuthToken(): string | null | Promise<string | null>;
|
||||
setAuthToken(token: string | null): void | Promise<void>;
|
||||
/** Current server session id, or null. */
|
||||
getSessionId(): string | null | Promise<string | null>;
|
||||
setSessionId(sessionId: string | null): void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Simple in-memory token store. Swap for expo-secure-store / localStorage in real apps. */
|
||||
export class MemoryTokenStore implements TokenStore {
|
||||
private authToken: string | null = null;
|
||||
private sessionId: string | null = null;
|
||||
getAuthToken() {
|
||||
return this.authToken;
|
||||
}
|
||||
setAuthToken(token: string | null) {
|
||||
this.authToken = token;
|
||||
}
|
||||
getSessionId() {
|
||||
return this.sessionId;
|
||||
}
|
||||
setSessionId(sessionId: string | null) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
method: HttpMethod;
|
||||
/** Endpoint path, e.g. `/api/Auth`. */
|
||||
path: string;
|
||||
query?: Record<string, unknown> | undefined;
|
||||
body?: unknown;
|
||||
/** Send the `Auth_Token` header (default: true except for auth/public endpoints). */
|
||||
includeAuthToken?: boolean;
|
||||
/** Send the `ParkSmarter_SessionId` header when available (default: true). */
|
||||
includeSessionId?: boolean;
|
||||
/** Abort signal (also drives the per-request timeout). */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ParkSmarterResponse<T> {
|
||||
status: number;
|
||||
data: T;
|
||||
headers: Headers;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
/** Thrown for non-2xx responses. `body` is the parsed server payload when JSON. */
|
||||
export class ParkSmarterApiError extends Error {
|
||||
status: number;
|
||||
body: unknown;
|
||||
requestId: string;
|
||||
/** Server-provided message when present (server uses PascalCase `Message`). */
|
||||
serverMessage?: string;
|
||||
constructor(status: number, body: unknown, requestId: string) {
|
||||
const serverMessage =
|
||||
body && typeof body === 'object'
|
||||
? (body as Record<string, unknown>).Message ??
|
||||
(body as Record<string, unknown>).message
|
||||
: undefined;
|
||||
super(
|
||||
`ParkSmarter API error ${status}` +
|
||||
(serverMessage ? `: ${serverMessage}` : ''),
|
||||
);
|
||||
this.name = 'ParkSmarterApiError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.requestId = requestId;
|
||||
if (typeof serverMessage === 'string') this.serverMessage = serverMessage;
|
||||
}
|
||||
}
|
||||
|
||||
export interface HttpClientConfig {
|
||||
environment: Environment;
|
||||
tokens: TokenStore;
|
||||
/** BCP-47-ish locale code sent as the `localeCode` query param (default: 'en'). */
|
||||
localeCode?: string;
|
||||
/** Per-request timeout in ms (default: 30000). */
|
||||
timeoutMs?: number;
|
||||
/** Override fetch (e.g. for React Native or tests). Defaults to global fetch. */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Override UUID generation. Defaults to crypto.randomUUID when available. */
|
||||
uuid?: () => string;
|
||||
}
|
||||
|
||||
function defaultUuid(): string {
|
||||
const c = (globalThis as { crypto?: Crypto }).crypto;
|
||||
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
|
||||
// RFC4122-ish fallback (non-crypto) for older runtimes.
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function serializeQuery(query: Record<string, unknown>): string {
|
||||
const parts: string[] = [];
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||
}
|
||||
return parts.length ? `?${parts.join('&')}` : '';
|
||||
}
|
||||
|
||||
export class HttpClient {
|
||||
private cfg: Required<Omit<HttpClientConfig, 'environment' | 'tokens'>> &
|
||||
Pick<HttpClientConfig, 'environment' | 'tokens'>;
|
||||
|
||||
constructor(config: HttpClientConfig) {
|
||||
this.cfg = {
|
||||
environment: config.environment,
|
||||
tokens: config.tokens,
|
||||
localeCode: config.localeCode ?? 'en',
|
||||
timeoutMs: config.timeoutMs ?? 30000,
|
||||
fetchImpl: config.fetchImpl ?? globalThis.fetch?.bind(globalThis),
|
||||
uuid: config.uuid ?? defaultUuid,
|
||||
};
|
||||
if (!this.cfg.fetchImpl) {
|
||||
throw new Error(
|
||||
'No fetch implementation available. Pass config.fetchImpl (e.g. node-fetch, or a polyfill).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get environment(): Environment {
|
||||
return this.cfg.environment;
|
||||
}
|
||||
|
||||
get tokens(): TokenStore {
|
||||
return this.cfg.tokens;
|
||||
}
|
||||
|
||||
setLocaleCode(localeCode: string) {
|
||||
this.cfg.localeCode = localeCode;
|
||||
}
|
||||
|
||||
async request<T>(opts: RequestOptions): Promise<ParkSmarterResponse<T>> {
|
||||
const { method } = opts;
|
||||
const requestId = this.cfg.uuid();
|
||||
|
||||
// The app appends `localeCode` to the query params of essentially every call.
|
||||
const query: Record<string, unknown> = {
|
||||
localeCode: this.cfg.localeCode,
|
||||
...(opts.query ?? {}),
|
||||
};
|
||||
const url =
|
||||
this.cfg.environment.baseUrl + opts.path + serializeQuery(query);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Application_Token: this.cfg.environment.appToken,
|
||||
'X-Request-Id': requestId,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const includeAuthToken = opts.includeAuthToken ?? true;
|
||||
const includeSessionId = opts.includeSessionId ?? true;
|
||||
|
||||
if (includeAuthToken) {
|
||||
const authToken = await this.cfg.tokens.getAuthToken();
|
||||
if (authToken) headers['Auth_Token'] = authToken;
|
||||
}
|
||||
if (includeSessionId) {
|
||||
const sessionId = await this.cfg.tokens.getSessionId();
|
||||
if (sessionId) headers['ParkSmarter_SessionId'] = sessionId;
|
||||
}
|
||||
|
||||
// Timeout wired through an AbortController, honoring any caller-supplied signal.
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs);
|
||||
if (opts.signal) {
|
||||
if (opts.signal.aborted) controller.abort();
|
||||
else opts.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.cfg.fetchImpl(url, {
|
||||
method,
|
||||
headers,
|
||||
body:
|
||||
opts.body !== undefined && opts.body !== null
|
||||
? JSON.stringify(opts.body)
|
||||
: undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
let data: unknown = undefined;
|
||||
if (text && text.trim().length) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the token store fresh from what the server echoes back:
|
||||
// - SessionId is returned on bootstrap/login.
|
||||
// - A fresh Auth_Token may be returned either at the top level (login) or
|
||||
// inside the common `Response: { Auth_Token, Message, Status }` envelope
|
||||
// (a rolling token-refresh mechanism). Only overwrite on a non-empty value.
|
||||
if (data && typeof data === 'object') {
|
||||
const obj = data as Record<string, unknown>;
|
||||
const envelope = obj.Response as Record<string, unknown> | undefined;
|
||||
|
||||
const sessionId = obj.SessionId;
|
||||
if (typeof sessionId === 'string' && sessionId) {
|
||||
await this.cfg.tokens.setSessionId(sessionId);
|
||||
}
|
||||
|
||||
const refreshed =
|
||||
(typeof obj.Auth_Token === 'string' && obj.Auth_Token) ||
|
||||
(envelope && typeof envelope.Auth_Token === 'string' && envelope.Auth_Token);
|
||||
if (refreshed) {
|
||||
await this.cfg.tokens.setAuthToken(refreshed);
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ParkSmarterApiError(res.status, data ?? text, requestId);
|
||||
}
|
||||
|
||||
return { status: res.status, data: data as T, headers: res.headers, requestId };
|
||||
}
|
||||
}
|
||||
20
parksmarter-client/src/index.ts
Normal file
20
parksmarter-client/src/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export { ParkSmarterClient } from './client.js';
|
||||
export type { ParkSmarterClientOptions } from './client.js';
|
||||
export {
|
||||
ENVIRONMENTS,
|
||||
DEFAULT_ENVIRONMENT,
|
||||
} from './environments.js';
|
||||
export type { Environment, EnvironmentName } from './environments.js';
|
||||
export {
|
||||
HttpClient,
|
||||
MemoryTokenStore,
|
||||
ParkSmarterApiError,
|
||||
} from './http.js';
|
||||
export type {
|
||||
TokenStore,
|
||||
RequestOptions,
|
||||
ParkSmarterResponse,
|
||||
HttpMethod,
|
||||
HttpClientConfig,
|
||||
} from './http.js';
|
||||
export * from './types.js';
|
||||
624
parksmarter-client/src/types.ts
Normal file
624
parksmarter-client/src/types.ts
Normal file
|
|
@ -0,0 +1,624 @@
|
|||
/**
|
||||
* ParkSmarter API data models.
|
||||
*
|
||||
* Two casing worlds exist:
|
||||
* - This client's *inputs* use friendly camelCase (see the `*Params` types).
|
||||
* - The server speaks PascalCase ("PSJSON"). Response models below therefore use
|
||||
* the exact server field spellings recovered from the app. Response interfaces
|
||||
* carry an index signature because not every field is guaranteed on every call;
|
||||
* the well-known fields are typed explicitly. Run a live capture to lock down
|
||||
* the optional tail for a given endpoint if you need exhaustiveness.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The common status/token-refresh envelope embedded in most authenticated
|
||||
* responses as `Response`. `Auth_Token` here is usually null but, when present,
|
||||
* is a rolling refresh of the user token (the client stores it automatically).
|
||||
* CONFIRMED via live capture.
|
||||
*/
|
||||
export interface ResponseEnvelope {
|
||||
Auth_Token: string | null;
|
||||
Message: string | null;
|
||||
Status: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Casing note (CONFIRMED via live capture): the meter/zone *response* uses
|
||||
* `ZoneId` / `SpaceId` / `CustomerId` (lowercase "d"), while estimate & session
|
||||
* *requests* expect `ZoneID` / `SpaceID` / `CustomerID` (capital "ID"). When you
|
||||
* feed a captured Zone into an estimate/session, read `zone.CustomerId` /
|
||||
* `space.SpaceId` and pass them as the client's camelCase params — the client
|
||||
* emits the capital-ID request spelling for you.
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Auth & account */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface LoginWithPhoneParams {
|
||||
/** Phone number, digits only (no country code prefix). */
|
||||
phoneNumber: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginWithAppleParams {
|
||||
emailAddress: string;
|
||||
appleId: string;
|
||||
providerAuth: string;
|
||||
providerIdentity: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/Auth response (login). CONFIRMED: `{ Status, Message, Auth_Token }`.
|
||||
* Auth_Token is persisted automatically; SessionId is delivered separately (by
|
||||
* bootstrap / headers) and also persisted.
|
||||
*/
|
||||
export interface AuthResponse {
|
||||
Status?: string;
|
||||
Message?: string;
|
||||
Auth_Token?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SignUpParams {
|
||||
emailAddress: string;
|
||||
/** Phone number, digits only. */
|
||||
mobilePhone: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RequestResetPasswordParams {
|
||||
/** Server `ResetPasswordType`; the app passes a numeric/string reason code. */
|
||||
reqType: string | number;
|
||||
/** Phone number, digits only. */
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export interface UpdatePasswordParams {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileParams {
|
||||
emailAddress: string;
|
||||
/** Phone number, digits only. */
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export interface RequestVerifyUserParams {
|
||||
/** Phone number, digits only. The client prefixes country code "1". */
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export interface VerifyUserParams {
|
||||
/** Phone number, digits only. The client prefixes country code "1". */
|
||||
phoneNumber: string;
|
||||
/** SMS confirmation code. */
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface UpdateDeviceTokenParams {
|
||||
/** Push token (server field `DeviceID`). */
|
||||
pushNotificationsToken: string;
|
||||
imeiNumber?: string;
|
||||
/** Defaults to '1' (Android) as the app hardcodes. */
|
||||
deviceType?: string;
|
||||
/** BCP-47 language, defaults to the client locale. */
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDeviceTokenResponse {
|
||||
Status?: string;
|
||||
Message?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GET /api/User response. CONFIRMED via live capture. */
|
||||
export interface UserDetail {
|
||||
PersonalEmailAddress?: string;
|
||||
/** Digits-only phone as a string. */
|
||||
PersonalPhone?: string;
|
||||
VehicleDetails?: VehicleDetail[];
|
||||
CreditCardDetails?: CreditCardDetail[];
|
||||
/** Numeric flag (0/1), not a boolean. */
|
||||
OffersOptIn?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Vehicles */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** CONFIRMED via live capture. Note `IsDefault` is a string ("true"/"false"), not a boolean. */
|
||||
export interface VehicleDetail {
|
||||
VehicleID?: number;
|
||||
VehiclePlate?: string;
|
||||
VehicleState?: string;
|
||||
VehicleAlias?: string;
|
||||
/** Server returns this as a string, e.g. "true" / "false". */
|
||||
IsDefault?: string;
|
||||
/** Per-item status fields, usually null on reads. */
|
||||
Status?: string | null;
|
||||
Message?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AddVehicleParams {
|
||||
plate: string;
|
||||
state: string;
|
||||
vehicleAlias?: string;
|
||||
isDefaultVehicle?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateVehicleParams extends AddVehicleParams {
|
||||
id: number | string;
|
||||
}
|
||||
|
||||
export interface VehicleMutationResponse {
|
||||
Status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Credit cards */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* CONFIRMED via live capture. The PAN is never returned — only `CCFirstSix` +
|
||||
* `CCLastFour`. `CCDefault` comes back as a string ("true"/"false"). Note the
|
||||
* response uses `ParentPBPCardId`, whereas the add/update *request* uses `ParentCCID`.
|
||||
*/
|
||||
export interface CreditCardDetail {
|
||||
CCID?: number;
|
||||
CCAlias?: string;
|
||||
/** First 6 digits (BIN), numeric string. */
|
||||
CCFirstSix?: string;
|
||||
/** Last 4 digits, numeric string. */
|
||||
CCLastFour?: string;
|
||||
CCExpDate?: string;
|
||||
CCZip?: string;
|
||||
/** String "true"/"false", not a boolean. */
|
||||
CCDefault?: string;
|
||||
ParentPBPCardId?: number | null;
|
||||
/** Per-item status fields, usually null on reads. */
|
||||
Status?: string | null;
|
||||
Message?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AddCardParams {
|
||||
cardNumber: string;
|
||||
alias?: string;
|
||||
/** Expiry, format as the app sends it (e.g. "MM/YY"). */
|
||||
expDate: string;
|
||||
zipCode: string;
|
||||
isDefaultCard?: boolean;
|
||||
/** Present when editing an existing card (server `ParentCCID`). */
|
||||
id?: number | string;
|
||||
}
|
||||
|
||||
export interface SetDefaultCardParams {
|
||||
id: number | string;
|
||||
isDefaultCard: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Meters / zones */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface LatLng {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
/** A rate/time policy slot within a space. CONFIRMED via live capture. */
|
||||
export interface SpacePolicy {
|
||||
CurrentSlot?: boolean;
|
||||
DayNumber?: number;
|
||||
DisplayString?: string;
|
||||
StartDateTime?: string;
|
||||
EndDateTime?: string;
|
||||
StartTimeDisplay?: string;
|
||||
EndTimeDisplay?: string;
|
||||
EventRateFlag?: boolean;
|
||||
MaxTime?: number;
|
||||
MessageHeader?: string;
|
||||
MessageText?: string;
|
||||
Rate?: number;
|
||||
RateType?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single space within a zone. CONFIRMED via live capture.
|
||||
* Note the id is `SpaceId` (lowercase "d") here; estimate/session requests want
|
||||
* the capital-ID `SpaceID` (the client maps camelCase params to that spelling).
|
||||
*/
|
||||
export interface Space {
|
||||
SpaceId?: number;
|
||||
SpaceName?: string;
|
||||
SSPMSpaceId?: number;
|
||||
MeterPurchaseType?: number;
|
||||
OccupancyStatus?: number;
|
||||
Policies?: SpacePolicy[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parking zone/meter as returned inside `Zones`. CONFIRMED via live capture.
|
||||
* `ScannerCode` is the value encoded in kiosk QR codes; `TerminalSerNo` is the
|
||||
* printed serial. Both drive the scan / lookup features.
|
||||
*/
|
||||
export interface Zone {
|
||||
ZoneId?: number;
|
||||
ZoneGuid?: string;
|
||||
ZoneName?: string;
|
||||
ZoneLocation?: string;
|
||||
ZonePlates?: unknown;
|
||||
/** Printed serial number (numeric string). */
|
||||
TerminalSerNo?: string;
|
||||
/** QR-encoded scanner code. */
|
||||
ScannerCode?: string;
|
||||
Lat?: number;
|
||||
Long?: number;
|
||||
CustomerId?: number;
|
||||
CustomerName?: string;
|
||||
/** Minutes offset / IANA-less numeric zone id as returned. */
|
||||
TimeZone?: number;
|
||||
DSTAdjust?: number;
|
||||
CityLogo?: string;
|
||||
DepartmentLogo?: string;
|
||||
BackgroundColor?: string;
|
||||
/** Returned as a numeric string. */
|
||||
ForegroundColor?: string;
|
||||
MinimumAmount?: number;
|
||||
MaxTime?: number;
|
||||
MeterType?: string;
|
||||
MeterTypeId?: number;
|
||||
/** Base rate, returned as a string. */
|
||||
Rate?: string;
|
||||
ProgressiveRate?: number;
|
||||
PercentageFull?: number;
|
||||
IsAllowOverAir?: boolean;
|
||||
IsAllowBLE?: boolean;
|
||||
IsExtension?: boolean;
|
||||
IsPaid?: boolean;
|
||||
Spaces?: Space[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GET /api/Meter and /api/MeterList. CONFIRMED via live capture. */
|
||||
export interface MetersResponse {
|
||||
Zones?: Zone[] | null;
|
||||
Response?: ResponseEnvelope;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Parking lots (ParkingLogix) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** One level/section within a ParkingLogix lot. CONFIRMED via live capture. */
|
||||
export interface ParkingLotDetail {
|
||||
ParkingLogixLotDetailId?: number;
|
||||
ParkingLogixLotId?: number;
|
||||
Level?: number;
|
||||
TotalSpaces?: number;
|
||||
FreeSpaces?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** CONFIRMED via live capture. Per-level occupancy lives in ParkingLogixLotDetails. */
|
||||
export interface ParkingLot {
|
||||
ParkingLogixLotId?: number;
|
||||
LotName?: string;
|
||||
LocationAddress?: string;
|
||||
Lat?: number;
|
||||
Long?: number;
|
||||
TotalSpaces?: number;
|
||||
DefaultFree?: boolean;
|
||||
ParkingLogixLotDetails?: ParkingLotDetail[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ParkingLotsResponse {
|
||||
ParkingLogixLots?: ParkingLot[] | null;
|
||||
Response?: ResponseEnvelope;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Estimates */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface MultiEstimateParams {
|
||||
zoneId: number | string;
|
||||
spaceId: number | string;
|
||||
customerId: number | string;
|
||||
/** Zone minimum credit (server `MinCreditAmount`). */
|
||||
minCreditAmount?: number | string;
|
||||
vehicleId: number | string;
|
||||
/** Optional BLE encrypted bytes string. */
|
||||
bleEncBytes?: string;
|
||||
}
|
||||
|
||||
export interface SingleEstimateParams {
|
||||
zoneId: number | string;
|
||||
spaceId: number | string;
|
||||
customerId: number | string;
|
||||
/** Server `ParkingDuration`, in minutes. */
|
||||
durationInMinutes: number;
|
||||
vehicleId: number | string;
|
||||
/** Server `CCID` — credit card id. */
|
||||
creditCardId: number | string;
|
||||
}
|
||||
|
||||
export interface ItemsEstimateParams {
|
||||
zoneId: number | string;
|
||||
spaceId: number | string;
|
||||
customerId: number | string;
|
||||
vehicleId: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One priced duration option. CONFIRMED via live capture.
|
||||
* Money/id fields come back as numeric *strings* here.
|
||||
*/
|
||||
export interface ParkingDetail {
|
||||
Minutes?: number;
|
||||
StartTime?: string;
|
||||
EndTime?: string;
|
||||
ParkingCost?: string;
|
||||
TransactionFee?: string;
|
||||
CurrentAmountPaid?: number;
|
||||
CustomerID?: string;
|
||||
CustomerName?: string;
|
||||
Zone?: string;
|
||||
ZoneID?: string;
|
||||
Space?: string;
|
||||
SpaceID?: string;
|
||||
VehicleID?: string;
|
||||
VehicleNumber?: string | null;
|
||||
CCNumber?: string | null;
|
||||
PricingToken?: string | null;
|
||||
TimeZone?: number;
|
||||
DSTAdjust?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GET /api/ParkingEstimateMulti. CONFIRMED via live capture (a full price ladder). */
|
||||
export interface MultiEstimateResponse {
|
||||
ParkingDetail?: ParkingDetail[];
|
||||
MinTime?: number;
|
||||
MaxTime?: number;
|
||||
TimeRemaining?: number;
|
||||
JumpRateEnabled?: boolean;
|
||||
JumpRateThreshold?: number;
|
||||
JumpRateSetValue?: number;
|
||||
Response?: ResponseEnvelope;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GET /api/ParkingEstimate. CONFIRMED via live capture (a single priced option). */
|
||||
export interface SingleEstimateResponse {
|
||||
ParkingDetail?: ParkingDetail;
|
||||
MinTime?: number;
|
||||
MaxTime?: number;
|
||||
CurrentCustomerDateTime?: string;
|
||||
Response?: ResponseEnvelope;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GET /api/ParkingEstimateItems. CONFIRMED via live capture. */
|
||||
export interface ItemsEstimateResponse {
|
||||
ZoneID?: number;
|
||||
ZoneName?: string;
|
||||
SpaceID?: number;
|
||||
SpaceName?: string;
|
||||
CustomerID?: number;
|
||||
CustomerName?: string;
|
||||
PurchaseMode?: string;
|
||||
Items?: unknown[] | null;
|
||||
TimeStampUTC?: number;
|
||||
TimeZone?: number;
|
||||
DSTAdjust?: number;
|
||||
Response?: ResponseEnvelope;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Parking sessions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface StartParkingSessionParams {
|
||||
creditCardId: number | string;
|
||||
spaceId: number | string;
|
||||
customerId: number | string;
|
||||
zoneId: number | string;
|
||||
vehicleId: number | string;
|
||||
/** ISO/formatted start time as the server expects. */
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
/** Minutes purchased (server `TimePurchased`). */
|
||||
minutesToPurchase: number;
|
||||
/** Base cost, dollars (server `ParkingCost`). */
|
||||
parkingCost: number;
|
||||
/** Fee, dollars (server `TransactionFee`). Defaults to 0. */
|
||||
transactionFee?: number;
|
||||
/** Zone minimum credit (server `MinCreditAmount`). */
|
||||
minCreditAmount?: number;
|
||||
meterTypeId: number | string;
|
||||
/** Optional BLE encrypted bytes string. */
|
||||
bleEncBytes?: string;
|
||||
}
|
||||
|
||||
export interface StartParkingSessionResponse {
|
||||
BleEncBytes?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** UNCONFIRMED element shape (no active sessions on the test account). */
|
||||
export interface ActiveSession {
|
||||
TransactionID?: number | string;
|
||||
ZoneName?: string;
|
||||
SpaceName?: string;
|
||||
Space?: string;
|
||||
StartTime?: string;
|
||||
EndTime?: string;
|
||||
StartTimeDisplay?: string;
|
||||
EndTimeDisplay?: string;
|
||||
TimeRemaining?: number | string;
|
||||
VehiclePlate?: string;
|
||||
VehicleNumber?: string;
|
||||
Amount?: number | string;
|
||||
IsExtension?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GET /api/ParkingSession. Envelope CONFIRMED; element shape UNCONFIRMED (no active sessions in test account). */
|
||||
export interface ActiveSessionsResponse {
|
||||
ParkingSession?: ActiveSession[] | null;
|
||||
CurrentCustomerTime?: string | null;
|
||||
Response?: ResponseEnvelope;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** UNCONFIRMED element shape (no past sessions on the test account). */
|
||||
export interface PastSession {
|
||||
TransactionID?: number | string;
|
||||
ZoneName?: string;
|
||||
StartTime?: string;
|
||||
EndTime?: string;
|
||||
Amount?: number | string;
|
||||
PaymentType?: string;
|
||||
PaymentDisplay?: string;
|
||||
VehiclePlate?: string;
|
||||
IsPaid?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GET /api/Session. Envelope CONFIRMED; element shape UNCONFIRMED (no past sessions in test account). */
|
||||
export interface PastSessionsResponse {
|
||||
Session?: PastSession[] | null;
|
||||
Response?: ResponseEnvelope;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PastSessionsParams {
|
||||
currentPage?: number | string;
|
||||
pageSize?: number | string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Receipts */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface ParkingReceipt {
|
||||
TransactionID?: number | string;
|
||||
ZoneName?: string;
|
||||
StartTime?: string;
|
||||
EndTime?: string;
|
||||
Amount?: number | string;
|
||||
Total?: number | string;
|
||||
TotalCost?: number | string;
|
||||
TransactionFee?: number | string;
|
||||
PaymentType?: string;
|
||||
VehiclePlate?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** UNCONFIRMED (no receipts on the test account) — field names from static analysis. */
|
||||
export interface ReceiptResponse {
|
||||
ParkingReceipt?: ParkingReceipt;
|
||||
Response?: ResponseEnvelope;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Notification settings */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** One toggle group within notification settings. */
|
||||
export interface NotificationSetting {
|
||||
messagesEnabled?: boolean;
|
||||
emailsEnabled?: boolean;
|
||||
messages?: boolean;
|
||||
emails?: boolean;
|
||||
}
|
||||
|
||||
/** Client-side view of the settings (server sends a numeric bitmask in `Settings`). */
|
||||
export interface NotificationSettings {
|
||||
parkingReceipt?: NotificationSetting;
|
||||
overnight?: NotificationSetting;
|
||||
tenMin?: NotificationSetting;
|
||||
fifteenMin?: NotificationSetting;
|
||||
sessionEnded?: NotificationSetting;
|
||||
sessionStart?: NotificationSetting;
|
||||
}
|
||||
|
||||
/** GET /api/Setting. CONFIRMED: `{ Response, Settings }`. */
|
||||
export interface RawSettingsResponse {
|
||||
/** Server bitmask as a numeric string. Decode per the app's expandSettings logic. */
|
||||
Settings?: string;
|
||||
Response?: ResponseEnvelope;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GET /api/State?ID=…. CONFIRMED wrapper `{ StatesResult }` (requires an ID; 404s without). */
|
||||
export interface StatesResponse {
|
||||
StatesResult?: StateItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Content / misc */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Terms/Privacy/About all return `{ Value: string }`. */
|
||||
export interface ValueResponse {
|
||||
Value?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FAQItem {
|
||||
Question?: string;
|
||||
Answer?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FAQResponse {
|
||||
FAQs?: FAQItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GET /api/ApplicationValidity — feature flags + maintenance + session bootstrap. */
|
||||
export interface ApplicationValidityResponse {
|
||||
SessionId?: string;
|
||||
RecommendUpgrade?: boolean;
|
||||
ForceUpgrade?: boolean;
|
||||
IsParkingEstimateMultiEnabled?: boolean;
|
||||
Config?: {
|
||||
IsInMaintenanceMode?: boolean;
|
||||
ExtendedVariablePBPZoneIds?: unknown;
|
||||
ExtendedVariableRateMaxDayMinutes?: number;
|
||||
ExtendedVariableRateMaxDay?: number;
|
||||
ExtendedVariableMultiDayValueThreshold?: number;
|
||||
ExtendedVariableRateSingleHourRate?: number;
|
||||
ExtendedVariableRateMaxDayJump?: number;
|
||||
ExtendedVariableRateMaxDayValueDollar?: number;
|
||||
ExtendedVariableRateMaxMode?: unknown;
|
||||
UseLimitedMetersFetch?: boolean;
|
||||
UseLoadMetersButton?: boolean;
|
||||
UseReloadMetersOnResume?: boolean;
|
||||
UseApplePay?: boolean;
|
||||
UseGooglePay?: boolean;
|
||||
ApplePayHostAddress?: string;
|
||||
UseSearchBySpace?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface StateItem {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
176
parksmarter-client/sweep.mjs
Normal file
176
parksmarter-client/sweep.mjs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* sweep.mjs — confirm real API response shapes WITHOUT running the app and
|
||||
* WITHOUT leaking your personal data.
|
||||
*
|
||||
* It logs into the ParkSmarter API with the library, calls the safe READ-ONLY
|
||||
* endpoints, and writes a "schema skeleton" (field names + value *types* only,
|
||||
* never the values) to ./capture/. Share ./capture/ freely — it contains no PII.
|
||||
*
|
||||
* It NEVER calls anything that spends money or mutates your account
|
||||
* (no start-session, add/update/delete card or vehicle, profile edits, etc.).
|
||||
*
|
||||
* Setup:
|
||||
* 1) npm run build (already done if dist/ exists)
|
||||
* 2) Create ./.creds.json (gitignored):
|
||||
* {
|
||||
* "phoneNumber": "5551234567", // digits only, no country code
|
||||
* "password": "…",
|
||||
* "environment": "prodv2", // optional
|
||||
* "lat": 40.4406, // optional: a coordinate near real meters
|
||||
* "lng": -79.9959 // optional
|
||||
* }
|
||||
* 3) node sweep.mjs
|
||||
*/
|
||||
import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { ParkSmarterClient } from './dist/index.js';
|
||||
|
||||
/* ---- load creds (file or env) ---- */
|
||||
let creds = {};
|
||||
if (existsSync('./.creds.json')) {
|
||||
creds = JSON.parse(readFileSync('./.creds.json', 'utf8'));
|
||||
}
|
||||
const phoneNumber = creds.phoneNumber ?? process.env.PS_PHONE;
|
||||
const password = creds.password ?? process.env.PS_PASS;
|
||||
const environment = creds.environment ?? process.env.PS_ENV ?? 'prodv2';
|
||||
const lat = creds.lat ?? (process.env.PS_LAT ? Number(process.env.PS_LAT) : undefined);
|
||||
const lng = creds.lng ?? (process.env.PS_LNG ? Number(process.env.PS_LNG) : undefined);
|
||||
|
||||
if (!phoneNumber || !password) {
|
||||
console.error(
|
||||
'Missing credentials. Create ./.creds.json {phoneNumber, password} or set PS_PHONE/PS_PASS.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* ---- schema skeleton: keep KEYS and TYPES, drop VALUES (no PII) ---- */
|
||||
function skeleton(v, depth = 0) {
|
||||
if (v === null) return 'null';
|
||||
if (Array.isArray(v)) {
|
||||
if (v.length === 0) return ['<empty>'];
|
||||
// merge keys across up to 5 elements so we don't miss sparse fields
|
||||
const sample = v.slice(0, 5).map((e) => skeleton(e, depth + 1));
|
||||
if (typeof sample[0] === 'object' && sample[0] !== null) {
|
||||
const merged = {};
|
||||
for (const s of sample) Object.assign(merged, s);
|
||||
return [merged, `<len:${v.length}>`];
|
||||
}
|
||||
return [sample[0], `<len:${v.length}>`];
|
||||
}
|
||||
if (typeof v === 'object') {
|
||||
const out = {};
|
||||
for (const k of Object.keys(v).sort()) out[k] = skeleton(v[k], depth + 1);
|
||||
return out;
|
||||
}
|
||||
// primitives: report type only, plus a coarse hint for strings
|
||||
if (typeof v === 'string') {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(v)) return 'string<date>';
|
||||
if (/^-?\d+(\.\d+)?$/.test(v)) return 'string<numeric>';
|
||||
return 'string';
|
||||
}
|
||||
return typeof v; // number | boolean
|
||||
}
|
||||
|
||||
const capDir = './capture';
|
||||
mkdirSync(capDir, { recursive: true });
|
||||
|
||||
const ps = new ParkSmarterClient({ environment, timeoutMs: 20000 });
|
||||
const results = {};
|
||||
|
||||
async function grab(name, fn) {
|
||||
try {
|
||||
const data = await fn();
|
||||
const skel = skeleton(data);
|
||||
results[name] = { ok: true, schema: skel };
|
||||
writeFileSync(`${capDir}/${name}.json`, JSON.stringify(skel, null, 2));
|
||||
const top = skel && typeof skel === 'object' ? Object.keys(skel) : skel;
|
||||
console.log(`✓ ${name}:`, JSON.stringify(top));
|
||||
} catch (e) {
|
||||
results[name] = { ok: false, error: `${e.name} ${e.status ?? ''} ${e.message}` };
|
||||
console.log(`✗ ${name}: ${e.name} ${e.status ?? ''} ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- run ---- */
|
||||
console.log(`\nEnvironment: ${environment}`);
|
||||
await grab('applicationValidity', () => ps.getApplicationValidity());
|
||||
|
||||
console.log('\nLogging in…');
|
||||
try {
|
||||
const auth = await ps.loginWithPhone({ phoneNumber, password });
|
||||
results.__login = { ok: true, schema: skeleton(auth) };
|
||||
writeFileSync(`${capDir}/_authResponse.json`, JSON.stringify(skeleton(auth), null, 2));
|
||||
console.log('✓ login: keys =', JSON.stringify(Object.keys(auth)));
|
||||
} catch (e) {
|
||||
console.log(`✗ login FAILED: ${e.name} ${e.status ?? ''} ${e.message}`);
|
||||
console.log(' (a 201 here means the account needs verification — see README note.)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* authenticated READ-ONLY sweep */
|
||||
await grab('userDetail', () => ps.getUserDetail());
|
||||
await grab('notificationSettings', () => ps.getNotificationSettings());
|
||||
await grab('activeSessions', () => ps.getActiveParkingSessions());
|
||||
await grab('pastSessions', () => ps.getPastParkingSessions({ currentPage: 1, pageSize: 10 }));
|
||||
await grab('parkingLots', () => ps.getParkingLots());
|
||||
await grab('states', () => ps.getStates());
|
||||
await grab('states_withId', () => ps.getStates(1));
|
||||
|
||||
let firstZone = null;
|
||||
if (lat != null && lng != null) {
|
||||
const meters = await (async () => {
|
||||
try {
|
||||
return await ps.getMetersByLocation({ latitude: lat, longitude: lng });
|
||||
} catch (e) {
|
||||
results.metersByLocation = { ok: false, error: `${e.name} ${e.status ?? ''} ${e.message}` };
|
||||
console.log(`✗ metersByLocation: ${e.name} ${e.status ?? ''} ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (meters) {
|
||||
writeFileSync(`${capDir}/metersByLocation.json`, JSON.stringify(skeleton(meters), null, 2));
|
||||
results.metersByLocation = { ok: true, schema: skeleton(meters) };
|
||||
console.log('✓ metersByLocation:', JSON.stringify(Object.keys(meters)));
|
||||
firstZone = meters?.Zones?.[0] ?? null;
|
||||
}
|
||||
await grab('limitedMetersByLocation', () =>
|
||||
ps.getLimitedMetersByLocation({ latitude: lat, longitude: lng }),
|
||||
);
|
||||
} else {
|
||||
console.log('\n(skip meters-by-location: add "lat"/"lng" to .creds.json to include them)');
|
||||
}
|
||||
|
||||
/* estimates are read-only price quotes — try them from a real zone + the user's vehicle */
|
||||
try {
|
||||
const me = await ps.getUserDetail();
|
||||
const vehicleId = me?.VehicleDetails?.[0]?.VehicleID;
|
||||
const zone = firstZone;
|
||||
const space = zone?.Spaces?.[0];
|
||||
const zoneId = zone?.ZoneId ?? zone?.ZoneID;
|
||||
const spaceId = space?.SpaceID ?? space?.SpaceId;
|
||||
const customerId = zone?.CustomerID ?? zone?.CustomerId;
|
||||
if (zoneId != null && spaceId != null && customerId != null && vehicleId != null) {
|
||||
const common = { zoneId, spaceId, customerId, vehicleId };
|
||||
await grab('estimateItems', () => ps.getParkingEstimateItems(common));
|
||||
await grab('estimateMulti', () => ps.getParkingEstimateMulti(common));
|
||||
await grab('estimateSingle', () =>
|
||||
ps.getParkingEstimateSingle({ ...common, durationInMinutes: 60, creditCardId: 0 }),
|
||||
);
|
||||
} else {
|
||||
console.log('(skip estimates: no zone/space/vehicle available to quote against)');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`(skip estimates: ${e.message})`);
|
||||
}
|
||||
|
||||
/* try a receipt shape from a past session id, if any (read-only) */
|
||||
try {
|
||||
const past = await ps.getPastParkingSessions({ currentPage: 1, pageSize: 1 });
|
||||
const tid = past?.Session?.[0]?.TransactionID;
|
||||
if (tid != null) await grab('parkingReceipt', () => ps.getParkingReceipt(tid));
|
||||
} catch {}
|
||||
|
||||
/* public content shapes */
|
||||
await grab('states_public', () => ps.getStates());
|
||||
|
||||
writeFileSync(`${capDir}/_summary.json`, JSON.stringify(results, null, 2));
|
||||
console.log('\nDone. Schema skeletons (no PII) written to ./capture/. Share that folder with me.');
|
||||
16
parksmarter-client/tsconfig.json
Normal file
16
parksmarter-client/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue