PWA Settings

Fixed webmanifest generation and pwa icon.

Made nav bar text optional.
This commit is contained in:
Stefan Lazic committed 2026-08-07 13:38:11 +01:00
1 parent a702320d4e
commit 769d3f9ebc
23 files changed
+1065 -91

No files matched your search

+8 -1
View File
@@ -35,7 +35,14 @@ export async function signIn(page: Page, email: string, password: string): Promi
await page.getByLabel("Email Address").fill(email);
await page.getByLabel("Password", { exact: true }).fill(password);
await page.getByRole("button", { name: "Sign In", exact: true }).click();
await expect(page).toHaveURL(/\/home/);
// Not asserting a specific destination here — a bare sign-in (no
// callbackUrl, which this helper never sets) now lands on a role-aware
// page (customer -> /home, barber -> /staff, owner/super admin ->
// /owner or /owner/calendar; see src/app/page.tsx), not uniformly
// /home. Every caller already `page.goto()`s to the specific route it
// needs right after this — this just confirms sign-in actually
// succeeded and navigated away from the form.
await expect(page).not.toHaveURL(/\/sign-in/);
}
function section(page: Page, headingName: string) {
+9
View File
@@ -25,6 +25,15 @@ const SECURITY_HEADERS = [
const nextConfig: NextConfig = {
reactStrictMode: true,
experimental: {
// Next's Server Action default (1MB) is too small for the PWA
// icon/splash-screen ZIP import (see updateAppIconSettingsAction's
// sibling importAppIconZipAction) — a full iOS splash-screen bundle
// from a generator like progressier.com is ~10-15MB. Also lifts the
// ceiling for regular media uploads (MediaPicker), which were
// previously constrained below their own MEDIA_MAX_UPLOAD_BYTES.
serverActions: { bodySizeLimit: "20mb" },
},
images: {
remotePatterns: [
{
+100
View File
@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"image-size": "^2.0.2",
"jszip": "^3.10.1",
"lucide-react": "^1.28.0",
"luxon": "^3.7.2",
"mariadb": "^3.5.3",
@@ -5500,6 +5501,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7342,6 +7349,12 @@
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -7969,6 +7982,18 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -8034,6 +8059,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -8957,6 +8991,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9299,6 +9339,12 @@
}
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -9433,6 +9479,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -9835,6 +9908,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -10053,6 +10132,21 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -10655,6 +10749,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/valibot": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
+1
View File
@@ -41,6 +41,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"image-size": "^2.0.2",
"jszip": "^3.10.1",
"lucide-react": "^1.28.0",
"luxon": "^3.7.2",
"mariadb": "^3.5.3",
@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE `Business` ADD COLUMN `icon192Url` VARCHAR(191) NULL,
ADD COLUMN `icon512Url` VARCHAR(191) NULL,
ADD COLUMN `maskableIcon192Url` VARCHAR(191) NULL,
ADD COLUMN `maskableIcon512Url` VARCHAR(191) NULL,
ADD COLUMN `splashScreenLandscapeUrl` VARCHAR(191) NULL,
ADD COLUMN `splashScreenPortraitUrl` VARCHAR(191) NULL;
@@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `splashScreenLandscapeUrl` on the `Business` table. All the data in the column will be lost.
- You are about to drop the column `splashScreenPortraitUrl` on the `Business` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE `Business` DROP COLUMN `splashScreenLandscapeUrl`,
DROP COLUMN `splashScreenPortraitUrl`,
ADD COLUMN `splashScreens` JSON NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `Business` ADD COLUMN `bottomNavShowLabels` BOOLEAN NOT NULL DEFAULT true;
+38 -8
View File
@@ -257,13 +257,43 @@ model Business {
/// src/app/icon.png and the branding fallback rules in
/// src/lib/business/branding.ts.
faviconUrl String?
/// Used for the PWA manifest icon set — falls back to the same neutral
/// app icon as the favicon when unset.
/// Apple touch icon — iOS Safari's "Add to Home Screen" icon. Falls back
/// to the same neutral app icon as the favicon when unset. Distinct from
/// the four PWA manifest icon fields below (Android/Chrome installs read
/// the manifest's `icons` array, not this field).
appIconUrl String?
/// Hex color, e.g. "#111b15" — PWA `theme_color` / manifest and
/// browser chrome. Falls back to the app's own theme color when unset.
themeColor String?
backgroundColor String?
/// PWA manifest `icons` array (Android/Chrome home-screen install) — one
/// upload per required size/purpose rather than generating them from a
/// single source image, since there's no image-processing pipeline in
/// this app. Each falls back independently to the app's own generated
/// icon when unset — see src/app/manifest.ts.
icon192Url String?
icon512Url String?
/// "Maskable" purpose — Android adaptive-icon safe zone, must have the
/// subject inset within the center ~80% since the OS may crop to a
/// circle/squircle/rounded-square.
maskableIcon192Url String?
maskableIcon512Url String?
/// iOS `apple-touch-startup-image` splash screens shown while the app
/// launches from the home screen — Safari has no manifest-driven splash
/// generation (unlike Chrome, which derives one from the manifest's
/// icon/name/background_color automatically), so this needs explicit
/// per-device images. An array of `{ media, url }` pairs — one exact
/// `(device-width/height/pixel-ratio/orientation)` media query per
/// device, matching the bundle a generator like progressier.com
/// produces — rather than the 2 generic portrait/landscape images this
/// field held before, since a real per-device bundle is now imported
/// wholesale (see importAppIconZipAction) instead of hand-picked.
splashScreens Json?
/// Icon-only vs icon+label bottom navigation (customer and staff/owner
/// alike — see components/navigation/bottom-nav.tsx). Super
/// Administrator only, same as the rest of this display-config block —
/// see updateNavigationSettingsAction.
bottomNavShowLabels Boolean @default(true)
/// Phase 9 — Welcome page background, desktop breakpoint. Falls back to
/// the existing plain background when unset. See src/app/page.tsx.
welcomeBackgroundDesktopUrl String?
@@ -1217,15 +1247,15 @@ model LoyaltyProgramme {
/// Phase 11 — replaces the hardcoded "Loyalty Card" title shown top-left
/// on the card. Null/empty falls back to "Loyalty Card", same as before
/// this was configurable.
cardTitleText String?
cardTitleText String?
/// Card-wide — applies to all card text, not just the title. See
/// LoyaltyCardFont's doc comment for why this is card-wide rather than a
/// control per element.
cardFont LoyaltyCardFont @default(SANS)
cardTitleTextSize LoyaltyCardTextSize @default(MD)
cardTitleTextColor String?
cardBorderColor String?
cardMemberNumberColor String?
cardFont LoyaltyCardFont @default(SANS)
cardTitleTextSize LoyaltyCardTextSize @default(MD)
cardTitleTextColor String?
cardBorderColor String?
cardMemberNumberColor String?
updatedAt DateTime @updatedAt
+3 -2
View File
@@ -1,6 +1,7 @@
import { auth } from "@/auth";
import { BottomNav, type BottomNavItem } from "@/components/navigation/bottom-nav";
import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding";
import { NotificationBell } from "@/features/notifications/components/notification-bell";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
@@ -22,14 +23,14 @@ const NAV_ITEMS: BottomNavItem[] = [
* notification.service.ts's IN_APP channel resolution).
*/
export default async function CustomerLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
const [session, branding] = await Promise.all([auth(), getBranding()]);
const unreadNotificationCount = session?.user ? await getUnreadNotificationCount(prisma, session.user.id) : 0;
return (
<div className="min-h-screen bg-stone pb-24">
{session?.user && <NotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
<div id="main-content">{children}</div>
<BottomNav items={NAV_ITEMS} variant="customer" />
<BottomNav items={NAV_ITEMS} variant="customer" showLabels={branding.bottomNavShowLabels} />
</div>
);
}
+43 -34
View File
@@ -13,6 +13,7 @@ import {
Gift,
Images,
ScissorsSquare,
Smartphone,
Stamp,
Star,
TrendingUp,
@@ -24,7 +25,7 @@ import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { todayIn } from "@/lib/time/business-time";
@@ -48,38 +49,44 @@ function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; l
);
}
const MANAGE_SECTIONS = [
{
title: "Business",
links: [
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
],
},
{
title: "Booking",
links: [
{ href: "/owner/services", label: "Services", icon: ScissorsSquare },
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
],
},
{
title: "Customers",
links: [{ href: "/owner/settings/reviews", label: "Reviews", icon: Star }],
},
{
title: "Team",
links: [{ href: "/owner/team", label: "Team Management", icon: Users }],
},
{
title: "Reports",
links: [{ href: "/owner/reports", label: "Reports", icon: BarChart3 }],
},
] as const;
/** App Icon & Home Screen is Super Administrator only (see /owner/settings/app) — everything else here is any Owner. */
function getManageSections(isSuper: boolean) {
return [
{
title: "Business",
links: [
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
...(isSuper
? [{ href: "/owner/settings/app", label: "App Icon & Home Screen", icon: Smartphone }]
: []),
],
},
{
title: "Booking",
links: [
{ href: "/owner/services", label: "Services", icon: ScissorsSquare },
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
],
},
{
title: "Customers",
links: [{ href: "/owner/settings/reviews", label: "Reviews", icon: Star }],
},
{
title: "Team",
links: [{ href: "/owner/team", label: "Team Management", icon: Users }],
},
{
title: "Reports",
links: [{ href: "/owner/reports", label: "Reports", icon: BarChart3 }],
},
];
}
function ManageSection({ title, links }: (typeof MANAGE_SECTIONS)[number]) {
function ManageSection({ title, links }: ReturnType<typeof getManageSections>[number]) {
return (
<div>
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">{title}</p>
@@ -108,14 +115,16 @@ export default async function OwnerDashboardPage() {
if (!context) redirect("/staff");
const today = todayIn(context.businessTimezone);
const [summary, unreadNotificationCount] = await Promise.all([
const [summary, unreadNotificationCount, isSuper] = await Promise.all([
getDashboardSummary(prisma, {
businessId: context.businessId,
businessTimezone: context.businessTimezone,
date: today,
}),
getUnreadNotificationCount(prisma, session.user.id),
isSuperAdmin(session.user.id),
]);
const manageSections = getManageSections(isSuper);
return (
<div className="pb-12">
@@ -237,7 +246,7 @@ export default async function OwnerDashboardPage() {
</Card>
<div className="space-y-5">
{MANAGE_SECTIONS.map((section) => (
{manageSections.map((section) => (
<ManageSection key={section.title} {...section} />
))}
</div>
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { isSuperAdmin } from "@/lib/auth/permissions";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { parseSplashScreens } from "@/lib/business/branding";
import { getBusinessProfile } from "@/services/business/business.service";
import { AppIconSettingsForm } from "@/features/owner/components/app-icon-settings-form";
export const metadata: Metadata = { title: "App Icon & Home Screen" };
export const dynamic = "force-dynamic";
/** Super Administrator only — not Owner, unlike every other /owner/settings/* page. See requireSuperAdminBusinessManager in features/owner/actions/business.ts for the matching action-layer check. */
export default async function OwnerAppIconSettingsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
if (!(await isSuperAdmin(session.user.id))) redirect("/owner");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
return (
<SettingsPageShell title="App Icon & Home Screen">
<AppIconSettingsForm
initial={{
shortName: business.shortName?.trim() || business.name,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor,
backgroundColor: business.backgroundColor,
icon192Url: business.icon192Url,
icon512Url: business.icon512Url,
maskableIcon192Url: business.maskableIcon192Url,
maskableIcon512Url: business.maskableIcon512Url,
splashScreens: parseSplashScreens(business.splashScreens),
bottomNavShowLabels: business.bottomNavShowLabels,
}}
/>
</SettingsPageShell>
);
}
@@ -38,9 +38,6 @@ export default async function OwnerBusinessDetailsPage() {
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
faviconUrl: business.faviconUrl,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor,
backgroundColor: business.backgroundColor,
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
+11 -1
View File
@@ -19,10 +19,20 @@ const inter = Inter({
*/
export async function generateMetadata(): Promise<Metadata> {
const branding = await getBranding();
return {
title: { default: branding.name, template: `%s | ${branding.name}` },
description: branding.description || "Book your next appointment in seconds.",
appleWebApp: { capable: true, statusBarStyle: "default", title: branding.shortName },
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: branding.shortName,
// One exact per-device `apple-touch-startup-image` entry per array
// item — imported wholesale from a generator's ZIP bundle (see
// importAppIconZipAction), not hand-picked. Empty array renders no
// splash screens at all, which is the correct "unconfigured" state.
startupImage: branding.splashScreens.length > 0 ? branding.splashScreens : undefined,
},
icons:
branding.faviconUrl || branding.appIconUrl
? { icon: branding.faviconUrl || undefined, apple: branding.appIconUrl || undefined }
+41 -12
View File
@@ -7,15 +7,24 @@ import { getBranding } from "@/lib/business/branding";
* Phase 8: name/description/colors reflect the installed business (see
* src/lib/business/branding.ts) rather than the internal product name.
*
* `icons` intentionally stays pointed at the app's own pre-generated,
* correctly-sized/maskable PWA icon set rather than a business's single
* `appIconUrl` — there's no image-processing pipeline in this app to
* derive the required 192/512/maskable variants from one arbitrary
* uploaded URL (see docs/phase-8-summary.md, "Known limitations"), so
* this is fallback rule #2 ("business name + a neutral generated icon"),
* applied deliberately rather than risking a broken/non-square install
* icon.
* `force-dynamic` is required, not optional — without it, Next has no
* signal that this route depends on request-time data (a plain DB read
* doesn't count as one of Next's dynamic-usage triggers) and statically
* renders it once at build time. Since the Docker image is built before
* the installation wizard ever runs, that bakes in the pre-install
* neutral fallback ("Book an Appointment"/"Booking") permanently — the
* real business name/icons would never appear no matter how the Business
* row changes afterward.
*
* Each of the four manifest icons falls back independently to the app's
* own pre-generated, correctly-sized/maskable PWA icon when a business
* hasn't uploaded its own — a business can set some without all four (see
* the "App Icon & Home Screen" super-admin settings screen). This is
* fallback rule #2 ("business name + a neutral generated icon") applied
* per-field, rather than risking a broken/non-square install icon.
*/
export const dynamic = "force-dynamic";
export default async function manifest(): Promise<MetadataRoute.Manifest> {
const branding = await getBranding();
return {
@@ -29,10 +38,30 @@ export default async function manifest(): Promise<MetadataRoute.Manifest> {
theme_color: branding.themeColor,
orientation: "portrait-primary",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icons/icon-maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icons/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{
src: branding.icon192Url || "/icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: branding.icon512Url || "/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: branding.maskableIcon192Url || "/icons/icon-maskable-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: branding.maskableIcon512Url || "/icons/icon-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
+15 -5
View File
@@ -5,7 +5,7 @@ import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { getBranding } from "@/lib/business/branding";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { hasPermission } from "@/lib/auth/permissions";
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-card";
@@ -14,10 +14,14 @@ import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-ca
* The unauthenticated entry point — always shown at `/` before
* authentication, per the brief's "Welcome routing" requirements.
* Authenticated visitors never see it: they're routed straight to their
* role's home (customer → /home, barber → /staff, owner → /owner) below.
* Unreachable at all before installation completes — src/proxy.ts
* redirects every non-exempt route (including this one) to /install
* until ApplicationInstallation flips to INSTALLED.
* role's calendar (barber → /staff, owner/super admin → /owner/calendar)
* or customer home below. This is also where a bare sign-in with no
* `callbackUrl` ends up — see safeRedirectPath's fallback in
* sign-in-form.tsx — so it's the one place that has to get every role
* right, not just staffProfile holders. Unreachable at all before
* installation completes — src/proxy.ts redirects every non-exempt route
* (including this one) to /install until ApplicationInstallation flips
* to INSTALLED.
*/
export default async function WelcomePage() {
const session = await auth();
@@ -27,6 +31,12 @@ export default async function WelcomePage() {
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
redirect(canManageStaff ? "/owner" : "/staff");
}
// A Super Administrator has no StaffProfile by design (see
// isSuperAdmin) — without this check they'd fall through to the
// customer /home below instead of the staff shell + Calendar tab
// their nav role (see lib/navigation/nav-config.ts) actually gives
// them once they land somewhere in it.
if (await isSuperAdmin(session.user.id)) redirect("/owner/calendar");
redirect("/home");
}
+11 -4
View File
@@ -46,6 +46,8 @@ export interface BottomNavProps {
* role-aware component.
*/
variant?: "customer" | "staff";
/** Business-wide, Super Administrator-only display setting (see Business.bottomNavShowLabels) — defaults to the original icon+label layout when a caller doesn't pass it. */
showLabels?: boolean;
}
/**
@@ -54,7 +56,7 @@ export interface BottomNavProps {
* and pass it down — one less thing to get out of sync as routes are
* added in later phases.
*/
export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
export function BottomNav({ items, variant = "customer", showLabels = true }: BottomNavProps) {
const pathname = usePathname();
const isStaff = variant === "staff";
const activeHref = resolveActiveHref(pathname, items);
@@ -85,14 +87,19 @@ export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
key={href}
href={href}
aria-current={active ? "page" : undefined}
// Icon-only drops the flex-col label row entirely (not just
// hides the text) so the tap target re-centers vertically
// instead of leaving an empty gap where the label used to be.
aria-label={showLabels ? undefined : label}
className={cn(
"flex flex-col items-center gap-1 rounded-lg px-3 py-1 transition-colors",
"flex items-center rounded-lg transition-colors",
showLabels ? "flex-col gap-1 px-3 py-1" : "justify-center px-4 py-2",
active ? (isStaff ? "text-white" : "text-ink") : "hover:text-ink",
isStaff && !active && "hover:text-white",
)}
>
<Icon className="h-5 w-5" aria-hidden="true" />
<span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>
<Icon className={showLabels ? "h-5 w-5" : "h-6 w-6"} aria-hidden="true" />
{showLabels && <span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>}
</Link>
);
})}
+1 -1
View File
@@ -33,7 +33,7 @@ export async function StaffShell({
<StaffSidebarNav items={items} businessName={branding.name} logoUrl={branding.logoUrl} />
<NotificationBell href={notificationsHref} unreadCount={unreadNotificationCount} />
<div id="main-content">{children}</div>
<BottomNav items={items} variant="staff" />
<BottomNav items={items} variant="staff" showLabels={branding.bottomNavShowLabels} />
</div>
);
}
@@ -14,7 +14,12 @@ import { safeRedirectPath } from "@/lib/utils/safe-redirect";
export function SignInForm() {
const searchParams = useSearchParams();
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/home");
// "/" rather than a hardcoded "/home" — the role isn't known client-side
// before sign-in completes, so a bare sign-in (no callbackUrl) routes
// through the root welcome page's own post-auth redirect, which already
// knows how to send a barber/owner/super admin to their Calendar tab
// instead of the customer home. See src/app/page.tsx.
const callbackUrl = safeRedirectPath(searchParams.get("callbackUrl"), "/");
const [formError, setFormError] = useState<string | null>(null);
const {
+268 -1
View File
@@ -1,17 +1,33 @@
"use server";
import JSZip from "jszip";
import { imageSize } from "image-size";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { isSuperAdmin } from "@/lib/auth/permissions";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { parseSplashScreens } from "@/lib/business/branding";
import type { SplashScreenEntry } from "@/lib/business/branding";
import { MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
import { createMediaAsset } from "@/services/media/media.service";
import {
setOpeningHours,
updateAppIconSettings,
updateBusinessProfile,
updateBusinessSettings,
updateNavigationSettings,
} from "@/services/business/business.service";
import type {
AppIconSettingsInput,
BusinessLocationInput,
BusinessProfileInput,
BusinessSettingsInput,
NavigationSettingsInput,
OpeningHoursDayInput,
} from "@/services/business/business.service";
import type { BusinessLocationInput, BusinessProfileInput, BusinessSettingsInput, OpeningHoursDayInput } from "@/services/business/business.service";
export interface BusinessActionResult {
success: boolean;
@@ -40,6 +56,27 @@ async function requireBusinessManager() {
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
}
/**
* Stricter than requireBusinessManager above — the App Icon & Home
* Screen settings are Super Administrator only (not Owner), unlike every
* other business-profile action in this file. Re-checked here rather
* than trusting the page-level redirect, same "enforce again at the
* action layer" rule the rest of the app follows.
*/
async function requireSuperAdminBusinessManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
if (!(await isSuperAdmin(session.user.id))) {
return { staff: null, error: "Only a Super Administrator can change these settings." } as const;
}
const context = await getActingBusinessContext();
if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
}
export async function updateBusinessProfileAction(params: {
profile: BusinessProfileInput;
location: BusinessLocationInput;
@@ -79,6 +116,236 @@ export async function updateBusinessSettingsAction(settings: BusinessSettingsInp
return { success: true };
}
export async function updateAppIconSettingsAction(settings: AppIconSettingsInput): Promise<BusinessActionResult> {
const { staff, error } = await requireSuperAdminBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
await updateAppIconSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_APP_ICON_SETTINGS_UPDATED",
entityType: "Business",
entityId: staff.businessId,
});
return { success: true };
}
/** Icon+label vs icon-only bottom navigation — Super Administrator only, same rationale as updateAppIconSettingsAction (a change here is visible to every signed-in user immediately, not scoped to the actor). */
export async function updateNavigationSettingsAction(
settings: NavigationSettingsInput,
): Promise<BusinessActionResult> {
const { staff, error } = await requireSuperAdminBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
await updateNavigationSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_NAVIGATION_SETTINGS_UPDATED",
entityType: "Business",
entityId: staff.businessId,
metadata: { bottomNavShowLabels: settings.bottomNavShowLabels },
});
return { success: true };
}
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function isPngBuffer(buffer: Buffer): boolean {
return buffer.length >= PNG_SIGNATURE.length && buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE);
}
/**
* Extracts `<link rel="apple-touch-startup-image" media="..." href="...">`
* tags from a generator's readme (e.g. progressier.com's PWA icon/splash
* bundle) — attribute-order-agnostic within each tag, since the only
* contract that matters is "this is an apple-touch-startup-image link
* with a media query and an href", not the exact source formatting.
*/
function parseAppleTouchStartupImageTags(readmeText: string): { media: string; hrefBasename: string }[] {
const tags = readmeText.match(/<link\b[^>]*rel="apple-touch-startup-image"[^>]*>/gi) ?? [];
const results: { media: string; hrefBasename: string }[] = [];
for (const tag of tags) {
const media = tag.match(/media="([^"]*)"/i)?.[1];
const href = tag.match(/href="([^"]*)"/i)?.[1];
if (!media || !href) continue;
results.push({ media, hrefBasename: href.split("/").pop() ?? href });
}
return results;
}
/** Validates, stores, and records one extracted ZIP image — null on anything that fails validation (caller turns that into a warning, not a hard failure). */
async function uploadZipImage(
entry: JSZip.JSZipObject,
staff: { businessId: string; userId: string },
): Promise<string | null> {
const buffer = Buffer.from(await entry.async("nodebuffer"));
if (!isPngBuffer(buffer) || buffer.length > MEDIA_MAX_UPLOAD_BYTES) return null;
let width: number | null = null;
let height: number | null = null;
try {
const dimensions = imageSize(buffer);
width = dimensions.width ?? null;
height = dimensions.height ?? null;
} catch {
// Best-effort — an unrecognised/corrupt header shouldn't block the upload.
}
const stored = await storeMediaFile({ buffer, mimeType: "image/png", category: "branding" });
await createMediaAsset(prisma, {
businessId: staff.businessId,
url: stored.url,
originalFilename: entry.name.split("/").pop() ?? null,
mimeType: "image/png",
sizeBytes: buffer.length,
width,
height,
category: "branding",
uploadedByUserId: staff.userId,
});
return stored.url;
}
export interface ImportAppIconZipResult {
success: boolean;
error?: string;
warnings?: string[];
applied?: {
appIconUrl: string | null;
icon192Url: string | null;
icon512Url: string | null;
maskableIcon192Url: string | null;
maskableIcon512Url: string | null;
splashScreens: SplashScreenEntry[];
};
}
/**
* Accepts a ZIP from a PWA icon/splash-screen generator (e.g.
* progressier.com) — extracts `icon.png` (applied to the Apple touch icon
* AND all four Android manifest icon fields, since the generator only
* produces one square source image) and every splash screen referenced
* by `readme.txt`'s `apple-touch-startup-image` tags (see
* parseAppleTouchStartupImageTags), each uploaded through the same media
* pipeline as a manual MediaPicker upload. Partial-success by design: a
* missing readme, a missing icon, or a handful of unreadable entries
* degrade to warnings rather than aborting the whole import — only "found
* nothing at all usable" is a hard failure.
*/
export async function importAppIconZipAction(formData: FormData): Promise<ImportAppIconZipResult> {
const { staff, error } = await requireSuperAdminBusinessManager();
if (!staff) return { success: false, error: error ?? "Not authorized." };
const file = formData.get("file");
if (!(file instanceof File)) return { success: false, error: "No file provided." };
if (!file.name.toLowerCase().endsWith(".zip")) {
return { success: false, error: "Please upload a .zip file." };
}
let zip: JSZip;
try {
zip = await JSZip.loadAsync(Buffer.from(await file.arrayBuffer()));
} catch {
return { success: false, error: "Couldn't read that file as a ZIP archive." };
}
const entries = Object.values(zip.files).filter((entry) => !entry.dir);
const entryByBasename = new Map(entries.map((entry) => [entry.name.split("/").pop() ?? entry.name, entry]));
const warnings: string[] = [];
const iconEntry = entries.find((entry) => /^icon\.png$/i.test(entry.name.split("/").pop() ?? ""));
let iconUrl: string | null = null;
if (iconEntry) {
iconUrl = await uploadZipImage(iconEntry, staff);
if (!iconUrl) warnings.push("Couldn't import icon.png — it may be too large or not a valid PNG.");
} else {
warnings.push("No icon.png found in the ZIP — icon fields were left unchanged.");
}
const readmeEntry = entries.find((entry) => (entry.name.split("/").pop() ?? "").toLowerCase() === "readme.txt");
const splashScreens: SplashScreenEntry[] = [];
if (readmeEntry) {
const tags = parseAppleTouchStartupImageTags(await readmeEntry.async("text"));
if (tags.length === 0) {
warnings.push("readme.txt was found but contained no apple-touch-startup-image tags.");
}
for (const { media, hrefBasename } of tags) {
const imageEntry = entryByBasename.get(hrefBasename);
if (!imageEntry) {
warnings.push(`Referenced splash image "${hrefBasename}" wasn't found in the ZIP — skipped.`);
continue;
}
const url = await uploadZipImage(imageEntry, staff);
if (url) {
splashScreens.push({ media, url });
} else {
warnings.push(`Couldn't import "${hrefBasename}" — skipped.`);
}
}
} else {
warnings.push("No readme.txt found in the ZIP — splash screens were left unchanged.");
}
if (!iconUrl && splashScreens.length === 0) {
return {
success: false,
error:
"Nothing usable was found in that ZIP — expected an icon.png and/or a readme.txt with apple-touch-startup-image tags.",
warnings,
};
}
const settings: AppIconSettingsInput = {};
if (iconUrl) {
settings.appIconUrl = iconUrl;
settings.icon192Url = iconUrl;
settings.icon512Url = iconUrl;
settings.maskableIcon192Url = iconUrl;
settings.maskableIcon512Url = iconUrl;
}
if (splashScreens.length > 0) settings.splashScreens = splashScreens;
await updateAppIconSettings(prisma, { businessId: staff.businessId, settings });
await writeAuditLog(prisma, {
actorUserId: staff.userId,
action: "BUSINESS_APP_ICONS_IMPORTED_FROM_ZIP",
entityType: "Business",
entityId: staff.businessId,
metadata: { iconImported: !!iconUrl, splashScreenCount: splashScreens.length, warningCount: warnings.length },
});
const current = await prisma.business.findUnique({
where: { id: staff.businessId },
select: {
appIconUrl: true,
icon192Url: true,
icon512Url: true,
maskableIcon192Url: true,
maskableIcon512Url: true,
splashScreens: true,
},
});
return {
success: true,
warnings: warnings.length > 0 ? warnings : undefined,
applied: {
appIconUrl: current?.appIconUrl ?? null,
icon192Url: current?.icon192Url ?? null,
icon512Url: current?.icon512Url ?? null,
maskableIcon192Url: current?.maskableIcon192Url ?? null,
maskableIcon512Url: current?.maskableIcon512Url ?? null,
splashScreens: parseSplashScreens(current?.splashScreens),
},
};
}
export interface OpeningHoursActionResult {
success: boolean;
error?: string;
@@ -0,0 +1,327 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { FileArchive } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { MediaPicker } from "@/features/media/components/media-picker";
import type { SplashScreenEntry } from "@/lib/business/branding";
import {
importAppIconZipAction,
updateAppIconSettingsAction,
updateNavigationSettingsAction,
} from "../actions/business";
export interface AppIconSettingsInitial {
shortName: string;
appIconUrl: string | null;
themeColor: string | null;
backgroundColor: string | null;
icon192Url: string | null;
icon512Url: string | null;
maskableIcon192Url: string | null;
maskableIcon512Url: string | null;
splashScreens: SplashScreenEntry[];
bottomNavShowLabels: boolean;
}
export function AppIconSettingsForm({ initial }: { initial: AppIconSettingsInitial }) {
const router = useRouter();
const [form, setForm] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importWarnings, setImportWarnings] = useState<string[]>([]);
const [importSuccessMessage, setImportSuccessMessage] = useState<string | null>(null);
const set = <K extends keyof AppIconSettingsInitial>(key: K, value: AppIconSettingsInitial[K]) => {
setSaved(false);
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleZipSelected = async (fileList: FileList | null) => {
const file = fileList?.[0];
if (!file) return;
setImportError(null);
setImportWarnings([]);
setImportSuccessMessage(null);
setIsImporting(true);
const formData = new FormData();
formData.set("file", file);
const result = await importAppIconZipAction(formData);
setIsImporting(false);
if (fileInputRef.current) fileInputRef.current.value = "";
if (!result.success || !result.applied) {
setImportError(result.error ?? "Couldn't import that ZIP.");
setImportWarnings(result.warnings ?? []);
return;
}
setForm((prev) => ({ ...prev, ...result.applied }));
setImportWarnings(result.warnings ?? []);
setImportSuccessMessage(
`Imported${result.applied.appIconUrl ? " icon" : ""}${
result.applied.appIconUrl && result.applied.splashScreens.length > 0 ? " and" : ""
}${
result.applied.splashScreens.length > 0
? ` ${result.applied.splashScreens.length} splash screen${result.applied.splashScreens.length === 1 ? "" : "s"}`
: ""
}. Review below, then Save.`,
);
router.refresh();
};
const handleSave = () => {
setError(null);
startTransition(async () => {
const [iconResult, navigationResult] = await Promise.all([
updateAppIconSettingsAction({
shortName: form.shortName || null,
appIconUrl: form.appIconUrl,
themeColor: form.themeColor,
backgroundColor: form.backgroundColor,
icon192Url: form.icon192Url,
icon512Url: form.icon512Url,
maskableIcon192Url: form.maskableIcon192Url,
maskableIcon512Url: form.maskableIcon512Url,
splashScreens: form.splashScreens,
}),
updateNavigationSettingsAction({ bottomNavShowLabels: form.bottomNavShowLabels }),
]);
if (!iconResult.success) {
setError(iconResult.error ?? "Couldn't save these settings.");
return;
}
if (!navigationResult.success) {
setError(navigationResult.error ?? "Couldn't save the navigation setting.");
return;
}
setSaved(true);
router.refresh();
});
};
return (
<div className="space-y-6">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Import from a Generator</h3>
<div className="rounded-md border-line space-y-3 border bg-white p-6">
<p className="text-muted text-xs">
Generate a bundle at {" "}
<span className="font-bold">progressier.com/pwa-icons-and-ios-splash-screen-generator</span> and upload
the ZIP here — the icon and every splash screen it contains are imported automatically, no manual
resizing needed.
</p>
<input
ref={fileInputRef}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
className="hidden"
onChange={(e) => void handleZipSelected(e.target.files)}
/>
<Button
type="button"
variant="secondary"
isLoading={isImporting}
onClick={() => fileInputRef.current?.click()}
>
<FileArchive className="h-4 w-4" aria-hidden="true" />
{isImporting ? "Importing…" : "Upload ZIP"}
</Button>
{importError && (
<p role="alert" className="text-danger text-sm font-medium">
{importError}
</p>
)}
{importSuccessMessage && !importError && (
<p role="status" className="text-accent-foreground text-sm font-medium">
{importSuccessMessage}
</p>
)}
{importWarnings.length > 0 && (
<ul className="text-muted list-inside list-disc space-y-1 text-xs">
{importWarnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
)}
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Preview</h3>
<div
className="rounded-md border-line flex items-center gap-4 border p-6"
style={{ backgroundColor: form.backgroundColor || undefined }}
>
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-2xl border border-line bg-white shadow-sm">
{form.icon512Url || form.appIconUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern as MediaPicker
<img src={form.icon512Url || form.appIconUrl || ""} alt="" className="h-full w-full object-cover" />
) : (
<div
className="flex h-full w-full items-center justify-center text-lg font-bold text-white"
style={{ backgroundColor: form.themeColor || "#111b15" }}
>
{(form.shortName || "A").charAt(0).toUpperCase()}
</div>
)}
</div>
<div>
<p className="text-sm font-bold">{form.shortName || "App Name"}</p>
<p className="text-muted text-xs">How the icon looks on a home screen</p>
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Name &amp; Colours</h3>
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input
label="App Name"
value={form.shortName}
maxLength={30}
onChange={(e) => set("shortName", e.target.value)}
hint="Shown under the icon once someone adds this to their home screen. Keep it short."
/>
<div className="grid grid-cols-2 gap-4">
<ColorPicker label="Theme Colour" value={form.themeColor} onChange={(hex) => set("themeColor", hex)} />
<ColorPicker
label="Background Colour"
value={form.backgroundColor}
onChange={(hex) => set("backgroundColor", hex)}
/>
</div>
<p className="text-muted text-xs">
Theme and background colour set the status bar and Android splash screen shown while the app opens
after installing.
</p>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Navigation</h3>
<div className="rounded-md border-line border bg-white p-6">
<Switch
label="Show labels on the bottom bar"
description="Turn off to show icons only, with no text underneath — applies to every customer and staff/owner bottom bar."
checked={form.bottomNavShowLabels}
onCheckedChange={(checked) => set("bottomNavShowLabels", checked)}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">iOS Icon</h3>
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<MediaPicker
label="Apple Touch Icon"
value={form.appIconUrl}
onChange={(url) => set("appIconUrl", url)}
category="branding"
hint="A square image works best — used when someone adds this to their home screen on iPhone/iPad."
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Android Icons</h3>
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<p className="text-muted text-xs">
Android/Chrome installs read these directly — each falls back to a neutral icon on its own if left
unset.
</p>
<MediaPicker
label="Icon — 192×192"
value={form.icon192Url}
onChange={(url) => set("icon192Url", url)}
category="branding"
hint="Square, 192×192px."
/>
<MediaPicker
label="Icon — 512×512"
value={form.icon512Url}
onChange={(url) => set("icon512Url", url)}
category="branding"
hint="Square, 512×512px."
/>
<MediaPicker
label="Maskable Icon — 192×192"
value={form.maskableIcon192Url}
onChange={(url) => set("maskableIcon192Url", url)}
category="branding"
hint="Square, 192×192px. Keep the subject within the center ~80% — Android may crop this to a circle or rounded square."
/>
<MediaPicker
label="Maskable Icon — 512×512"
value={form.maskableIcon512Url}
onChange={(url) => set("maskableIcon512Url", url)}
category="branding"
hint="Square, 512×512px. Same center-safe-zone rule as above."
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">iOS Splash Screens</h3>
<div className="rounded-md border-line space-y-3 border bg-white p-6">
{form.splashScreens.length === 0 ? (
<p className="text-muted text-sm">No splash screens imported yet — upload a ZIP above.</p>
) : (
<>
<p className="text-sm font-bold">
{form.splashScreens.length} splash screen{form.splashScreens.length === 1 ? "" : "s"} imported
</p>
<div className="grid grid-cols-6 gap-2 sm:grid-cols-8">
{form.splashScreens.map((s, i) => (
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern as MediaPicker
<img
key={i}
src={s.url}
alt=""
className="border-line aspect-[9/19] w-full rounded border object-cover"
/>
))}
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => set("splashScreens", [])}
>
Remove all splash screens
</Button>
</>
)}
</div>
</section>
<div className="space-y-4">
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
{saved && !error && (
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved — changes are live immediately.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}
@@ -3,7 +3,6 @@
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
@@ -22,9 +21,6 @@ export interface BusinessDetailsInitial {
welcomeBackgroundDesktopUrl: string | null;
welcomeBackgroundMobileUrl: string | null;
faviconUrl: string | null;
appIconUrl: string | null;
themeColor: string | null;
backgroundColor: string | null;
phone: string;
email: string;
website: string;
@@ -69,9 +65,6 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
welcomeBackgroundDesktopUrl: form.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: form.welcomeBackgroundMobileUrl,
faviconUrl: form.faviconUrl,
appIconUrl: form.appIconUrl,
themeColor: form.themeColor,
backgroundColor: form.backgroundColor,
phone: form.phone || undefined,
email: form.email || undefined,
website: form.website || undefined,
@@ -152,17 +145,6 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
category="branding"
hint="Browser tab icon. Falls back to the app default when unset."
/>
<MediaPicker
label="App Icon"
value={form.appIconUrl}
onChange={(url) => set("appIconUrl", url)}
category="branding"
hint="Used when someone installs this as an app on their phone."
/>
<div className="grid grid-cols-2 gap-4">
<ColorPicker label="Theme Colour" value={form.themeColor} onChange={(hex) => set("themeColor", hex)} />
<ColorPicker label="Background Colour" value={form.backgroundColor} onChange={(hex) => set("backgroundColor", hex)} />
</div>
</div>
</section>
+46
View File
@@ -25,6 +25,12 @@ import { prisma } from "@/lib/db/prisma";
* Business row exists yet, so it must never throw.
*/
/** One iOS `apple-touch-startup-image` entry — an exact per-device media query paired with its splash image, matching the shape a generator like progressier.com's ZIP bundle produces. See importAppIconZipAction. */
export interface SplashScreenEntry {
media: string;
url: string;
}
export interface BrandingInfo {
configured: boolean;
name: string;
@@ -33,9 +39,19 @@ export interface BrandingInfo {
logoUrl: string | null;
coverImageUrl: string | null;
faviconUrl: string | null;
/** Apple touch icon (iOS "Add to Home Screen") — distinct from the manifest icon fields below, which Android/Chrome installs read instead. */
appIconUrl: string | null;
themeColor: string;
backgroundColor: string;
/** PWA manifest `icons` array — see src/app/manifest.ts. Each falls back independently to the app's own generated icon when unset. */
icon192Url: string | null;
icon512Url: string | null;
maskableIcon192Url: string | null;
maskableIcon512Url: string | null;
/** iOS `apple-touch-startup-image` splash screens — see src/app/layout.tsx. */
splashScreens: SplashScreenEntry[];
/** Icon-only vs icon+label bottom navigation — see components/navigation/bottom-nav.tsx. */
bottomNavShowLabels: boolean;
/** Welcome page (/) background — see src/app/page.tsx. Independent images, not one image cropped two ways. */
welcomeBackgroundDesktopUrl: string | null;
welcomeBackgroundMobileUrl: string | null;
@@ -43,6 +59,18 @@ export interface BrandingInfo {
phone: string | null;
}
/** Defensive parse of the `Business.splashScreens` JSON column — never trusts its shape at compile time. Drops any entry missing a string `media`/`url` rather than throwing. */
export function parseSplashScreens(value: unknown): SplashScreenEntry[] {
if (!Array.isArray(value)) return [];
return value.filter(
(entry): entry is SplashScreenEntry =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as Record<string, unknown>).media === "string" &&
typeof (entry as Record<string, unknown>).url === "string",
);
}
const DEFAULT_THEME_COLOR = "#111b15";
const DEFAULT_BACKGROUND_COLOR = "#f4f6f4";
@@ -58,6 +86,12 @@ const UNCONFIGURED_BRANDING: BrandingInfo = {
appIconUrl: null,
themeColor: DEFAULT_THEME_COLOR,
backgroundColor: DEFAULT_BACKGROUND_COLOR,
icon192Url: null,
icon512Url: null,
maskableIcon192Url: null,
maskableIcon512Url: null,
splashScreens: [],
bottomNavShowLabels: true,
welcomeBackgroundDesktopUrl: null,
welcomeBackgroundMobileUrl: null,
email: null,
@@ -85,6 +119,12 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
appIconUrl: true,
themeColor: true,
backgroundColor: true,
icon192Url: true,
icon512Url: true,
maskableIcon192Url: true,
maskableIcon512Url: true,
splashScreens: true,
bottomNavShowLabels: true,
welcomeBackgroundDesktopUrl: true,
welcomeBackgroundMobileUrl: true,
email: true,
@@ -107,6 +147,12 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
appIconUrl: business.appIconUrl,
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
backgroundColor: business.backgroundColor || DEFAULT_BACKGROUND_COLOR,
icon192Url: business.icon192Url,
icon512Url: business.icon512Url,
maskableIcon192Url: business.maskableIcon192Url,
maskableIcon512Url: business.maskableIcon512Url,
splashScreens: parseSplashScreens(business.splashScreens),
bottomNavShowLabels: business.bottomNavShowLabels,
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
email: business.email,
+71
View File
@@ -1,8 +1,10 @@
import { Prisma } from "@/generated/prisma/client";
import type { PrismaClient } from "@/generated/prisma/client";
import { Weekday } from "@/generated/prisma/enums";
import { calendarDateOf, minutesFromMidnight, weekdayOf } from "@/lib/time/business-time";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import type { SplashScreenEntry } from "@/lib/business/branding";
/**
* The business's own profile, address, policies, and opening hours —
@@ -141,6 +143,75 @@ export async function updateBusinessSettings(
return { success: true };
}
/**
* The subset of Business fields that actually drive "Add to Home Screen"
* — manifest short_name/theme_color/background_color/icons (see
* src/app/manifest.ts) and the iOS apple touch icon/splash screens (see
* layout.tsx) — split out from BusinessProfileInput/updateBusinessProfile
* so this one focused screen doesn't also have to carry a
* BusinessLocationInput through its save (that pair always transacts
* together for the main Business Details form, which this isn't part
* of). Restricted to Super Administrators only — see
* updateAppIconSettingsAction.
*/
export interface AppIconSettingsInput {
/** The name shown under the icon once installed — distinct from the full business `name` (manifest's short_name). */
shortName?: string | null;
/** Apple touch icon (iOS "Add to Home Screen") — distinct from the manifest icon fields below. */
appIconUrl?: string | null;
themeColor?: string | null;
backgroundColor?: string | null;
/** PWA manifest `icons` array (Android/Chrome) — see src/app/manifest.ts. */
icon192Url?: string | null;
icon512Url?: string | null;
maskableIcon192Url?: string | null;
maskableIcon512Url?: string | null;
/** iOS splash screens — see src/app/layout.tsx. Replaces the whole set on every save; there's no per-entry add/remove, only a wholesale re-import or clear. */
splashScreens?: SplashScreenEntry[] | null;
}
export async function updateAppIconSettings(
prisma: PrismaClient,
params: { businessId: string; settings: AppIconSettingsInput },
): Promise<{ success: boolean }> {
const { splashScreens, ...rest } = params.settings;
await prisma.business.update({
where: { id: params.businessId },
data: {
...rest,
...(splashScreens !== undefined && {
splashScreens:
splashScreens === null ? Prisma.JsonNull : (splashScreens as unknown as Prisma.InputJsonValue),
}),
},
});
return { success: true };
}
/**
* Business-wide app-display toggles that aren't PWA-icon-specific enough
* to belong in AppIconSettingsInput above, but are still Super
* Administrator only (same rendering-affects-everyone-instantly reason —
* see updateNavigationSettingsAction). Kept as its own input/function
* rather than folded into AppIconSettingsInput so that interface's own
* "drives Add to Home Screen" doc comment stays accurate.
*/
export interface NavigationSettingsInput {
/** Icon+label vs icon-only bottom navigation — see components/navigation/bottom-nav.tsx. */
bottomNavShowLabels: boolean;
}
export async function updateNavigationSettings(
prisma: PrismaClient,
params: { businessId: string; settings: NavigationSettingsInput },
): Promise<{ success: boolean }> {
await prisma.business.update({
where: { id: params.businessId },
data: { ...params.settings },
});
return { success: true };
}
// ── Opening hours ──────────────────────────────────────────────────────
export interface OpeningHoursDayInput {