feat(membership): replace purchase modal with membership page

This commit is contained in:
Jesse
2026-08-07 18:24:16 +08:00
parent 07223b6b4a
commit 280d3e7a35
20 changed files with 2901 additions and 179 deletions
+155
View File
@@ -0,0 +1,155 @@
export type MembershipProductType = "credit_pack" | "trial" | "subscription";
export type MembershipEntitlement = {
featureKey: string;
allowanceType: string;
allowanceCount: number | null;
resetPeriod: string;
modelTier: string | null;
fairUsePolicyId: string | null;
metadata: unknown;
};
export type MembershipProduct = {
id: string;
productId: string;
code: string;
version: number;
name: string;
description: string;
productType: MembershipProductType;
billingPeriod: "none" | "day" | "month" | "year";
intervalCount: number;
priceCents: number;
currency: string;
credits: number;
entitlements: MembershipEntitlement[];
};
export type MembershipPlanAlias = "trial" | "monthly" | "yearly";
export function membershipHref(source: string, options: { redeem?: boolean; plan?: MembershipPlanAlias } = {}) {
const params = new URLSearchParams({ source });
if (options.redeem) params.set("redeem", "1");
if (options.plan) params.set("plan", options.plan);
return `/membership?${params.toString()}`;
}
export function planDisplayLabel(alias: MembershipPlanAlias) {
const labels: Record<MembershipPlanAlias, string> = {
trial: "体验",
monthly: "月卡",
yearly: "年卡",
};
return labels[alias];
}
export function formatPrice(priceCents: number, currency = "CNY") {
return `${currency === "CNY" ? "¥" : `${currency} `}${(priceCents / 100).toFixed(2)}`;
}
export function formatMembershipDate(value: string | null | undefined) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("zh-CN");
}
export function orderStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = {
paid: "已支付",
pending: "待支付",
failed: "支付失败",
closed: "已关闭",
cancelled: "已取消",
};
return (status && labels[status]) || status || "未知";
}
export function membershipSourceNotice(source: string | null | undefined) {
const notices: Record<string, string> = {
credits: "来自对话页余额入口",
"account-menu": "来自账户菜单",
"insufficient-credits": "余额不足,请先补充点数或开通会员后再发送问题",
rectification: "生时校正需要点数或会员权益",
};
return notices[source ?? ""] ?? "";
}
export const BALANCE_SYNC_KEY = "jyotisha:balance:updated";
export const BALANCE_CHANGED_EVENT = "jyotisha:balance:changed";
export function notifyBalanceChanged(credits: number) {
try {
localStorage.setItem(BALANCE_SYNC_KEY, JSON.stringify({ credits, at: Date.now() }));
} catch {
// Storage can be unavailable in privacy modes; the window event still fires.
}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(BALANCE_CHANGED_EVENT, { detail: { credits } }));
}
}
export function planAlias(product: Pick<MembershipProduct, "productType" | "billingPeriod">): MembershipPlanAlias | null {
if (product.productType === "trial") return "trial";
if (product.productType !== "subscription") return null;
if (product.billingPeriod === "month") return "monthly";
if (product.billingPeriod === "year") return "yearly";
return null;
}
export function selectMembershipPlans(products: readonly MembershipProduct[]) {
const aliases: MembershipPlanAlias[] = ["trial", "monthly", "yearly"];
return aliases.flatMap((alias) => {
const product = products.find((candidate) => planAlias(candidate) === alias);
return product ? [product] : [];
});
}
export function formatProductDuration(product: Pick<MembershipProduct, "productType" | "billingPeriod" | "intervalCount">) {
if (product.productType === "credit_pack") return "购买后立即到账";
const unit = product.billingPeriod === "day" ? "天" : product.billingPeriod === "month" ? "个月" : "年";
return `${product.intervalCount} ${unit}`;
}
export function productAudience(product: Pick<MembershipProduct, "productType" | "billingPeriod">) {
if (product.productType === "trial") return "适合初次体验完整咨询流程";
if (product.productType === "credit_pack") return "适合按需补充点数、灵活使用";
if (product.billingPeriod === "year") return "适合长期记录与持续复盘";
return "适合稳定使用咨询与报告功能";
}
const featureLabels: Record<string, string> = {
"chat.standard": "标准 AI 咨询",
"report.full": "完整个人报告",
rectification: "生时校正",
};
export function entitlementLabel(entitlement: MembershipEntitlement) {
const feature = featureLabels[entitlement.featureKey] ?? entitlement.featureKey;
if (entitlement.allowanceType === "unlimited") return `${feature}不限点数(遵循合理使用规则)`;
if (entitlement.allowanceType === "credits") return `${entitlement.allowanceCount ?? 0} 点咨询点数`;
if (typeof entitlement.allowanceCount === "number") return `${feature} ${entitlement.allowanceCount}`;
return feature;
}
export function redeemErrorMessage(status: number, payload: unknown) {
if (status === 429) return "请求过于频繁";
if (payload && typeof payload === "object" && "code" in payload) {
const code = String((payload as { code?: unknown }).code ?? "");
const messages: Record<string, string> = {
invalid_code: "兑换码不存在",
expired_code: "兑换码已过期",
already_redeemed: "兑换码已被使用",
account_not_eligible: "兑换码不适用于当前账户",
rate_limited: "请求过于频繁",
};
if (messages[code]) return messages[code];
}
if (status >= 500) return "系统异常,请稍后重试";
if (payload && typeof payload === "object" && "error" in payload) {
const error = (payload as { error?: unknown }).error;
if (typeof error === "string" && error) return error;
}
return "系统异常,请稍后重试";
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Allowlisted columns a user may read from their own payment orders.
* Sensitive settlement material (epay_trade_no, raw_notify_payload_hash,
* user_id, full snapshots) is deliberately excluded.
*/
export const PAYMENT_ORDERS_SELECT = [
"order_no",
"product_code",
"product_snapshot",
"money_cents",
"status",
"grant_status",
"created_at",
"paid_at",
] as const;
export type PaymentOrderRow = {
order_no: string;
product_code: string | null;
product_snapshot: { name?: string } | null;
money_cents: number;
status: string;
grant_status: string;
created_at: Date | string;
paid_at: Date | string | null;
};
export type PaymentOrderSummary = {
orderNo: string;
product: string | null;
name: string | null;
price: number;
status: string;
grantStatus: string;
createdAt: string;
paidAt: string | null;
};
function toIso(value: Date | string): string {
return value instanceof Date ? value.toISOString() : value;
}
export function formatPaymentOrders(
rows: readonly PaymentOrderRow[],
): PaymentOrderSummary[] {
return rows.map((row) => ({
orderNo: row.order_no,
product: row.product_code,
name: row.product_snapshot?.name ?? row.product_code,
price: row.money_cents,
status: row.status,
grantStatus: row.grant_status,
createdAt: toIso(row.created_at),
paidAt: row.paid_at ? toIso(row.paid_at) : null,
}));
}
+46
View File
@@ -0,0 +1,46 @@
export type RedeemErrorCode =
| "invalid_code"
| "expired_code"
| "already_redeemed"
| "account_not_eligible"
| "rate_limited"
| "system_error";
export type RedeemErrorResponse = {
status: number;
message: string;
code: RedeemErrorCode;
};
/**
* Maps a redeem_code RPC error_code to a stable API error contract.
* revoked_code is normalized to invalid_code (the stable code set has no
* revoked variant). Unknown or system-side error codes collapse to
* system_error. account_not_eligible is kept as a mapping only: there is no
* account-eligibility restriction model, so no check is fabricated.
*/
export function redeemErrorResponse(errorCode: string): RedeemErrorResponse {
switch (errorCode) {
case "expired_code":
return { status: 410, message: "兑换码已过期", code: "expired_code" };
case "already_redeemed":
return { status: 409, message: "兑换码已被使用", code: "already_redeemed" };
case "account_not_eligible":
return { status: 403, message: "该兑换码不适用于当前账户", code: "account_not_eligible" };
case "rate_limited":
return { status: 429, message: "请求过于频繁", code: "rate_limited" };
case "revoked_code":
return { status: 404, message: "兑换码已撤销", code: "invalid_code" };
case "invalid_code":
return { status: 404, message: "兑换码不存在", code: "invalid_code" };
case "profile_missing":
return { status: 500, message: "系统异常,请稍后重试", code: "system_error" };
default:
return { status: 500, message: "系统异常,请稍后重试", code: "system_error" };
}
}
export function redeemInputErrorResponse(): RedeemErrorResponse {
return { status: 400, message: "请输入有效兑换码", code: "invalid_code" };
}