280 lines
13 KiB
TypeScript
280 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|