PWA Settings
Fixed webmanifest generation and pwa icon. Made nav bar text optional.
This commit is contained in:
23 files changed
+1031
-57
No files matched your search
+8
-1
@@ -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("Email Address").fill(email);
|
||||||
await page.getByLabel("Password", { exact: true }).fill(password);
|
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||||
await page.getByRole("button", { name: "Sign In", exact: true }).click();
|
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) {
|
function section(page: Page, headingName: string) {
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ const SECURITY_HEADERS = [
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: true,
|
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: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+100
@@ -23,6 +23,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"image-size": "^2.0.2",
|
"image-size": "^2.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"mariadb": "^3.5.3",
|
"mariadb": "^3.5.3",
|
||||||
@@ -5500,6 +5501,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -7342,6 +7349,12 @@
|
|||||||
"node": ">=16.x"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -7969,6 +7982,18 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/jwa": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
@@ -8034,6 +8059,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -8957,6 +8991,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"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": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -9433,6 +9479,33 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
@@ -9835,6 +9908,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
@@ -10053,6 +10132,21 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||||
@@ -10655,6 +10749,12 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/valibot": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"image-size": "^2.0.2",
|
"image-size": "^2.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"mariadb": "^3.5.3",
|
"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;
|
||||||
+32
-2
@@ -257,13 +257,43 @@ model Business {
|
|||||||
/// src/app/icon.png and the branding fallback rules in
|
/// src/app/icon.png and the branding fallback rules in
|
||||||
/// src/lib/business/branding.ts.
|
/// src/lib/business/branding.ts.
|
||||||
faviconUrl String?
|
faviconUrl String?
|
||||||
/// Used for the PWA manifest icon set — falls back to the same neutral
|
/// Apple touch icon — iOS Safari's "Add to Home Screen" icon. Falls back
|
||||||
/// app icon as the favicon when unset.
|
/// 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?
|
appIconUrl String?
|
||||||
/// Hex color, e.g. "#111b15" — PWA `theme_color` / manifest and
|
/// Hex color, e.g. "#111b15" — PWA `theme_color` / manifest and
|
||||||
/// browser chrome. Falls back to the app's own theme color when unset.
|
/// browser chrome. Falls back to the app's own theme color when unset.
|
||||||
themeColor String?
|
themeColor String?
|
||||||
backgroundColor 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
|
/// Phase 9 — Welcome page background, desktop breakpoint. Falls back to
|
||||||
/// the existing plain background when unset. See src/app/page.tsx.
|
/// the existing plain background when unset. See src/app/page.tsx.
|
||||||
welcomeBackgroundDesktopUrl String?
|
welcomeBackgroundDesktopUrl String?
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { BottomNav, type BottomNavItem } from "@/components/navigation/bottom-nav";
|
import { BottomNav, type BottomNavItem } from "@/components/navigation/bottom-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { getBranding } from "@/lib/business/branding";
|
||||||
import { NotificationBell } from "@/features/notifications/components/notification-bell";
|
import { NotificationBell } from "@/features/notifications/components/notification-bell";
|
||||||
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
||||||
|
|
||||||
@@ -22,14 +23,14 @@ const NAV_ITEMS: BottomNavItem[] = [
|
|||||||
* notification.service.ts's IN_APP channel resolution).
|
* notification.service.ts's IN_APP channel resolution).
|
||||||
*/
|
*/
|
||||||
export default async function CustomerLayout({ children }: { children: React.ReactNode }) {
|
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;
|
const unreadNotificationCount = session?.user ? await getUnreadNotificationCount(prisma, session.user.id) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone pb-24">
|
<div className="min-h-screen bg-stone pb-24">
|
||||||
{session?.user && <NotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
|
{session?.user && <NotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
<div id="main-content">{children}</div>
|
<div id="main-content">{children}</div>
|
||||||
<BottomNav items={NAV_ITEMS} variant="customer" />
|
<BottomNav items={NAV_ITEMS} variant="customer" showLabels={branding.bottomNavShowLabels} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Gift,
|
Gift,
|
||||||
Images,
|
Images,
|
||||||
ScissorsSquare,
|
ScissorsSquare,
|
||||||
|
Smartphone,
|
||||||
Stamp,
|
Stamp,
|
||||||
Star,
|
Star,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -24,7 +25,7 @@ import { auth } from "@/auth";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
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 { Permission } from "@/generated/prisma/enums";
|
||||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
import { todayIn } from "@/lib/time/business-time";
|
import { todayIn } from "@/lib/time/business-time";
|
||||||
@@ -48,13 +49,18 @@ function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; l
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MANAGE_SECTIONS = [
|
/** 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",
|
title: "Business",
|
||||||
links: [
|
links: [
|
||||||
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
|
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
|
||||||
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
|
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
|
||||||
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
|
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
|
||||||
|
...(isSuper
|
||||||
|
? [{ href: "/owner/settings/app", label: "App Icon & Home Screen", icon: Smartphone }]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -77,9 +83,10 @@ const MANAGE_SECTIONS = [
|
|||||||
title: "Reports",
|
title: "Reports",
|
||||||
links: [{ href: "/owner/reports", label: "Reports", icon: BarChart3 }],
|
links: [{ href: "/owner/reports", label: "Reports", icon: BarChart3 }],
|
||||||
},
|
},
|
||||||
] as const;
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function ManageSection({ title, links }: (typeof MANAGE_SECTIONS)[number]) {
|
function ManageSection({ title, links }: ReturnType<typeof getManageSections>[number]) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">{title}</p>
|
<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");
|
if (!context) redirect("/staff");
|
||||||
|
|
||||||
const today = todayIn(context.businessTimezone);
|
const today = todayIn(context.businessTimezone);
|
||||||
const [summary, unreadNotificationCount] = await Promise.all([
|
const [summary, unreadNotificationCount, isSuper] = await Promise.all([
|
||||||
getDashboardSummary(prisma, {
|
getDashboardSummary(prisma, {
|
||||||
businessId: context.businessId,
|
businessId: context.businessId,
|
||||||
businessTimezone: context.businessTimezone,
|
businessTimezone: context.businessTimezone,
|
||||||
date: today,
|
date: today,
|
||||||
}),
|
}),
|
||||||
getUnreadNotificationCount(prisma, session.user.id),
|
getUnreadNotificationCount(prisma, session.user.id),
|
||||||
|
isSuperAdmin(session.user.id),
|
||||||
]);
|
]);
|
||||||
|
const manageSections = getManageSections(isSuper);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
@@ -237,7 +246,7 @@ export default async function OwnerDashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{MANAGE_SECTIONS.map((section) => (
|
{manageSections.map((section) => (
|
||||||
<ManageSection key={section.title} {...section} />
|
<ManageSection key={section.title} {...section} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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,
|
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
|
||||||
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
|
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
|
||||||
faviconUrl: business.faviconUrl,
|
faviconUrl: business.faviconUrl,
|
||||||
appIconUrl: business.appIconUrl,
|
|
||||||
themeColor: business.themeColor,
|
|
||||||
backgroundColor: business.backgroundColor,
|
|
||||||
phone: business.phone ?? "",
|
phone: business.phone ?? "",
|
||||||
email: business.email ?? "",
|
email: business.email ?? "",
|
||||||
website: business.website ?? "",
|
website: business.website ?? "",
|
||||||
|
|||||||
+11
-1
@@ -19,10 +19,20 @@ const inter = Inter({
|
|||||||
*/
|
*/
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const branding = await getBranding();
|
const branding = await getBranding();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: { default: branding.name, template: `%s | ${branding.name}` },
|
title: { default: branding.name, template: `%s | ${branding.name}` },
|
||||||
description: branding.description || "Book your next appointment in seconds.",
|
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:
|
icons:
|
||||||
branding.faviconUrl || branding.appIconUrl
|
branding.faviconUrl || branding.appIconUrl
|
||||||
? { icon: branding.faviconUrl || undefined, apple: branding.appIconUrl || undefined }
|
? { icon: branding.faviconUrl || undefined, apple: branding.appIconUrl || undefined }
|
||||||
|
|||||||
+41
-12
@@ -7,15 +7,24 @@ import { getBranding } from "@/lib/business/branding";
|
|||||||
* Phase 8: name/description/colors reflect the installed business (see
|
* Phase 8: name/description/colors reflect the installed business (see
|
||||||
* src/lib/business/branding.ts) rather than the internal product name.
|
* src/lib/business/branding.ts) rather than the internal product name.
|
||||||
*
|
*
|
||||||
* `icons` intentionally stays pointed at the app's own pre-generated,
|
* `force-dynamic` is required, not optional — without it, Next has no
|
||||||
* correctly-sized/maskable PWA icon set rather than a business's single
|
* signal that this route depends on request-time data (a plain DB read
|
||||||
* `appIconUrl` — there's no image-processing pipeline in this app to
|
* doesn't count as one of Next's dynamic-usage triggers) and statically
|
||||||
* derive the required 192/512/maskable variants from one arbitrary
|
* renders it once at build time. Since the Docker image is built before
|
||||||
* uploaded URL (see docs/phase-8-summary.md, "Known limitations"), so
|
* the installation wizard ever runs, that bakes in the pre-install
|
||||||
* this is fallback rule #2 ("business name + a neutral generated icon"),
|
* neutral fallback ("Book an Appointment"/"Booking") permanently — the
|
||||||
* applied deliberately rather than risking a broken/non-square install
|
* real business name/icons would never appear no matter how the Business
|
||||||
* icon.
|
* 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> {
|
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||||
const branding = await getBranding();
|
const branding = await getBranding();
|
||||||
return {
|
return {
|
||||||
@@ -29,10 +38,30 @@ export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
|||||||
theme_color: branding.themeColor,
|
theme_color: branding.themeColor,
|
||||||
orientation: "portrait-primary",
|
orientation: "portrait-primary",
|
||||||
icons: [
|
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: branding.icon192Url || "/icons/icon-192.png",
|
||||||
{ src: "/icons/icon-maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
|
sizes: "192x192",
|
||||||
{ src: "/icons/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
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
@@ -5,7 +5,7 @@ import { auth } from "@/auth";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getBranding } from "@/lib/business/branding";
|
import { getBranding } from "@/lib/business/branding";
|
||||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
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 { Permission } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-card";
|
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
|
* The unauthenticated entry point — always shown at `/` before
|
||||||
* authentication, per the brief's "Welcome routing" requirements.
|
* authentication, per the brief's "Welcome routing" requirements.
|
||||||
* Authenticated visitors never see it: they're routed straight to their
|
* Authenticated visitors never see it: they're routed straight to their
|
||||||
* role's home (customer → /home, barber → /staff, owner → /owner) below.
|
* role's calendar (barber → /staff, owner/super admin → /owner/calendar)
|
||||||
* Unreachable at all before installation completes — src/proxy.ts
|
* or customer home below. This is also where a bare sign-in with no
|
||||||
* redirects every non-exempt route (including this one) to /install
|
* `callbackUrl` ends up — see safeRedirectPath's fallback in
|
||||||
* until ApplicationInstallation flips to INSTALLED.
|
* 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() {
|
export default async function WelcomePage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -27,6 +31,12 @@ export default async function WelcomePage() {
|
|||||||
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||||
redirect(canManageStaff ? "/owner" : "/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");
|
redirect("/home");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface BottomNavProps {
|
|||||||
* role-aware component.
|
* role-aware component.
|
||||||
*/
|
*/
|
||||||
variant?: "customer" | "staff";
|
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
|
* and pass it down — one less thing to get out of sync as routes are
|
||||||
* added in later phases.
|
* added in later phases.
|
||||||
*/
|
*/
|
||||||
export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
|
export function BottomNav({ items, variant = "customer", showLabels = true }: BottomNavProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isStaff = variant === "staff";
|
const isStaff = variant === "staff";
|
||||||
const activeHref = resolveActiveHref(pathname, items);
|
const activeHref = resolveActiveHref(pathname, items);
|
||||||
@@ -85,14 +87,19 @@ export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
|
|||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
aria-current={active ? "page" : undefined}
|
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(
|
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",
|
active ? (isStaff ? "text-white" : "text-ink") : "hover:text-ink",
|
||||||
isStaff && !active && "hover:text-white",
|
isStaff && !active && "hover:text-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
<Icon className={showLabels ? "h-5 w-5" : "h-6 w-6"} aria-hidden="true" />
|
||||||
<span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>
|
{showLabels && <span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export async function StaffShell({
|
|||||||
<StaffSidebarNav items={items} businessName={branding.name} logoUrl={branding.logoUrl} />
|
<StaffSidebarNav items={items} businessName={branding.name} logoUrl={branding.logoUrl} />
|
||||||
<NotificationBell href={notificationsHref} unreadCount={unreadNotificationCount} />
|
<NotificationBell href={notificationsHref} unreadCount={unreadNotificationCount} />
|
||||||
<div id="main-content">{children}</div>
|
<div id="main-content">{children}</div>
|
||||||
<BottomNav items={items} variant="staff" />
|
<BottomNav items={items} variant="staff" showLabels={branding.bottomNavShowLabels} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,12 @@ import { safeRedirectPath } from "@/lib/utils/safe-redirect";
|
|||||||
|
|
||||||
export function SignInForm() {
|
export function SignInForm() {
|
||||||
const searchParams = useSearchParams();
|
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 [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import JSZip from "jszip";
|
||||||
|
import { imageSize } from "image-size";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { Permission } from "@/generated/prisma/enums";
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||||
|
import { isSuperAdmin } from "@/lib/auth/permissions";
|
||||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
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 {
|
import {
|
||||||
setOpeningHours,
|
setOpeningHours,
|
||||||
|
updateAppIconSettings,
|
||||||
updateBusinessProfile,
|
updateBusinessProfile,
|
||||||
updateBusinessSettings,
|
updateBusinessSettings,
|
||||||
|
updateNavigationSettings,
|
||||||
|
} from "@/services/business/business.service";
|
||||||
|
import type {
|
||||||
|
AppIconSettingsInput,
|
||||||
|
BusinessLocationInput,
|
||||||
|
BusinessProfileInput,
|
||||||
|
BusinessSettingsInput,
|
||||||
|
NavigationSettingsInput,
|
||||||
|
OpeningHoursDayInput,
|
||||||
} from "@/services/business/business.service";
|
} from "@/services/business/business.service";
|
||||||
import type { BusinessLocationInput, BusinessProfileInput, BusinessSettingsInput, OpeningHoursDayInput } from "@/services/business/business.service";
|
|
||||||
|
|
||||||
export interface BusinessActionResult {
|
export interface BusinessActionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -40,6 +56,27 @@ async function requireBusinessManager() {
|
|||||||
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
|
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: {
|
export async function updateBusinessProfileAction(params: {
|
||||||
profile: BusinessProfileInput;
|
profile: BusinessProfileInput;
|
||||||
location: BusinessLocationInput;
|
location: BusinessLocationInput;
|
||||||
@@ -79,6 +116,236 @@ export async function updateBusinessSettingsAction(settings: BusinessSettingsInp
|
|||||||
return { success: true };
|
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 {
|
export interface OpeningHoursActionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
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 & 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 { useRouter } from "next/navigation";
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ColorPicker } from "@/components/ui/color-picker";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select } from "@/components/ui/select";
|
import { Select } from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -22,9 +21,6 @@ export interface BusinessDetailsInitial {
|
|||||||
welcomeBackgroundDesktopUrl: string | null;
|
welcomeBackgroundDesktopUrl: string | null;
|
||||||
welcomeBackgroundMobileUrl: string | null;
|
welcomeBackgroundMobileUrl: string | null;
|
||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
appIconUrl: string | null;
|
|
||||||
themeColor: string | null;
|
|
||||||
backgroundColor: string | null;
|
|
||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
website: string;
|
website: string;
|
||||||
@@ -69,9 +65,6 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
|||||||
welcomeBackgroundDesktopUrl: form.welcomeBackgroundDesktopUrl,
|
welcomeBackgroundDesktopUrl: form.welcomeBackgroundDesktopUrl,
|
||||||
welcomeBackgroundMobileUrl: form.welcomeBackgroundMobileUrl,
|
welcomeBackgroundMobileUrl: form.welcomeBackgroundMobileUrl,
|
||||||
faviconUrl: form.faviconUrl,
|
faviconUrl: form.faviconUrl,
|
||||||
appIconUrl: form.appIconUrl,
|
|
||||||
themeColor: form.themeColor,
|
|
||||||
backgroundColor: form.backgroundColor,
|
|
||||||
phone: form.phone || undefined,
|
phone: form.phone || undefined,
|
||||||
email: form.email || undefined,
|
email: form.email || undefined,
|
||||||
website: form.website || undefined,
|
website: form.website || undefined,
|
||||||
@@ -152,17 +145,6 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
|||||||
category="branding"
|
category="branding"
|
||||||
hint="Browser tab icon. Falls back to the app default when unset."
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ import { prisma } from "@/lib/db/prisma";
|
|||||||
* Business row exists yet, so it must never throw.
|
* 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 {
|
export interface BrandingInfo {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -33,9 +39,19 @@ export interface BrandingInfo {
|
|||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
coverImageUrl: string | null;
|
coverImageUrl: string | null;
|
||||||
faviconUrl: 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;
|
appIconUrl: string | null;
|
||||||
themeColor: string;
|
themeColor: string;
|
||||||
backgroundColor: 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. */
|
/** Welcome page (/) background — see src/app/page.tsx. Independent images, not one image cropped two ways. */
|
||||||
welcomeBackgroundDesktopUrl: string | null;
|
welcomeBackgroundDesktopUrl: string | null;
|
||||||
welcomeBackgroundMobileUrl: string | null;
|
welcomeBackgroundMobileUrl: string | null;
|
||||||
@@ -43,6 +59,18 @@ export interface BrandingInfo {
|
|||||||
phone: string | null;
|
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_THEME_COLOR = "#111b15";
|
||||||
const DEFAULT_BACKGROUND_COLOR = "#f4f6f4";
|
const DEFAULT_BACKGROUND_COLOR = "#f4f6f4";
|
||||||
|
|
||||||
@@ -58,6 +86,12 @@ const UNCONFIGURED_BRANDING: BrandingInfo = {
|
|||||||
appIconUrl: null,
|
appIconUrl: null,
|
||||||
themeColor: DEFAULT_THEME_COLOR,
|
themeColor: DEFAULT_THEME_COLOR,
|
||||||
backgroundColor: DEFAULT_BACKGROUND_COLOR,
|
backgroundColor: DEFAULT_BACKGROUND_COLOR,
|
||||||
|
icon192Url: null,
|
||||||
|
icon512Url: null,
|
||||||
|
maskableIcon192Url: null,
|
||||||
|
maskableIcon512Url: null,
|
||||||
|
splashScreens: [],
|
||||||
|
bottomNavShowLabels: true,
|
||||||
welcomeBackgroundDesktopUrl: null,
|
welcomeBackgroundDesktopUrl: null,
|
||||||
welcomeBackgroundMobileUrl: null,
|
welcomeBackgroundMobileUrl: null,
|
||||||
email: null,
|
email: null,
|
||||||
@@ -85,6 +119,12 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
|
|||||||
appIconUrl: true,
|
appIconUrl: true,
|
||||||
themeColor: true,
|
themeColor: true,
|
||||||
backgroundColor: true,
|
backgroundColor: true,
|
||||||
|
icon192Url: true,
|
||||||
|
icon512Url: true,
|
||||||
|
maskableIcon192Url: true,
|
||||||
|
maskableIcon512Url: true,
|
||||||
|
splashScreens: true,
|
||||||
|
bottomNavShowLabels: true,
|
||||||
welcomeBackgroundDesktopUrl: true,
|
welcomeBackgroundDesktopUrl: true,
|
||||||
welcomeBackgroundMobileUrl: true,
|
welcomeBackgroundMobileUrl: true,
|
||||||
email: true,
|
email: true,
|
||||||
@@ -107,6 +147,12 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
|
|||||||
appIconUrl: business.appIconUrl,
|
appIconUrl: business.appIconUrl,
|
||||||
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
|
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
|
||||||
backgroundColor: business.backgroundColor || DEFAULT_BACKGROUND_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,
|
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
|
||||||
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
|
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
|
||||||
email: business.email,
|
email: business.email,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
import type { PrismaClient } from "@/generated/prisma/client";
|
import type { PrismaClient } from "@/generated/prisma/client";
|
||||||
import { Weekday } from "@/generated/prisma/enums";
|
import { Weekday } from "@/generated/prisma/enums";
|
||||||
import { calendarDateOf, minutesFromMidnight, weekdayOf } from "@/lib/time/business-time";
|
import { calendarDateOf, minutesFromMidnight, weekdayOf } from "@/lib/time/business-time";
|
||||||
import { fail, ok } from "@/lib/validation-result";
|
import { fail, ok } from "@/lib/validation-result";
|
||||||
import type { ValidationResult } 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 —
|
* The business's own profile, address, policies, and opening hours —
|
||||||
@@ -141,6 +143,75 @@ export async function updateBusinessSettings(
|
|||||||
return { success: true };
|
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 ──────────────────────────────────────────────────────
|
// ── Opening hours ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface OpeningHoursDayInput {
|
export interface OpeningHoursDayInput {
|
||||||
|
|||||||
Reference in new issue
Block a user