TorbatYar/frontend/app/onboarding/page.tsx
Mortezakoohjani 800b0ba2c5 Deploy TorbatYar for torbatyar.ir with nginx multi-tenant routing.
Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 21:43:33 +03:30

439 lines
15 KiB
TypeScript
Raw 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 { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { AuthGuard } from "@/components/AuthGuard";
import { Button, Container } from "@/components/ui";
import { api, ApiError, type TenantContext } from "@/lib/api";
import { useMe } from "@/hooks/useMe";
type Step = "business" | "branding" | "domain" | "review";
const STEPS: { id: Step; label: string }[] = [
{ id: "business", label: "اطلاعات کسب‌وکار" },
{ id: "branding", label: "برندینگ" },
{ id: "domain", label: "دامنه" },
{ id: "review", label: "بازبینی و تکمیل" },
];
function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
const inputClass =
"mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-gray-50";
function StepIndicator({ current }: { current: Step }) {
const currentIndex = STEPS.findIndex((s) => s.id === current);
return (
<ol className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
{STEPS.map((step, index) => {
const isActive = step.id === current;
const isDone = index < currentIndex;
return (
<li key={step.id} className="flex items-center gap-2">
<span
className={`flex h-7 w-7 items-center justify-center rounded-full font-bold ${
isActive
? "bg-primary text-white"
: isDone
? "bg-[var(--color-primary-muted)] text-primary"
: "bg-gray-100 text-gray-400"
}`}
>
{index + 1}
</span>
<span className={isActive ? "font-semibold text-secondary" : "text-gray-500"}>
{step.label}
</span>
{index < STEPS.length - 1 && <span className="mx-1 text-gray-300">/</span>}
</li>
);
})}
</ol>
);
}
function OnboardingWizard() {
const router = useRouter();
const { me, loading: meLoading } = useMe();
const [step, setStep] = useState<Step>("business");
const [ctx, setCtx] = useState<TenantContext | null>(null);
const [bootstrapping, setBootstrapping] = useState(true);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
// فرم گام ۱
const [businessName, setBusinessName] = useState("");
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [businessType, setBusinessType] = useState("");
// فرم گام ۲
const [primaryColor, setPrimaryColor] = useState("#0284c7");
const [secondaryColor, setSecondaryColor] = useState("#0f172a");
const [logoUrl, setLogoUrl] = useState("");
// فرم گام ۳
const [customDomain, setCustomDomain] = useState("");
// اگر کاربر tenant ناتمام داشته باشد، از همان‌جا ادامه بده؛ اگر onboarding کامل است برو به داشبورد.
useEffect(() => {
if (meLoading) return;
if (!me) {
setBootstrapping(false);
return;
}
if (!me.onboarding_required) {
router.replace("/dashboard");
return;
}
if (me.current_tenant_id) {
api.tenantContext
.current()
.then((tenantCtx) => {
setCtx(tenantCtx);
setStep("branding");
})
.catch(() => {
/* اگر دریافت نشد، از ابتدا شروع کن */
})
.finally(() => setBootstrapping(false));
} else {
setBootstrapping(false);
}
}, [me, meLoading, router]);
const handleBusinessNameChange = (value: string) => {
setBusinessName(value);
if (!slugTouched) setSlug(slugify(value));
};
const handleCreateTenant = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const result = await api.onboarding.createTenant({
business_name: businessName,
slug,
business_type: businessType.trim() || undefined,
});
setCtx(result);
setStep("branding");
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ایجاد workspace ناموفق بود"
);
} finally {
setLoading(false);
}
};
const handleBranding = async (e: FormEvent) => {
e.preventDefault();
if (!ctx) return;
setError("");
setLoading(true);
try {
const result = await api.onboarding.updateBranding(ctx.tenant.id, {
primary_color: primaryColor || undefined,
secondary_color: secondaryColor || undefined,
logo_url: logoUrl.trim() || undefined,
});
setCtx(result);
setStep("domain");
} catch (err) {
setError(err instanceof ApiError ? err.message : "ذخیره برندینگ ناموفق بود");
} finally {
setLoading(false);
}
};
const handleDomain = async (e: FormEvent) => {
e.preventDefault();
if (!ctx) return;
setError("");
setLoading(true);
try {
let result = ctx;
if (customDomain.trim()) {
result = await api.onboarding.updateDomain(ctx.tenant.id, {
custom_domain: customDomain.trim(),
});
setCtx(result);
}
setStep("review");
} catch (err) {
setError(err instanceof ApiError ? err.message : "ثبت دامنه ناموفق بود");
} finally {
setLoading(false);
}
};
const handleComplete = async () => {
if (!ctx) return;
setError("");
setLoading(true);
try {
await api.onboarding.complete(ctx.tenant.id);
router.push("/dashboard");
} catch (err) {
setError(err instanceof ApiError ? err.message : "تکمیل onboarding ناموفق بود");
setLoading(false);
}
};
if (meLoading || bootstrapping) {
return (
<Container className="py-16 text-center">
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
</Container>
);
}
return (
<Container className="py-10">
<div className="mx-auto max-w-xl">
<h1 className="text-2xl font-bold text-secondary">راهاندازی workspace</h1>
<p className="mt-2 text-sm text-gray-600">
چند مرحله ساده تا آمادهشدن workspace شما باقی مانده است.
</p>
<div className="mt-6">
<StepIndicator current={step} />
</div>
{error && (
<p className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</p>
)}
<div className="mt-6 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm">
{step === "business" && (
<form onSubmit={handleCreateTenant} className="space-y-4">
<label className="block text-sm font-medium text-secondary">
نام کسبوکار
<input
required
value={businessName}
onChange={(e) => handleBusinessNameChange(e.target.value)}
disabled={loading}
className={inputClass}
placeholder="مثلاً کافه تربت"
/>
</label>
<label className="block text-sm font-medium text-secondary">
آدرس (slug)
<input
required
dir="ltr"
value={slug}
onChange={(e) => {
setSlugTouched(true);
setSlug(e.target.value);
}}
disabled={loading}
className={`${inputClass} font-mono`}
placeholder="cafe-torbat"
/>
</label>
<label className="block text-sm font-medium text-secondary">
نوع کسبوکار (اختیاری)
<input
value={businessType}
onChange={(e) => setBusinessType(e.target.value)}
disabled={loading}
className={inputClass}
placeholder="مثلاً کافه، فروشگاه، خدمات"
/>
</label>
<Button
type="submit"
className="w-full"
loading={loading}
loadingText="در حال ایجاد..."
>
ادامه
</Button>
</form>
)}
{step === "branding" && ctx && (
<form onSubmit={handleBranding} className="space-y-4">
<label className="block text-sm font-medium text-secondary">
رنگ اصلی
<div className="mt-1 flex items-center gap-3">
<input
type="color"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
disabled={loading}
className="h-10 w-14 cursor-pointer rounded-lg border border-gray-200"
/>
<input
dir="ltr"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
disabled={loading}
className={`${inputClass} mt-0 font-mono`}
/>
</div>
</label>
<label className="block text-sm font-medium text-secondary">
رنگ فرعی
<div className="mt-1 flex items-center gap-3">
<input
type="color"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
disabled={loading}
className="h-10 w-14 cursor-pointer rounded-lg border border-gray-200"
/>
<input
dir="ltr"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
disabled={loading}
className={`${inputClass} mt-0 font-mono`}
/>
</div>
</label>
<label className="block text-sm font-medium text-secondary">
آدرس لوگو (اختیاری)
<input
dir="ltr"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
disabled={loading}
className={`${inputClass} font-mono`}
placeholder="https://example.com/logo.png"
/>
</label>
<div className="flex gap-3">
<Button
type="submit"
className="w-full"
loading={loading}
loadingText="در حال ذخیره..."
>
ادامه
</Button>
</div>
</form>
)}
{step === "domain" && ctx && (
<form onSubmit={handleDomain} className="space-y-4">
<div>
<p className="text-sm font-medium text-secondary">زیردامنه اختصاصی</p>
<p
dir="ltr"
className="mt-1 w-full rounded-xl border border-gray-200 bg-gray-50 px-4 py-2.5 font-mono text-gray-600"
>
{ctx.primary_domain || `${ctx.tenant.slug}.your-domain`}
</p>
</div>
<label className="block text-sm font-medium text-secondary">
دامنه اختصاصی سفارشی (اختیاری)
<input
dir="ltr"
value={customDomain}
onChange={(e) => setCustomDomain(e.target.value)}
disabled={loading}
className={`${inputClass} font-mono`}
placeholder="shop.example.com"
/>
</label>
<Button
type="submit"
className="w-full"
loading={loading}
loadingText="در حال ذخیره..."
>
ادامه
</Button>
</form>
)}
{step === "review" && ctx && (
<div className="space-y-4">
<div className="rounded-xl bg-gray-50 p-4 text-sm">
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-gray-500">نام کسبوکار</dt>
<dd className="font-semibold text-secondary">{ctx.tenant.name}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">آدرس</dt>
<dd dir="ltr" className="font-mono text-secondary">
{ctx.tenant.slug}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">زیردامنه</dt>
<dd dir="ltr" className="font-mono text-secondary">
{ctx.primary_domain || "—"}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">پلن</dt>
<dd className="font-semibold text-secondary">
{ctx.plan_name || "—"}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-gray-500">رنگ اصلی</dt>
<dd className="flex items-center gap-2">
<span
className="inline-block h-4 w-4 rounded-full border border-gray-200"
style={{ backgroundColor: ctx.tenant.primary_color || "#0284c7" }}
/>
<span dir="ltr" className="font-mono text-secondary">
{ctx.tenant.primary_color || "—"}
</span>
</dd>
</div>
</dl>
</div>
<Button
onClick={handleComplete}
className="w-full"
loading={loading}
loadingText="در حال فعال‌سازی..."
>
تکمیل و ورود به داشبورد
</Button>
</div>
)}
</div>
</div>
</Container>
);
}
export default function OnboardingPage() {
return (
<AuthGuard>
<OnboardingWizard />
</AuthGuard>
);
}