3 files changed
+95
-121
No files matched your search
@@ -0,0 +1,32 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (location: string) => {
|
||||||
|
throw new Error(`redirect:${location}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db/prisma", () => {
|
||||||
|
throw new Error("Business Prisma must not load while importing the Manager root.");
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/features/welcome/pages/business-welcome-page", () => {
|
||||||
|
throw new Error("Business welcome page must not load while importing the Manager root.");
|
||||||
|
});
|
||||||
|
|
||||||
|
import RootPage from "@/app/page";
|
||||||
|
|
||||||
|
describe("root deployment entry point", () => {
|
||||||
|
const originalManagerMode = process.env.MANAGER_MODE;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalManagerMode === undefined) delete process.env.MANAGER_MODE;
|
||||||
|
else process.env.MANAGER_MODE = originalManagerMode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects Manager root without evaluating business page or Prisma", async () => {
|
||||||
|
process.env.MANAGER_MODE = "true";
|
||||||
|
|
||||||
|
await expect(RootPage()).rejects.toThrow("redirect:/manager");
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-121
@@ -1,125 +1,10 @@
|
|||||||
import { Scissors } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
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, 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";
|
|
||||||
|
|
||||||
/**
|
/** Deployment-mode entry point. Keep this module database-free: Next can
|
||||||
* The unauthenticated entry point — always shown at `/` before
|
* evaluate the root route module before running the page function. */
|
||||||
* authentication, per the brief's "Welcome routing" requirements.
|
export default async function RootPage() {
|
||||||
* Authenticated visitors never see it: they're routed straight to their
|
if (process.env.MANAGER_MODE === "true") redirect("/manager");
|
||||||
* 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();
|
|
||||||
if (session?.user) {
|
|
||||||
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
|
||||||
if (staffProfile) {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [branding, business] = await Promise.all([getBranding(), getPrimaryBusiness()]);
|
const { default: BusinessWelcomePage } = await import("@/features/welcome/pages/business-welcome-page");
|
||||||
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
|
return <BusinessWelcomePage />;
|
||||||
|
|
||||||
// The hero card's photo — the welcome-specific background the owner
|
|
||||||
// uploaded for this exact screen takes priority (it was purpose-built for
|
|
||||||
// it, see BrandingInfo.welcomeBackgroundMobileUrl/DesktopUrl); the
|
|
||||||
// general-purpose cover shot is only a fallback for a business that
|
|
||||||
// configured a cover image but never set a dedicated welcome background.
|
|
||||||
// Desktop and mobile keep their own independent images, same as before.
|
|
||||||
const heroImageMobileUrl = branding.welcomeBackgroundMobileUrl ?? branding.welcomeBackgroundDesktopUrl ?? branding.coverImageUrl;
|
|
||||||
const heroImageDesktopUrl = branding.welcomeBackgroundDesktopUrl ?? branding.welcomeBackgroundMobileUrl ?? branding.coverImageUrl;
|
|
||||||
|
|
||||||
const hasHeroImage = !!(heroImageMobileUrl || heroImageDesktopUrl);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div id="main-content" className="relative flex min-h-screen flex-col justify-end overflow-hidden bg-ink px-8 pb-16 text-white">
|
|
||||||
{/* Full-bleed background — desktop/mobile are independent images, not one crop stretched two ways. */}
|
|
||||||
{heroImageMobileUrl && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 block bg-cover bg-center md:hidden"
|
|
||||||
style={{ backgroundImage: `url(${heroImageMobileUrl})` }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{heroImageDesktopUrl && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 hidden bg-cover bg-center md:block"
|
|
||||||
style={{ backgroundImage: `url(${heroImageDesktopUrl})` }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasHeroImage && (
|
|
||||||
<div className="from-ink via-ink/60 absolute inset-0 bg-gradient-to-t to-transparent" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative z-10 mx-auto flex w-full max-w-sm flex-1 flex-col justify-end">
|
|
||||||
<div className="mx-auto mb-8 flex items-center justify-center overflow-hidden text-ink">
|
|
||||||
{branding.logoUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL; not a build-time-known local asset for next/image.
|
|
||||||
<img src={branding.logoUrl} alt="" className="h-48 w-48 object-cover" />
|
|
||||||
) : (
|
|
||||||
<Scissors className="h-8 w-8" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h1 className="mb-4 text-3xl font-bold tracking-tight">{branding.name}</h1>
|
|
||||||
<p className="mb-8 max-w-xs text-lg text-white/85">
|
|
||||||
Find, book & manage your appointments with ease.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Button asChild variant="secondary" size="lg" className="w-full bg-white">
|
|
||||||
<Link href="/register">Create Account</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
className="w-full border-2 border-white/20 bg-transparent text-white hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<Link href="/sign-in">Sign In</Link>
|
|
||||||
</Button>
|
|
||||||
{guestBookingEnabled && (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="ghost"
|
|
||||||
size="lg"
|
|
||||||
className="w-full text-white/70 hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
|
||||||
<Link href="/book">Continue as Guest</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button asChild variant="ghost" size="lg" className="w-full text-white/70 hover:bg-white/10 hover:text-white">
|
|
||||||
<Link href="/home">Browse Services</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<WelcomeInstallCard businessName={branding.name} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Scissors } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
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, 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";
|
||||||
|
|
||||||
|
/** The normal business welcome screen. Kept outside the root entry point so
|
||||||
|
* Manager mode can redirect without evaluating business-only imports. */
|
||||||
|
export default async function BusinessWelcomePage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (session?.user) {
|
||||||
|
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||||
|
if (staffProfile) {
|
||||||
|
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||||
|
redirect(canManageStaff ? "/owner" : "/staff");
|
||||||
|
}
|
||||||
|
if (await isSuperAdmin(session.user.id)) redirect("/owner/calendar");
|
||||||
|
redirect("/home");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [branding, business] = await Promise.all([getBranding(), getPrimaryBusiness()]);
|
||||||
|
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
|
||||||
|
const heroImageMobileUrl = branding.welcomeBackgroundMobileUrl ?? branding.welcomeBackgroundDesktopUrl ?? branding.coverImageUrl;
|
||||||
|
const heroImageDesktopUrl = branding.welcomeBackgroundDesktopUrl ?? branding.welcomeBackgroundMobileUrl ?? branding.coverImageUrl;
|
||||||
|
const hasHeroImage = !!(heroImageMobileUrl || heroImageDesktopUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="main-content" className="relative flex min-h-screen flex-col justify-end overflow-hidden bg-ink px-8 pb-16 text-white">
|
||||||
|
{heroImageMobileUrl && <div className="absolute inset-0 block bg-cover bg-center md:hidden" style={{ backgroundImage: `url(${heroImageMobileUrl})` }} aria-hidden="true" />}
|
||||||
|
{heroImageDesktopUrl && <div className="absolute inset-0 hidden bg-cover bg-center md:block" style={{ backgroundImage: `url(${heroImageDesktopUrl})` }} aria-hidden="true" />}
|
||||||
|
{hasHeroImage && <div className="from-ink via-ink/60 absolute inset-0 bg-gradient-to-t to-transparent" aria-hidden="true" />}
|
||||||
|
<div className="relative z-10 mx-auto flex w-full max-w-sm flex-1 flex-col justify-end">
|
||||||
|
<div className="mx-auto mb-8 flex items-center justify-center overflow-hidden text-ink">
|
||||||
|
{branding.logoUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- external business-supplied URL.
|
||||||
|
<img src={branding.logoUrl} alt="" className="h-48 w-48 object-cover" />
|
||||||
|
) : <Scissors className="h-8 w-8" aria-hidden="true" />}
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-4 text-3xl font-bold tracking-tight">{branding.name}</h1>
|
||||||
|
<p className="mb-8 max-w-xs text-lg text-white/85">Find, book & manage your appointments with ease.</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button asChild variant="secondary" size="lg" className="w-full bg-white"><Link href="/register">Create Account</Link></Button>
|
||||||
|
<Button asChild variant="secondary" size="lg" className="w-full border-2 border-white/20 bg-transparent text-white hover:bg-white/10"><Link href="/sign-in">Sign In</Link></Button>
|
||||||
|
{guestBookingEnabled && <Button asChild variant="ghost" size="lg" className="w-full text-white/70 hover:bg-white/10 hover:text-white"><Link href="/book">Continue as Guest</Link></Button>}
|
||||||
|
<Button asChild variant="ghost" size="lg" className="w-full text-white/70 hover:bg-white/10 hover:text-white"><Link href="/home">Browse Services</Link></Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6"><WelcomeInstallCard businessName={branding.name} /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user