Implement ops operational closure
This commit is contained in:
@@ -70,6 +70,17 @@ import { getTurnstileTokenForAction } from "@/lib/turnstile-client";
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
function pointSourceLabel(source?: string, isEn = false) {
|
||||
const key = String(source || "").trim().toLowerCase();
|
||||
if (key === "feedback_reward") return isEn ? "Feedback reward" : "反馈奖励";
|
||||
if (key === "ops_manual_grant") return isEn ? "Ops manual grant" : "后台补发";
|
||||
if (key === "paid_referral") return isEn ? "Paid referral" : "有效付费邀请";
|
||||
if (key === "growth_milestone_reward") return isEn ? "Growth reward" : "增长奖励";
|
||||
if (key === "points_redemption") return isEn ? "Payment redemption" : "支付抵扣";
|
||||
if (key === "ops_subscription_deduction") return isEn ? "Ops deduction" : "后台订阅扣分";
|
||||
return key || (isEn ? "Unknown source" : "未知来源");
|
||||
}
|
||||
|
||||
export function AccountCenter() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -535,6 +546,9 @@ export function AccountCenter() {
|
||||
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
|
||||
? Math.max(0, monthlyReferralPointsLimitRaw)
|
||||
: monthlyReferralLimit * referralRewardPoints;
|
||||
const pointsLedger = backend?.points_ledger;
|
||||
const pointSourceRows = Object.entries(pointsLedger?.by_source ?? {});
|
||||
const recentPointEvents = pointsLedger?.recent ?? [];
|
||||
|
||||
// ── Telegram bind command ──────────────────────────────
|
||||
const bindCommand = telegramBindCommand || copy.telegramBindCommandPlaceholder;
|
||||
@@ -897,6 +911,64 @@ export function AccountCenter() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="lg:col-span-12 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<h3 className="flex items-center gap-2 text-lg font-bold text-slate-950">
|
||||
<Coins size={20} className="text-yellow-500" />
|
||||
{isEn ? "Point Sources" : "积分来源"}
|
||||
</h3>
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
{isEn ? "Current balance" : "当前余额"} {Number(pointsLedger?.balance ?? totalPoints).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{pointSourceRows.length === 0 && recentPointEvents.length === 0 ? (
|
||||
<p className="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-500">
|
||||
{copy.pointsRule}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{pointSourceRows.map(([source, item]) => (
|
||||
<div key={source} className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-xs font-bold text-slate-600">
|
||||
{pointSourceLabel(source, isEn)}
|
||||
</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-950">
|
||||
{Number(item?.points ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-500">
|
||||
{Number(item?.count ?? 0).toLocaleString()} {isEn ? "events" : "笔记录"} · {source}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200">
|
||||
<div className="border-b border-slate-200 px-4 py-2 text-xs font-bold uppercase text-slate-500">
|
||||
{isEn ? "Recent point ledger" : "最近积分流水"}
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{recentPointEvents.slice(0, 5).map((event, index) => (
|
||||
<div key={`${event.id ?? index}-${event.source}`} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-slate-800">
|
||||
{pointSourceLabel(event.source, isEn)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[11px] text-slate-500">
|
||||
{event.reference_type || "ledger"} · {event.created_at ? formatTime(event.created_at, locale) : "--"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`font-mono font-bold ${Number(event.delta_points ?? 0) >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{Number(event.delta_points ?? 0) >= 0 ? "+" : ""}
|
||||
{Number(event.delta_points ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Subscription Info & Paywall */}
|
||||
<div className="lg:col-span-12 relative">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const types = fs.readFileSync(path.join(projectRoot, "components", "account", "types.ts"), "utf8");
|
||||
const accountCenter = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
types.includes("points_ledger") &&
|
||||
types.includes("PointsLedgerSummary") &&
|
||||
types.includes("PointsLedgerEntry"),
|
||||
"auth/me account types must include points ledger summary and entries",
|
||||
);
|
||||
assert(
|
||||
accountCenter.includes("points_ledger") &&
|
||||
accountCenter.includes("积分来源") &&
|
||||
accountCenter.includes("feedback_reward") &&
|
||||
accountCenter.includes("ops_manual_grant"),
|
||||
"account center must display point source explainability from auth/me",
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export type AuthMeResponse = {
|
||||
user_id?: string | null;
|
||||
email?: string | null;
|
||||
points?: number;
|
||||
points_ledger?: PointsLedgerSummary;
|
||||
weekly_points?: number;
|
||||
weekly_rank?: number | string | null;
|
||||
entitlement_mode?: string | null;
|
||||
@@ -25,6 +26,24 @@ export type AuthMeResponse = {
|
||||
entitlement_snapshot_reason?: string | null;
|
||||
};
|
||||
|
||||
export type PointsLedgerEntry = {
|
||||
id?: number;
|
||||
source?: string;
|
||||
delta_points?: number;
|
||||
balance_after?: number;
|
||||
actor_email?: string;
|
||||
reference_type?: string;
|
||||
reference_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type PointsLedgerSummary = {
|
||||
balance?: number;
|
||||
recent?: PointsLedgerEntry[];
|
||||
by_source?: Record<string, { points?: number; count?: number }>;
|
||||
};
|
||||
|
||||
export type ReferralSummary = {
|
||||
code?: string;
|
||||
discount_usdc?: string;
|
||||
|
||||
Reference in New Issue
Block a user