Files
ponzischeme89 6d44e05de4 v1
2026-04-18 07:23:55 +12:00

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:

  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

<!-- 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:

{#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:

<!-- ❌ 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/ 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.