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>
754 lines
26 KiB
TypeScript
754 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useRouter, useSearchParams, useParams } from "next/navigation";
|
||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||
import { useEffect, Suspense, useState } from "react";
|
||
import {
|
||
PageHeader,
|
||
Button,
|
||
Card,
|
||
CardContent,
|
||
Badge,
|
||
LoadingState,
|
||
EmptyState,
|
||
Select,
|
||
Input,
|
||
FormField,
|
||
} from "@/components/ds";
|
||
import { beautyBusinessApi } from "@/modules/beauty/services/beauty-business-api";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { getStoredToken } from "@/lib/auth";
|
||
import {
|
||
CenterCard,
|
||
SalonCard,
|
||
ServiceCard,
|
||
StaffCard,
|
||
SearchResultRow,
|
||
StatWidget,
|
||
TimelineItem,
|
||
Hero,
|
||
PageSection,
|
||
} from "@/modules/beauty/design-system";
|
||
import { BeautyPageError, BeautyPageLoader, useBeautyServiceMeta } from "./shared";
|
||
import { toast } from "sonner";
|
||
|
||
function AuthGateCTA({ returnPath }: { returnPath: string }) {
|
||
return (
|
||
<div className="mx-auto max-w-lg py-20 text-center">
|
||
<h1 className="text-2xl font-bold text-secondary">ورود لازم است</h1>
|
||
<p className="mt-3 text-[var(--muted)]">برای دسترسی به این بخش باید وارد حساب workspace شوید.</p>
|
||
<Link href={`/login?returnUrl=${encodeURIComponent(returnPath)}`} className="mt-6 inline-block">
|
||
<Button size="lg">ورود / ثبتنام</Button>
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function usePublicTenantQueries(enabled: boolean) {
|
||
const { tenantId } = useTenantId();
|
||
const active = enabled && !!tenantId;
|
||
|
||
const orgsQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "public-orgs"],
|
||
queryFn: () => beautyBusinessApi.organizations.list(tenantId!),
|
||
enabled: active,
|
||
});
|
||
const branchesQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "public-branches"],
|
||
queryFn: () => beautyBusinessApi.branches.list(tenantId!),
|
||
enabled: active,
|
||
});
|
||
const servicesQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "public-services"],
|
||
queryFn: () => beautyBusinessApi.services.list(tenantId!),
|
||
enabled: active,
|
||
});
|
||
const staffQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "public-staff"],
|
||
queryFn: () => beautyBusinessApi.staff.list(tenantId!),
|
||
enabled: active,
|
||
});
|
||
const packagesQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "public-packages"],
|
||
queryFn: () => beautyBusinessApi.packageDefinitions.list(tenantId!),
|
||
enabled: active,
|
||
});
|
||
const campaignsQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "public-campaigns"],
|
||
queryFn: () => beautyBusinessApi.marketingCampaigns.list(tenantId!),
|
||
enabled: active,
|
||
});
|
||
|
||
return { tenantId, orgsQ, branchesQ, servicesQ, staffQ, packagesQ, campaignsQ, active };
|
||
}
|
||
|
||
export function PublicHome() {
|
||
const { capsQ } = useBeautyServiceMeta();
|
||
const features = Object.entries(capsQ.data?.features ?? {}).filter(([, v]) => v).slice(0, 6);
|
||
|
||
return (
|
||
<div>
|
||
<Hero
|
||
badge="SaaS زیبایی — تربت بیوتی"
|
||
title="نوبتدهی، سالن و خدمات زیبایی"
|
||
description="پلتفرم یکپارچه برای مشتری، پذیرش، پرسنل و مدیر — متصل به API واقعی beauty-business."
|
||
actions={
|
||
<>
|
||
<Link href="/login?returnUrl=/beauty/hub">
|
||
<Button size="lg">ورود به پورتال</Button>
|
||
</Link>
|
||
<Link href="/beauty/site/book">
|
||
<Button size="lg" variant="outline">
|
||
رزرو نوبت
|
||
</Button>
|
||
</Link>
|
||
</>
|
||
}
|
||
/>
|
||
{features.length > 0 ? (
|
||
<PageSection>
|
||
<h2 className="mb-6 text-center text-xl font-semibold text-secondary">قابلیتهای فعال</h2>
|
||
<div className="flex flex-wrap justify-center gap-2">
|
||
{features.map(([k]) => (
|
||
<Badge key={k} tone="success">
|
||
{k}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</PageSection>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PublicFeatures() {
|
||
const { capsQ } = useBeautyServiceMeta();
|
||
if (capsQ.isLoading) return <BeautyPageLoader />;
|
||
|
||
return (
|
||
<PageSection>
|
||
<PageHeader title="امکانات" description={`فاز ${capsQ.data?.phase} — ${capsQ.data?.commercial_product}`} />
|
||
<ul className="mt-8 grid gap-3 sm:grid-cols-2">
|
||
{Object.entries(capsQ.data?.features ?? {}).map(([key, on]) => (
|
||
<li
|
||
key={key}
|
||
className="flex items-center justify-between rounded-2xl border border-[var(--border)] bg-[var(--surface)] px-4 py-3"
|
||
>
|
||
<span className="text-secondary">{key}</span>
|
||
<Badge tone={on ? "success" : "default"}>{on ? "فعال" : "آینده"}</Badge>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicPricing() {
|
||
const { capsQ, metricsQ } = useBeautyServiceMeta();
|
||
if (capsQ.isLoading) return <BeautyPageLoader />;
|
||
|
||
return (
|
||
<PageSection>
|
||
<PageHeader title="قیمتگذاری" description="بر اساس قابلیتهای فعال سرویس." />
|
||
<Card className="mt-8">
|
||
<CardContent className="space-y-4 pt-6">
|
||
<p className="font-semibold text-secondary">{capsQ.data?.commercial_product}</p>
|
||
<p className="text-sm text-[var(--muted)]">فاز: {capsQ.data?.phase}</p>
|
||
<p className="text-sm text-[var(--muted)]">
|
||
حالت یکپارچگی: {capsQ.data?.independence.integration_mode}
|
||
</p>
|
||
{metricsQ.data?.note ? (
|
||
<p className="text-xs text-[var(--muted)]">{metricsQ.data.note}</p>
|
||
) : null}
|
||
<Link href="/login?returnUrl=/beauty/hub">
|
||
<Button>شروع با workspace</Button>
|
||
</Link>
|
||
</CardContent>
|
||
</Card>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicFaq() {
|
||
const { capsQ } = useBeautyServiceMeta();
|
||
const items = [
|
||
{
|
||
q: "آیا standalone کار میکند؟",
|
||
a: capsQ.data?.independence.standalone ? "بله — standalone فعال است." : "خیر — نیاز به superapp.",
|
||
},
|
||
{
|
||
q: "یکپارچگیها",
|
||
a: (capsQ.data?.independence.integrations ?? []).join("، ") || "—",
|
||
},
|
||
{ q: "نسخه سرویس", a: capsQ.data?.version ?? "—" },
|
||
];
|
||
|
||
return (
|
||
<PageSection className="max-w-3xl">
|
||
<PageHeader title="سوالات متداول" />
|
||
<div className="mt-8 space-y-4">
|
||
{items.map((item) => (
|
||
<Card key={item.q}>
|
||
<CardContent className="pt-5">
|
||
<p className="font-semibold text-secondary">{item.q}</p>
|
||
<p className="mt-2 text-sm text-[var(--muted)]">{item.a}</p>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicAbout() {
|
||
const healthQ = useQuery({
|
||
queryKey: ["beauty", "health"],
|
||
queryFn: () => beautyBusinessApi.health(),
|
||
});
|
||
|
||
if (healthQ.isLoading) return <BeautyPageLoader />;
|
||
|
||
return (
|
||
<PageSection className="max-w-3xl">
|
||
<PageHeader title="درباره تربت بیوتی" description="سرویس SaaS زیبایی تربتیار." />
|
||
<Card className="mt-8">
|
||
<CardContent className="space-y-3 pt-6 text-sm text-[var(--muted)]">
|
||
<p>
|
||
سرویس: <strong className="text-secondary">{healthQ.data?.service}</strong>
|
||
</p>
|
||
<p>
|
||
نسخه: <strong className="text-secondary">{healthQ.data?.version}</strong>
|
||
</p>
|
||
<p>وضعیت: {healthQ.data?.status}</p>
|
||
</CardContent>
|
||
</Card>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicContact() {
|
||
const { capsQ } = useBeautyServiceMeta();
|
||
return (
|
||
<PageSection className="max-w-3xl">
|
||
<PageHeader title="تماس" description="برای دسترسی به workspace با پشتیبانی تربتیار تماس بگیرید." />
|
||
<Card className="mt-8">
|
||
<CardContent className="space-y-3 pt-6">
|
||
<p className="text-sm text-[var(--muted)]">محصول: {capsQ.data?.commercial_product}</p>
|
||
<Link href="/login?returnUrl=/beauty/site/contact">
|
||
<Button>ورود workspace</Button>
|
||
</Link>
|
||
</CardContent>
|
||
</Card>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
function AuthenticatedFindPage({
|
||
title,
|
||
filter,
|
||
returnPath,
|
||
}: {
|
||
title: string;
|
||
filter: (org: { business_format: string }) => boolean;
|
||
returnPath: string;
|
||
}) {
|
||
const authed = !!getStoredToken();
|
||
const { orgsQ, branchesQ, active } = usePublicTenantQueries(authed);
|
||
|
||
if (!authed) return <AuthGateCTA returnPath={returnPath} />;
|
||
if (!active || orgsQ.isLoading || branchesQ.isLoading) return <BeautyPageLoader />;
|
||
if (orgsQ.error) return <BeautyPageError error={orgsQ.error} onRetry={() => orgsQ.refetch()} />;
|
||
|
||
const orgs = (orgsQ.data ?? []).filter(filter);
|
||
const branches = (branchesQ.data ?? []).filter((b) =>
|
||
orgs.some((o) => o.id === b.organization_id)
|
||
);
|
||
|
||
return (
|
||
<PageSection>
|
||
<PageHeader title={title} description="نتایج از API واقعی — پس از ورود." />
|
||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||
{orgs.map((o) => (
|
||
<CenterCard key={o.id} org={o} />
|
||
))}
|
||
{branches.map((b) => (
|
||
<SalonCard key={b.id} branch={b} />
|
||
))}
|
||
</div>
|
||
{orgs.length === 0 && branches.length === 0 ? <EmptyState title="نتیجهای یافت نشد" /> : null}
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicFindSalon() {
|
||
return (
|
||
<AuthenticatedFindPage
|
||
title="یافتن سالن"
|
||
returnPath="/beauty/site/find-salon"
|
||
filter={(o) => o.business_format === "salon" || o.business_format === "mixed"}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function PublicFindLaser() {
|
||
return (
|
||
<AuthenticatedFindPage
|
||
title="یافتن مرکز لیزر"
|
||
returnPath="/beauty/site/find-laser"
|
||
filter={(o) => o.business_format === "clinic" || o.business_format === "mixed"}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function PublicFindSpa() {
|
||
return (
|
||
<AuthenticatedFindPage
|
||
title="یافتن اسپا"
|
||
returnPath="/beauty/site/find-spa"
|
||
filter={(o) => o.business_format === "spa" || o.business_format === "mixed"}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function PublicSearchInner() {
|
||
const sp = useSearchParams();
|
||
const q = (sp.get("q") ?? "").trim().toLowerCase();
|
||
const authed = !!getStoredToken();
|
||
const { orgsQ, branchesQ, servicesQ, staffQ, active } = usePublicTenantQueries(authed);
|
||
|
||
if (!authed) return <AuthGateCTA returnPath={`/beauty/site/search?q=${encodeURIComponent(q)}`} />;
|
||
if (!active || orgsQ.isLoading) return <BeautyPageLoader />;
|
||
|
||
const orgs = (orgsQ.data ?? []).filter(
|
||
(o) => !q || o.name.toLowerCase().includes(q) || o.code.toLowerCase().includes(q)
|
||
);
|
||
const branches = (branchesQ.data ?? []).filter(
|
||
(b) => !q || b.name.toLowerCase().includes(q) || b.code.toLowerCase().includes(q)
|
||
);
|
||
const services = (servicesQ.data ?? []).filter(
|
||
(s) => !q || s.name.toLowerCase().includes(q) || s.code.toLowerCase().includes(q)
|
||
);
|
||
const staff = (staffQ.data ?? []).filter(
|
||
(s) => !q || s.display_name.toLowerCase().includes(q) || s.code.toLowerCase().includes(q)
|
||
);
|
||
|
||
return (
|
||
<PageSection>
|
||
<PageHeader title="جستجو" description={q ? `نتایج برای «${q}»` : "همه نتایج"} />
|
||
<div className="mt-8 space-y-3">
|
||
{orgs.map((o) => (
|
||
<SearchResultRow key={o.id} title={o.name} meta={o.code} href={`/beauty/site/center/${o.id}`} />
|
||
))}
|
||
{branches.map((b) => (
|
||
<SearchResultRow key={b.id} title={b.name} meta={b.code} href={`/beauty/site/salon/${b.id}`} />
|
||
))}
|
||
{services.map((s) => (
|
||
<SearchResultRow key={s.id} title={s.name} meta={s.code} href={`/beauty/site/service/${s.id}`} />
|
||
))}
|
||
{staff.map((s) => (
|
||
<SearchResultRow key={s.id} title={s.display_name} meta={s.kind} href={`/beauty/site/staff/${s.id}`} />
|
||
))}
|
||
</div>
|
||
{orgs.length + branches.length + services.length + staff.length === 0 ? (
|
||
<EmptyState title="نتیجهای یافت نشد" />
|
||
) : null}
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicSearch() {
|
||
return (
|
||
<Suspense fallback={<LoadingState />}>
|
||
<PublicSearchInner />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
export function CenterDetail() {
|
||
const params = useParams();
|
||
const id = String(params.id);
|
||
const { tenantId } = useTenantId();
|
||
const orgQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "center", id],
|
||
queryFn: () => beautyBusinessApi.organizations.get(tenantId!, id),
|
||
enabled: !!tenantId && !!id,
|
||
});
|
||
|
||
if (!tenantId || orgQ.isLoading) return <BeautyPageLoader />;
|
||
if (orgQ.error) return <BeautyPageError error={orgQ.error} onRetry={() => orgQ.refetch()} />;
|
||
|
||
return (
|
||
<PageSection className="max-w-3xl">
|
||
<CenterCard org={orgQ.data!} />
|
||
<Link href="/beauty/site/book" className="mt-6 inline-block">
|
||
<Button>رزرو نوبت</Button>
|
||
</Link>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function SalonDetail() {
|
||
const params = useParams();
|
||
const id = String(params.id);
|
||
const { tenantId } = useTenantId();
|
||
const branchQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "salon", id],
|
||
queryFn: () => beautyBusinessApi.branches.get(tenantId!, id),
|
||
enabled: !!tenantId && !!id,
|
||
});
|
||
|
||
if (!tenantId || branchQ.isLoading) return <BeautyPageLoader />;
|
||
if (branchQ.error) return <BeautyPageError error={branchQ.error} onRetry={() => branchQ.refetch()} />;
|
||
|
||
return (
|
||
<PageSection className="max-w-3xl">
|
||
<SalonCard branch={branchQ.data!} />
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function ServiceDetail() {
|
||
const params = useParams();
|
||
const id = String(params.id);
|
||
const { tenantId } = useTenantId();
|
||
const serviceQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "service", id],
|
||
queryFn: () => beautyBusinessApi.services.get(tenantId!, id),
|
||
enabled: !!tenantId && !!id,
|
||
});
|
||
|
||
if (!tenantId || serviceQ.isLoading) return <BeautyPageLoader />;
|
||
if (serviceQ.error) return <BeautyPageError error={serviceQ.error} onRetry={() => serviceQ.refetch()} />;
|
||
|
||
return (
|
||
<PageSection className="max-w-3xl">
|
||
<ServiceCard service={serviceQ.data!} />
|
||
<Link href="/beauty/site/book" className="mt-6 inline-block">
|
||
<Button>رزرو این خدمت</Button>
|
||
</Link>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function StaffDetail() {
|
||
const params = useParams();
|
||
const id = String(params.id);
|
||
const { tenantId } = useTenantId();
|
||
const staffQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "staff-detail", id],
|
||
queryFn: () => beautyBusinessApi.staff.get(tenantId!, id),
|
||
enabled: !!tenantId && !!id,
|
||
});
|
||
|
||
if (!tenantId || staffQ.isLoading) return <BeautyPageLoader />;
|
||
if (staffQ.error) return <BeautyPageError error={staffQ.error} onRetry={() => staffQ.refetch()} />;
|
||
|
||
return (
|
||
<PageSection className="max-w-3xl">
|
||
<StaffCard staff={staffQ.data!} />
|
||
<Link href="/beauty/site/book" className="mt-6 inline-block">
|
||
<Button>رزرو با این پرسنل</Button>
|
||
</Link>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicBook() {
|
||
const authed = !!getStoredToken();
|
||
const { tenantId } = useTenantId();
|
||
const router = useRouter();
|
||
const [step, setStep] = useState(1);
|
||
const [orgId, setOrgId] = useState("");
|
||
const [branchId, setBranchId] = useState("");
|
||
const [serviceId, setServiceId] = useState("");
|
||
const [staffId, setStaffId] = useState("");
|
||
const [scheduledStart, setScheduledStart] = useState("");
|
||
const [scheduledEnd, setScheduledEnd] = useState("");
|
||
|
||
const orgsQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "book-orgs"],
|
||
queryFn: () => beautyBusinessApi.organizations.list(tenantId!),
|
||
enabled: authed && !!tenantId,
|
||
});
|
||
const branchesQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "book-branches", orgId],
|
||
queryFn: () => beautyBusinessApi.branches.list(tenantId!),
|
||
enabled: authed && !!tenantId && !!orgId,
|
||
select: (rows) => rows.filter((b) => b.organization_id === orgId),
|
||
});
|
||
const servicesQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "book-services", orgId],
|
||
queryFn: () => beautyBusinessApi.services.list(tenantId!),
|
||
enabled: authed && !!tenantId && !!orgId,
|
||
select: (rows) => rows.filter((s) => s.organization_id === orgId),
|
||
});
|
||
const staffQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "book-staff", orgId],
|
||
queryFn: () => beautyBusinessApi.staff.list(tenantId!),
|
||
enabled: authed && !!tenantId && !!orgId,
|
||
select: (rows) => rows.filter((s) => s.organization_id === orgId),
|
||
});
|
||
|
||
const createM = useMutation({
|
||
mutationFn: () =>
|
||
beautyBusinessApi.appointments.create(tenantId!, {
|
||
organization_id: orgId,
|
||
branch_id: branchId,
|
||
code: `WEB-${Date.now()}`,
|
||
scheduled_start: scheduledStart,
|
||
scheduled_end: scheduledEnd,
|
||
service_ref: serviceId || undefined,
|
||
staff_ref: staffId || undefined,
|
||
status: "requested",
|
||
}),
|
||
onSuccess: (appt) => {
|
||
toast.success("نوبت ثبت شد");
|
||
router.push(`/beauty/site/book/success?id=${appt.id}`);
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
if (!authed) return <AuthGateCTA returnPath="/beauty/site/book" />;
|
||
if (!tenantId || orgsQ.isLoading) return <BeautyPageLoader />;
|
||
|
||
return (
|
||
<PageSection className="max-w-2xl">
|
||
<PageHeader title="رزرو نوبت" description={`مرحله ${step} از 5 — org → branch → service → staff → confirm`} />
|
||
<Card className="mt-8">
|
||
<CardContent className="space-y-4 pt-6">
|
||
{step === 1 ? (
|
||
<FormField label="سازمان">
|
||
<Select value={orgId} onChange={(e) => setOrgId(e.target.value)}>
|
||
<option value="">انتخاب کنید</option>
|
||
{(orgsQ.data ?? []).map((o) => (
|
||
<option key={o.id} value={o.id}>
|
||
{o.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
) : null}
|
||
{step === 2 ? (
|
||
<FormField label="شعبه">
|
||
<Select value={branchId} onChange={(e) => setBranchId(e.target.value)}>
|
||
<option value="">انتخاب کنید</option>
|
||
{(branchesQ.data ?? []).map((b) => (
|
||
<option key={b.id} value={b.id}>
|
||
{b.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
) : null}
|
||
{step === 3 ? (
|
||
<FormField label="خدمت">
|
||
<Select value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
|
||
<option value="">انتخاب کنید</option>
|
||
{(servicesQ.data ?? []).map((s) => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
) : null}
|
||
{step === 4 ? (
|
||
<FormField label="پرسنل (اختیاری)">
|
||
<Select value={staffId} onChange={(e) => setStaffId(e.target.value)}>
|
||
<option value="">هر پرسنل</option>
|
||
{(staffQ.data ?? []).map((s) => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.display_name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
) : null}
|
||
{step === 5 ? (
|
||
<>
|
||
<FormField label="شروع">
|
||
<Input type="datetime-local" value={scheduledStart} onChange={(e) => setScheduledStart(e.target.value)} />
|
||
</FormField>
|
||
<FormField label="پایان">
|
||
<Input type="datetime-local" value={scheduledEnd} onChange={(e) => setScheduledEnd(e.target.value)} />
|
||
</FormField>
|
||
</>
|
||
) : null}
|
||
<div className="flex justify-between gap-2 pt-2">
|
||
{step > 1 ? (
|
||
<Button type="button" variant="outline" onClick={() => setStep((s) => s - 1)}>
|
||
قبلی
|
||
</Button>
|
||
) : (
|
||
<span />
|
||
)}
|
||
{step < 5 ? (
|
||
<Button
|
||
type="button"
|
||
onClick={() => setStep((s) => s + 1)}
|
||
disabled={
|
||
(step === 1 && !orgId) ||
|
||
(step === 2 && !branchId) ||
|
||
(step === 3 && !serviceId)
|
||
}
|
||
>
|
||
بعدی
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
disabled={!scheduledStart || !scheduledEnd || createM.isPending}
|
||
onClick={() => createM.mutate()}
|
||
>
|
||
ثبت نوبت
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
function BookSuccessInner() {
|
||
const sp = useSearchParams();
|
||
const id = sp.get("id");
|
||
|
||
return (
|
||
<PageSection className="max-w-lg text-center">
|
||
<h1 className="text-2xl font-bold text-secondary">درخواست ثبت شد</h1>
|
||
<p className="mt-3 text-[var(--muted)]">وضعیت نوبت را از پورتال مشتری یا tracking پیگیری کنید.</p>
|
||
{id ? (
|
||
<Link href={`/beauty/site/tracking/${id}`} className="mt-4 inline-block">
|
||
<Button variant="outline">پیگیری</Button>
|
||
</Link>
|
||
) : null}
|
||
<Link href="/beauty/customer/appointments/upcoming" className="mt-6 inline-block">
|
||
<Button>نوبتهای من</Button>
|
||
</Link>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function BookSuccess() {
|
||
return (
|
||
<Suspense fallback={<LoadingState />}>
|
||
<BookSuccessInner />
|
||
</Suspense>
|
||
);
|
||
}
|
||
|
||
export function Tracking() {
|
||
const params = useParams();
|
||
const id = String(params.id);
|
||
const { tenantId } = useTenantId();
|
||
const apptQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "tracking", id],
|
||
queryFn: () => beautyBusinessApi.appointments.get(tenantId!, id),
|
||
enabled: !!tenantId && !!id,
|
||
});
|
||
|
||
if (!tenantId || apptQ.isLoading) return <BeautyPageLoader />;
|
||
if (apptQ.error) return <BeautyPageError error={apptQ.error} onRetry={() => apptQ.refetch()} />;
|
||
|
||
const a = apptQ.data!;
|
||
const steps = [
|
||
{ title: "ثبت", time: a.created_at, active: true },
|
||
{ title: "زمانبندی", time: a.scheduled_start, active: a.status !== "requested" },
|
||
{ title: "وضعیت", time: a.updated_at, active: ["confirmed", "completed"].includes(a.status) },
|
||
];
|
||
|
||
return (
|
||
<PageSection className="max-w-lg">
|
||
<PageHeader title={`پیگیری ${a.code}`} description={`وضعیت: ${a.status}`} />
|
||
<div className="mt-8">
|
||
{steps.map((s) => (
|
||
<TimelineItem
|
||
key={s.title}
|
||
title={s.title}
|
||
time={new Date(s.time).toLocaleString("fa-IR")}
|
||
active={s.active}
|
||
/>
|
||
))}
|
||
</div>
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicReviews() {
|
||
const authed = !!getStoredToken();
|
||
const { campaignsQ, active } = usePublicTenantQueries(authed);
|
||
if (!authed) return <AuthGateCTA returnPath="/beauty/site/reviews" />;
|
||
if (!active || campaignsQ.isLoading) return <BeautyPageLoader />;
|
||
|
||
return (
|
||
<PageSection>
|
||
<PageHeader title="نظرات و بازخورد" description="کمپینهای بازاریابی مرتبط." />
|
||
<EmptyState title="ماژول نظرات — از کمپینهای فعال استفاده کنید" />
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicGallery() {
|
||
const authed = !!getStoredToken();
|
||
const { servicesQ, active } = usePublicTenantQueries(authed);
|
||
if (!authed) return <AuthGateCTA returnPath="/beauty/site/gallery" />;
|
||
if (!active || servicesQ.isLoading) return <BeautyPageLoader />;
|
||
|
||
return (
|
||
<PageSection>
|
||
<PageHeader title="گالری خدمات" description="نمونه خدمات از کاتالوگ." />
|
||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||
{(servicesQ.data ?? []).map((s) => (
|
||
<ServiceCard key={s.id} service={s} />
|
||
))}
|
||
</div>
|
||
{(servicesQ.data ?? []).length === 0 ? <EmptyState title="خدمتی ثبت نشده" /> : null}
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicPackages() {
|
||
const authed = !!getStoredToken();
|
||
const { packagesQ, active } = usePublicTenantQueries(authed);
|
||
if (!authed) return <AuthGateCTA returnPath="/beauty/site/packages" />;
|
||
if (!active || packagesQ.isLoading) return <BeautyPageLoader />;
|
||
|
||
return (
|
||
<PageSection>
|
||
<PageHeader title="پکیجها" description="پکیجهای فعال سالن." />
|
||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||
{(packagesQ.data ?? []).map((p) => (
|
||
<Card key={p.id}>
|
||
<CardContent className="pt-5">
|
||
<p className="font-semibold text-secondary">{p.name}</p>
|
||
<p className="mt-1 text-sm text-[var(--muted)]">{p.total_sessions} جلسه</p>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
{(packagesQ.data ?? []).length === 0 ? <EmptyState title="پکیجی ثبت نشده" /> : null}
|
||
</PageSection>
|
||
);
|
||
}
|
||
|
||
export function PublicCampaigns() {
|
||
const authed = !!getStoredToken();
|
||
const { campaignsQ, active } = usePublicTenantQueries(authed);
|
||
if (!authed) return <AuthGateCTA returnPath="/beauty/site/campaigns" />;
|
||
if (!active || campaignsQ.isLoading) return <BeautyPageLoader />;
|
||
|
||
return (
|
||
<PageSection>
|
||
<PageHeader title="کمپینها" description="کمپینهای بازاریابی فعال." />
|
||
<div className="mt-8 space-y-3">
|
||
{(campaignsQ.data ?? []).map((c) => (
|
||
<SearchResultRow key={c.id} title={c.name} meta={c.channel ?? c.code} href={`/beauty/marketing/campaigns`} />
|
||
))}
|
||
</div>
|
||
{(campaignsQ.data ?? []).length === 0 ? <EmptyState title="کمپینی ثبت نشده" /> : null}
|
||
</PageSection>
|
||
);
|
||
}
|