TorbatYar/frontend/app/pricing/page.tsx

158 lines
10 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 { 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>
);
}