PWA Settings

Fixed webmanifest generation and pwa icon.

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

No files matched your search

+3 -2
View File
@@ -1,6 +1,7 @@
import { auth } from "@/auth";
import { BottomNav, type BottomNavItem } from "@/components/navigation/bottom-nav";
import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding";
import { NotificationBell } from "@/features/notifications/components/notification-bell";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
@@ -22,14 +23,14 @@ const NAV_ITEMS: BottomNavItem[] = [
* notification.service.ts's IN_APP channel resolution).
*/
export default async function CustomerLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
const [session, branding] = await Promise.all([auth(), getBranding()]);
const unreadNotificationCount = session?.user ? await getUnreadNotificationCount(prisma, session.user.id) : 0;
return (
<div className="min-h-screen bg-stone pb-24">
{session?.user && <NotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
<div id="main-content">{children}</div>
<BottomNav items={NAV_ITEMS} variant="customer" />
<BottomNav items={NAV_ITEMS} variant="customer" showLabels={branding.bottomNavShowLabels} />
</div>
);
}
+43 -34
View File
@@ -13,6 +13,7 @@ import {
Gift,
Images,
ScissorsSquare,
Smartphone,
Stamp,
Star,
TrendingUp,
@@ -24,7 +25,7 @@ import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { todayIn } from "@/lib/time/business-time";
@@ -48,38 +49,44 @@ function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; l
);
}
const MANAGE_SECTIONS = [
{
title: "Business",
links: [
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
],
},
{
title: "Booking",
links: [
{ href: "/owner/services", label: "Services", icon: ScissorsSquare },
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
],
},
{
title: "Customers",
links: [{ href: "/owner/settings/reviews", label: "Reviews", icon: Star }],
},
{
title: "Team",
links: [{ href: "/owner/team", label: "Team Management", icon: Users }],
},
{
title: "Reports",
links: [{ href: "/owner/reports", label: "Reports", icon: BarChart3 }],
},
] as const;
/** App Icon & Home Screen is Super Administrator only (see /owner/settings/app) — everything else here is any Owner. */
function getManageSections(isSuper: boolean) {
return [
{
title: "Business",
links: [
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
...(isSuper
? [{ href: "/owner/settings/app", label: "App Icon & Home Screen", icon: Smartphone }]
: []),
],
},
{
title: "Booking",
links: [
{ href: "/owner/services", label: "Services", icon: ScissorsSquare },
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
],
},
{
title: "Customers",
links: [{ href: "/owner/settings/reviews", label: "Reviews", icon: Star }],
},
{
title: "Team",
links: [{ href: "/owner/team", label: "Team Management", icon: Users }],
},
{
title: "Reports",
links: [{ href: "/owner/reports", label: "Reports", icon: BarChart3 }],
},
];
}
function ManageSection({ title, links }: (typeof MANAGE_SECTIONS)[number]) {
function ManageSection({ title, links }: ReturnType<typeof getManageSections>[number]) {
return (
<div>
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">{title}</p>
@@ -108,14 +115,16 @@ export default async function OwnerDashboardPage() {
if (!context) redirect("/staff");
const today = todayIn(context.businessTimezone);
const [summary, unreadNotificationCount] = await Promise.all([
const [summary, unreadNotificationCount, isSuper] = await Promise.all([
getDashboardSummary(prisma, {
businessId: context.businessId,
businessTimezone: context.businessTimezone,
date: today,
}),
getUnreadNotificationCount(prisma, session.user.id),
isSuperAdmin(session.user.id),
]);
const manageSections = getManageSections(isSuper);
return (
<div className="pb-12">
@@ -237,7 +246,7 @@ export default async function OwnerDashboardPage() {
</Card>
<div className="space-y-5">
{MANAGE_SECTIONS.map((section) => (
{manageSections.map((section) => (
<ManageSection key={section.title} {...section} />
))}
</div>
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { isSuperAdmin } from "@/lib/auth/permissions";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { parseSplashScreens } from "@/lib/business/branding";
import { getBusinessProfile } from "@/services/business/business.service";
import { AppIconSettingsForm } from "@/features/owner/components/app-icon-settings-form";
export const metadata: Metadata = { title: "App Icon & Home Screen" };
export const dynamic = "force-dynamic";
/** Super Administrator only — not Owner, unlike every other /owner/settings/* page. See requireSuperAdminBusinessManager in features/owner/actions/business.ts for the matching action-layer check. */
export default async function OwnerAppIconSettingsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
if (!(await isSuperAdmin(session.user.id))) redirect("/owner");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
return (
<SettingsPageShell title="App Icon & Home Screen">
<AppIconSettingsForm
initial={{
shortName: business.shortName?.trim() || business.name,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor,
backgroundColor: business.backgroundColor,
icon192Url: business.icon192Url,
icon512Url: business.icon512Url,
maskableIcon192Url: business.maskableIcon192Url,
maskableIcon512Url: business.maskableIcon512Url,
splashScreens: parseSplashScreens(business.splashScreens),
bottomNavShowLabels: business.bottomNavShowLabels,
}}
/>
</SettingsPageShell>
);
}
@@ -38,9 +38,6 @@ export default async function OwnerBusinessDetailsPage() {
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
faviconUrl: business.faviconUrl,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor,
backgroundColor: business.backgroundColor,
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
+11 -1
View File
@@ -19,10 +19,20 @@ const inter = Inter({
*/
export async function generateMetadata(): Promise<Metadata> {
const branding = await getBranding();
return {
title: { default: branding.name, template: `%s | ${branding.name}` },
description: branding.description || "Book your next appointment in seconds.",
appleWebApp: { capable: true, statusBarStyle: "default", title: branding.shortName },
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: branding.shortName,
// One exact per-device `apple-touch-startup-image` entry per array
// item — imported wholesale from a generator's ZIP bundle (see
// importAppIconZipAction), not hand-picked. Empty array renders no
// splash screens at all, which is the correct "unconfigured" state.
startupImage: branding.splashScreens.length > 0 ? branding.splashScreens : undefined,
},
icons:
branding.faviconUrl || branding.appIconUrl
? { icon: branding.faviconUrl || undefined, apple: branding.appIconUrl || undefined }
+41 -12
View File
@@ -7,15 +7,24 @@ import { getBranding } from "@/lib/business/branding";
* Phase 8: name/description/colors reflect the installed business (see
* src/lib/business/branding.ts) rather than the internal product name.
*
* `icons` intentionally stays pointed at the app's own pre-generated,
* correctly-sized/maskable PWA icon set rather than a business's single
* `appIconUrl` — there's no image-processing pipeline in this app to
* derive the required 192/512/maskable variants from one arbitrary
* uploaded URL (see docs/phase-8-summary.md, "Known limitations"), so
* this is fallback rule #2 ("business name + a neutral generated icon"),
* applied deliberately rather than risking a broken/non-square install
* icon.
* `force-dynamic` is required, not optional — without it, Next has no
* signal that this route depends on request-time data (a plain DB read
* doesn't count as one of Next's dynamic-usage triggers) and statically
* renders it once at build time. Since the Docker image is built before
* the installation wizard ever runs, that bakes in the pre-install
* neutral fallback ("Book an Appointment"/"Booking") permanently — the
* real business name/icons would never appear no matter how the Business
* row changes afterward.
*
* Each of the four manifest icons falls back independently to the app's
* own pre-generated, correctly-sized/maskable PWA icon when a business
* hasn't uploaded its own — a business can set some without all four (see
* the "App Icon & Home Screen" super-admin settings screen). This is
* fallback rule #2 ("business name + a neutral generated icon") applied
* per-field, rather than risking a broken/non-square install icon.
*/
export const dynamic = "force-dynamic";
export default async function manifest(): Promise<MetadataRoute.Manifest> {
const branding = await getBranding();
return {
@@ -29,10 +38,30 @@ export default async function manifest(): Promise<MetadataRoute.Manifest> {
theme_color: branding.themeColor,
orientation: "portrait-primary",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icons/icon-maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icons/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{
src: branding.icon192Url || "/icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: branding.icon512Url || "/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: branding.maskableIcon192Url || "/icons/icon-maskable-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: branding.maskableIcon512Url || "/icons/icon-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
+15 -5
View File
@@ -5,7 +5,7 @@ import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { getBranding } from "@/lib/business/branding";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { hasPermission } from "@/lib/auth/permissions";
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-card";
@@ -14,10 +14,14 @@ import { WelcomeInstallCard } from "@/features/pwa/components/welcome-install-ca
* The unauthenticated entry point — always shown at `/` before
* authentication, per the brief's "Welcome routing" requirements.
* Authenticated visitors never see it: they're routed straight to their
* role's home (customer → /home, barber → /staff, owner → /owner) below.
* Unreachable at all before installation completes — src/proxy.ts
* redirects every non-exempt route (including this one) to /install
* until ApplicationInstallation flips to INSTALLED.
* role's calendar (barber → /staff, owner/super admin → /owner/calendar)
* or customer home below. This is also where a bare sign-in with no
* `callbackUrl` ends up — see safeRedirectPath's fallback in
* sign-in-form.tsx — so it's the one place that has to get every role
* right, not just staffProfile holders. Unreachable at all before
* installation completes — src/proxy.ts redirects every non-exempt route
* (including this one) to /install until ApplicationInstallation flips
* to INSTALLED.
*/
export default async function WelcomePage() {
const session = await auth();
@@ -27,6 +31,12 @@ export default async function WelcomePage() {
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
redirect(canManageStaff ? "/owner" : "/staff");
}
// A Super Administrator has no StaffProfile by design (see
// isSuperAdmin) — without this check they'd fall through to the
// customer /home below instead of the staff shell + Calendar tab
// their nav role (see lib/navigation/nav-config.ts) actually gives
// them once they land somewhere in it.
if (await isSuperAdmin(session.user.id)) redirect("/owner/calendar");
redirect("/home");
}