feat(repairs-app): the phone app shell, manifest and service worker
This commit is contained in:
parent
745593ee35
commit
10810142b0
6 changed files with 210 additions and 0 deletions
25
public/manifest-repairs-app.json
Normal file
25
public/manifest-repairs-app.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "WFDASH — Repairs",
|
||||
"short_name": "Repairs",
|
||||
"description": "Track repair jobs and book machines in.",
|
||||
"start_url": "/repairs-app",
|
||||
"scope": "/repairs-app",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#FDF6F5",
|
||||
"theme_color": "#E85D4C",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
50
public/sw-repairs-app.js
Normal file
50
public/sw-repairs-app.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Service worker for the /repairs-app PWA.
|
||||
//
|
||||
// A shell cache, NOT an offline app. The repairs person always has
|
||||
// signal, so live data must never be served stale: the fetch handler
|
||||
// ignores /api/ entirely and only the shell is cached.
|
||||
const CACHE_NAME = "wfdash-repairs-app-v1";
|
||||
|
||||
const PRECACHE_ASSETS = [
|
||||
"/repairs-app",
|
||||
"/manifest-repairs-app.json",
|
||||
"/offline",
|
||||
"/android-chrome-192x192.png",
|
||||
"/android-chrome-512x512.png",
|
||||
"/apple-touch-icon.png",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
// Never serve API data from the cache — a stale repair list is worse
|
||||
// than no repair list.
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request).catch(() =>
|
||||
caches.match(request).then((cached) => cached || caches.match("/offline"))
|
||||
)
|
||||
);
|
||||
});
|
||||
62
src/app/(repairs-app)/repairs-app/layout.tsx
Normal file
62
src/app/(repairs-app)/repairs-app/layout.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { MobileAuthGate } from "@/app/(mobile)/m/auth-gate";
|
||||
import { RepairsTabBar } from "./tab-bar";
|
||||
|
||||
/**
|
||||
* Repairs phone-app shell.
|
||||
*
|
||||
* The fourth instance of the per-route-group PWA pattern already running
|
||||
* here (`/m` boss phone, `/counter` till, `/rep` rep visits), so it
|
||||
* reuses `MobileAuthGate` and gets its own manifest + service worker.
|
||||
*
|
||||
* Deliberately not in the sidebar — matching `/m`, `/counter` and `/rep`.
|
||||
* The repairs person installs this on their home screen and never
|
||||
* navigates to it from a menu.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: "WFDASH — Repairs",
|
||||
description: "Track repair jobs and book machines in.",
|
||||
manifest: "/manifest-repairs-app.json",
|
||||
icons: { apple: "/apple-touch-icon.png" },
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "Repairs",
|
||||
},
|
||||
other: {
|
||||
// iOS only honours the apple-prefixed name. Without this tag the
|
||||
// home-screen icon opens as a plain Safari bookmark instead of a
|
||||
// standalone app.
|
||||
"apple-mobile-web-app-capable": "yes",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#E85D4C",
|
||||
};
|
||||
|
||||
export default function RepairsAppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<MobileAuthGate>
|
||||
<main
|
||||
className="min-h-screen bg-background text-foreground antialiased"
|
||||
style={{
|
||||
paddingTop: "env(safe-area-inset-top)",
|
||||
// Room for the fixed tab bar plus the home indicator.
|
||||
paddingBottom: "calc(env(safe-area-inset-bottom) + 72px)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<RepairsTabBar />
|
||||
</main>
|
||||
</MobileAuthGate>
|
||||
);
|
||||
}
|
||||
20
src/app/(repairs-app)/repairs-app/tab-bar.test.tsx
Normal file
20
src/app/(repairs-app)/repairs-app/tab-bar.test.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RepairsTabBar } from "./tab-bar";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/repairs-app",
|
||||
}));
|
||||
|
||||
describe("RepairsTabBar", () => {
|
||||
it("shows the Repairs tab as a labelled link to the board", () => {
|
||||
render(<RepairsTabBar />);
|
||||
const link = screen.getByRole("link", { name: /repairs/i });
|
||||
expect(link).toHaveAttribute("href", "/repairs-app");
|
||||
});
|
||||
|
||||
it("has no dead controls — every element is a link", () => {
|
||||
render(<RepairsTabBar />);
|
||||
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
46
src/app/(repairs-app)/repairs-app/tab-bar.tsx
Normal file
46
src/app/(repairs-app)/repairs-app/tab-bar.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* The phone app's only navigation. There is no sidebar and no office
|
||||
* chrome in here — this is a standalone app for one job.
|
||||
*
|
||||
* Phase 2 ships the Repairs tab only. Phase 4 adds the "Ask Giellie"
|
||||
* tab; the tabs array is the single place that changes. A tab that does
|
||||
* nothing yet would be an unexplained control, and this app's whole
|
||||
* point is that every control means something.
|
||||
*/
|
||||
const TABS = [{ href: "/repairs-app", label: "Repairs" }];
|
||||
|
||||
export function RepairsTabBar() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background"
|
||||
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
||||
>
|
||||
<ul className="flex">
|
||||
{TABS.map((tab) => {
|
||||
const active = pathname.startsWith(tab.href);
|
||||
return (
|
||||
<li key={tab.href} className="flex-1">
|
||||
<Link
|
||||
href={tab.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={
|
||||
"flex min-h-[56px] items-center justify-center text-base font-medium " +
|
||||
(active ? "text-primary" : "text-muted-foreground")
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { useEffect } from "react";
|
|||
* - /new-order → /sw-counter.js (same counter PWA shell)
|
||||
* - /manager → /sw-manager.js (manager orders-tracking PWA)
|
||||
* - /rep → /sw-rep.js (rep visit-logging PWA, offline-first)
|
||||
* - /repairs-app → /sw-repairs-app.js (repairs phone-app PWA)
|
||||
* - everything else → /sw.js (main WFDASH app)
|
||||
*
|
||||
* All SWs are scoped to `/` so co-existence is fine — they each
|
||||
|
|
@ -26,6 +27,12 @@ function pickSwPath(): string {
|
|||
if (path === "/manager" || path.startsWith("/manager/")) {
|
||||
return "/sw-manager.js";
|
||||
}
|
||||
// Above the /rep check on purpose. "/repairs-app" does NOT match
|
||||
// "/rep/" — the slash is what separates them — but keeping the more
|
||||
// specific path first means nobody has to work that out later.
|
||||
if (path === "/repairs-app" || path.startsWith("/repairs-app/")) {
|
||||
return "/sw-repairs-app.js";
|
||||
}
|
||||
if (path === "/rep" || path.startsWith("/rep/")) {
|
||||
return "/sw-rep.js";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue