TorbatYar/frontend/modules/commercial/features/discover.tsx

616 lines
25 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, 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";
import {
enumLabel,
formatMoney,
friendlyMessage,
humanizeCommercialCode,
recommendationReason,
} from "../presentation";
type WizardStep =
| "assessment"
| "recommendations"
| "bundles"
| "pricing"
| "trial_summary"
| "create_workspace";
function businessQuestion(question: AssessmentQuestion): string {
const key = question.question_id.toLowerCase();
if (/business.*type|industry/.test(key)) return "نوع کسب‌وکار شما چیست؟";
if (/employee|staff|personnel/.test(key)) return "چند پرسنل دارید؟";
if (/branch/.test(key)) return "چند شعبه دارید؟";
if (/in.?person|physical|pos/.test(key)) return "آیا فروش حضوری دارید؟";
if (/online|ecommerce/.test(key)) return "فروش آنلاین دارید؟";
if (/booking|reservation/.test(key)) return "به مدیریت رزرو نیاز دارید؟";
if (/delivery|shipping/.test(key)) return "ارسال کالا دارید؟";
if (/loyal|club/.test(key)) return "باشگاه مشتریان نیاز دارید؟";
if (/website|domain/.test(key)) return "وب‌سایت دارید؟";
return friendlyMessage(question.prompt, "این ویژگی برای کسب‌وکار شما لازم است؟");
}
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)}
/>
{businessQuestion(question)}
</label>
);
}
if (question.input_type === "number") {
return (
<label className="block text-sm font-medium text-secondary dark:text-gray-100">
{businessQuestion(question)}
<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">
{businessQuestion(question)}
</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);
}}
>
{humanizeCommercialCode(opt.value, opt.label)}
</button>
);
})}
</div>
</fieldset>
);
}
return (
<label className="block text-sm font-medium text-secondary dark:text-gray-100">
{businessQuestion(question)}
<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: "آزمایش رایگان" },
];
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 = recommendedProducts.length ? (productsRes?.data ?? []).filter(
(p) => !recommendedProducts.some((r) => r.code === p.product_code) && p.visibility !== "hidden"
).slice(0, 3) : [];
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">
با چند سؤال کوتاه، نیازهای واقعی کسبوکار شما را میشناسیم.
</p>
{schemaRes?.availability === "unavailable" ||
schemaRes?.availability === "empty" ||
!questions.length ? (
<div className="mt-6 space-y-4">
<CommercialEmptyState
title="فعلاً امکان ارزیابی خودکار نیست"
description="کمی بعد دوباره تلاش کنید یا پلن‌ها را مستقیماً ببینید."
/>
<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">
فقط گزینههایی را میبینید که با پاسخهای شما همخوانی دارند.
</p>
</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">{recommendationReason(r.reason)}</p>
) : null}
</div>
))}
</div>
) : (
<CommercialEmptyState
title="هنوز پیشنهاد دقیقی آماده نیست"
description="پاسخ‌ها را دوباره بررسی کنید تا پیشنهاد دقیق‌تری دریافت کنید."
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">
امکان ارتقا در هر زمان
</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">
محصولات، امکانات و روش پرداخت هر بسته را در یک نگاه مقایسه کنید.
</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 });
}}
pricingItems={pricingRes?.data || []}
products={productsRes?.data || []}
/>
))}
</div>
) : (
<CommercialEmptyState title="بسته‌ای برای نمایش نیست" description="پس از تکمیل پاسخ‌ها، بسته مناسب شما نمایش داده می‌شود." />
)}
<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">
هزینه ماهانه، سالانه و دوره آزمایشی را شفاف مقایسه کنید.
</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">
{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}
</p>
{item.trial_days != null ? (
<Badge variant="default" className="mt-2">
{item.trial_days} روز آزمایش رایگان
</Badge>
) : null}
</li>
))}
</ul>
) : (
<CommercialEmptyState title="قیمت این بسته هنوز اعلام نشده" description="برای دریافت قیمت با فروش تماس بگیرید." />
)}
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setStep("bundles")}>
بازگشت
</Button>
<Button onClick={() => setStep("trial_summary")}>خلاصه دوره آزمایشی</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">خلاصه دوره آزمایشی</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
پس از ثبتنام، فضای کاری و زیردامنه رایگان شما ساخته میشود و میتوانید امکانات انتخابی را امتحان کنید.
</p>
{selectedBundle ? (
<p className="text-sm">
بسته: <span className="font-semibold">{selectedBundleObj?.display_name || "بسته پیشنهادی"}</span>
</p>
) : (
<p className="text-xs text-gray-500">بستهای انتخاب نشده است؛ میتوانید بعداً از بخش اشتراک انتخاب کنید.</p>
)}
<ul className="space-y-1 text-xs text-gray-500">
<li> ساخت فضای کاری و زیردامنه</li>
<li> داشبورد و دسترسی به محصولات</li>
<li> اتصال دامنه اختصاصی پس از فعالسازی اشتراک</li>
</ul>
<Button className="w-full" size="lg" onClick={goWorkspace}>
ساخت فضای کاری
</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">
انتخابهای شما ذخیره میشود و پس از ورود، راهاندازی را از همینجا ادامه میدهید.
</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>
);
}