338 lines
13 KiB
TypeScript
338 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 { api, ApiError, type Subscription } from "@/lib/api";
|
||
import { useMe } from "@/hooks/useMe";
|
||
import {
|
||
loadCommercialInvoices,
|
||
loadCommercialPricing,
|
||
loadCommercialTransactions,
|
||
loadCommercialUsage,
|
||
} from "../adapters";
|
||
import { 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";
|
||
|
||
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<Subscription | 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 () => {
|
||
try {
|
||
const subscription = await api.subscriptions.get(tenantId).catch(() => null);
|
||
setSub(subscription);
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.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<{
|
||
id?: string;
|
||
status?: string;
|
||
plan_id?: string;
|
||
trial_ends_at?: string | null;
|
||
starts_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) {
|
||
setMsg(`عملیات ${mode} از commercial API ثبت شد.`);
|
||
if (commercial.data.status) {
|
||
setSub({
|
||
id: commercial.data.id || "commercial",
|
||
tenant_id: me.current_tenant_id,
|
||
plan_id: commercial.data.plan_id || item.pricing_item_code,
|
||
status: commercial.data.status as Subscription["status"],
|
||
trial_ends_at: commercial.data.trial_ends_at ?? null,
|
||
starts_at: commercial.data.starts_at ?? new Date().toISOString(),
|
||
ends_at: null,
|
||
created_at: new Date().toISOString(),
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
const planId =
|
||
typeof item.metadata?.plan_id === "string" ? item.metadata.plan_id : null;
|
||
if (!planId) {
|
||
setError(
|
||
commercial.message ||
|
||
"فعالسازی نیازمند commercial subscriptions API یا plan_id در metadata است."
|
||
);
|
||
return;
|
||
}
|
||
|
||
const ends = new Date();
|
||
ends.setDate(ends.getDate() + (item.trial_days || 14));
|
||
const created = await api.subscriptions.create(me.current_tenant_id, {
|
||
plan_id: planId,
|
||
status: mode === "trial" ? "trialing" : "active",
|
||
trial_ends_at: mode === "trial" ? ends.toISOString() : null,
|
||
starts_at: new Date().toISOString(),
|
||
});
|
||
setSub(created);
|
||
setMsg("اشتراک از طریق bridge ثبت شد.");
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.message : "عملیات ناموفق بود");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const applyCoupon = async () => {
|
||
if (!me?.current_tenant_id || !coupon.trim()) return;
|
||
setBusy(true);
|
||
setError("");
|
||
const res = await commercialPost("/api/v1/commercial/coupons/apply", {
|
||
tenant_id: me.current_tenant_id,
|
||
coupon_code: coupon.trim(),
|
||
});
|
||
if (!res.ok) setError(res.message);
|
||
else setMsg("کوپن ارسال شد (در صورت پشتیبانی بکاند).");
|
||
setBusy(false);
|
||
};
|
||
|
||
if (meLoading) {
|
||
return (
|
||
<PlatformShell title="Billing">
|
||
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
|
||
</PlatformShell>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<PlatformShell
|
||
title="Billing و Subscription"
|
||
subtitle="قیمت، فاکتور، تراکنش و ارتقا از commercial APIs"
|
||
>
|
||
<div className="mb-4">
|
||
<Link href="/dashboard">
|
||
<Button variant="outline" size="sm">
|
||
داشبورد
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
|
||
<SubscriptionBanner status={sub?.status} planName={sub?.plan_id} />
|
||
{sub ? (
|
||
<div className="mt-3">
|
||
<PlanBadge label={sub.plan_id} status={sub.status} />
|
||
{sub.trial_ends_at ? (
|
||
<p className="mt-2 text-xs text-gray-500" dir="ltr">
|
||
trial_ends_at: {sub.trial_ends_at}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{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="COUPON"
|
||
value={coupon}
|
||
onChange={(e) => setCoupon(e.target.value)}
|
||
/>
|
||
<Button size="sm" variant="outline" loading={busy} onClick={() => void applyCoupon()}>
|
||
اعمال کوپن
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="mt-8">
|
||
<SectionTitle title="کاتالوگ قیمتگذاری" subtitle="از commercial pricing — بدون مبلغ جعلی" />
|
||
{pricing?.availability === "ready" && pricing.data.length ? (
|
||
<ul className="grid gap-3 sm:grid-cols-2">
|
||
{pricing.data.map((item) => (
|
||
<li
|
||
key={item.pricing_item_code}
|
||
className="rounded-2xl border border-gray-100 bg-white p-4 text-sm"
|
||
>
|
||
<p className="font-semibold text-secondary">{item.display_name}</p>
|
||
<p className="mt-1 font-mono text-[10px] text-gray-400" dir="ltr">
|
||
{item.pricing_item_code}
|
||
</p>
|
||
<p className="mt-2 text-xs text-gray-600">
|
||
مدل: {item.pricing_model || "—"} · ارز: {item.currency || "—"} · مبلغ:{" "}
|
||
{item.amount_minor != null ? item.amount_minor : "از بکاند"}
|
||
{item.tax_class_code ? ` · مالیات: ${item.tax_class_code}` : ""}
|
||
</p>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
loading={busy}
|
||
onClick={() => void activatePricing(item, "trial")}
|
||
>
|
||
Trial
|
||
</Button>
|
||
<Button size="sm" loading={busy} onClick={() => void activatePricing(item, "active")}>
|
||
فعالسازی
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
loading={busy}
|
||
onClick={() => void activatePricing(item, "upgrade")}
|
||
>
|
||
ارتقا
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
loading={busy}
|
||
onClick={() => void activatePricing(item, "downgrade")}
|
||
>
|
||
کاهش
|
||
</Button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<CommercialEmptyState
|
||
title="قیمتگذاری خالی است"
|
||
description={pricing?.message}
|
||
hint="پلنها از رجیستری تجاری میآیند؛ تا پر شدن کاتالوگ میتوانید باندلها را در کشف ببینید."
|
||
actionHref="/discover"
|
||
actionLabel="کشف باندل"
|
||
secondaryHref="/help"
|
||
secondaryLabel="راهنما"
|
||
/>
|
||
)}
|
||
</section>
|
||
|
||
<section className="mt-8">
|
||
<SectionTitle title="فاکتورها" />
|
||
{invoices?.availability === "ready" && invoices.data.length ? (
|
||
<ul className="space-y-2">
|
||
{invoices.data.map((inv) => (
|
||
<li key={inv.invoice_code} className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm">
|
||
{inv.display_name || inv.invoice_code} · {inv.status || "—"} ·{" "}
|
||
{inv.amount_minor != null ? inv.amount_minor : "—"} {inv.currency || ""}
|
||
{inv.href ? (
|
||
<Link href={inv.href} className="ms-2 text-xs text-primary">
|
||
رسید
|
||
</Link>
|
||
) : null}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<CommercialEmptyState
|
||
title="فاکتوری نیست"
|
||
description={invoices?.message}
|
||
hint="پس از فعالسازی اشتراک یا Trial، فاکتورها اینجا ظاهر میشوند."
|
||
actionHref="/apps"
|
||
actionLabel="باز کردن اپها"
|
||
secondaryHref="/help"
|
||
secondaryLabel="پشتیبانی"
|
||
/>
|
||
)}
|
||
</section>
|
||
|
||
<section className="mt-8">
|
||
<SectionTitle title="تاریخچه پرداخت / تراکنش" />
|
||
{txns?.availability === "ready" && txns.data.length ? (
|
||
<ul className="space-y-2">
|
||
{txns.data.map((t) => (
|
||
<li key={t.transaction_code} className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm">
|
||
{t.display_name || t.transaction_code} · {t.status || "—"} ·{" "}
|
||
{t.amount_minor != null ? t.amount_minor : "—"} {t.currency || ""}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<CommercialEmptyState
|
||
title="تراکنشی نیست"
|
||
description={txns?.message}
|
||
hint="پرداختهای موفق و ناموفق پس از اتصال درگاه اینجا ثبت میشوند."
|
||
actionHref="/billing"
|
||
actionLabel="تازهسازی Billing"
|
||
secondaryHref="/help"
|
||
secondaryLabel="راهنمای پرداخت"
|
||
/>
|
||
)}
|
||
</section>
|
||
|
||
<section className="mt-8">
|
||
<SectionTitle title="Usage" />
|
||
{usage?.availability === "ready" && usage.data.length ? (
|
||
<ul className="grid gap-2 sm:grid-cols-2">
|
||
{usage.data.map((q) => (
|
||
<li key={q.key} className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm">
|
||
{q.label || q.key}: {q.used ?? "—"} / {q.limit ?? "∞"} {q.unit || ""}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<CommercialEmptyState
|
||
title="مصرف در دسترس نیست"
|
||
description={usage?.message}
|
||
hint="شمارندههای Trial و سهمیه از entitlement API میآیند."
|
||
actionHref="/dashboard"
|
||
actionLabel="بازگشت به داشبورد"
|
||
secondaryHref="/discover"
|
||
secondaryLabel="ارتقا از کشف"
|
||
/>
|
||
)}
|
||
</section>
|
||
</PlatformShell>
|
||
);
|
||
}
|
||
|
||
export function CommercialBillingPage() {
|
||
return (
|
||
<AuthGuard>
|
||
<BillingInner />
|
||
</AuthGuard>
|
||
);
|
||
}
|