Move business logic out of App Router into module packages, add boundary validation scripts, and keep all routes as thin re-exports without changing URLs or API behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
203 lines
6.7 KiB
TypeScript
203 lines
6.7 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useEffect, Suspense } from "react";
|
||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||
import { AuthGuard } from "@/components/AuthGuard";
|
||
import { LoadingState, PageHeader, Card, CardContent, Button } from "@/components/ds";
|
||
import { BEAUTY_PORTALS, type BeautyPortalId } from "@/modules/beauty/constants/portals";
|
||
import { cn } from "@/lib/utils";
|
||
import { ChevronLeft } from "lucide-react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { beautyBusinessApi } from "@/modules/beauty/services/beauty-business-api";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { AggregateStatsRow, BeautyPageLoader } from "./shared";
|
||
|
||
export function BeautyHub() {
|
||
return (
|
||
<AuthGuard>
|
||
<BeautyHubInner />
|
||
</AuthGuard>
|
||
);
|
||
}
|
||
|
||
function BeautyHubInner() {
|
||
const { tenantId } = useTenantId();
|
||
const healthQ = useQuery({
|
||
queryKey: ["beauty", "health"],
|
||
queryFn: () => beautyBusinessApi.health(),
|
||
});
|
||
|
||
return (
|
||
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6">
|
||
<PageHeader
|
||
title="تربت بیوتی"
|
||
description="پورتال مورد نظر را انتخاب کنید."
|
||
actions={
|
||
healthQ.data ? (
|
||
<span className="text-xs text-[var(--muted)]">v{healthQ.data.version}</span>
|
||
) : null
|
||
}
|
||
/>
|
||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||
{BEAUTY_PORTALS.map((portal) => {
|
||
const Icon = portal.icon;
|
||
return (
|
||
<Link key={portal.id} href={portal.basePath}>
|
||
<Card className="h-full transition-all hover:border-[var(--beauty-accent)]/40 hover:shadow-[var(--shadow-md)]">
|
||
<CardContent className="flex h-full flex-col gap-4 pt-6">
|
||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--beauty-accent-soft)] text-[var(--beauty-accent)]">
|
||
<Icon className="h-6 w-6" />
|
||
</div>
|
||
<div>
|
||
<p className="text-lg font-semibold text-secondary">{portal.label}</p>
|
||
<p className="mt-1 text-sm text-[var(--muted)]">{portal.description}</p>
|
||
</div>
|
||
<span className="mt-auto text-xs text-[var(--beauty-accent)]">ورود ←</span>
|
||
</CardContent>
|
||
</Card>
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="mt-8 flex flex-wrap gap-3">
|
||
<Link href="/beauty/site">
|
||
<Button variant="outline">سایت عمومی</Button>
|
||
</Link>
|
||
<Link href="/dashboard">
|
||
<Button variant="ghost">workspace</Button>
|
||
</Link>
|
||
</div>
|
||
{!tenantId ? (
|
||
<p className="mt-6 text-sm text-[var(--muted)]">tenant فعال لازم است.</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AuthLoginRedirectInner() {
|
||
const sp = useSearchParams();
|
||
const router = useRouter();
|
||
const returnUrl = sp.get("returnUrl") ?? "/beauty/hub";
|
||
|
||
useEffect(() => {
|
||
router.replace(`/login?returnUrl=${encodeURIComponent(returnUrl)}`);
|
||
}, [router, returnUrl]);
|
||
|
||
return <LoadingState label="در حال انتقال به ورود…" />;
|
||
}
|
||
|
||
export function BeautyAuthLogin() {
|
||
return (
|
||
<Suspense fallback={<LoadingState />}>
|
||
<AuthLoginRedirectInner />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
export function BeautyAuthRegister() {
|
||
return <BeautyAuthLogin />;
|
||
}
|
||
|
||
export function BeautyAuthOtp() {
|
||
return <BeautyAuthLogin />;
|
||
}
|
||
|
||
export function BeautyAuthForgotPassword() {
|
||
return <BeautyAuthLogin />;
|
||
}
|
||
|
||
export function BeautyAuth2fa() {
|
||
return <BeautyAuthLogin />;
|
||
}
|
||
|
||
export function BeautyAuthProfileCompletion() {
|
||
return <BeautyAuthLogin />;
|
||
}
|
||
|
||
const PORTAL_IDS: BeautyPortalId[] = ["customer", "owner", "reception", "staff", "admin"];
|
||
|
||
export function BeautyMobilePortal() {
|
||
const params = useParams();
|
||
const portal = String(params.portal) as BeautyPortalId;
|
||
const meta = BEAUTY_PORTALS.find((p) => p.id === portal);
|
||
const { tenantId } = useTenantId();
|
||
|
||
const statsQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "mobile-stats", portal],
|
||
queryFn: async () => {
|
||
const [orgs, branches, customers, appts] = await Promise.all([
|
||
beautyBusinessApi.organizations.list(tenantId!),
|
||
beautyBusinessApi.branches.list(tenantId!),
|
||
beautyBusinessApi.customers.list(tenantId!),
|
||
beautyBusinessApi.appointments.list(tenantId!),
|
||
]);
|
||
return {
|
||
organizations: orgs.length,
|
||
branches: branches.length,
|
||
customers: customers.length,
|
||
appointments: appts.length,
|
||
};
|
||
},
|
||
enabled: !!tenantId && !!meta,
|
||
});
|
||
|
||
if (!meta || !PORTAL_IDS.includes(portal)) {
|
||
return (
|
||
<div className="p-6 text-center">
|
||
<p className="text-secondary">پورتال نامعتبر</p>
|
||
<Link href="/beauty/hub" className="mt-4 inline-block">
|
||
<Button>بازگشت</Button>
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const Icon = meta.icon;
|
||
|
||
return (
|
||
<AuthGuard>
|
||
<div className="min-h-screen bg-[var(--canvas)] p-4 pb-24">
|
||
<div className="mb-6 flex items-center gap-3">
|
||
<Link href="/beauty/hub">
|
||
<Button variant="ghost" size="icon">
|
||
<ChevronLeft className="h-5 w-5" />
|
||
</Button>
|
||
</Link>
|
||
<div className="flex items-center gap-3">
|
||
<div className="rounded-xl bg-[var(--beauty-accent-soft)] p-2 text-[var(--beauty-accent)]">
|
||
<Icon className="h-5 w-5" />
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-secondary">{meta.label}</p>
|
||
<p className="text-xs text-[var(--muted)]">{meta.description}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{!tenantId || statsQ.isLoading ? (
|
||
<BeautyPageLoader />
|
||
) : (
|
||
<AggregateStatsRow
|
||
stats={[
|
||
{ label: "سازمان", value: statsQ.data?.organizations ?? 0 },
|
||
{ label: "شعبه", value: statsQ.data?.branches ?? 0 },
|
||
{ label: "مشتری", value: statsQ.data?.customers ?? 0 },
|
||
{ label: "نوبت", value: statsQ.data?.appointments ?? 0 },
|
||
]}
|
||
/>
|
||
)}
|
||
|
||
<Link
|
||
href={meta.basePath}
|
||
className={cn(
|
||
"mt-8 flex w-full items-center justify-center rounded-2xl bg-[var(--beauty-accent)] px-4 py-4 font-medium text-white shadow-[var(--shadow-md)]"
|
||
)}
|
||
>
|
||
ورود به داشبورد کامل
|
||
</Link>
|
||
</div>
|
||
</AuthGuard>
|
||
);
|
||
}
|