This commit is contained in:
Stefan Lazic committed 2026-08-04 09:57:58 +01:00
1 parent 546613e677
commit 4785510144
82 files changed
+4903 -287

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
/**
* The one place every customer/staff/owner-visible surface gets the
* business's white-label identity from — see docs/phase-8-summary.md.
* "NextChair" is the internal product name only (repo, Docker project,
* package name, technical docs/logs); it must never be the fallback a
* real person sees. Fallback order, per the brief:
* 1. Configured business name + logo
* 2. Configured business name + the app's own neutral icon (handled
* naturally: leaving `logoUrl`/`appIconUrl`/`faviconUrl` null falls
* through to the static /icon.png / /apple-icon.png files Next
* already serves via its file-convention icons — no separate
* "neutral icon" asset needed)
* 3. Neutral wording with no visible product name at all — only
* reachable before installation completes, since every other route
* is gated behind a completed install (see
* src/lib/installation/installation-state.ts).
*
* Deliberately a separate, lighter query from getPrimaryBusiness() (which
* `findFirstOrThrow`s and eager-loads locations/hours/settings/
* bookingRule) — this is called from `generateMetadata` and the root
* layout on *every* request, including pre-install ones where no
* Business row exists yet, so it must never throw.
*/
export interface BrandingInfo {
configured: boolean;
name: string;
shortName: string;
description: string | null;
logoUrl: string | null;
coverImageUrl: string | null;
faviconUrl: string | null;
appIconUrl: string | null;
themeColor: string;
backgroundColor: string;
email: string | null;
phone: string | null;
}
const DEFAULT_THEME_COLOR = "#111b15";
const DEFAULT_BACKGROUND_COLOR = "#f4f6f4";
/** Neutral wording only — never the internal product name. */
const UNCONFIGURED_BRANDING: BrandingInfo = {
configured: false,
name: "Book an Appointment",
shortName: "Booking",
description: null,
logoUrl: null,
coverImageUrl: null,
faviconUrl: null,
appIconUrl: null,
themeColor: DEFAULT_THEME_COLOR,
backgroundColor: DEFAULT_BACKGROUND_COLOR,
email: null,
phone: null,
};
export const getBranding = cache(async (): Promise<BrandingInfo> => {
// Reached from the root layout's generateMetadata, which Next may
// invoke while statically prerendering a route at *build* time (e.g.
// /_not-found) — no real database is reachable then (see the
// Dockerfile's placeholder build-time DATABASE_URL). A transient/
// unreachable database is treated exactly like "not installed yet":
// neutral fallback wording, never a crashed build or a 500 at runtime.
let business;
try {
business = await prisma.business.findFirst({
orderBy: { createdAt: "asc" },
select: {
name: true,
shortName: true,
description: true,
logoUrl: true,
coverImageUrl: true,
faviconUrl: true,
appIconUrl: true,
themeColor: true,
backgroundColor: true,
email: true,
phone: true,
},
});
} catch {
return UNCONFIGURED_BRANDING;
}
if (!business) return UNCONFIGURED_BRANDING;
return {
configured: true,
name: business.name,
shortName: business.shortName?.trim() || business.name,
description: business.description,
logoUrl: business.logoUrl,
coverImageUrl: business.coverImageUrl,
faviconUrl: business.faviconUrl,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
backgroundColor: business.backgroundColor || DEFAULT_BACKGROUND_COLOR,
email: business.email,
phone: business.phone,
};
});
/** "Book an Appointment | Business Name" — the shared page-title format used in generateMetadata across the app. */
export function brandedTitle(pageTitle: string, branding: BrandingInfo): string {
return `${pageTitle} | ${branding.name}`;
}