TorbatYar/frontend/modules/commercial/features/landing.tsx
2026-07-28 20:39:10 +03:30

530 lines
21 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 { 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>
);
}