158 lines
10 KiB
TypeScript
158 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { Badge, Button, Container } from "@/components/ui";
|
||
import { loadCommercialBundles, loadCommercialPricing, loadCommercialProducts } from "@/modules/commercial/adapters";
|
||
import type { AdapterResult, CommercialBundle, CommercialPricingItem, CommercialProduct } from "@/modules/commercial/types";
|
||
import { CommercialEmptyState, CommercialSkeleton } from "@/modules/commercial/components";
|
||
import { formatMoney, humanizeCommercialCode } from "@/modules/commercial/presentation";
|
||
|
||
type PlanGroup = {
|
||
key: string;
|
||
title: string;
|
||
description: string;
|
||
monthly?: CommercialPricingItem;
|
||
yearly?: CommercialPricingItem;
|
||
trial?: CommercialPricingItem;
|
||
enterprise?: CommercialPricingItem;
|
||
bundle?: CommercialBundle;
|
||
};
|
||
|
||
function planKey(item: CommercialPricingItem): string {
|
||
return item.pricing_item_code
|
||
.replace(/\.(monthly|yearly|annual|trial|enterprise_quote)$/i, "")
|
||
.replace(/^price\./, "");
|
||
}
|
||
|
||
export default function PricingPage() {
|
||
const [pricing, setPricing] = useState<AdapterResult<CommercialPricingItem[]> | null>(null);
|
||
const [bundles, setBundles] = useState<AdapterResult<CommercialBundle[]> | null>(null);
|
||
const [products, setProducts] = useState<AdapterResult<CommercialProduct[]> | null>(null);
|
||
|
||
useEffect(() => {
|
||
void Promise.all([loadCommercialPricing(), loadCommercialBundles(), loadCommercialProducts()]).then(([prices, packages, catalog]) => {
|
||
setPricing(prices);
|
||
setBundles(packages);
|
||
setProducts(catalog);
|
||
});
|
||
}, []);
|
||
|
||
const plans = useMemo<PlanGroup[]>(() => {
|
||
if (pricing?.availability !== "ready") return [];
|
||
const groups = new Map<string, PlanGroup>();
|
||
pricing.data.forEach((item) => {
|
||
const key = planKey(item);
|
||
const group = groups.get(key) || {
|
||
key,
|
||
title: humanizeCommercialCode(item.pricing_item_code, item.display_name),
|
||
description: "همه ابزارهای لازم برای شروع و رشد کسبوکار شما",
|
||
};
|
||
const model = String(item.pricing_model || item.pricing_item_code.split(".").at(-1) || "").toLowerCase();
|
||
if (model.includes("year") || model.includes("annual")) group.yearly = item;
|
||
else if (model.includes("trial")) group.trial = item;
|
||
else if (model.includes("enterprise") || item.amount_minor == null) group.enterprise = item;
|
||
else group.monthly = item;
|
||
groups.set(key, group);
|
||
});
|
||
return Array.from(groups.values()).map((group) => ({
|
||
...group,
|
||
bundle: bundles?.data.find((bundle) =>
|
||
bundle.pricing_refs?.some((ref) => planKey({ pricing_item_code: ref, display_name: "" }) === group.key)
|
||
),
|
||
}));
|
||
}, [bundles?.data, pricing]);
|
||
|
||
const popularIndex = plans.length > 2 ? 1 : 0;
|
||
const productName = (code: string) =>
|
||
products?.data.find((product) => product.product_code === code)?.display_name || humanizeCommercialCode(code);
|
||
|
||
return (
|
||
<main className="min-h-screen bg-gradient-to-b from-sky-50/70 via-white to-white pb-20">
|
||
<Container className="py-14 sm:py-20">
|
||
<header className="mx-auto max-w-3xl text-center">
|
||
<Badge variant="primary">قیمتگذاری شفاف و قابل ارتقا</Badge>
|
||
<h1 className="mt-5 text-4xl font-extrabold tracking-tight text-secondary sm:text-5xl">پلن مناسب رشد کسبوکار شما</h1>
|
||
<p className="mt-4 text-base leading-8 text-gray-600">
|
||
رایگان امتحان کنید، هر زمان خواستید ارتقا دهید و با پرداخت سالانه هزینه کمتری بپردازید.
|
||
</p>
|
||
<div className="mt-7 flex flex-wrap justify-center gap-3">
|
||
<Link href="/discover"><Button size="lg">شروع رایگان</Button></Link>
|
||
<a href="#compare"><Button size="lg" variant="outline">مقایسه پلنها</Button></a>
|
||
</div>
|
||
</header>
|
||
|
||
{!pricing ? (
|
||
<CommercialSkeleton rows={3} className="mt-12 grid gap-5 lg:grid-cols-3" />
|
||
) : plans.length ? (
|
||
<section className="mt-14 grid items-stretch gap-6 lg:grid-cols-3" aria-label="پلنهای اشتراک">
|
||
{plans.map((plan, index) => {
|
||
const featured = index === popularIndex;
|
||
const trialDays = plan.trial?.trial_days || plan.monthly?.trial_days || plan.yearly?.trial_days;
|
||
const capabilityNames = (plan.bundle?.capability_codes || plan.bundle?.feature_refs || []).slice(0, 6);
|
||
return (
|
||
<article key={plan.key} className={`relative flex flex-col rounded-3xl border bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-xl ${featured ? "border-primary ring-2 ring-primary/10" : "border-gray-200"}`}>
|
||
{featured ? <Badge variant="primary" className="absolute -top-3 right-6">محبوبترین</Badge> : null}
|
||
{plan.bundle?.recommendation_badge ? <span className="mb-3 text-xs font-semibold text-emerald-700">پیشنهادشده برای کسبوکار شما</span> : null}
|
||
<h2 className="text-2xl font-extrabold text-secondary">{plan.title}</h2>
|
||
<p className="mt-2 min-h-12 text-sm leading-6 text-gray-600">{plan.bundle?.description || plan.description}</p>
|
||
<div className="mt-6 space-y-3 rounded-2xl bg-gray-50 p-4">
|
||
<div className="flex items-baseline justify-between gap-3">
|
||
<span className="text-sm text-gray-500">ماهانه</span>
|
||
<strong className="text-xl text-secondary">{formatMoney(plan.monthly?.amount_minor, plan.monthly?.currency)}</strong>
|
||
</div>
|
||
<div className="flex items-baseline justify-between gap-3">
|
||
<span className="text-sm text-gray-500">سالانه</span>
|
||
<strong className="text-xl text-secondary">{formatMoney(plan.yearly?.amount_minor, plan.yearly?.currency)}</strong>
|
||
</div>
|
||
{plan.enterprise ? <p className="border-t border-gray-200 pt-3 text-sm font-semibold text-primary">راهکار سازمانی با استعلام قیمت</p> : null}
|
||
</div>
|
||
{trialDays ? <div className="mt-4 rounded-xl bg-emerald-50 px-3 py-2 text-sm font-medium text-emerald-800">{trialDays.toLocaleString("fa-IR")} روز آزمایش رایگان</div> : null}
|
||
|
||
{plan.bundle?.product_codes?.length ? (
|
||
<div className="mt-6">
|
||
<h3 className="text-sm font-bold text-secondary">محصولات شاملشده</h3>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{plan.bundle.product_codes.map((code) => <span key={code} className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-800">{productName(code)}</span>)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{capabilityNames.length ? (
|
||
<ul className="mt-5 flex-1 space-y-2 text-sm text-gray-700">
|
||
{capabilityNames.map((code) => <li key={code} className="flex gap-2"><span className="text-emerald-600">✓</span><span>{humanizeCommercialCode(code)}</span></li>)}
|
||
</ul>
|
||
) : <div className="flex-1" />}
|
||
<div className="mt-7 grid gap-2">
|
||
<Link href="/discover"><Button className="w-full">{featured ? "انتخاب پلن پیشنهادی" : "شروع و انتخاب پلن"}</Button></Link>
|
||
<a href="#compare"><Button variant="outline" className="w-full">مقایسه امکانات</Button></a>
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</section>
|
||
) : (
|
||
<div className="mt-12"><CommercialEmptyState title="پلنی برای نمایش آماده نیست" description="بهزودی پلنهای قابل خرید در این بخش نمایش داده میشوند." actionHref="/discover" actionLabel="دریافت پیشنهاد شخصی" /></div>
|
||
)}
|
||
|
||
{plans.length ? (
|
||
<section id="compare" className="mt-20 scroll-mt-10">
|
||
<div className="text-center"><h2 className="text-3xl font-extrabold text-secondary">مقایسه سریع پلنها</h2><p className="mt-2 text-sm text-gray-600">جزئیات مهم را در یک نگاه مقایسه کنید.</p></div>
|
||
<div className="mt-8 overflow-x-auto rounded-3xl border border-gray-200 bg-white shadow-sm">
|
||
<table className="w-full min-w-[680px] text-sm">
|
||
<thead className="bg-gray-50"><tr><th className="px-5 py-4 text-right">پلن</th><th className="px-5 py-4 text-right">پرداخت ماهانه</th><th className="px-5 py-4 text-right">پرداخت سالانه</th><th className="px-5 py-4 text-right">آزمایش رایگان</th><th className="px-5 py-4 text-right">محصولات</th></tr></thead>
|
||
<tbody>{plans.map((plan) => <tr key={plan.key} className="border-t border-gray-100"><td className="px-5 py-4 font-bold text-secondary">{plan.title}</td><td className="px-5 py-4">{formatMoney(plan.monthly?.amount_minor, plan.monthly?.currency)}</td><td className="px-5 py-4">{formatMoney(plan.yearly?.amount_minor, plan.yearly?.currency)}</td><td className="px-5 py-4">{plan.trial?.trial_days || plan.monthly?.trial_days ? "دارد" : "ندارد"}</td><td className="px-5 py-4">{(plan.bundle?.product_codes?.length || 0).toLocaleString("fa-IR")} محصول</td></tr>)}</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<section className="mt-16 rounded-3xl bg-secondary px-6 py-10 text-center text-white sm:px-10">
|
||
<h2 className="text-2xl font-extrabold">برای انتخاب مطمئن کمک میخواهید؟</h2>
|
||
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-white/75">چند سؤال کوتاه پاسخ دهید تا فقط گزینههای مناسب کسبوکار شما پیشنهاد شوند.</p>
|
||
<Link href="/discover" className="mt-6 inline-block"><Button className="bg-white text-secondary hover:bg-gray-100">دریافت پیشنهاد هوشمند</Button></Link>
|
||
</section>
|
||
</Container>
|
||
</main>
|
||
);
|
||
}
|