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

615 lines
24 KiB
TypeScript
Raw Permalink 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, useSearchParams } from "next/navigation";
import { Badge, Button, Container } from "@/components/ui";
import { useAuth } from "@/hooks/useAuth";
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 =
| "assessment"
| "recommendations"
| "bundles"
| "pricing"
| "trial_summary"
| "create_workspace";
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 bg-white px-4 py-2.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary dark:border-gray-700 dark:bg-gray-900";
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 dark:text-gray-100">
{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.input_type === "single") && question.options?.length) {
const multi = question.input_type === "multi";
const selected = multi ? (Array.isArray(value) ? (value as string[]) : []) : null;
return (
<fieldset>
<legend className="text-sm font-medium text-secondary dark:text-gray-100">
{question.prompt}
</legend>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{question.options.map((opt) => {
const on = multi ? selected!.includes(opt.value) : value === opt.value;
return (
<button
key={opt.value}
type="button"
className={`rounded-2xl border p-4 text-right text-sm transition ${
on
? "border-primary bg-[var(--color-primary-soft)] shadow-sm"
: "border-gray-200 bg-white hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900"
}`}
onClick={() => {
if (multi) {
onChange(
on ? selected!.filter((v) => v !== opt.value) : [...selected!, opt.value]
);
} else onChange(opt.value);
}}
>
{opt.label}
</button>
);
})}
</div>
</fieldset>
);
}
return (
<label className="block text-sm font-medium text-secondary dark:text-gray-100">
{question.prompt}
<input
className={inputClass}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
const STEPS: { id: WizardStep; label: string }[] = [
{ id: "assessment", label: "کشف" },
{ id: "recommendations", label: "پیشنهاد" },
{ id: "bundles", label: "باندل" },
{ id: "pricing", label: "قیمت" },
{ id: "trial_summary", label: "Trial" },
];
export function CommercialDiscoverExperience() {
const { isAuthenticated, login, loading: authLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const typeParam = searchParams.get("type");
const [step, setStep] = useState<WizardStep>("assessment");
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);
const [booted, setBooted] = useState(false);
useEffect(() => {
const session = loadAssessmentSession();
const nextAnswers = { ...session.answers };
if (typeParam && !nextAnswers.business_type_code) {
nextAnswers.business_type_code = typeParam;
}
setAnswers(nextAnswers);
if (session.selected_bundle_code) setSelectedBundle(session.selected_bundle_code);
void (async () => {
setBusy(true);
const [schema, products, bundles, pricing] = await Promise.all([
loadAssessmentSchema(),
loadCommercialProducts(),
loadCommercialBundles(),
loadCommercialPricing(),
]);
setSchemaRes(schema);
setProductsRes(products);
setBundlesRes(bundles);
setPricingRes(pricing);
setBusy(false);
setBooted(true);
})();
}, [typeParam]);
const persist = useCallback((next: AssessmentSession) => {
saveAssessmentSession(next);
}, []);
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);
const top = reco.data.recommended_bundles[0]?.code;
if (top && !selectedBundle) {
setSelectedBundle(top);
persist({ answers, selected_bundle_code: top });
}
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 optionalProducts = (productsRes?.data ?? []).filter(
(p) => !recommendedProducts.some((r) => r.code === p.product_code) && p.visibility !== "hidden"
);
const recommendedBundles = (recoRes?.data.recommended_bundles ?? [])
.map((r) => ({ ...r, bundle: bundleByCode.get(r.code) }))
.filter((r) => r.bundle);
const displayBundles =
recommendedBundles.length > 0
? recommendedBundles.map((r) => r.bundle!)
: bundlesRes?.data ?? [];
const selectedBundleObj = selectedBundle ? bundleByCode.get(selectedBundle) : null;
const pricingForBundle = (pricingRes?.data ?? []).filter((p) =>
selectedBundleObj?.pricing_refs?.includes(p.pricing_item_code)
);
const goWorkspace = () => {
persist({
answers,
selected_bundle_code: selectedBundle,
selected_product_codes: recoRes?.data.recommended_products.map((p) => p.code),
activate_trial: true,
});
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");
}
};
const stepIndex = STEPS.findIndex((s) => s.id === step);
if (!booted) {
return (
<Container className="py-24 text-center">
<div className="mx-auto h-10 w-10 animate-pulse rounded-full bg-[var(--color-primary-muted)]" />
<p className="mt-4 text-sm text-gray-500">آمادهسازی مسیر کشف</p>
</Container>
);
}
return (
<div className="min-h-[70vh] bg-[var(--canvas)] pb-16 dark:bg-gray-950">
<div className="border-b border-gray-100 bg-white dark:border-gray-800 dark:bg-gray-950">
<Container className="py-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<Link href="/" className="text-xs text-primary">
بازگشت به خانه
</Link>
<h1 className="mt-2 text-2xl font-bold text-secondary dark:text-gray-100">
کشف و راهاندازی
</h1>
</div>
<ol className="flex flex-wrap gap-2">
{STEPS.map((s, i) => (
<li
key={s.id}
className={`rounded-full px-3 py-1 text-xs font-medium ${
i <= stepIndex
? "bg-primary text-white"
: "bg-gray-100 text-gray-500 dark:bg-gray-800"
}`}
>
{s.label}
</li>
))}
</ol>
</div>
</Container>
</div>
{step === "assessment" ? (
<Container className="py-10">
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-bold text-secondary dark:text-gray-100">ارزیابی نیازها</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
سؤالات از assessment schema صنایع و نیازها بدون لیست ثابت frontend.
</p>
{schemaRes?.availability === "unavailable" ||
schemaRes?.availability === "empty" ||
!questions.length ? (
<div className="mt-6 space-y-4">
<CommercialEmptyState
title="اسکیمای ارزیابی در دسترس نیست"
description={schemaRes?.message}
/>
<div className="flex flex-wrap gap-2">
<Button
loading={busy}
onClick={() => {
setRecoRes({
availability: "unavailable",
data: {
recommended_products: [],
recommended_bundles: [],
recommended_capabilities: [],
recommended_pricing: [],
},
message: schemaRes?.message,
source: "none",
});
setStep("recommendations");
}}
>
ادامه با کاتالوگ رجیستری
</Button>
</div>
</div>
) : currentQ ? (
<div className="mt-6 space-y-4 rounded-3xl border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${((qIndex + 1) / questions.length) * 100}%` }}
/>
</div>
<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-10">
<div>
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">
پیشنهاد شخصیسازیشده
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
محصولات الزامی، اختیاری و مسیر رشد از recommendation + registry.
</p>
{recoRes?.message ? (
<p className="mt-3 rounded-xl bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
{recoRes.message}
</p>
) : null}
{recoRes?.data.rule_trace?.length ? (
<details className="mt-4 rounded-xl border border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-950">
<summary className="cursor-pointer text-sm font-semibold text-secondary dark:text-gray-100">
چرا این پیشنهاد؟
</summary>
<ol className="mt-3 list-decimal space-y-1 ps-5 text-xs text-gray-600 dark:text-gray-400">
{recoRes.data.rule_trace.map((step, i) => (
<li key={`${i}-${step}`}>{step}</li>
))}
</ol>
</details>
) : null}
</div>
<section>
<h3 className="mb-3 text-sm font-semibold text-secondary dark:text-gray-100">
محصولات پیشنهادی / الزامی
</h3>
{recommendedProducts.length ? (
<div className="grid gap-3 sm:grid-cols-2">
{recommendedProducts.map((r) => (
<div key={r.code} className="space-y-1">
<ProductCard
product={r.product!}
recommendationScore={r.score}
/>
{r.reason ? (
<p className="px-1 text-xs text-gray-500">{r.reason}</p>
) : null}
</div>
))}
</div>
) : (
<CommercialEmptyState
title="پیشنهاد محصول خالی است"
description={productsRes?.message || recoRes?.message}
hint="پاسخ‌های ارزیابی را بررسی کنید یا بعداً وقتی رجیستری پر شد دوباره امتحان کنید."
actionHref="/discover"
actionLabel="تکرار ارزیابی"
secondaryHref="/pricing"
secondaryLabel="مشاهده قیمت‌ها"
/>
)}
</section>
{optionalProducts.length ? (
<section>
<h3 className="mb-3 text-sm font-semibold text-secondary dark:text-gray-100">
محصولات اختیاری / قابل گسترش
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{optionalProducts.slice(0, 6).map((p) => (
<ProductCard key={p.product_code} product={p} />
))}
</div>
</section>
) : null}
{selectedBundleObj ? (
<div className="rounded-2xl border border-primary/30 bg-[var(--color-primary-soft)] p-4 text-sm">
<p className="font-semibold text-secondary">باندل پیشنهادی فعلی</p>
<p className="mt-1">{selectedBundleObj.display_name}</p>
{selectedBundleObj.upgrade_path_code ? (
<p className="mt-1 text-xs text-primary">
مسیر رشد: {selectedBundleObj.upgrade_path_code}
</p>
) : null}
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setStep("assessment")}>
بازگشت
</Button>
<Button onClick={() => setStep("bundles")}>مقایسه باندل</Button>
</div>
</div>
</Container>
) : null}
{step === "bundles" ? (
<Container className="py-10">
<div className="mx-auto max-w-4xl space-y-6">
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">مقایسه باندل</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Starter / Professional / Enterprise از metadata باندل بدون پلن ثابت.
</p>
{displayBundles.length ? (
<div className="grid gap-4 lg:grid-cols-2">
{displayBundles.map((b) => (
<BundleCard
key={b.bundle_code}
bundle={b}
recommendationScore={
recoRes?.data.recommended_bundles.find((r) => r.code === b.bundle_code)?.score
}
selected={selectedBundle === b.bundle_code}
onSelect={() => {
setSelectedBundle(b.bundle_code);
persist({ answers, selected_bundle_code: b.bundle_code });
}}
/>
))}
</div>
) : (
<CommercialEmptyState title="باندلی نیست" description={bundlesRes?.message} />
)}
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setStep("recommendations")}>
بازگشت
</Button>
<Button onClick={() => setStep("pricing")}>مقایسه قیمت</Button>
</div>
</div>
</Container>
) : null}
{step === "pricing" ? (
<Container className="py-10">
<div className="mx-auto max-w-3xl space-y-6">
<h2 className="text-2xl font-bold text-secondary dark:text-gray-100">مقایسه قیمت</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
آیتمهای قیمت از رجیستری؛ تخفیف و پروموشن از discount_refs / metadata.
</p>
{(pricingForBundle.length ? pricingForBundle : pricingRes?.data ?? []).length ? (
<ul className="grid gap-3 sm:grid-cols-2">
{(pricingForBundle.length ? pricingForBundle : pricingRes!.data).map((item) => (
<li
key={item.pricing_item_code}
className="rounded-2xl border border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
>
<p className="font-semibold text-secondary dark:text-gray-100">
{item.display_name}
</p>
<p className="mt-1 text-xs text-gray-500">
{item.pricing_model || "—"} ·{" "}
{item.amount_minor != null
? `${item.amount_minor} ${item.currency || ""}`
: "مبلغ از بک‌اند"}
</p>
{item.trial_days != null ? (
<Badge variant="default" className="mt-2">
Trial {item.trial_days} روز
</Badge>
) : null}
</li>
))}
</ul>
) : (
<CommercialEmptyState title="قیمت‌گذاری خالی است" description={pricingRes?.message} />
)}
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setStep("bundles")}>
بازگشت
</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-3xl border border-gray-100 bg-white p-8 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h2 className="text-xl font-bold text-secondary dark:text-gray-100">خلاصه Trial</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
پس از ثبتنام و ساخت workspace، trial از Billing / commercial subscriptions فعال
میشود بدون اعداد ساختگی در این صفحه.
</p>
{selectedBundle ? (
<p className="text-sm">
باندل: <span className="font-semibold">{selectedBundle}</span>
</p>
) : (
<p className="text-xs text-gray-500">باندل انتخاب نشده میتوانید بعداً در Billing انتخاب کنید.</p>
)}
<ul className="space-y-1 text-xs text-gray-500">
<li> ساخت tenant و زیردامنه</li>
<li> داشبورد تجاری و لانچر اپها</li>
<li> دامنه اختصاصی پس از entitlement</li>
</ul>
<Button className="w-full" size="lg" 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-3xl border border-gray-100 bg-white p-8 text-center shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h2 className="text-xl font-bold text-secondary dark:text-gray-100">ثبتنام / ورود</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
انتخابهای ارزیابی در session میماند و پس از ورود به onboarding منتقل میشود.
</p>
<Button
className="w-full"
loading={authLoading}
onClick={() => {
persist({
answers,
selected_bundle_code: selectedBundle,
activate_trial: true,
});
login();
}}
>
ورود
</Button>
<Link href="/register?redirect=/onboarding" className="block">
<Button variant="outline" className="w-full">
ثبتنام
</Button>
</Link>
</div>
</Container>
) : null}
</div>
);
}