feat(membership): replace purchase modal with membership page
This commit is contained in:
@@ -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 "系统异常,请稍后重试";
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
Reference in New Issue
Block a user