WFDASH-WEBAPP/src/app/layout.tsx
Dave a7341397a6 fix(pwa): restore apple-mobile-web-app-capable so iOS installs standalone
Adding /rep to an iPad home screen and tapping it opened the main app,
not the rep app — while the shortcut URL was still /rep.

Cause: Next 16 renders `appleWebApp.capable: true` as the standardised
<meta name="mobile-web-app-capable"> and no longer emits the
apple-prefixed name. iOS only reads the apple-prefixed one. Without it
the icon is a plain Safari bookmark rather than a standalone app, so
tapping it foregrounds Safari on whatever tab was already open — which
presents exactly as "it opened the main app instead".

This was not specific to /rep. Verified against the running app that
/m (Boss Phone) had the same gap, so it has never actually launched
standalone on iOS either; /counter likewise. The comment in
(counter)/layout.tsx asserting the root layout already supplied this
tag was wrong — the root also only set `appleWebApp.capable`.

Declared in the root layout so all four surfaces inherit it, and
repeated in the (rep) and (mobile) layouts because a child's
`metadata.other` replaces the parent's rather than merging into it.
Corrected the misleading counter comment.

Verified by fetching each route and asserting the rendered head:
/rep, /m, /new-order and /dashboard now all emit
apple-mobile-web-app-capable="yes" while still resolving their own
manifest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:56:01 +02:00

211 lines
6.5 KiB
TypeScript

import type { Metadata, Viewport } from "next";
import {
Geist,
Geist_Mono,
Inter,
Space_Grotesk,
DM_Sans,
Playfair_Display,
Lato,
Nunito,
Outfit,
JetBrains_Mono,
} from "next/font/google";
import "leaflet/dist/leaflet.css";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { AuthProvider } from "@/components/auth-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { ThemeBootstrapScript } from "@/components/theme-bootstrap";
// Geist (default app fonts)
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
// Theme-bound fonts (registered as CSS vars for the theme picker to swap).
// Each font is restricted to the two weights actually used in the app
// (400 regular + 700 bold). Without these, next/font/google preloads
// every available weight and the browser logs warnings about unused
// preload tags (and the page wastes bandwidth fetching woff2 files
// that will never render).
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
});
const spaceGrotesk = Space_Grotesk({
variable: "--font-space-grotesk",
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
});
const dmSans = DM_Sans({
variable: "--font-dm-sans",
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
});
const playfair = Playfair_Display({
variable: "--font-playfair",
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
});
const lato = Lato({
variable: "--font-lato",
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
});
const nunito = Nunito({
variable: "--font-nunito",
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
});
const outfit = Outfit({
variable: "--font-outfit",
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains-mono",
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
});
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#4C96D7",
};
// `metadataBase` is required for OpenGraph / Twitter absolute URL resolution,
// otherwise Next falls back to `http://localhost:4001` in dev and emits a
// warning on every page render. We re-use the public PocketBase URL since
// the app and PB sit on the same host in dev, and in prod the same env var
// points at the deployed origin (`NEXT_PUBLIC_*` is also bundle-visible).
const METADATA_BASE =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
(typeof process.env.NEXT_PUBLIC_POCKETBASE_URL === "string"
? process.env.NEXT_PUBLIC_POCKETBASE_URL.replace(/\/+$/, "")
: "http://localhost:4001");
export const metadata: Metadata = {
metadataBase: new URL(METADATA_BASE),
title: "WFDASH - Business CRM",
description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
manifest: "/manifest.json",
// Counter PWA manifest — iOS/Android pick the most-specific match
// for the installed app icon + start_url when this is the only
// <link rel="manifest"> they recognise. The main `/manifest.json`
// stays the default for the main app.
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/favicon-96x96.png", sizes: "96x96", type: "image/png" },
{ url: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
{ url: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
],
apple: "/apple-touch-icon.png",
},
authors: [{ name: "WFDASH" }],
keywords: ["CRM", "Business", "Quotes", "Orders", "Customers", "Suppliers"],
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "WFDASH",
},
other: {
// Next 16 renders `appleWebApp.capable: true` as the standardised
// `<meta name="mobile-web-app-capable">` and no longer emits the
// apple-prefixed name — which is the only one iOS actually reads.
//
// Without this, an "Add to Home Screen" icon is a plain Safari
// bookmark rather than a standalone app: tapping it foregrounds
// Safari on whatever tab was already open, which presents as "it
// opened the main app instead of mine". Verified missing on /m and
// /rep against the running app before this was added.
//
// Declared at the root so every surface inherits it (/, /m,
// /counter, /rep). The PWA layouts that define their own `other`
// repeat it, because a child's `other` replaces the parent's
// rather than merging into it.
"apple-mobile-web-app-capable": "yes",
},
openGraph: {
type: "website",
title: "WFDASH - Business CRM",
description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
siteName: "WFDASH",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "WFDASH - Business CRM",
},
],
},
twitter: {
card: "summary_large_image",
title: "WFDASH - Business CRM",
description: "Business CRM for managing customers, companies, suppliers, quotes, and orders",
images: ["/twitter-image.png"],
},
};
// All theme-bound CSS vars must be present on <html> so themes can swap them
const FONT_VARS = [
geistSans.variable,
geistMono.variable,
inter.variable,
spaceGrotesk.variable,
dmSans.variable,
playfair.variable,
lato.variable,
nunito.variable,
outfit.variable,
jetbrainsMono.variable,
].join(" ");
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html
lang="en"
className={`${FONT_VARS} h-full antialiased`}
data-theme="wfdash"
suppressHydrationWarning
>
<head>
{/* Apply persisted theme before hydration to prevent flash of wrong theme */}
<ThemeBootstrapScript />
</head>
<body className="min-h-full flex flex-col">
<ServiceWorkerRegistration />
<ThemeProvider>
<TooltipProvider>
<AuthProvider>
{children}
</AuthProvider>
</TooltipProvider>
<Toaster richColors />
</ThemeProvider>
</body>
</html>
);
}