TorbatYar/frontend/app/onboarding/page.tsx
2026-07-28 20:39:10 +03:30

460 lines
17 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, 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>
);
}