13 KiB
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:
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.
// 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:
hooks.server.jsnever redirects paths that already contain/m/.- Mobile layouts never redirect mobile users.
?d=1stops both server and client redirects.
Auth in Mobile Routes
Admin mobile (/admin/m/)
- SSR disabled —
+layout.jssetsssr: false, identical to the desktop admin. - Auth is handled entirely client-side in
+layout.svelteviagetAdminAccessToken(). - 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
localStorageon mount.
Members mobile (/members/m/)
- SSR enabled —
+layout.server.jsruns on the server and checks thegw_member_sessioncookie. - If the cookie is missing, a server-side
307 redirectto/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
returnToparam. - Client-side auth state is further validated in
+layout.svelteviaisLoggedIn().
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
<!-- 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-actionfor 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:
{#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
- Create
src/routes/admin/m/<section>/+page.svelte. - Use
MobilePageShellas the root component in the template. - If the page needs a back button, set
showBackand optionallybackHref. - Add an entry to
BASE_NAVinsrc/routes/admin/m/+layout.svelteif it should appear in the bottom nav. Otherwise link to it from an existing page.
Adding a new members mobile page
- Create
src/routes/members/m/<section>/+page.svelte. - Auth is handled by
+layout.server.js— no per-page auth check needed. - Use
MobilePageShelland shared components. - Add a nav entry in
ACTIVE_NAVinsrc/routes/members/m/+layout.svelteif it needs a tab.
Adding a new shared component
- Create
src/lib/mobile/components/Mobile<Name>.svelte. - Use only
--m-*CSS custom properties — never hardcode colours. - Ensure all interactive elements have
min-height: var(--m-touch-min, 44px). - Annotate props with JSDoc comments at the top of the
<script>block. - Export from the component directly — no barrel index needed.
Avoiding Desktop/Mobile Concerns Mixing
Never do this:
<!-- ❌ Don't add mobile breakpoints to desktop components -->
<style>
@media (max-width: 768px) {
.my-desktop-component { display: none; }
}
</style>
<!-- ❌ 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/andsrc/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_REconstant andisMobileUA()function. - Added
maybeMobileRedirect()which returns a307 Responsefor mobile users on desktop routes. - The redirect fires after subdomain rewriting so the canonical pathname is used for matching.
- Added
event.locals.isMobilefor 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 ?? falseto the returned data object.
src/routes/+layout.svelte
- Added a comment clarifying that
/admin/m/*and/members/m/*paths satisfy the existingisAdmin/isMemberschecks and correctly bypass the marketing site chrome.
Follow-up Recommendations
-
Members login → mobile redirect. After a member logs in via
/members/login,memberApi.jscurrently doesgoto('/members/dashboard'). On mobile, this client-side navigation skipshooks.server.jsand lands the user on the desktop dashboard. Fix: detect mobile in the post-login goto (useisMobileDevice()from$lib/mobile/utils/device.js) and redirect to/members/m/homeinstead. -
Onboarding mobile experience. Onboarding (
/members/onboarding) is currently excluded from mobile redirects. Consider building a dedicated/members/m/onboardingflow usingMobileFormFieldandMobilePageShellwith back navigation. -
Admin mobile — individual client detail page. The customers list links to
/admin/members/:id(desktop). Build/admin/m/customers/:id/+page.sveltefor a mobile-optimised client profile. -
Offline / PWA support. The mobile shell is PWA-ready (safe area support, overscroll contain, touch optimisations). Add a
manifest.jsonand service worker to make it installable. -
Swipe-back gesture. iOS-style swipe-back for stacked navigation can be layered on top using Svelte's
flytransition with a touch gesture handler on theMobilePageShellcontainer. -
Dark mode. The
--m-*token layer makes adding a dark theme straightforward. Addprefers-color-scheme: darkoverrides inMobileAppShell's<style>block. -
Pull-to-refresh. The
m-shell__mainscroll region is a natural candidate for a pull-to-refresh implementation.