315 lines
13 KiB
Markdown
315 lines
13 KiB
Markdown
# Goodwalk Mobile Architecture
|
|
|
|
> Developer reference for the dedicated mobile experience layer introduced in April 2026.
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
Goodwalk's mobile experience is a **first-class application layer** built within the existing SvelteKit project. It is not a responsive version of the desktop — mobile users are automatically routed into dedicated mobile routes that use purpose-built layouts, components, and navigation patterns.
|
|
|
|
```
|
|
Desktop users → /admin/* /members/*
|
|
Mobile users → /admin/m/* /members/m/*
|
|
```
|
|
|
|
Both the Admin and Member mobile experiences share the same component library and design token system while keeping their routes, permissions, and business logic completely separate.
|
|
|
|
---
|
|
|
|
## Folder Structure
|
|
|
|
```
|
|
frontend/src/
|
|
├── lib/
|
|
│ └── mobile/
|
|
│ ├── utils/
|
|
│ │ └── device.js UA detection: isMobileUA(), isMobileDevice()
|
|
│ ├── stores/
|
|
│ │ └── mobile.js isMobile Svelte store + initMobileStore()
|
|
│ ├── guards/
|
|
│ │ └── routeGuard.js guardMobileRoute() — client-side desktop redirect
|
|
│ └── components/
|
|
│ ├── MobileAppShell.svelte
|
|
│ ├── MobilePageShell.svelte
|
|
│ ├── MobileHeader.svelte
|
|
│ ├── MobileBottomNav.svelte
|
|
│ ├── MobileSection.svelte
|
|
│ ├── MobileCard.svelte
|
|
│ ├── MobileListItem.svelte
|
|
│ ├── MobileStatCard.svelte
|
|
│ ├── MobileActionBar.svelte
|
|
│ ├── MobileSearchBar.svelte
|
|
│ ├── MobileFormField.svelte
|
|
│ ├── MobileEmptyState.svelte
|
|
│ ├── MobileFilterSheet.svelte
|
|
│ └── MobileTabs.svelte
|
|
│
|
|
└── routes/
|
|
├── +layout.server.js Exposes isMobile to client via page data
|
|
├── admin/
|
|
│ └── m/ Admin mobile routes
|
|
│ ├── +layout.js ssr: false (matches desktop admin)
|
|
│ ├── +layout.svelte Auth + theme + nav shell
|
|
│ ├── dashboard/+page.svelte
|
|
│ ├── bookings/+page.svelte
|
|
│ ├── customers/+page.svelte
|
|
│ ├── reporting/+page.svelte
|
|
│ └── settings/+page.svelte
|
|
└── members/
|
|
└── m/ Members mobile routes
|
|
├── +layout.server.js Cookie auth guard (mirrors desktop)
|
|
├── +layout.svelte Auth sync + polling + nav shell
|
|
├── home/+page.svelte
|
|
├── bookings/+page.svelte
|
|
├── walks/+page.svelte
|
|
├── messages/+page.svelte
|
|
└── profile/+page.svelte
|
|
```
|
|
|
|
---
|
|
|
|
## How Mobile Routing Works
|
|
|
|
### 1. Server-side detection (hooks.server.js)
|
|
|
|
On every GET request, `hooks.server.js` tests the `User-Agent` header against a mobile phone pattern:
|
|
|
|
```js
|
|
const MOBILE_UA_RE = /iPhone|Android.*Mobile|IEMobile|BlackBerry|Opera Mini/i;
|
|
```
|
|
|
|
**Tablets** do NOT match this pattern (Android tablets omit "Mobile" in the UA; iPad is excluded). Tablets receive the desktop experience.
|
|
|
|
If a phone-class UA is detected and the user is on a desktop route (`/admin/*` or `/members/*`), a `307` redirect is issued to the corresponding mobile entry point.
|
|
|
|
### 2. Redirect targets
|
|
|
|
The redirect target is calculated relative to the browser's visible URL, accounting for subdomain rewriting:
|
|
|
|
| Environment | User visits | Redirected to |
|
|
|---|---|---|
|
|
| Local dev | `/admin` | `/admin/m/dashboard` |
|
|
| Local dev | `/members/dashboard` | `/members/m/home` |
|
|
| Admin subdomain | `admin.goodwalk.co.nz/` | `admin.goodwalk.co.nz/m/dashboard` (→ internally `/admin/m/dashboard`) |
|
|
| Members subdomain | `members.goodwalk.co.nz/bookings` | `members.goodwalk.co.nz/m/home` |
|
|
|
|
### 3. Redirect exclusions
|
|
|
|
These paths are **never redirected** to mobile routes, even on a phone:
|
|
|
|
- `*/login`, `*/verify` — auth flows work on all platforms
|
|
- `*/claim`, `*/onboarding` — onboarding is currently desktop-only
|
|
- Paths already under `/m/` — prevents redirect loops
|
|
- Requests with `?d=1` — explicit desktop override (useful for QA, power users)
|
|
|
|
### 4. Client-side desktop guard (guardMobileRoute)
|
|
|
|
Mobile layout files call `guardMobileRoute()` in `onMount`. If a desktop browser somehow lands on a `/m/` route (e.g. shared link, direct URL entry), it is sent back to the desktop equivalent.
|
|
|
|
```js
|
|
// In any /admin/m/ or /members/m/ +layout.svelte:
|
|
import { guardMobileRoute } from '$lib/mobile/guards/routeGuard.js';
|
|
onMount(() => {
|
|
guardMobileRoute($page.url.pathname, $page.url.search);
|
|
});
|
|
```
|
|
|
|
The guard respects `?d=1` — if that param is present, the user stays on the mobile route regardless of device (for QA testing mobile on desktop).
|
|
|
|
### 5. Preventing redirect loops
|
|
|
|
Loop prevention is layered:
|
|
|
|
1. `hooks.server.js` never redirects paths that already contain `/m/`.
|
|
2. Mobile layouts never redirect mobile users.
|
|
3. `?d=1` stops both server and client redirects.
|
|
|
|
---
|
|
|
|
## Auth in Mobile Routes
|
|
|
|
### Admin mobile (`/admin/m/`)
|
|
|
|
- **SSR disabled** — `+layout.js` sets `ssr: false`, identical to the desktop admin.
|
|
- Auth is handled entirely **client-side** in `+layout.svelte` via `getAdminAccessToken()`.
|
|
- If no token is found, a login form is rendered in place of the page content.
|
|
- After login, `saveAdminTokens()` is called and the layout re-renders the shell.
|
|
- The admin theme preference is loaded from `localStorage` on mount.
|
|
|
|
### Members mobile (`/members/m/`)
|
|
|
|
- **SSR enabled** — `+layout.server.js` runs on the server and checks the `gw_member_session` cookie.
|
|
- If the cookie is missing, a server-side `307 redirect` to `/members/login?returnTo=<path>` is issued.
|
|
- After login (on the desktop login page), the session cookie is set and the member is returned to the mobile route via the `returnTo` param.
|
|
- Client-side auth state is further validated in `+layout.svelte` via `isLoggedIn()`.
|
|
|
|
---
|
|
|
|
## How to Use Shared Mobile Components
|
|
|
|
All components live in `src/lib/mobile/components/` and use the `--m-*` CSS custom property token layer provided by `MobileAppShell`.
|
|
|
|
### Basic page structure
|
|
|
|
```svelte
|
|
<!-- A mobile page file (+page.svelte inside /admin/m/ or /members/m/) -->
|
|
<script>
|
|
import MobilePageShell from '$lib/mobile/components/MobilePageShell.svelte';
|
|
import MobileSection from '$lib/mobile/components/MobileSection.svelte';
|
|
import MobileCard from '$lib/mobile/components/MobileCard.svelte';
|
|
</script>
|
|
|
|
<MobilePageShell title="My Page" showBack>
|
|
<svelte:fragment slot="header-action">
|
|
<!-- Optional icon button in the top-right -->
|
|
</svelte:fragment>
|
|
|
|
<MobileSection title="Section Heading">
|
|
<MobileCard>
|
|
Content here
|
|
</MobileCard>
|
|
</MobileSection>
|
|
</MobilePageShell>
|
|
```
|
|
|
|
### Key component contracts
|
|
|
|
**MobileAppShell** — set once per layout (`+layout.svelte`), never in individual pages.
|
|
- Props: `variant` (`'admin'|'members'`), `navItems`, `currentPath`
|
|
|
|
**MobilePageShell** — set once per page, wraps all page content.
|
|
- Props: `title`, `showBack`, `backHref`, `transparent`
|
|
- Slot: `header-action` for right-side icon buttons
|
|
|
|
**MobileBottomNav** — rendered automatically by `MobileAppShell`. Never add it manually to a page.
|
|
|
|
**MobileCard** — use `href` prop to make it a tappable link. Avoid `on:click` on cards when a simple link will do.
|
|
|
|
**MobileListItem** — always provide `divider={false}` on the last item in a list.
|
|
|
|
**MobileEmptyState** — always include all three variant cases in data-fetching pages:
|
|
```svelte
|
|
{#if loading}
|
|
<MobileEmptyState variant="loading" />
|
|
{:else if error}
|
|
<MobileEmptyState variant="error" icon="⚠️" title="Error" body={error} />
|
|
{:else if items.length === 0}
|
|
<MobileEmptyState icon="🐾" title="Nothing here yet" body="..." />
|
|
{:else}
|
|
<!-- list content -->
|
|
{/if}
|
|
```
|
|
|
|
**MobileFilterSheet** — use `bind:open` to control visibility. The sheet handles its own `Escape` key and overlay click dismissal.
|
|
|
|
---
|
|
|
|
## How Admin and Member Mobile Experiences Differ
|
|
|
|
| Concern | Admin mobile | Members mobile |
|
|
|---|---|---|
|
|
| Route prefix | `/admin/m/` | `/members/m/` |
|
|
| SSR | Disabled (`ssr: false`) | Enabled |
|
|
| Auth | Client-side localStorage JWT | Server cookie + localStorage |
|
|
| Theme | Admin theme switcher (5 variants) | Fixed green brand palette |
|
|
| Nav items | Dashboard, Clients, Add Client, Reporting, Settings | Home, Book, Walks, Messages, Account |
|
|
| Feature flags | `bookings_enabled`, `walks_enabled`, etc. | Same set, independently fetched |
|
|
| Business logic | Manages all clients/bookings/walks | Views own data only |
|
|
| Login screen | Rendered inline in the layout | Separate `/members/login` page |
|
|
|
|
---
|
|
|
|
## Extending the Mobile Shell
|
|
|
|
### Adding a new admin mobile page
|
|
|
|
1. Create `src/routes/admin/m/<section>/+page.svelte`.
|
|
2. Use `MobilePageShell` as the root component in the template.
|
|
3. If the page needs a back button, set `showBack` and optionally `backHref`.
|
|
4. Add an entry to `BASE_NAV` in `src/routes/admin/m/+layout.svelte` if it should appear in the bottom nav. Otherwise link to it from an existing page.
|
|
|
|
### Adding a new members mobile page
|
|
|
|
1. Create `src/routes/members/m/<section>/+page.svelte`.
|
|
2. Auth is handled by `+layout.server.js` — no per-page auth check needed.
|
|
3. Use `MobilePageShell` and shared components.
|
|
4. Add a nav entry in `ACTIVE_NAV` in `src/routes/members/m/+layout.svelte` if it needs a tab.
|
|
|
|
### Adding a new shared component
|
|
|
|
1. Create `src/lib/mobile/components/Mobile<Name>.svelte`.
|
|
2. Use only `--m-*` CSS custom properties — never hardcode colours.
|
|
3. Ensure all interactive elements have `min-height: var(--m-touch-min, 44px)`.
|
|
4. Annotate props with JSDoc comments at the top of the `<script>` block.
|
|
5. Export from the component directly — no barrel index needed.
|
|
|
|
---
|
|
|
|
## Avoiding Desktop/Mobile Concerns Mixing
|
|
|
|
**Never do this:**
|
|
|
|
```svelte
|
|
<!-- ❌ Don't add mobile breakpoints to desktop components -->
|
|
<style>
|
|
@media (max-width: 768px) {
|
|
.my-desktop-component { display: none; }
|
|
}
|
|
</style>
|
|
```
|
|
|
|
```svelte
|
|
<!-- ❌ Don't import desktop-specific values into mobile components -->
|
|
<script>
|
|
import { adminThemePresets } from '$lib/adminApi'; // belongs in admin layout, not mobile component
|
|
</script>
|
|
```
|
|
|
|
**Do this instead:**
|
|
|
|
- Keep mobile-specific logic in `src/lib/mobile/` and `src/routes/*/m/`.
|
|
- Pass data down via props from the mobile layout to mobile components.
|
|
- If a concern is shared (e.g. feature flags, API fetching), import from the existing shared lib (`$lib/featureSettings`, `$lib/memberApi`, etc.) — these are fine to use in mobile pages.
|
|
- The mobile components themselves (`MobileCard`, `MobileSection`, etc.) should contain zero business logic.
|
|
|
|
---
|
|
|
|
## Migration Notes
|
|
|
|
The following changes were made to existing files:
|
|
|
|
### `src/hooks.server.js`
|
|
|
|
- Added `MOBILE_UA_RE` constant and `isMobileUA()` function.
|
|
- Added `maybeMobileRedirect()` which returns a `307 Response` for mobile users on desktop routes.
|
|
- The redirect fires **after** subdomain rewriting so the canonical pathname is used for matching.
|
|
- Added `event.locals.isMobile` for downstream use.
|
|
- The subdomain rewriter now skips paths starting with `/m/` (prevents double-prefixing mobile routes on subdomains).
|
|
|
|
### `src/routes/+layout.server.js`
|
|
|
|
- Added `isMobile: locals.isMobile ?? false` to the returned data object.
|
|
|
|
### `src/routes/+layout.svelte`
|
|
|
|
- Added a comment clarifying that `/admin/m/*` and `/members/m/*` paths satisfy the existing `isAdmin`/`isMembers` checks and correctly bypass the marketing site chrome.
|
|
|
|
---
|
|
|
|
## Follow-up Recommendations
|
|
|
|
1. **Members login → mobile redirect.** After a member logs in via `/members/login`, `memberApi.js` currently does `goto('/members/dashboard')`. On mobile, this client-side navigation skips `hooks.server.js` and lands the user on the desktop dashboard. Fix: detect mobile in the post-login goto (use `isMobileDevice()` from `$lib/mobile/utils/device.js`) and redirect to `/members/m/home` instead.
|
|
|
|
2. **Onboarding mobile experience.** Onboarding (`/members/onboarding`) is currently excluded from mobile redirects. Consider building a dedicated `/members/m/onboarding` flow using `MobileFormField` and `MobilePageShell` with back navigation.
|
|
|
|
3. **Admin mobile — individual client detail page.** The customers list links to `/admin/members/:id` (desktop). Build `/admin/m/customers/:id/+page.svelte` for a mobile-optimised client profile.
|
|
|
|
4. **Offline / PWA support.** The mobile shell is PWA-ready (safe area support, overscroll contain, touch optimisations). Add a `manifest.json` and service worker to make it installable.
|
|
|
|
5. **Swipe-back gesture.** iOS-style swipe-back for stacked navigation can be layered on top using Svelte's `fly` transition with a touch gesture handler on the `MobilePageShell` container.
|
|
|
|
6. **Dark mode.** The `--m-*` token layer makes adding a dark theme straightforward. Add `prefers-color-scheme: dark` overrides in `MobileAppShell`'s `<style>` block.
|
|
|
|
7. **Pull-to-refresh.** The `m-shell__main` scroll region is a natural candidate for a pull-to-refresh implementation.
|