diff --git a/e2e/helpers.ts b/e2e/helpers.ts index b2f1d30..c80f20a 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -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) { diff --git a/next.config.ts b/next.config.ts index 85fc4e8..cc8037d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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: [ { diff --git a/package-lock.json b/package-lock.json index 771354b..acd12f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index cc3eccc..73519bb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260807084132_add_pwa_icon_splash_fields/migration.sql b/prisma/migrations/20260807084132_add_pwa_icon_splash_fields/migration.sql new file mode 100644 index 0000000..4719976 --- /dev/null +++ b/prisma/migrations/20260807084132_add_pwa_icon_splash_fields/migration.sql @@ -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; diff --git a/prisma/migrations/20260807093710_pwa_zip_import_splash_screens_json/migration.sql b/prisma/migrations/20260807093710_pwa_zip_import_splash_screens_json/migration.sql new file mode 100644 index 0000000..a2bbf03 --- /dev/null +++ b/prisma/migrations/20260807093710_pwa_zip_import_splash_screens_json/migration.sql @@ -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; diff --git a/prisma/migrations/20260807112629_bottom_nav_show_labels_toggle/migration.sql b/prisma/migrations/20260807112629_bottom_nav_show_labels_toggle/migration.sql new file mode 100644 index 0000000..7b5ef07 --- /dev/null +++ b/prisma/migrations/20260807112629_bottom_nav_show_labels_toggle/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `Business` ADD COLUMN `bottomNavShowLabels` BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e46ffcc..401104c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/(customer)/layout.tsx b/src/app/(customer)/layout.tsx index 25b63cc..58b6ddb 100644 --- a/src/app/(customer)/layout.tsx +++ b/src/app/(customer)/layout.tsx @@ -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 (
{session?.user && }
{children}
- +
); } diff --git a/src/app/(owner)/owner/page.tsx b/src/app/(owner)/owner/page.tsx index 095b8f5..bfa64ad 100644 --- a/src/app/(owner)/owner/page.tsx +++ b/src/app/(owner)/owner/page.tsx @@ -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[number]) { return (

{title}

@@ -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 (
@@ -237,7 +246,7 @@ export default async function OwnerDashboardPage() {
- {MANAGE_SECTIONS.map((section) => ( + {manageSections.map((section) => ( ))}
diff --git a/src/app/(owner)/owner/settings/app/page.tsx b/src/app/(owner)/owner/settings/app/page.tsx new file mode 100644 index 0000000..4779bb4 --- /dev/null +++ b/src/app/(owner)/owner/settings/app/page.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/(owner)/owner/settings/business/page.tsx b/src/app/(owner)/owner/settings/business/page.tsx index 0804f67..6bae6e8 100644 --- a/src/app/(owner)/owner/settings/business/page.tsx +++ b/src/app/(owner)/owner/settings/business/page.tsx @@ -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 ?? "", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 062048c..54b2541 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -19,10 +19,20 @@ const inter = Inter({ */ export async function generateMetadata(): Promise { 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 } diff --git a/src/app/manifest.ts b/src/app/manifest.ts index c1fa7a4..41761ce 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -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 { const branding = await getBranding(); return { @@ -29,10 +38,30 @@ export default async function manifest(): Promise { 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", + }, ], }; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 01a0ee6..bd28ae5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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"); } diff --git a/src/components/navigation/bottom-nav.tsx b/src/components/navigation/bottom-nav.tsx index 848d05a..ca74c7c 100644 --- a/src/components/navigation/bottom-nav.tsx +++ b/src/components/navigation/bottom-nav.tsx @@ -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", )} > -
); } diff --git a/src/features/auth/components/sign-in-form.tsx b/src/features/auth/components/sign-in-form.tsx index 7ed9317..3442e90 100644 --- a/src/features/auth/components/sign-in-form.tsx +++ b/src/features/auth/components/sign-in-form.tsx @@ -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(null); const { diff --git a/src/features/owner/actions/business.ts b/src/features/owner/actions/business.ts index 944d804..a565e0b 100644 --- a/src/features/owner/actions/business.ts +++ b/src/features/owner/actions/business.ts @@ -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 { + 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 { + 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 `` + * 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(/]*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 { + 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 { + 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; diff --git a/src/features/owner/components/app-icon-settings-form.tsx b/src/features/owner/components/app-icon-settings-form.tsx new file mode 100644 index 0000000..636492e --- /dev/null +++ b/src/features/owner/components/app-icon-settings-form.tsx @@ -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(null); + const [saved, setSaved] = useState(false); + const [isPending, startTransition] = useTransition(); + + const fileInputRef = useRef(null); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(null); + const [importWarnings, setImportWarnings] = useState([]); + const [importSuccessMessage, setImportSuccessMessage] = useState(null); + + const set = (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 ( +
+
+

Import from a Generator

+
+

+ Generate a bundle at {" "} + progressier.com/pwa-icons-and-ios-splash-screen-generator and upload + the ZIP here — the icon and every splash screen it contains are imported automatically, no manual + resizing needed. +

+ void handleZipSelected(e.target.files)} + /> + + {importError && ( +

+ {importError} +

+ )} + {importSuccessMessage && !importError && ( +

+ {importSuccessMessage} +

+ )} + {importWarnings.length > 0 && ( +
    + {importWarnings.map((w, i) => ( +
  • {w}
  • + ))} +
+ )} +
+
+ +
+

Preview

+
+
+ {form.icon512Url || form.appIconUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern as MediaPicker + + ) : ( +
+ {(form.shortName || "A").charAt(0).toUpperCase()} +
+ )} +
+
+

{form.shortName || "App Name"}

+

How the icon looks on a home screen

+
+
+
+ +
+

Name & Colours

+
+ set("shortName", e.target.value)} + hint="Shown under the icon once someone adds this to their home screen. Keep it short." + /> +
+ set("themeColor", hex)} /> + set("backgroundColor", hex)} + /> +
+

+ Theme and background colour set the status bar and Android splash screen shown while the app opens + after installing. +

+
+
+ +
+

Navigation

+
+ set("bottomNavShowLabels", checked)} + /> +
+
+ +
+

iOS Icon

+
+ set("appIconUrl", url)} + category="branding" + hint="A square image works best — used when someone adds this to their home screen on iPhone/iPad." + /> +
+
+ +
+

Android Icons

+
+

+ Android/Chrome installs read these directly — each falls back to a neutral icon on its own if left + unset. +

+ set("icon192Url", url)} + category="branding" + hint="Square, 192×192px." + /> + set("icon512Url", url)} + category="branding" + hint="Square, 512×512px." + /> + 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." + /> + set("maskableIcon512Url", url)} + category="branding" + hint="Square, 512×512px. Same center-safe-zone rule as above." + /> +
+
+ +
+

iOS Splash Screens

+
+ {form.splashScreens.length === 0 ? ( +

No splash screens imported yet — upload a ZIP above.

+ ) : ( + <> +

+ {form.splashScreens.length} splash screen{form.splashScreens.length === 1 ? "" : "s"} imported +

+
+ {form.splashScreens.map((s, i) => ( + // eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern as MediaPicker + + ))} +
+ + + )} +
+
+ +
+ {error && ( +

+ {error} +

+ )} + {saved && !error && ( +

+ Saved — changes are live immediately. +

+ )} + +
+
+ ); +} diff --git a/src/features/owner/components/business-details-form.tsx b/src/features/owner/components/business-details-form.tsx index bc60a2a..e9a89b7 100644 --- a/src/features/owner/components/business-details-form.tsx +++ b/src/features/owner/components/business-details-form.tsx @@ -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." /> - set("appIconUrl", url)} - category="branding" - hint="Used when someone installs this as an app on their phone." - /> -
- set("themeColor", hex)} /> - set("backgroundColor", hex)} /> -
diff --git a/src/lib/business/branding.ts b/src/lib/business/branding.ts index 3f7b712..3d866b1 100644 --- a/src/lib/business/branding.ts +++ b/src/lib/business/branding.ts @@ -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).media === "string" && + typeof (entry as Record).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 => { 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 => { 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, diff --git a/src/services/business/business.service.ts b/src/services/business/business.service.ts index 54f6667..c0eb62b 100644 --- a/src/services/business/business.service.ts +++ b/src/services/business/business.service.ts @@ -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 {