PWA Settings
Fixed webmanifest generation and pwa icon. Made nav bar text optional.
This commit is contained in:
23 files changed
+1065
-91
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user