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