TorbatYar/frontend/components/TenantSitePage.tsx
2026-07-28 20:39:10 +03:30

256 lines
8.7 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Badge, Button, Container } from "@/components/ui";
import { useAuth } from "@/hooks/useAuth";
import { useMe } from "@/hooks/useMe";
import { useTenantHost } from "@/hooks/useTenantHost";
import { api, type PublicTenantSite } from "@/lib/api";
import { applyTheme } from "@/lib/theme";
import { loadCommercialProducts } from "@/modules/commercial/adapters";
import { CommercialEmptyState, ProductCard } from "@/modules/commercial/components";
import type { CommercialProduct } from "@/modules/commercial/types";
/**
* صفحه ساب‌دامین tenant:
* - مهمان / غیرعضو → لندینگ تمپلیت برند کسب‌وکار
* - عضو/مالک لاگین‌شده → دسترسی به سرویس‌ها و داشبورد
*/
export function TenantSitePage() {
const host = useTenantHost();
const { isAuthenticated, login, loading: authLoading, signOut, user } = useAuth();
const { me, loading: meLoading } = useMe();
const [site, setSite] = useState<PublicTenantSite | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError("");
try {
const data = await api.public.tenantSite({
slug: host.slug,
host: host.hostname,
});
if (cancelled) return;
setSite(data);
applyTheme({
site_name: data.name,
primary_color: data.primary_color || "#0284c7",
secondary_color: data.secondary_color || "#0f172a",
logo_url: data.logo_url || "",
support_email: "",
});
if (typeof document !== "undefined") {
document.title = data.name;
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "بارگذاری سایت ناموفق بود");
setSite(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [host.slug, host.hostname]);
const membership = useMemo(() => {
if (!me || !site) return null;
return me.memberships.find((m) => m.tenant_slug === site.slug) ?? null;
}, [me, site]);
const isMember = Boolean(isAuthenticated && membership);
const isOwner = Boolean(membership?.is_owner);
if (loading || (isAuthenticated && meLoading)) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (error || !site) {
return (
<Container className="py-20 text-center">
<h1 className="text-2xl font-bold text-secondary">سایت یافت نشد</h1>
<p className="mt-3 text-gray-600">{error || "این دامنه به workspace فعالی وصل نیست."}</p>
<a href={`https://${host.baseDomain}`} className="mt-8 inline-block text-primary underline">
بازگشت به تربتیار
</a>
</Container>
);
}
if (isMember) {
return (
<TenantMemberHome
site={site}
isOwner={isOwner}
role={membership?.role}
userLabel={user?.username || user?.email || me?.mobile || "کاربر"}
onSignOut={signOut}
/>
);
}
return (
<TenantPublicLanding
site={site}
onLogin={() => login()}
authLoading={authLoading}
platformUrl={`https://${host.baseDomain}`}
/>
);
}
function TenantPublicLanding({
site,
onLogin,
authLoading,
platformUrl,
}: {
site: PublicTenantSite;
onLogin: () => void;
authLoading: boolean;
platformUrl: string;
}) {
const initial = site.name.trim().charAt(0) || "ت";
return (
<div className="relative min-h-[calc(100vh-4rem)] overflow-hidden">
<div
className="pointer-events-none absolute inset-0 opacity-90"
style={{
background: `radial-gradient(ellipse 80% 60% at 50% -10%, var(--color-primary-muted), transparent),
linear-gradient(180deg, var(--color-primary-soft) 0%, #fff 55%)`,
}}
/>
<Container className="relative flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center py-16 text-center">
<div className="mb-6 flex h-20 w-20 items-center justify-center overflow-hidden rounded-3xl bg-primary text-3xl font-bold text-white shadow-lg shadow-primary/25">
{site.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={site.logo_url} alt="" className="h-full w-full object-cover" />
) : (
initial
)}
</div>
{site.business_type && (
<Badge variant="primary" className="mb-4">
{site.business_type}
</Badge>
)}
<h1 className="max-w-2xl text-4xl font-extrabold leading-tight text-secondary sm:text-5xl">
{site.name}
</h1>
<p className="mt-4 max-w-xl text-base leading-8 text-gray-600 sm:text-lg">
{site.tagline}. بهزودی صفحه عمومی و سرویسهای این کسبوکار اینجا تکمیل میشود.
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<Button size="lg" onClick={onLogin} loading={authLoading} loadingText="...">
ورود مالک / همکاران
</Button>
<a href={platformUrl}>
<Button size="lg" variant="outline">
درباره تربتیار
</Button>
</a>
</div>
{site.primary_domain && (
<p dir="ltr" className="mt-10 font-mono text-xs text-gray-400">
{site.primary_domain}
</p>
)}
</Container>
</div>
);
}
function TenantMemberHome({
site,
isOwner,
role,
userLabel,
onSignOut,
}: {
site: PublicTenantSite;
isOwner: boolean;
role?: string;
userLabel: string;
onSignOut: () => void;
}) {
return (
<div className="pb-16">
<section className="border-b border-gray-100 bg-gradient-to-b from-[var(--color-primary-soft)] to-white">
<Container className="py-10 sm:py-12">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<Badge variant="primary" className="mb-3">
{isOwner ? "مالک workspace" : role || "عضو"}
</Badge>
<h1 className="text-3xl font-extrabold text-secondary">{site.name}</h1>
<p className="mt-2 text-sm text-gray-600">
خوش آمدید {userLabel} سرویسهای تربتیار برای این کسبوکار
</p>
{site.primary_domain && (
<p dir="ltr" className="mt-2 font-mono text-xs text-gray-400">
{site.primary_domain}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard">
<Button variant="primary">داشبورد workspace</Button>
</Link>
<Link href="/dashboard/settings">
<Button variant="outline">تنظیمات</Button>
</Link>
<Button variant="ghost" onClick={onSignOut}>
خروج
</Button>
</div>
</div>
</Container>
</section>
<TenantDiscoveredApps />
</div>
);
}
function TenantDiscoveredApps() {
const [products, setProducts] = useState<CommercialProduct[]>([]);
const [message, setMessage] = useState<string | undefined>();
useEffect(() => {
void loadCommercialProducts().then((r) => {
setProducts(r.data.filter((p) => p.visibility !== "hidden"));
setMessage(r.message);
});
}, []);
return (
<Container className="py-10">
<h2 className="mb-2 text-xl font-bold text-secondary">محصولات کشفشده</h2>
<p className="mb-8 text-sm text-gray-600">
از رجیستری سرویس/محصول بدون آرایه ثابت frontend.
</p>
{products.length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<ProductCard key={p.product_code} product={p} />
))}
</div>
) : (
<CommercialEmptyState title="محصولی کشف نشد" description={message} />
)}
</Container>
);
}