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

280 lines
13 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, useState } from "react";
import Link from "next/link";
import { AuthGuard } from "@/components/AuthGuard";
import { Button, SectionTitle } from "@/components/ui";
import { useMe } from "@/hooks/useMe";
import {
loadCommercialInvoices,
loadCommercialPricing,
loadCommercialTransactions,
loadCommercialUsage,
} from "../adapters";
import { commercialGet, commercialPost } from "../adapters/http";
import type {
CommercialInvoice,
CommercialTransaction,
} from "../adapters/billing-ledger";
import type { AdapterResult, CommercialPricingItem, CommercialUsageCounter } from "../types";
import { CommercialEmptyState, PlanBadge, PlatformShell, SubscriptionBanner } from "../components";
import { CommercialSkeleton } from "../components/CommercialSkeleton";
import {
enumLabel,
formatDate,
formatMoney,
friendlyMessage,
humanizeCommercialCode,
} from "../presentation";
type CommercialSubscription = {
id?: string;
status?: string | null;
plan_code?: string | null;
bundle_code?: string | null;
pricing_item_code?: string | null;
trial_ends_at?: string | null;
current_period_end?: string | null;
};
function BillingInner() {
const { me, loading: meLoading } = useMe();
const [pricing, setPricing] = useState<AdapterResult<CommercialPricingItem[]> | null>(null);
const [usage, setUsage] = useState<AdapterResult<CommercialUsageCounter[]> | null>(null);
const [invoices, setInvoices] = useState<AdapterResult<CommercialInvoice[]> | null>(null);
const [txns, setTxns] = useState<AdapterResult<CommercialTransaction[]> | null>(null);
const [sub, setSub] = useState<CommercialSubscription | null>(null);
const [coupon, setCoupon] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState("");
useEffect(() => {
void loadCommercialPricing().then(setPricing);
}, []);
useEffect(() => {
if (!me?.current_tenant_id) return;
const tenantId = me.current_tenant_id;
void (async () => {
const dash = await commercialGet<{
subscription?: CommercialSubscription | null;
}>(`/api/v1/commercial/dashboard?tenant_id=${encodeURIComponent(tenantId)}`);
if (dash.ok) setSub(dash.data.subscription ?? null);
else setError(friendlyMessage(dash.message, "اطلاعات اشتراک بارگذاری نشد. لطفاً دوباره تلاش کنید."));
void Promise.all([
loadCommercialUsage(tenantId).then(setUsage),
loadCommercialInvoices(tenantId).then(setInvoices),
loadCommercialTransactions(tenantId).then(setTxns),
]);
})();
}, [me?.current_tenant_id]);
const activatePricing = async (
item: CommercialPricingItem,
mode: "trial" | "active" | "upgrade" | "downgrade"
) => {
if (!me?.current_tenant_id) return;
setBusy(true);
setError("");
setMsg("");
try {
const commercial = await commercialPost<{
subscription?: CommercialSubscription;
id?: string;
status?: string;
plan_code?: string | null;
trial_ends_at?: string | null;
}>("/api/v1/commercial/subscriptions", {
tenant_id: me.current_tenant_id,
pricing_item_code: item.pricing_item_code,
mode,
status: mode === "trial" ? "trialing" : "active",
coupon_code: coupon.trim() || undefined,
});
if (!commercial.ok) {
setError(friendlyMessage(commercial.message, "فعال‌سازی پلن انجام نشد. لطفاً دوباره تلاش کنید."));
return;
}
const next = commercial.data.subscription ?? {
id: commercial.data.id,
status: commercial.data.status,
plan_code: commercial.data.plan_code ?? item.pricing_item_code,
pricing_item_code: item.pricing_item_code,
trial_ends_at: commercial.data.trial_ends_at ?? null,
};
setSub(next);
setMsg(mode === "trial" ? "دوره آزمایشی شما با موفقیت فعال شد." : "پلن شما با موفقیت به‌روزرسانی شد.");
} catch (err) {
setError(friendlyMessage(err instanceof Error ? err.message : null, "انجام این عملیات ممکن نشد. لطفاً دوباره تلاش کنید."));
} finally {
setBusy(false);
}
};
const applyCoupon = async () => {
if (!me?.current_tenant_id || !coupon.trim()) return;
setBusy(true);
setError("");
setMsg("");
setMsg(`کد تخفیف «${coupon.trim()}» ذخیره شد و هنگام انتخاب پلن اعمال می‌شود.`);
setBusy(false);
};
if (meLoading) {
return (
<PlatformShell title="اشتراک و پرداخت">
<CommercialSkeleton rows={4} />
</PlatformShell>
);
}
const planLabel = sub?.plan_code || sub?.pricing_item_code || sub?.bundle_code || undefined;
return (
<PlatformShell
title="اشتراک و پرداخت"
subtitle="پلن فعلی، دوره آزمایشی، پرداخت‌ها و میزان استفاده را یکجا مدیریت کنید."
>
<div className="mb-4">
<Link href="/dashboard">
<Button variant="outline" size="sm">
داشبورد
</Button>
</Link>
</div>
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"><p className="text-xs text-gray-500">پلن فعلی</p><div className="mt-3"><PlanBadge label={planLabel || "بدون پلن"} status={sub?.status} /></div></div>
<div className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"><p className="text-xs text-gray-500">وضعیت اشتراک</p><p className="mt-3 font-bold text-secondary">{enumLabel(sub?.status)}</p></div>
<div className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"><p className="text-xs text-gray-500">پایان دوره آزمایشی</p><p className="mt-3 font-bold text-secondary">{formatDate(sub?.trial_ends_at)}</p></div>
<div className="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm"><p className="text-xs text-gray-500">پرداخت بعدی</p><p className="mt-3 font-bold text-secondary">{formatDate(sub?.current_period_end)}</p></div>
</section>
<div className="mt-4"><SubscriptionBanner status={sub?.status ?? undefined} planName={planLabel} /></div>
{error ? <p className="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p> : null}
{msg ? <p className="mt-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{msg}</p> : null}
<section className="mt-8">
<SectionTitle title="کد تخفیف" />
<div className="flex flex-wrap gap-2">
<input
className="rounded-xl border border-gray-200 px-3 py-2 text-sm"
dir="ltr"
placeholder="کد تخفیف را وارد کنید"
value={coupon}
onChange={(e) => setCoupon(e.target.value)}
/>
<Button size="sm" disabled={busy || !coupon.trim()} onClick={() => void applyCoupon()}>
اعمال
</Button>
</div>
</section>
<section className="mt-10">
<SectionTitle title="ارتقا یا تغییر پلن" subtitle="پلن مناسب را انتخاب کنید؛ تغییرات پیش از تأیید نهایی به شما نمایش داده می‌شود." />
{!pricing || pricing.availability === "loading" ? (
<CommercialSkeleton rows={3} />
) : pricing.availability !== "ready" || !pricing.data.length ? (
<CommercialEmptyState
title="پلنی برای خرید آماده نیست"
description="لطفاً کمی بعد دوباره بررسی کنید یا با پشتیبانی تماس بگیرید."
actionHref="/help"
actionLabel="تماس با پشتیبانی"
/>
) : (
<ul className="mt-4 space-y-3">
{pricing.data.map((item) => (
<li
key={item.pricing_item_code}
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-gray-100 bg-white p-4"
>
<div>
<p className="font-semibold text-secondary">{item.display_name}</p>
<p className="mt-1 text-sm text-gray-600">{enumLabel(item.pricing_model)} · {formatMoney(item.amount_minor, item.currency)}</p>
{item.trial_days ? <p className="mt-1 text-xs text-emerald-700">{item.trial_days.toLocaleString("fa-IR")} روز آزمایش رایگان</p> : null}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => void activatePricing(item, "trial")}
>
آزمایش رایگان
</Button>
<Button size="sm" disabled={busy} onClick={() => void activatePricing(item, "active")}>
فعالسازی
</Button>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => void activatePricing(item, "upgrade")}
>
ارتقا
</Button>
</div>
</li>
))}
</ul>
)}
</section>
<section className="mt-10">
<SectionTitle title="میزان استفاده" />
{usage?.availability === "ready" && usage.data.length ? (
<ul className="mt-3 space-y-2 text-sm">
{usage.data.map((u) => (
<li key={u.key} className="rounded-xl bg-gray-50 px-4 py-3">
<div className="flex items-center justify-between"><span>{humanizeCommercialCode(u.key, u.label)}</span><span>{(u.used ?? 0).toLocaleString("fa-IR")} از {u.limit == null ? "نامحدود" : u.limit.toLocaleString("fa-IR")}</span></div>
{u.limit ? <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-200"><div className="h-full rounded-full bg-primary" style={{ width: `${Math.min(100, ((u.used || 0) / u.limit) * 100)}%` }} /></div> : null}
</li>
))}
</ul>
) : (
<CommercialEmptyState title="هنوز مصرفی ثبت نشده است" description="پس از شروع استفاده، میزان مصرف امکانات اینجا نمایش داده می‌شود." />
)}
</section>
<section className="mt-10">
<SectionTitle title="فاکتورها و تاریخچه پرداخت" />
{invoices?.availability === "ready" && invoices.data.length ? (
<ul className="space-y-2 text-sm">
{invoices.data.map((inv) => (
<li key={inv.invoice_code} className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-100 px-4 py-3">
<div><p className="font-medium text-secondary">{humanizeCommercialCode(inv.invoice_code, inv.display_name)}</p><p className="mt-1 text-xs text-gray-500">{formatDate(inv.issued_at)}</p></div>
<div className="text-left"><p className="font-bold text-secondary">{formatMoney(inv.amount_minor, inv.currency)}</p><p className="mt-1 text-xs text-gray-500">{enumLabel(inv.status)}</p></div>
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">هنوز فاکتوری صادر نشده است.</p>
)}
{txns?.availability === "ready" && txns.data.length ? (
<ul className="mt-3 space-y-2 text-sm">
{txns.data.map((t) => (
<li key={t.transaction_code} className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-100 px-4 py-3">
<div><p className="font-medium text-secondary">{humanizeCommercialCode(t.transaction_code, t.display_name)}</p><p className="mt-1 text-xs text-gray-500">{formatDate(t.occurred_at)}</p></div>
<div className="text-left"><p className="font-bold text-secondary">{formatMoney(t.amount_minor, t.currency)}</p><p className="mt-1 text-xs text-gray-500">{enumLabel(t.status)}</p></div>
</li>
))}
</ul>
) : (
<p className="mt-2 text-sm text-gray-500">هنوز پرداختی انجام نشده است.</p>
)}
</section>
</PlatformShell>
);
}
export function CommercialBillingPage() {
return (
<AuthGuard>
<BillingInner />
</AuthGuard>
);
}