530 lines
21 KiB
TypeScript
530 lines
21 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { Badge, Button, Container } from "@/components/ui";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { useTheme } from "@/hooks/useTheme";
|
||
import {
|
||
loadAssessmentSchema,
|
||
loadCommercialBundles,
|
||
loadCommercialPricing,
|
||
loadCommercialProducts,
|
||
requestRecommendations,
|
||
} from "../adapters";
|
||
import type {
|
||
AdapterResult,
|
||
AssessmentQuestion,
|
||
AssessmentSchema,
|
||
CommercialBundle,
|
||
CommercialPricingItem,
|
||
CommercialProduct,
|
||
RecommendationResult,
|
||
} from "../types";
|
||
import {
|
||
BundleCard,
|
||
CommercialEmptyState,
|
||
ProductCard,
|
||
} from "../components";
|
||
|
||
import {
|
||
loadAssessmentSession,
|
||
saveAssessmentSession,
|
||
type AssessmentSession,
|
||
} from "../session";
|
||
|
||
type WizardStep =
|
||
| "landing"
|
||
| "assessment"
|
||
| "recommendations"
|
||
| "trial_summary"
|
||
| "create_workspace";
|
||
|
||
export type { AssessmentSession };
|
||
export { loadAssessmentSession, saveAssessmentSession };
|
||
|
||
function QuestionField({
|
||
question,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
question: AssessmentQuestion;
|
||
value: unknown;
|
||
onChange: (v: unknown) => void;
|
||
}) {
|
||
const inputClass =
|
||
"mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary";
|
||
|
||
if (question.input_type === "boolean") {
|
||
return (
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(value)}
|
||
onChange={(e) => onChange(e.target.checked)}
|
||
/>
|
||
{question.prompt}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
if (question.input_type === "number") {
|
||
return (
|
||
<label className="block text-sm font-medium text-secondary">
|
||
{question.prompt}
|
||
<input
|
||
type="number"
|
||
className={inputClass}
|
||
value={typeof value === "number" || typeof value === "string" ? value : ""}
|
||
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
if (question.input_type === "multi" && question.options?.length) {
|
||
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||
return (
|
||
<fieldset>
|
||
<legend className="text-sm font-medium text-secondary">{question.prompt}</legend>
|
||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||
{question.options.map((opt) => {
|
||
const on = selected.includes(opt.value);
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
className={`rounded-xl border p-3 text-right text-sm ${
|
||
on ? "border-primary bg-[var(--color-primary-soft)]" : "border-gray-200 bg-white"
|
||
}`}
|
||
onClick={() => {
|
||
onChange(on ? selected.filter((v) => v !== opt.value) : [...selected, opt.value]);
|
||
}}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</fieldset>
|
||
);
|
||
}
|
||
|
||
if (question.input_type === "single" && question.options?.length) {
|
||
return (
|
||
<fieldset>
|
||
<legend className="text-sm font-medium text-secondary">{question.prompt}</legend>
|
||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||
{question.options.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
className={`rounded-xl border p-3 text-right text-sm ${
|
||
value === opt.value
|
||
? "border-primary bg-[var(--color-primary-soft)]"
|
||
: "border-gray-200 bg-white"
|
||
}`}
|
||
onClick={() => onChange(opt.value)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</fieldset>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<label className="block text-sm font-medium text-secondary">
|
||
{question.prompt}
|
||
<input
|
||
className={inputClass}
|
||
value={typeof value === "string" ? value : ""}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function CommercialLandingExperience() {
|
||
const { theme } = useTheme();
|
||
const { isAuthenticated, login, loading: authLoading } = useAuth();
|
||
const router = useRouter();
|
||
const siteName = theme?.site_name ?? "TorbatYar";
|
||
|
||
const [step, setStep] = useState<WizardStep>("landing");
|
||
const [schemaRes, setSchemaRes] = useState<AdapterResult<AssessmentSchema> | null>(null);
|
||
const [productsRes, setProductsRes] = useState<AdapterResult<CommercialProduct[]> | null>(null);
|
||
const [bundlesRes, setBundlesRes] = useState<AdapterResult<CommercialBundle[]> | null>(null);
|
||
const [pricingRes, setPricingRes] = useState<AdapterResult<CommercialPricingItem[]> | null>(null);
|
||
const [recoRes, setRecoRes] = useState<AdapterResult<RecommendationResult> | null>(null);
|
||
const [answers, setAnswers] = useState<Record<string, unknown>>({});
|
||
const [selectedBundle, setSelectedBundle] = useState<string | null>(null);
|
||
const [qIndex, setQIndex] = useState(0);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const session = loadAssessmentSession();
|
||
setAnswers(session.answers || {});
|
||
if (session.selected_bundle_code) setSelectedBundle(session.selected_bundle_code);
|
||
}, []);
|
||
|
||
const persist = useCallback((next: AssessmentSession) => {
|
||
saveAssessmentSession(next);
|
||
}, []);
|
||
|
||
const startAssessment = async () => {
|
||
setBusy(true);
|
||
const [schema, products, bundles, pricing] = await Promise.all([
|
||
loadAssessmentSchema(),
|
||
loadCommercialProducts(),
|
||
loadCommercialBundles(),
|
||
loadCommercialPricing(),
|
||
]);
|
||
setSchemaRes(schema);
|
||
setProductsRes(products);
|
||
setBundlesRes(bundles);
|
||
setPricingRes(pricing);
|
||
setQIndex(0);
|
||
setStep("assessment");
|
||
setBusy(false);
|
||
};
|
||
|
||
const questions = schemaRes?.data.questions ?? [];
|
||
const currentQ = questions[qIndex];
|
||
|
||
const runRecommendations = async () => {
|
||
setBusy(true);
|
||
persist({ answers, selected_bundle_code: selectedBundle });
|
||
const reco = await requestRecommendations(answers);
|
||
setRecoRes(reco);
|
||
setStep("recommendations");
|
||
setBusy(false);
|
||
};
|
||
|
||
const productByCode = useMemo(() => {
|
||
const map = new Map<string, CommercialProduct>();
|
||
(productsRes?.data ?? []).forEach((p) => map.set(p.product_code, p));
|
||
return map;
|
||
}, [productsRes]);
|
||
|
||
const bundleByCode = useMemo(() => {
|
||
const map = new Map<string, CommercialBundle>();
|
||
(bundlesRes?.data ?? []).forEach((b) => map.set(b.bundle_code, b));
|
||
return map;
|
||
}, [bundlesRes]);
|
||
|
||
const recommendedProducts = (recoRes?.data.recommended_products ?? [])
|
||
.map((r) => ({ ...r, product: productByCode.get(r.code) }))
|
||
.filter((r) => r.product);
|
||
|
||
const recommendedBundles = (recoRes?.data.recommended_bundles ?? [])
|
||
.map((r) => ({ ...r, bundle: bundleByCode.get(r.code) }))
|
||
.filter((r) => r.bundle);
|
||
|
||
const goWorkspace = () => {
|
||
persist({
|
||
answers,
|
||
selected_bundle_code: selectedBundle,
|
||
selected_product_codes: recoRes?.data.recommended_products.map((p) => p.code),
|
||
});
|
||
if (isAuthenticated) {
|
||
const intent = typeof answers.business_type_code === "string" ? answers.business_type_code : "";
|
||
router.push(intent ? `/onboarding?intent=${encodeURIComponent(intent)}` : "/onboarding");
|
||
} else {
|
||
setStep("create_workspace");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="pb-16">
|
||
{step === "landing" ? (
|
||
<section className="relative overflow-hidden">
|
||
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-primary-soft)] via-white to-[var(--color-secondary-muted)]" />
|
||
<Container className="relative py-16 sm:py-24">
|
||
<div className="mx-auto max-w-3xl text-center">
|
||
<Badge variant="primary" className="mb-5 px-4 py-1.5 text-sm">
|
||
Commercial SaaS Runtime
|
||
</Badge>
|
||
<h1 className="text-4xl font-extrabold tracking-tight text-secondary sm:text-5xl">
|
||
{siteName}
|
||
</h1>
|
||
<p className="mx-auto mt-5 max-w-2xl text-base leading-8 text-gray-600 sm:text-lg">
|
||
نوع کسبوکار و نیازها را مشخص کنید؛ محصولات، باندل و trial از رجیستری پلتفرم پیشنهاد
|
||
میشود — نه از لیست ثابت اپلیکیشنها.
|
||
</p>
|
||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||
<Button size="lg" loading={busy} loadingText="..." onClick={() => void startAssessment()}>
|
||
شروع ارزیابی کسبوکار
|
||
</Button>
|
||
{isAuthenticated ? (
|
||
<Link href="/dashboard">
|
||
<Button variant="outline" size="lg">
|
||
داشبورد تجاری
|
||
</Button>
|
||
</Link>
|
||
) : (
|
||
<Button variant="outline" size="lg" onClick={() => login()} loading={authLoading}>
|
||
ورود
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<ol className="mx-auto mt-12 grid max-w-3xl gap-2 text-xs text-gray-500 sm:grid-cols-4">
|
||
{["ارزیابی", "پیشنهاد", "Trial", "Workspace"].map((s, i) => (
|
||
<li key={s} className="rounded-xl border border-gray-200 bg-white/80 px-3 py-2">
|
||
{i + 1}. {s}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
</Container>
|
||
</section>
|
||
) : null}
|
||
|
||
{step === "assessment" ? (
|
||
<Container className="py-10">
|
||
<div className="mx-auto max-w-xl">
|
||
<h2 className="text-2xl font-bold text-secondary">ارزیابی نیازها</h2>
|
||
<p className="mt-2 text-sm text-gray-600">
|
||
سؤالات از metadata بکاند بارگذاری میشوند و برای صنایع آینده بدون تغییر frontend گسترش
|
||
مییابند.
|
||
</p>
|
||
|
||
{schemaRes?.availability === "unavailable" || schemaRes?.availability === "empty" ? (
|
||
<div className="mt-6 space-y-4">
|
||
<CommercialEmptyState
|
||
title="اسکیمای ارزیابی در دسترس نیست"
|
||
description={schemaRes.message}
|
||
/>
|
||
<p className="text-xs text-gray-500">
|
||
میتوانید بدون پیشنهاد موتور، ساخت workspace را ادامه دهید. پس از ثبت schema در Admin،
|
||
همین صفحه سؤالات را نشان میدهد.
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" onClick={() => setStep("landing")}>
|
||
بازگشت
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
setStep("recommendations");
|
||
setRecoRes({
|
||
availability: "unavailable",
|
||
data: {
|
||
recommended_products: [],
|
||
recommended_bundles: [],
|
||
recommended_capabilities: [],
|
||
recommended_pricing: [],
|
||
},
|
||
message: schemaRes.message,
|
||
source: "none",
|
||
});
|
||
}}
|
||
>
|
||
ادامه بدون پیشنهاد
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : currentQ ? (
|
||
<div className="mt-6 space-y-4 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm">
|
||
<p className="text-xs text-gray-400">
|
||
سؤال {qIndex + 1} از {questions.length}
|
||
</p>
|
||
<QuestionField
|
||
question={currentQ}
|
||
value={answers[currentQ.question_id]}
|
||
onChange={(v) => {
|
||
const next = { ...answers, [currentQ.question_id]: v };
|
||
setAnswers(next);
|
||
persist({ answers: next, selected_bundle_code: selectedBundle });
|
||
}}
|
||
/>
|
||
<div className="flex flex-wrap gap-2 pt-2">
|
||
<Button variant="outline" disabled={qIndex === 0} onClick={() => setQIndex((i) => i - 1)}>
|
||
قبلی
|
||
</Button>
|
||
{qIndex < questions.length - 1 ? (
|
||
<Button
|
||
onClick={() => setQIndex((i) => i + 1)}
|
||
disabled={currentQ.required && answers[currentQ.question_id] == null}
|
||
>
|
||
بعدی
|
||
</Button>
|
||
) : (
|
||
<Button loading={busy} onClick={() => void runRecommendations()}>
|
||
دریافت پیشنهاد
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Container>
|
||
) : null}
|
||
|
||
{step === "recommendations" ? (
|
||
<Container className="py-10">
|
||
<div className="mx-auto max-w-4xl space-y-8">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-secondary">پیشنهاد محصولات و باندل</h2>
|
||
<p className="mt-2 text-sm text-gray-600">
|
||
نتایج از رجیستری/موتور پیشنهاد؛ در صورت نبود سرویس، داده جعلی نمایش داده نمیشود.
|
||
</p>
|
||
{recoRes?.message ? (
|
||
<p className="mt-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||
{recoRes.message}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<section>
|
||
<h3 className="mb-3 text-sm font-semibold text-secondary">محصولات پیشنهادی</h3>
|
||
{recommendedProducts.length ? (
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{recommendedProducts.map((r) => (
|
||
<ProductCard
|
||
key={r.code}
|
||
product={r.product!}
|
||
recommendationScore={r.score}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : productsRes?.availability === "ready" && productsRes.data.length ? (
|
||
<div className="space-y-3">
|
||
<CommercialEmptyState
|
||
title="پیشنهاد موتور خالی است"
|
||
description="کاتالوگ محصولات کشفشده از رجیستری سرویس نمایش داده میشود."
|
||
/>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{productsRes.data.slice(0, 8).map((p) => (
|
||
<ProductCard key={p.product_code} product={p} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<CommercialEmptyState
|
||
title="محصولی برای نمایش نیست"
|
||
description={productsRes?.message || recoRes?.message}
|
||
/>
|
||
)}
|
||
</section>
|
||
|
||
<section>
|
||
<h3 className="mb-3 text-sm font-semibold text-secondary">باندل پیشنهادی</h3>
|
||
{recommendedBundles.length ? (
|
||
<div className="grid gap-3">
|
||
{recommendedBundles.map((r) => (
|
||
<BundleCard
|
||
key={r.code}
|
||
bundle={r.bundle!}
|
||
recommendationScore={r.score}
|
||
selected={selectedBundle === r.code}
|
||
onSelect={() => {
|
||
setSelectedBundle(r.code);
|
||
persist({ answers, selected_bundle_code: r.code });
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : bundlesRes?.availability === "ready" && bundlesRes.data.length ? (
|
||
<div className="grid gap-3">
|
||
{bundlesRes.data.map((b) => (
|
||
<BundleCard
|
||
key={b.bundle_code}
|
||
bundle={b}
|
||
selected={selectedBundle === b.bundle_code}
|
||
onSelect={() => {
|
||
setSelectedBundle(b.bundle_code);
|
||
persist({ answers, selected_bundle_code: b.bundle_code });
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<CommercialEmptyState
|
||
title="باندلی در رجیستری نیست"
|
||
description={bundlesRes?.message}
|
||
/>
|
||
)}
|
||
</section>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" onClick={() => setStep("assessment")}>
|
||
بازگشت به ارزیابی
|
||
</Button>
|
||
<Button onClick={() => setStep("trial_summary")}>خلاصه Trial</Button>
|
||
</div>
|
||
</div>
|
||
</Container>
|
||
) : null}
|
||
|
||
{step === "trial_summary" ? (
|
||
<Container className="py-10">
|
||
<div className="mx-auto max-w-xl space-y-4 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm">
|
||
<h2 className="text-xl font-bold text-secondary">خلاصه دوره آزمایشی</h2>
|
||
<p className="text-sm text-gray-600">
|
||
جزئیات trial (روز، سهمیه، قوانین) از سیاستها و قیمتگذاری بکاند خوانده میشود. تا زمان
|
||
در دسترس بودن رجیستری، فقط وضعیت آمادگی نمایش داده میشود — بدون اعداد ساختگی.
|
||
</p>
|
||
{pricingRes?.availability === "ready" && pricingRes.data.length ? (
|
||
<ul className="space-y-2 text-sm">
|
||
{pricingRes.data
|
||
.filter((p) => p.pricing_model === "trial" || p.trial_days)
|
||
.map((p) => (
|
||
<li key={p.pricing_item_code} className="rounded-xl bg-gray-50 px-3 py-2">
|
||
{p.display_name}
|
||
{p.trial_days ? ` — ${p.trial_days} روز` : ""}
|
||
<span className="mr-2 font-mono text-[10px] text-gray-400" dir="ltr">
|
||
{p.pricing_item_code}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<CommercialEmptyState
|
||
title="آیتم trial در کاتالوگ قیمت نیست"
|
||
description={pricingRes?.message}
|
||
/>
|
||
)}
|
||
{selectedBundle ? (
|
||
<p className="text-sm">
|
||
باندل انتخابی: <span className="font-semibold">{selectedBundle}</span>
|
||
</p>
|
||
) : (
|
||
<p className="text-xs text-gray-500">باندلی انتخاب نشده است.</p>
|
||
)}
|
||
<Button className="w-full" onClick={goWorkspace}>
|
||
ساخت Workspace
|
||
</Button>
|
||
</div>
|
||
</Container>
|
||
) : null}
|
||
|
||
{step === "create_workspace" ? (
|
||
<Container className="py-10">
|
||
<div className="mx-auto max-w-md space-y-4 rounded-2xl border border-gray-100 bg-white p-6 text-center shadow-sm">
|
||
<h2 className="text-xl font-bold text-secondary">ورود برای ساخت Workspace</h2>
|
||
<p className="text-sm text-gray-600">
|
||
برای ایجاد tenant و داشبورد تجاری وارد شوید. انتخابهای ارزیابی در session نگه داشته
|
||
میشود.
|
||
</p>
|
||
<Button
|
||
className="w-full"
|
||
loading={authLoading}
|
||
onClick={() => {
|
||
persist({ answers, selected_bundle_code: selectedBundle });
|
||
login();
|
||
}}
|
||
>
|
||
ورود / ثبتنام
|
||
</Button>
|
||
<Link href="/onboarding" className="block text-sm text-primary hover:underline">
|
||
اگر وارد شدهاید، ادامه onboarding
|
||
</Link>
|
||
</div>
|
||
</Container>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|