690 files changed
+12045
-33943
No files matched your search
@@ -0,0 +1,166 @@
|
||||
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.
|
||||
*/
|
||||
|
||||
/** One iOS `apple-touch-startup-image` entry — an exact per-device media query paired with its splash image, matching the shape a generator like progressier.com's ZIP bundle produces. See importAppIconZipAction. */
|
||||
export interface SplashScreenEntry {
|
||||
media: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BrandingInfo {
|
||||
configured: boolean;
|
||||
name: string;
|
||||
shortName: string;
|
||||
description: string | null;
|
||||
logoUrl: string | null;
|
||||
coverImageUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
/** Apple touch icon (iOS "Add to Home Screen") — distinct from the manifest icon fields below, which Android/Chrome installs read instead. */
|
||||
appIconUrl: string | null;
|
||||
themeColor: string;
|
||||
backgroundColor: string;
|
||||
/** PWA manifest `icons` array — see src/app/manifest.ts. Each falls back independently to the app's own generated icon when unset. */
|
||||
icon192Url: string | null;
|
||||
icon512Url: string | null;
|
||||
maskableIcon192Url: string | null;
|
||||
maskableIcon512Url: string | null;
|
||||
/** iOS `apple-touch-startup-image` splash screens — see src/app/layout.tsx. */
|
||||
splashScreens: SplashScreenEntry[];
|
||||
/** Icon-only vs icon+label bottom navigation — see components/navigation/bottom-nav.tsx. */
|
||||
bottomNavShowLabels: boolean;
|
||||
/** Welcome page (/) background — see src/app/page.tsx. Independent images, not one image cropped two ways. */
|
||||
welcomeBackgroundDesktopUrl: string | null;
|
||||
welcomeBackgroundMobileUrl: string | null;
|
||||
email: 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_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,
|
||||
icon192Url: null,
|
||||
icon512Url: null,
|
||||
maskableIcon192Url: null,
|
||||
maskableIcon512Url: null,
|
||||
splashScreens: [],
|
||||
bottomNavShowLabels: true,
|
||||
welcomeBackgroundDesktopUrl: null,
|
||||
welcomeBackgroundMobileUrl: null,
|
||||
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 env vars). 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,
|
||||
icon192Url: true,
|
||||
icon512Url: true,
|
||||
maskableIcon192Url: true,
|
||||
maskableIcon512Url: true,
|
||||
splashScreens: true,
|
||||
bottomNavShowLabels: true,
|
||||
welcomeBackgroundDesktopUrl: true,
|
||||
welcomeBackgroundMobileUrl: 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,
|
||||
icon192Url: business.icon192Url,
|
||||
icon512Url: business.icon512Url,
|
||||
maskableIcon192Url: business.maskableIcon192Url,
|
||||
maskableIcon512Url: business.maskableIcon512Url,
|
||||
splashScreens: parseSplashScreens(business.splashScreens),
|
||||
bottomNavShowLabels: business.bottomNavShowLabels,
|
||||
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
|
||||
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
|
||||
email: business.email,
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cache } from "react";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { isSuperAdmin } from "@/lib/auth/permissions";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
|
||||
/**
|
||||
* Resolves "which business is the signed-in user acting on behalf of" for
|
||||
* pages/actions that only need the business id/name/timezone — not full
|
||||
* staff identity (see getActingStaff for that, which stays the right
|
||||
* choice for anything that needs a real StaffProfile id, e.g. loyalty
|
||||
* stamp authorship or appointment reassignment).
|
||||
*
|
||||
* A regular staff member/owner resolves via their StaffProfile, same as
|
||||
* getActingStaff. A Super Administrator (see scripts/ensure-super-admin.ts)
|
||||
* deliberately has no StaffProfile at all — that's what keeps them out of
|
||||
* the Team list/barber picker/appointment FKs by construction rather than
|
||||
* a filter — so they resolve straight to the app's one business instead
|
||||
* (v1 is single-business, same assumption getPrimaryBusiness() already
|
||||
* makes everywhere else). Returns null only for a signed-in user who is
|
||||
* neither staff nor a super admin.
|
||||
*/
|
||||
export const getActingBusinessContext = cache(async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
const staffProfile = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
select: { id: true, businessId: true, business: { select: { name: true, timezone: true } } },
|
||||
});
|
||||
if (staffProfile) {
|
||||
return {
|
||||
userId: session.user.id,
|
||||
staffId: staffProfile.id,
|
||||
businessId: staffProfile.businessId,
|
||||
businessName: staffProfile.business.name,
|
||||
businessTimezone: staffProfile.business.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
if (!(await isSuperAdmin(session.user.id))) return null;
|
||||
|
||||
const business = await getPrimaryBusiness();
|
||||
return {
|
||||
userId: session.user.id,
|
||||
// A Super Administrator has no StaffProfile by design (see isSuperAdmin) —
|
||||
// null here is what lets callers tell "acting as real staff" apart from
|
||||
// "acting as a Super Administrator with no personal staff identity",
|
||||
// e.g. to withhold note authorship (see CustomerNotes' actingStaffId).
|
||||
staffId: null,
|
||||
businessId: business.id,
|
||||
businessName: business.name,
|
||||
businessTimezone: business.timezone,
|
||||
};
|
||||
});
|
||||
|
||||
export type ActingBusinessContext = NonNullable<Awaited<ReturnType<typeof getActingBusinessContext>>>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
/**
|
||||
* v1 is single-business (see docs/implementation-plan.md §11) — every
|
||||
* page that needs "the business" fetches it through here rather than
|
||||
* hardcoding a slug or re-querying independently. Wrapped in React's
|
||||
* `cache()` so multiple Server Components in the same request (a layout
|
||||
* and its page, for instance) share one query instead of issuing it
|
||||
* once each.
|
||||
*/
|
||||
export const getPrimaryBusiness = cache(async () => {
|
||||
return prisma.business.findFirstOrThrow({
|
||||
include: {
|
||||
locations: true,
|
||||
hours: true,
|
||||
settings: true,
|
||||
bookingRule: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { minutesFromMidnight, todayIn, weekdayOf } from "@/lib/time/business-time";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
|
||||
export interface OpeningStatus {
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DayHoursRow {
|
||||
dayOfWeek: Weekday;
|
||||
isClosed: boolean;
|
||||
opensAtMinutes: number | null;
|
||||
closesAtMinutes: number | null;
|
||||
}
|
||||
|
||||
function formatMinutes(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely presentational (read-only "open now?" label for the home page)
|
||||
* — not part of the booking engine's own open/closed check, which
|
||||
* AvailabilityService already performs independently as a real booking
|
||||
* rule. This is display-only, so it lives here rather than in
|
||||
* services/scheduling.
|
||||
*/
|
||||
export function computeOpeningStatus(hours: DayHoursRow[], timezone: string): OpeningStatus {
|
||||
const today = todayIn(timezone);
|
||||
const weekday = weekdayOf(today);
|
||||
const todayHours = hours.find((h) => h.dayOfWeek === weekday);
|
||||
|
||||
if (!todayHours || todayHours.isClosed || todayHours.opensAtMinutes === null || todayHours.closesAtMinutes === null) {
|
||||
return { isOpen: false, label: "Closed today" };
|
||||
}
|
||||
|
||||
const nowMinutes = minutesFromMidnight(new Date(), timezone);
|
||||
const isOpen = nowMinutes >= todayHours.opensAtMinutes && nowMinutes < todayHours.closesAtMinutes;
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
label: isOpen ? `Open until ${formatMinutes(todayHours.closesAtMinutes)}` : `Closed · Opens ${formatMinutes(todayHours.opensAtMinutes)}`,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user