460 lines
17 KiB
TypeScript
460 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, Suspense, useEffect, useMemo, useState } from "react";
|
||
import { useRouter, useSearchParams } 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";
|
||
import { FeatureLock } from "@/modules/commercial/components";
|
||
import { evaluateDomainPolicy, loadWorkspaceSnapshot } from "@/modules/commercial/adapters";
|
||
import { commercialPost } from "@/modules/commercial/adapters/http";
|
||
import {
|
||
loadAssessmentSession,
|
||
saveAssessmentSession,
|
||
} from "@/modules/commercial/session";
|
||
|
||
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 searchParams = useSearchParams();
|
||
const intentParam = searchParams.get("intent");
|
||
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 [domainUnlocked, setDomainUnlocked] = useState(false);
|
||
const [domainReason, setDomainReason] = useState<string | null>(null);
|
||
|
||
const session = useMemo(() => loadAssessmentSession(), []);
|
||
const [businessName, setBusinessName] = useState("");
|
||
const [slug, setSlug] = useState("");
|
||
const [slugTouched, setSlugTouched] = useState(false);
|
||
const [businessType, setBusinessType] = useState(
|
||
(typeof session.answers.business_type_code === "string"
|
||
? session.answers.business_type_code
|
||
: intentParam) || ""
|
||
);
|
||
|
||
const [primaryColor, setPrimaryColor] = useState("#0284c7");
|
||
const [secondaryColor, setSecondaryColor] = useState("#0f172a");
|
||
const [logoUrl, setLogoUrl] = useState("");
|
||
const [customDomain, setCustomDomain] = useState("");
|
||
|
||
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(async (tenantCtx) => {
|
||
setCtx(tenantCtx);
|
||
const snap = await loadWorkspaceSnapshot();
|
||
if (snap.data) {
|
||
setDomainUnlocked(snap.data.custom_domain_unlocked);
|
||
setDomainReason(snap.data.domain_policy?.reason ?? null);
|
||
} else {
|
||
const pol = await evaluateDomainPolicy({
|
||
tenant_id: tenantCtx.tenant.id,
|
||
subscription_status: tenantCtx.subscription_status,
|
||
});
|
||
setDomainUnlocked(Boolean(pol.data.custom_domain_allowed));
|
||
setDomainReason(pol.data.reason ?? null);
|
||
}
|
||
setStep("branding");
|
||
})
|
||
.catch(() => undefined)
|
||
.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 || undefined,
|
||
});
|
||
setCtx(result);
|
||
const pol = await evaluateDomainPolicy({
|
||
tenant_id: result.tenant.id,
|
||
subscription_status: result.subscription_status,
|
||
});
|
||
setDomainUnlocked(Boolean(pol.data.custom_domain_allowed));
|
||
setDomainReason(pol.data.reason ?? null);
|
||
saveAssessmentSession({
|
||
...loadAssessmentSession(),
|
||
answers: {
|
||
...loadAssessmentSession().answers,
|
||
business_type_code: businessType,
|
||
},
|
||
});
|
||
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()) {
|
||
if (!domainUnlocked) {
|
||
setError(
|
||
domainReason ||
|
||
"دامنه اختصاصی بر اساس سیاست تجاری قفل است. از زیردامنه پلتفرم استفاده کنید."
|
||
);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
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);
|
||
const session = loadAssessmentSession();
|
||
if (session.activate_trial || session.selected_bundle_code) {
|
||
await commercialPost("/api/v1/commercial/subscriptions", {
|
||
tenant_id: ctx.tenant.id,
|
||
mode: "trial",
|
||
status: "trialing",
|
||
bundle_code: session.selected_bundle_code || undefined,
|
||
answers: session.answers,
|
||
}).catch(() => null);
|
||
}
|
||
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>
|
||
);
|
||
}
|
||
|
||
const selectedBundle = loadAssessmentSession().selected_bundle_code;
|
||
|
||
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">پس از تکمیل به داشبورد تجاری هدایت میشوید.</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>
|
||
) : null}
|
||
|
||
<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>
|
||
<p className="text-xs text-gray-500" dir="ltr">
|
||
{slug || "slug"}.torbatyar.ir
|
||
</p>
|
||
<label className="block text-sm font-medium text-secondary">
|
||
نوع کسبوکار (از ارزیابی / metadata)
|
||
<input
|
||
value={businessType}
|
||
onChange={(e) => setBusinessType(e.target.value)}
|
||
disabled={loading}
|
||
className={inputClass}
|
||
placeholder="کد نوع از رجیستری ارزیابی"
|
||
/>
|
||
</label>
|
||
{selectedBundle ? (
|
||
<p className="rounded-xl bg-gray-50 px-3 py-2 text-xs text-secondary">
|
||
باندل session: <span className="font-mono">{selectedBundle}</span>
|
||
</p>
|
||
) : null}
|
||
<Button type="submit" className="w-full" loading={loading} loadingText="در حال ایجاد...">
|
||
ادامه
|
||
</Button>
|
||
</form>
|
||
) : null}
|
||
|
||
{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 border border-gray-200"
|
||
/>
|
||
<input
|
||
dir="ltr"
|
||
value={primaryColor}
|
||
onChange={(e) => setPrimaryColor(e.target.value)}
|
||
disabled={loading}
|
||
className={`${inputClass} 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 border border-gray-200"
|
||
/>
|
||
<input
|
||
dir="ltr"
|
||
value={secondaryColor}
|
||
onChange={(e) => setSecondaryColor(e.target.value)}
|
||
disabled={loading}
|
||
className={`${inputClass} 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}
|
||
placeholder="https://..."
|
||
/>
|
||
</label>
|
||
<Button type="submit" className="w-full" loading={loading} loadingText="در حال ذخیره...">
|
||
ادامه
|
||
</Button>
|
||
</form>
|
||
) : null}
|
||
|
||
{step === "domain" && ctx ? (
|
||
<form onSubmit={handleDomain} className="space-y-4">
|
||
<p className="rounded-xl bg-sky-50 px-3 py-2 text-xs text-sky-900">
|
||
زیردامنه فعال:{" "}
|
||
<span className="font-mono" dir="ltr">
|
||
{ctx.tenant.slug}.torbatyar.ir
|
||
</span>
|
||
</p>
|
||
<FeatureLock
|
||
locked={!domainUnlocked}
|
||
reason={
|
||
domainReason ||
|
||
"دامنه اختصاصی بر اساس سیاست تجاری قفل است."
|
||
}
|
||
fallback={
|
||
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
|
||
{domainReason ||
|
||
"دامنه اختصاصی قفل است — سیاست تجاری را در Billing/Admin بررسی کنید."}
|
||
</div>
|
||
}
|
||
>
|
||
<label className="block text-sm font-medium text-secondary">
|
||
دامنه اختصاصی
|
||
<input
|
||
dir="ltr"
|
||
value={customDomain}
|
||
onChange={(e) => setCustomDomain(e.target.value)}
|
||
disabled={loading || !domainUnlocked}
|
||
className={inputClass}
|
||
placeholder="shop.example.com"
|
||
/>
|
||
</label>
|
||
</FeatureLock>
|
||
<Button type="submit" className="w-full" loading={loading} loadingText="در حال ذخیره...">
|
||
ادامه
|
||
</Button>
|
||
</form>
|
||
) : null}
|
||
|
||
{step === "review" && ctx ? (
|
||
<div className="space-y-4">
|
||
<div className="rounded-xl bg-gray-50 p-4 text-sm">
|
||
<p>
|
||
<span className="text-gray-500">نام:</span> {ctx.tenant.name}
|
||
</p>
|
||
<p className="mt-1">
|
||
<span className="text-gray-500">slug:</span>{" "}
|
||
<span dir="ltr" className="font-mono">
|
||
{ctx.tenant.slug}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
className="w-full"
|
||
loading={loading}
|
||
loadingText="در حال تکمیل..."
|
||
onClick={handleComplete}
|
||
>
|
||
تکمیل و ورود به داشبورد تجاری
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</Container>
|
||
);
|
||
}
|
||
|
||
export default function OnboardingPage() {
|
||
return (
|
||
<AuthGuard>
|
||
<Suspense
|
||
fallback={
|
||
<Container className="py-16 text-center">
|
||
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
|
||
</Container>
|
||
}
|
||
>
|
||
<OnboardingWizard />
|
||
</Suspense>
|
||
</AuthGuard>
|
||
);
|
||
}
|