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>
439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|