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
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
formatPaymentOrders,
PAYMENT_ORDERS_SELECT,
type PaymentOrderRow,
} from "@/lib/payment-orders";
export const runtime = "nodejs";
const MAX_ORDERS = 20;
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const { data, error } = await supabase
.from("payment_orders")
.select(PAYMENT_ORDERS_SELECT.join(","))
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.limit(MAX_ORDERS);
if (error) {
return NextResponse.json(
{ error: "暂时无法查询订单" },
{ status: 500 },
);
}
return NextResponse.json({
orders: formatPaymentOrders((data ?? []) as unknown as PaymentOrderRow[]),
});
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json(
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
{ status: 503 },
);
}
return NextResponse.json({ error: "订单服务暂时不可用" }, { status: 500 });
}
}
+51 -25
View File
@@ -1,36 +1,48 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { hashRedeemCode, normalizeRedeemCode } from "@/lib/supabase/codes";
import { hashRedeemCode } from "@/lib/supabase/codes";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
redeemErrorResponse,
redeemInputErrorResponse,
} from "@/lib/redeem-response";
export const runtime = "nodejs";
const requestSchema = z.object({ code: z.string().max(100) });
const redeemErrors: Record<string, { status: number; message: string }> = {
unauthorized: { status: 401, message: "请先登录" },
invalid_code: { status: 404, message: "兑换码不存在" },
expired_code: { status: 410, message: "兑换码已过期" },
revoked_code: { status: 410, message: "兑换码已撤销" },
already_redeemed: { status: 409, message: "兑换码已被使用" },
profile_missing: { status: 500, message: "账户资料不存在,请稍后重试" },
};
export async function POST(request: Request) {
try {
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
const parsed = requestSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsed.success) {
return NextResponse.json({ error: "请输入有效兑换码" }, { status: 400 });
const inputError = redeemInputErrorResponse();
return NextResponse.json(
{ error: inputError.message, code: inputError.code },
{ status: inputError.status },
);
}
const code = normalizeRedeemCode(parsed.data.code);
if (!/^JYOTISH-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code)) {
return NextResponse.json({ error: "兑换码格式不正确" }, { status: 400 });
// Only surrounding whitespace is trimmed; case is never changed. Every
// non-empty value is hashed as-is and evaluated by the database, so
// lowercase or malformed attempts still hit the RPC failure rate limit
// instead of being short-circuited client-side.
const code = parsed.data.code.trim();
if (!code) {
const inputError = redeemInputErrorResponse();
return NextResponse.json(
{ error: inputError.message, code: inputError.code },
{ status: inputError.status },
);
}
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
@@ -40,23 +52,37 @@ export async function POST(request: Request) {
});
if (error) {
return NextResponse.json({ error: "兑换失败,请稍后重试" }, { status: 500 });
const systemError = redeemErrorResponse("system_error");
return NextResponse.json(
{ error: systemError.message, code: systemError.code },
{ status: systemError.status },
);
}
const result = Array.isArray(data) ? data[0] : data;
if (!result?.success) {
const mapped = redeemErrors[result?.error_code] ?? {
status: 500,
message: "兑换失败,请稍后重试",
};
return NextResponse.json({ error: mapped.message }, { status: mapped.status });
const mapped = redeemErrorResponse(result?.error_code);
return NextResponse.json(
{ error: mapped.message, code: mapped.code },
{ status: mapped.status },
);
}
return NextResponse.json({ credits: result.credits });
return NextResponse.json({
awardedCredits: result.awarded_credits ?? null,
credits: result.credits,
});
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
return NextResponse.json(
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
{ status: 503 },
);
}
return NextResponse.json({ error: "兑换服务暂时不可用" }, { status: 500 });
const systemError = redeemErrorResponse("system_error");
return NextResponse.json(
{ error: systemError.message, code: systemError.code },
{ status: systemError.status },
);
}
}
+718
View File
@@ -1867,3 +1867,721 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
max-width: 120mm;
}
}
/* ---- Membership page ---- */
.membership-page {
height: 100dvh;
min-height: 100dvh;
overflow-y: auto;
background: var(--color-canvas);
}
.membership-shell {
width: min(1160px, 100%);
margin: 0 auto;
padding: var(--space-6) var(--space-5) var(--space-16);
display: grid;
gap: var(--space-6);
}
.membership-loading {
padding: var(--space-16) var(--space-5);
color: var(--color-ink-secondary);
text-align: center;
}
.membership-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.membership-header-left {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.membership-back {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
color: var(--color-ink);
cursor: pointer;
font-size: 14px;
}
.membership-back svg {
width: 16px;
height: 16px;
}
.membership-title {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-sm);
font-weight: 400;
letter-spacing: -.03em;
}
.membership-redeem-entry {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 14px;
border: 1px solid color-mix(in srgb, var(--color-action) 34%, transparent);
border-radius: var(--radius-md);
background: var(--color-action-soft);
color: var(--color-action-hover);
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.membership-redeem-entry svg {
width: 16px;
height: 16px;
}
.membership-notice {
margin: 0;
padding: var(--space-3) var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-action) 22%, var(--color-border));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-action-soft) 62%, transparent);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.membership-hero {
position: relative;
min-height: 260px;
overflow: hidden;
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
align-items: center;
gap: var(--space-8);
padding: var(--space-10);
border: 1px solid color-mix(in srgb, var(--color-action) 14%, var(--color-border));
border-radius: var(--radius-xl);
background: radial-gradient(circle at 84% 32%, rgba(133, 67, 47, .12), transparent 34%), linear-gradient(135deg, var(--color-canvas) 0%, var(--color-canvas-soft) 100%);
}
.membership-hero::after {
position: absolute;
inset: 0;
opacity: .2;
pointer-events: none;
background-image: radial-gradient(rgba(29, 29, 31, .24) .45px, transparent .45px);
background-size: 6px 6px;
content: "";
mask-image: linear-gradient(to right, transparent 8%, #000 72%);
}
.membership-hero-copy {
position: relative;
z-index: 1;
max-width: 720px;
}
.membership-greeting {
margin: 0 0 var(--space-3);
color: var(--color-action);
font-size: var(--type-body-sm);
font-weight: 600;
letter-spacing: .02em;
}
.membership-hero h2 {
max-width: 640px;
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: clamp(32px, 4vw, 48px);
font-weight: 400;
letter-spacing: -.045em;
line-height: 1.05;
text-wrap: balance;
}
.membership-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-6);
}
.membership-summary-card {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-action) 18%, var(--color-border));
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-canvas) 86%, transparent);
}
.membership-summary-card span {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.membership-summary-card strong {
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-title-lg);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.membership-summary-card small {
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
line-height: 1.5;
}
.membership-celestial-visual {
position: relative;
z-index: 1;
width: 220px;
height: 220px;
justify-self: end;
}
.membership-orbit {
position: absolute;
border: 1px solid color-mix(in srgb, var(--color-action) 34%, transparent);
border-radius: 50%;
}
.membership-orbit-outer {
inset: 10px 14px;
transform: rotate(-12deg);
}
.membership-orbit-inner {
inset: 42px 54px;
border-color: color-mix(in srgb, var(--color-ink-tertiary) 42%, transparent);
transform: rotate(20deg);
}
.membership-orbit-mini {
inset: 76px 90px;
border-color: color-mix(in srgb, var(--color-action) 26%, transparent);
border-style: dashed;
}
.membership-orbit-dot {
position: absolute;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--color-action);
box-shadow: 0 0 0 5px color-mix(in srgb, var(--color-action-soft) 72%, transparent);
}
.membership-orbit-dot-primary {
top: 28px;
right: 38px;
}
.membership-orbit-dot-secondary {
bottom: 34px;
left: 34px;
width: 6px;
height: 6px;
background: var(--color-ink-tertiary);
box-shadow: none;
}
.membership-celestial-mark {
position: absolute;
top: 50%;
left: 50%;
width: 52px;
height: 52px;
border: 1px solid color-mix(in srgb, var(--color-action) 18%, var(--color-border));
border-radius: 50%;
background: color-mix(in srgb, var(--color-canvas) 88%, transparent) url("/jyotish-logo.png") center / 30px 30px no-repeat;
box-shadow: 0 14px 36px -24px rgba(29, 29, 31, .48);
transform: translate(-50%, -50%);
}
.membership-section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.membership-section-heading h2 {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-title-lg);
font-weight: 400;
letter-spacing: -.02em;
}
.membership-section-heading p {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.membership-orders-refresh {
flex: 0 0 auto;
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-ink-secondary);
cursor: pointer;
font-size: var(--type-caption);
}
.membership-order-list {
display: grid;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.membership-order-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
}
.membership-order-row > div {
display: grid;
gap: 2px;
min-width: 0;
}
.membership-order-row strong {
color: var(--color-ink);
font-size: var(--type-body-sm);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.membership-order-row small {
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
font-variant-numeric: tabular-nums;
}
.membership-order-row b {
color: var(--color-ink);
font-size: var(--type-body-sm);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.membership-order-status {
padding: 3px 9px;
border-radius: 999px;
font-size: var(--type-caption);
}
.membership-order-status.status-paid {
background: var(--color-success-muted);
color: var(--color-success);
}
.membership-order-status.status-pending {
background: var(--color-action-soft);
color: var(--color-action-hover);
}
.membership-order-status.status-failed {
background: var(--color-danger-muted);
color: var(--color-danger);
}
.membership-empty {
margin: 0;
padding: var(--space-5);
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
text-align: center;
}
.membership-plan-grid,
.membership-credit-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
}
.membership-plan-card,
.membership-credit-card {
position: relative;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-6);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-canvas);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.membership-plan-card--recommended {
border-color: color-mix(in srgb, var(--color-action) 52%, var(--color-border));
background: color-mix(in srgb, var(--color-action-soft) 46%, var(--color-canvas));
box-shadow: 0 1px 2px rgba(29, 29, 31, .06), 0 14px 34px -20px rgba(133, 67, 47, .34);
}
.membership-plan-card--recommended::before {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--color-action);
content: "";
}
.membership-plan-card--current {
border-color: color-mix(in srgb, var(--color-success) 40%, var(--color-border));
}
.membership-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.membership-card-kicker {
display: block;
margin-bottom: 4px;
color: var(--color-action);
font-size: var(--type-overline);
font-weight: 600;
letter-spacing: .05em;
}
.membership-card-header h3,
.membership-credit-copy h3 {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-title-md);
font-weight: 400;
letter-spacing: -.01em;
}
.membership-card-badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
justify-content: flex-end;
}
.membership-badge {
padding: 3px 9px;
border-radius: 999px;
font-size: var(--type-caption);
font-weight: 600;
}
.membership-badge--recommended {
background: var(--color-action);
color: var(--color-on-dark);
}
.membership-badge--current {
background: var(--color-success-muted);
color: var(--color-success);
}
.membership-card-price {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-sm);
font-weight: 400;
font-variant-numeric: tabular-nums;
letter-spacing: -.02em;
}
.membership-card-price small {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
font-family: var(--font-body);
font-weight: 400;
letter-spacing: 0;
}
.membership-card-audience {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.6;
}
.membership-card-entitlements {
display: grid;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.membership-card-entitlements li {
position: relative;
padding-left: var(--space-4);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.membership-card-entitlements li::before {
position: absolute;
top: .62em;
left: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: color-mix(in srgb, var(--color-action) 60%, transparent);
content: "";
}
.membership-rules {
margin-top: auto;
border-top: 1px solid var(--color-border);
padding-top: var(--space-3);
}
.membership-rules summary {
cursor: pointer;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
font-weight: 600;
user-select: none;
}
.membership-rules ul {
display: grid;
gap: var(--space-1);
margin: var(--space-2) 0 0;
padding: 0 0 0 var(--space-4);
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
line-height: 1.55;
}
.membership-buy {
width: 100%;
margin-top: var(--space-2);
}
.membership-credit-card {
gap: var(--space-2);
}
.membership-credit-copy p {
margin: var(--space-1) 0 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.membership-payment-panel {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
border: 1px solid color-mix(in srgb, var(--color-action) 30%, var(--color-border));
border-radius: var(--radius-lg);
background: var(--color-action-soft);
}
.membership-payment-icon {
display: grid;
place-items: center;
color: var(--color-action);
}
.membership-payment-icon svg {
width: 28px;
height: 28px;
}
.membership-payment-copy {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.membership-payment-copy h3 {
margin: 0;
color: var(--color-ink);
font-size: var(--type-title-md);
font-weight: 600;
}
.membership-payment-copy p {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
font-variant-numeric: tabular-nums;
}
.membership-payment-panel .dialog-actions {
margin: 0;
}
.membership-redeem {
width: min(100%, 480px);
}
@media (max-width: 767px) {
.membership-hero {
grid-template-columns: 1fr;
padding: var(--space-6);
}
.membership-celestial-visual {
display: none;
}
.membership-summary {
grid-template-columns: 1fr;
}
.membership-order-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.membership-order-row b {
display: none;
}
.membership-payment-panel {
grid-template-columns: auto minmax(0, 1fr);
}
.membership-payment-panel .dialog-actions {
grid-column: 1 / -1;
}
}
@media (max-width: 639px) {
.membership-plan-card--recommended {
order: -1;
}
}
@media (min-width: 640px) {
.membership-plan-grid,
.membership-credit-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.membership-plan-grid,
.membership-credit-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.membership-plan-card,
.membership-credit-card,
.membership-hero {
animation: none;
}
}
.membership-tabs {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.membership-tab {
position: relative;
min-height: 44px;
padding: 0 16px;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--color-ink-secondary);
cursor: pointer;
font-size: var(--type-body-sm);
font-weight: 600;
}
.membership-tab.is-active,
.membership-tab[aria-selected="true"] {
color: var(--color-action);
border-bottom-color: var(--color-action);
}
.membership-panel {
min-width: 0;
}
.membership-plan-card--highlighted {
border-color: var(--color-action);
outline: 2px solid color-mix(in srgb, var(--color-action) 38%, transparent);
outline-offset: 2px;
}
.membership-payment-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
padding: var(--space-3) var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-danger) 42%, var(--color-border));
border-radius: var(--radius-md);
background: var(--color-danger-muted);
}
.membership-payment-error p {
margin: 0;
color: var(--color-danger);
font-size: var(--type-body-sm);
line-height: 1.55;
}
@media (max-width: 639px) {
.membership-payment-error {
align-items: flex-start;
flex-direction: column;
}
}
+702
View File
@@ -0,0 +1,702 @@
"use client";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { useSearchParams } from "next/navigation";
import { ArrowLeft, CheckCircle2, Gift, Sparkles, X } from "lucide-react";
import { keepFocusWithin } from "@/lib/focus-trap";
import {
entitlementLabel,
formatMembershipDate,
formatPrice,
formatProductDuration,
membershipSourceNotice,
notifyBalanceChanged,
orderStatusLabel,
planAlias,
planDisplayLabel,
productAudience,
redeemErrorMessage,
selectMembershipPlans,
type MembershipPlanAlias,
type MembershipProduct,
} from "@/lib/membership";
type MembershipAccount = {
user: { id: string; email: string | null };
credits: number;
activeSubscription: {
id: string;
status: string;
startsAt: string;
endsAt: string;
productCode: string;
productVersion: number;
product: { name?: string; productType?: string } | null;
entitlements: unknown;
} | null;
};
type PaymentOrder = {
orderNo: string;
status: string;
credits: number;
moneyCents: number;
currency: string;
productName: string;
paidAt: string | null;
createdAt: string;
};
type PaymentOrderState = {
orderNo: string;
payUrl: string | null;
qrCode: string | null;
status: string;
productId: string;
};
function normalizeOrders(list: readonly unknown[]): PaymentOrder[] {
return list.map((raw) => {
const item = (raw ?? {}) as Record<string, unknown>;
const snapshot = (item.product_snapshot ?? item.product ?? {}) as Record<string, unknown>;
const price =
typeof item.price === "number"
? item.price
: typeof item.money_cents === "number"
? item.money_cents
: typeof item.moneyCents === "number"
? item.moneyCents
: 0;
return {
orderNo: String(item.order_no ?? item.orderNo ?? ""),
status: String(item.status ?? "unknown"),
credits: typeof item.credits === "number" ? item.credits : 0,
moneyCents: price,
currency: String(item.currency ?? "CNY"),
productName:
typeof snapshot.name === "string"
? snapshot.name
: typeof item.name === "string"
? item.name
: String(item.product_code ?? item.productCode ?? ""),
paidAt: item.paid_at ? String(item.paid_at) : item.paidAt ? String(item.paidAt) : null,
createdAt: String(item.created_at ?? item.createdAt ?? ""),
};
});
}
function goBack() {
if (window.history.length > 1) window.history.back();
else window.location.assign("/");
}
function MembershipContent() {
const searchParams = useSearchParams();
const source = searchParams.get("source");
const planHint = searchParams.get("plan");
const highlightedPlan: MembershipPlanAlias | null =
planHint === "trial" || planHint === "monthly" || planHint === "yearly" ? planHint : null;
const [account, setAccount] = useState<MembershipAccount | null>(null);
const [accountError, setAccountError] = useState("");
const [paymentPackages, setPaymentPackages] = useState<MembershipProduct[]>([]);
const [paymentEnabled, setPaymentEnabled] = useState(false);
const [packagesError, setPackagesError] = useState("");
const [orders, setOrders] = useState<PaymentOrder[]>([]);
const [ordersState, setOrdersState] = useState<"loading" | "ready" | "unavailable">("loading");
const [redeemOpen, setRedeemOpen] = useState(() => searchParams.get("redeem") === "1");
const [redeemCode, setRedeemCode] = useState("");
const [redeemError, setRedeemError] = useState("");
const [redeemMessage, setRedeemMessage] = useState("");
const [redeeming, setRedeeming] = useState(false);
const [paymentOrder, setPaymentOrder] = useState<PaymentOrderState | null>(null);
const [paymentError, setPaymentError] = useState("");
const [payingProductId, setPayingProductId] = useState<string | null>(null);
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
const [activeSegment, setActiveSegment] = useState<"membership" | "credits">("membership");
const [redeemDone, setRedeemDone] = useState(false);
const redeemDialogRef = useRef<HTMLElement>(null);
const redeemInputRef = useRef<HTMLInputElement>(null);
const highlightedCardRef = useRef<HTMLElement | null>(null);
const planHighlightScrolled = useRef(false);
const activeSubscription = account?.activeSubscription ?? null;
const currentProductCode = activeSubscription?.status === "active"
? activeSubscription.productCode
: null;
const fetchAccountData = useCallback(async (): Promise<MembershipAccount | null> => {
try {
const response = await fetch("/api/account", { cache: "no-store" });
if (response.status === 401) {
window.location.assign("/login");
return null;
}
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || "暂时无法读取账户信息");
setAccount(payload);
setAccountError("");
return payload as MembershipAccount;
} catch (caught) {
setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息");
return null;
}
}, []);
const resetPaymentState = useCallback(() => {
setPaymentEnabled(false);
setPaymentPackages([]);
setPaymentOrder(null);
setPaymentError("");
setPayingProductId(null);
setSelectedProductId(null);
}, []);
const fetchPackages = useCallback(async () => {
try {
const response = await fetch("/api/payment/packages", { cache: "no-store" });
const payload = await response.json().catch(() => null);
if (response.ok && payload?.enabled === true) {
setPaymentEnabled(true);
setPaymentPackages(payload.packages || []);
setPackagesError("");
return;
}
if (!response.ok) {
setPackagesError("套餐支付暂时不可用,请稍后重试");
resetPaymentState();
}
} catch {
setPackagesError("套餐支付暂时不可用,请稍后重试");
resetPaymentState();
}
}, [resetPaymentState]);
const fetchOrders = useCallback(async () => {
try {
const response = await fetch("/api/payment/orders", { cache: "no-store" });
if (!response.ok) {
setOrdersState("unavailable");
setOrders([]);
return;
}
const payload = await response.json().catch(() => null);
const list = Array.isArray(payload)
? payload
: payload && typeof payload === "object" && Array.isArray(payload.orders)
? payload.orders
: [];
setOrders(normalizeOrders(list));
setOrdersState("ready");
} catch {
setOrdersState("unavailable");
setOrders([]);
}
}, []);
useEffect(() => {
void (async () => {
await fetchAccountData();
await fetchPackages();
await fetchOrders();
})();
}, [fetchAccountData, fetchOrders, fetchPackages]);
useEffect(() => {
if (!redeemOpen) return;
window.requestAnimationFrame(() => redeemInputRef.current?.focus());
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") closeRedeem();
else if (redeemDialogRef.current) keepFocusWithin(event, redeemDialogRef.current);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [redeemOpen]);
useEffect(() => {
if (!account) return;
const onBalanceStorage = (event: StorageEvent) => {
if (event.key === "jyotisha:balance:updated") void fetchAccountData();
};
const onBalanceChanged = () => void fetchAccountData();
window.addEventListener("storage", onBalanceStorage);
window.addEventListener("jyotisha:balance:changed", onBalanceChanged);
return () => {
window.removeEventListener("storage", onBalanceStorage);
window.removeEventListener("jyotisha:balance:changed", onBalanceChanged);
};
}, [account, fetchAccountData]);
useEffect(() => {
if (!highlightedPlan || planHighlightScrolled.current || paymentPackages.length === 0) return;
planHighlightScrolled.current = true;
const card = highlightedCardRef.current;
if (!card) return;
window.requestAnimationFrame(() => {
card.scrollIntoView({ behavior: "smooth", block: "center" });
card.focus({ preventScroll: true });
});
}, [highlightedPlan, paymentPackages.length]);
function closeRedeem() {
setRedeemOpen(false);
setRedeemError("");
const url = new URL(window.location.href);
url.searchParams.delete("redeem");
window.history.replaceState(null, "", `${url.pathname}${url.search}`);
}
async function redeem(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = redeemCode.trim();
if (!code || redeeming) return;
setRedeeming(true);
setRedeemError("");
setRedeemMessage("");
try {
const response = await fetch("/api/redeem", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code }),
});
const payload = await response.json().catch(() => null);
if (response.status === 401) {
window.location.assign("/login");
return;
}
if (!response.ok) throw new Error(redeemErrorMessage(response.status, payload));
const result = payload as { awardedCredits?: number; credits: number; message?: string };
const awarded = typeof result.awardedCredits === "number" ? result.awardedCredits : result.credits;
setRedeemCode("");
setRedeemDone(true);
setRedeemMessage(result.message || `本次到账 ${awarded} 点,最新余额 ${result.credits} 点。`);
const refreshed = await fetchAccountData();
notifyBalanceChanged(refreshed?.credits ?? result.credits);
void fetchOrders();
} catch (caught) {
setRedeemError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试");
} finally {
setRedeeming(false);
}
}
async function createPayment(productId: string) {
if (payingProductId) return;
setSelectedProductId(productId);
setPayingProductId(productId);
setPaymentError("");
try {
const response = await fetch("/api/payment/epay/create", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ productId }),
});
const payload = await response.json().catch(() => null);
if (response.status === 401) {
window.location.assign("/login");
return;
}
if (!response.ok) throw new Error(payload?.error || "创建支付失败");
if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") {
throw new Error("创建支付失败");
}
setPaymentOrder({
orderNo: payload.orderNo,
payUrl: payload.payUrl,
qrCode: payload.qrCode ?? null,
status: "pending",
productId,
});
window.open(payload.payUrl, "_blank", "noopener,noreferrer");
} catch (caught) {
setPaymentError(caught instanceof Error ? caught.message : "创建支付失败");
} finally {
setPayingProductId(null);
}
}
useEffect(() => {
if (!paymentOrder || paymentOrder.status !== "pending") return;
const timer = window.setInterval(() => {
void fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (!response.ok) return;
const failed = (typeof payload.status === "string"
&& ["failed", "closed", "cancelled"].includes(payload.status))
|| payload.grantStatus === "failed";
const paid = payload.status === "paid" && !failed;
setPaymentOrder((current) => current ? {
...current,
status: paid ? "paid" : failed ? "failed" : "pending",
} : current);
if (paid) {
const refreshed = await fetchAccountData();
notifyBalanceChanged(refreshed?.credits ?? 0);
void fetchOrders();
}
});
}, 3000);
return () => window.clearInterval(timer);
}, [fetchAccountData, fetchOrders, paymentOrder]);
const plans = selectMembershipPlans(paymentPackages);
const creditPacks = paymentPackages.filter((product) => product.productType === "credit_pack");
const sourceNotice = membershipSourceNotice(source);
return (
<main className="membership-page">
<div className="membership-shell">
<header className="membership-header">
<div className="membership-header-left">
<button className="membership-back" type="button" onClick={goBack} aria-label="返回">
<ArrowLeft aria-hidden="true" />
<span></span>
</button>
<h1 className="membership-title"></h1>
</div>
<button className="membership-redeem-entry" type="button" onClick={() => setRedeemOpen(true)}>
<Gift aria-hidden="true" />
<span></span>
</button>
</header>
{sourceNotice && <p className="membership-notice" role="status">{sourceNotice}</p>}
{highlightedPlan && (
<p className="membership-notice" role="status">{planDisplayLabel(highlightedPlan)}</p>
)}
<section className="membership-hero" aria-labelledby="membership-hero-title">
<div className="membership-hero-copy">
<p className="membership-greeting">{account?.user.email ? `账户:${account.user.email}` : "欢迎回来"}</p>
<h2 id="membership-hero-title">
{activeSubscription ? "保持会员状态,持续获得完整权益" : "开通会员,解锁完整咨询体验"}
</h2>
<div className="membership-summary">
<article className="membership-summary-card">
<span></span>
<strong>{activeSubscription ? activeSubscription.product?.name || activeSubscription.productCode : "未开通会员"}</strong>
<small>{activeSubscription
? `${formatMembershipDate(activeSubscription.startsAt)}${formatMembershipDate(activeSubscription.endsAt)}`
: "体验 / 月卡 / 年卡见下方套餐"}</small>
</article>
<article className="membership-summary-card">
<span></span>
<strong>{account ? `${account.credits}` : accountError || "正在读取…"}</strong>
<small></small>
</article>
</div>
</div>
<div className="membership-celestial-visual" aria-hidden="true">
<span className="membership-orbit membership-orbit-outer" />
<span className="membership-orbit membership-orbit-inner" />
<span className="membership-orbit membership-orbit-mini" />
<span className="membership-orbit-dot membership-orbit-dot-primary" />
<span className="membership-orbit-dot membership-orbit-dot-secondary" />
<span className="membership-celestial-mark" />
</div>
</section>
<section className="membership-orders" aria-labelledby="membership-orders-title">
<div className="membership-section-heading">
<h2 id="membership-orders-title"></h2>
<button className="membership-orders-refresh" type="button" onClick={() => void fetchOrders()}></button>
</div>
{ordersState === "loading" && <p className="membership-empty"></p>}
{ordersState === "unavailable" && <p className="membership-empty"></p>}
{ordersState === "ready" && orders.length === 0 && <p className="membership-empty"></p>}
{orders.length > 0 && (
<ul className="membership-order-list">
{orders.map((order) => (
<li className="membership-order-row" key={order.orderNo}>
<div>
<strong>{order.productName || "订单"}</strong>
<small>{order.orderNo}</small>
</div>
<span className={`membership-order-status status-${order.status}`}>
{orderStatusLabel(order.status)}
</span>
<b>{formatPrice(order.moneyCents, order.currency)}</b>
</li>
))}
</ul>
)}
</section>
<section className="membership-segment" aria-label="套餐与点数">
<div className="membership-tabs" role="tablist" aria-label="套餐分类">
<button
className={`membership-tab${activeSegment === "membership" ? " is-active" : ""}`}
id="membership-plans-tab"
role="tab"
type="button"
aria-selected={activeSegment === "membership"}
aria-controls="membership-plans-panel"
onClick={() => setActiveSegment("membership")}
>
</button>
<button
className={`membership-tab${activeSegment === "credits" ? " is-active" : ""}`}
id="membership-credits-tab"
role="tab"
type="button"
aria-selected={activeSegment === "credits"}
aria-controls="membership-credits-panel"
onClick={() => setActiveSegment("credits")}
>
</button>
</div>
{paymentError && (
<div className="membership-payment-error" role="alert">
<p>{paymentError}</p>
{selectedProductId && (
<button
className="button-secondary"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(selectedProductId)}
>
{payingProductId === selectedProductId ? "创建中…" : "重新支付"}
</button>
)}
</div>
)}
{packagesError && <p className="form-error" role="alert">{packagesError}</p>}
{activeSegment === "membership" && (
<div
className="membership-panel"
id="membership-plans-panel"
role="tabpanel"
aria-labelledby="membership-plans-tab"
>
{!packagesError && !paymentEnabled && <p className="membership-empty">线</p>}
{plans.length > 0 && (
<div className="membership-plan-grid">
{plans.map((product) => {
const alias = planAlias(product);
const recommended = alias === "monthly";
const isCurrent = currentProductCode === product.code;
const highlighted = alias === highlightedPlan;
return (
<article
key={product.id}
ref={highlighted ? highlightedCardRef : undefined}
tabIndex={highlighted ? -1 : undefined}
aria-current={highlighted ? "true" : undefined}
className={`membership-plan-card${recommended ? " membership-plan-card--recommended" : ""}${isCurrent ? " membership-plan-card--current" : ""}${highlighted ? " membership-plan-card--highlighted" : ""}`}
>
<header className="membership-card-header">
<div>
<span className="membership-card-kicker">{planDisplayLabel(alias ?? "monthly")}</span>
<h3>{product.name}</h3>
</div>
<div className="membership-card-badges">
{recommended && <span className="membership-badge membership-badge--recommended"></span>}
{isCurrent && <span className="membership-badge membership-badge--current"></span>}
</div>
</header>
<p className="membership-card-price">
{formatPrice(product.priceCents, product.currency)}
<small>/{formatProductDuration(product)}</small>
</p>
<p className="membership-card-audience">{productAudience(product)}</p>
<ul className="membership-card-entitlements">
{product.entitlements.map((entitlement, index) => (
<li key={index}>{entitlementLabel(entitlement)}</li>
))}
{product.entitlements.length === 0 && <li></li>}
</ul>
<details className="membership-rules">
<summary></summary>
<ul>
<li>{isCurrent
? `当前有效期至 ${formatMembershipDate(activeSubscription?.endsAt)},续费从到期后顺延,不会覆盖未到期权益。`
: "支付成功后权益立即生效。"}</li>
<li></li>
<li></li>
</ul>
</details>
<button
className={recommended ? "button-primary membership-buy" : "button-secondary membership-buy"}
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingProductId === product.id ? "创建中…" : isCurrent ? "续费" : product.productType === "trial" ? "立即开通" : "立即购买"}
</button>
</article>
);
})}
</div>
)}
{!packagesError && paymentEnabled && plans.length === 0 && <p className="membership-empty"></p>}
</div>
)}
{activeSegment === "credits" && (
<div
className="membership-panel"
id="membership-credits-panel"
role="tabpanel"
aria-labelledby="membership-credits-tab"
>
{creditPacks.length > 0 && (
<div className="membership-credit-grid">
{creditPacks.map((product) => (
<article className="membership-credit-card" key={product.id}>
<div className="membership-credit-copy">
<span className="membership-card-kicker">{product.credits > 0 ? `${product.credits}` : "点数包"}</span>
<h3>{product.name}</h3>
<p>{product.description || "按需补充咨询点数"}</p>
</div>
<p className="membership-card-price">{formatPrice(product.priceCents, product.currency)}</p>
<ul className="membership-card-entitlements">
{product.entitlements.map((entitlement, index) => (
<li key={index}>{entitlementLabel(entitlement)}</li>
))}
</ul>
<details className="membership-rules">
<summary></summary>
<ul>
<li></li>
<li></li>
</ul>
</details>
<button
className="button-secondary membership-buy"
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingProductId === product.id ? "创建中…" : "立即购买"}
</button>
</article>
))}
</div>
)}
{!packagesError && paymentEnabled && creditPacks.length === 0 && <p className="membership-empty"></p>}
</div>
)}
</section>
{paymentOrder && (
<section className="membership-payment-panel" role="status" aria-live="polite">
<span className="membership-payment-icon" aria-hidden="true">
{paymentOrder.status === "paid" ? <CheckCircle2 /> : paymentOrder.status === "failed" ? <X /> : <Sparkles />}
</span>
<div className="membership-payment-copy">
{paymentOrder.status === "paid" && (
<>
<h3></h3>
<p>{paymentOrder.orderNo}</p>
<p>使</p>
</>
)}
{paymentOrder.status === "failed" && (
<>
<h3></h3>
<p>{paymentOrder.orderNo}</p>
<p></p>
</>
)}
{paymentOrder.status === "pending" && (
<>
<h3></h3>
<p>{paymentOrder.orderNo}</p>
<p></p>
</>
)}
</div>
<div className="dialog-actions">
{paymentOrder.status === "failed" && (
<button
className="button-secondary"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(paymentOrder.productId)}
>
{payingProductId === paymentOrder.productId ? "创建中…" : "重新支付"}
</button>
)}
<button className="button-primary" type="button" onClick={goBack}></button>
</div>
</section>
)}
</div>
{redeemOpen && (
<div className="account-modal-overlay" onMouseDown={closeRedeem}>
<section
className="account-modal membership-redeem"
ref={redeemDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="membership-redeem-title"
onMouseDown={(event) => event.stopPropagation()}
>
<header className="account-modal-header">
<h2 id="membership-redeem-title"></h2>
<button className="dialog-close" aria-label="关闭" type="button" onClick={closeRedeem}>
<X aria-hidden="true" />
</button>
</header>
<div className="redeem-balance">
<span>{activeSubscription ? "当前会员" : "当前余额"}</span>
<strong>{activeSubscription
? `${activeSubscription.product?.name || activeSubscription.productCode} · ${formatMembershipDate(activeSubscription.endsAt)} 到期`
: `${account?.credits ?? 0}`}</strong>
</div>
<form className="redeem-form" onSubmit={redeem}>
<label htmlFor="membership-redeem-code"></label>
<div>
<input
id="membership-redeem-code"
ref={redeemInputRef}
autoComplete="off"
value={redeemCode}
onChange={(event) => {
setRedeemCode(event.target.value);
setRedeemError("");
setRedeemMessage("");
setRedeemDone(false);
}}
placeholder="输入完整兑换码"
/>
<button className="button-primary" type="submit" disabled={!redeemCode.trim() || redeeming || redeemDone}>
{redeeming ? "兑换中" : "立即兑换"}
</button>
</div>
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
{redeemDone && (
<div className="dialog-actions">
<button className="button-primary" type="button" onClick={closeRedeem}></button>
</div>
)}
</form>
<details className="membership-rules">
<summary></summary>
<ul>
<li> JYOTISH-XXXX-XXXX </li>
<li></li>
<li>使退</li>
</ul>
</details>
</section>
</div>
)}
</main>
);
}
export default function MembershipPage() {
return (
<Suspense fallback={<main className="membership-page"><div className="membership-loading"></div></main>}>
<MembershipContent />
</Suspense>
);
}
+28 -129
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowUp, ArrowUpRight, ShieldCheck, Sparkles, Square, X } from "lucide-react";
import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
import { useGSAP } from "@gsap/react";
import { gsap } from "gsap";
import { useEffect, useRef, useState } from "react";
@@ -74,6 +74,7 @@ import {
} from "@/lib/birth-time-guided-preview";
import { defaultGuidedJyotishTopics, generalGuidedJyotishTopics } from "@/lib/guided-jyotish-topics";
import { keepFocusWithin } from "@/lib/focus-trap";
import { BALANCE_SYNC_KEY, BALANCE_CHANGED_EVENT, membershipHref } from "@/lib/membership";
import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
@@ -202,7 +203,7 @@ type Account = {
profile: unknown;
};
type OnboardingStep = "name" | "birth" | "place" | "rectification";
type AccountDialog = "profile" | "redeem" | "logout";
type AccountDialog = "profile" | "logout";
type DailyStarlanguageCard = { trend: string; action: string; caution: string };
type DailyStarlanguageApiResponse = {
status?: "ok";
@@ -240,13 +241,11 @@ const rectifyBeforeConsultationSuggestion = "先完成生时校正";
const accountDialogTitles = {
profile: "个人资料",
redeem: "兑换点数",
logout: "退出登录?",
} as const satisfies Record<AccountDialog, string>;
const accountDialogClasses = {
profile: "profile-modal",
redeem: "redeem-modal",
logout: "logout-modal",
} as const satisfies Record<AccountDialog, string>;
@@ -926,15 +925,6 @@ export default function Home() {
const [avatarSaving, setAvatarSaving] = useState(false);
const [account, setAccount] = useState<Account | null>(null);
const [accountError, setAccountError] = useState("");
const [redeemCode, setRedeemCode] = useState("");
const [redeemError, setRedeemError] = useState("");
const [redeemMessage, setRedeemMessage] = useState("");
const [redeeming, setRedeeming] = useState(false);
const [paymentEnabled, setPaymentEnabled] = useState(false);
const [paymentPackages, setPaymentPackages] = useState<Array<{ id: string; name: string; description: string; productType: "credit_pack" | "trial" | "subscription"; billingPeriod: string; intervalCount: number; priceCents: number; credits: number }>>([]);
const [paymentOrder, setPaymentOrder] = useState<{ orderNo: string; payUrl: string | null; qrCode: string | null; status: string } | null>(null);
const [paymentError, setPaymentError] = useState("");
const [payingPackageId, setPayingPackageId] = useState<string | null>(null);
const [signingOut, setSigningOut] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [pinnedSessionIds, setPinnedSessionIds] = useState<string[]>([]);
@@ -976,10 +966,8 @@ export default function Home() {
const conversationEnd = useRef<HTMLDivElement>(null);
const accountTrigger = useRef<HTMLButtonElement>(null);
const accountDialog = useRef<HTMLElement>(null);
const creditTrigger = useRef<HTMLButtonElement>(null);
const dialogReturnTarget = useRef<HTMLButtonElement | null>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const redeemInput = useRef<HTMLInputElement>(null);
const composerInput = useRef<HTMLTextAreaElement>(null);
const starterWorkbench = useRef<HTMLDivElement>(null);
const pendingConsultation = useRef<PendingConsultation | null>(null);
@@ -1446,8 +1434,7 @@ export default function Home() {
if (activeAccountDialog === null) return;
window.requestAnimationFrame(() => {
if (signingOut) return;
if (activeAccountDialog === "redeem") redeemInput.current?.focus();
else closeButton.current?.focus();
closeButton.current?.focus();
});
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
@@ -1464,6 +1451,25 @@ export default function Home() {
return () => window.removeEventListener("keydown", closeOnEscape);
}, [activeAccountDialog, signingOut]);
useEffect(() => {
if (!accountId || !hydrated) return;
const onBalanceStorage = (event: StorageEvent) => {
if (event.key === BALANCE_SYNC_KEY) void refreshAccount();
};
const onBalanceChanged = () => void refreshAccount();
const onPageShow = (event: PageTransitionEvent) => {
if (event.persisted) void refreshAccount();
};
window.addEventListener("storage", onBalanceStorage);
window.addEventListener(BALANCE_CHANGED_EVENT, onBalanceChanged);
window.addEventListener("pageshow", onPageShow);
return () => {
window.removeEventListener("storage", onBalanceStorage);
window.removeEventListener(BALANCE_CHANGED_EVENT, onBalanceChanged);
window.removeEventListener("pageshow", onPageShow);
};
}, [accountId, hydrated]);
async function refreshAccount() {
const requestIdentity = accountRefreshGuard.current.begin();
try {
@@ -1674,14 +1680,6 @@ export default function Home() {
setProfileNotice("");
setAvatarNotice("");
break;
case "redeem":
setRedeemError("");
setRedeemMessage("");
setPaymentEnabled(false);
setPaymentPackages([]);
setPaymentOrder(null);
setPaymentError("");
break;
case "logout":
break;
default: {
@@ -1977,77 +1975,6 @@ export default function Home() {
}
}
useEffect(() => {
if (activeAccountDialog !== "redeem") return;
void fetch("/api/payment/packages", { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (response.ok && payload?.enabled === true) {
setPaymentEnabled(true);
setPaymentPackages(payload.packages || []);
return;
}
if (!response.ok) setPaymentError("套餐支付暂时不可用,请稍后重试");
}).catch(() => {
setPaymentError("套餐支付暂时不可用,请稍后重试");
});
}, [activeAccountDialog]);
useEffect(() => {
if (!paymentOrder || paymentOrder.status === "paid") return;
const timer = window.setInterval(() => {
void fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (!response.ok) return;
setPaymentOrder((current) => current ? { ...current, status: payload.status } : current);
if (payload.status === "paid") void refreshAccount();
});
}, 3000);
return () => window.clearInterval(timer);
}, [paymentOrder]);
async function createPayment(packageId: string) {
if (payingPackageId) return;
setPayingPackageId(packageId); setPaymentError("");
try {
const response = await fetch("/api/payment/epay/create", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ packageId }) });
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || "创建支付失败");
if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") throw new Error("创建支付失败");
setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode ?? null, status: "pending" });
window.open(payload.payUrl, "_blank", "noopener,noreferrer");
} catch (caught) { setPaymentError(caught instanceof Error ? caught.message : "创建支付失败"); } finally { setPayingPackageId(null); }
}
async function redeem(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = redeemCode.trim();
if (!code || redeeming) return;
setRedeeming(true);
setRedeemError("");
setRedeemMessage("");
try {
const response = await fetch("/api/redeem", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code }),
});
const payload = await response.json().catch(() => null);
if (response.status === 401) {
window.location.assign("/login");
return;
}
if (!response.ok) throw new Error(payloadMessage(payload, "兑换码无效或已使用"));
const result = payload as { credits: number; message?: string };
setAccount((current) => current ? { ...current, credits: result.credits } : current);
setRedeemCode("");
setRedeemMessage(result.message || `兑换成功,当前余额 ${result.credits} 点。`);
} catch (caught) {
setRedeemError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试");
} finally {
setRedeeming(false);
}
}
async function signOut() {
if (signingOut) return;
setSigningOut(true);
@@ -2419,7 +2346,7 @@ export default function Home() {
: initialConsultationRoute;
if (account.credits <= 0 && !account.activeSubscription) {
openAccountDialog("redeem", creditTrigger.current);
window.location.assign(membershipHref("insufficient-credits"));
return false;
}
@@ -2552,7 +2479,7 @@ export default function Home() {
const contentType = response.headers.get("content-type") ?? "";
const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() };
if (response.status === 401) window.location.assign("/login");
if (response.status === 402) openAccountDialog("redeem", creditTrigger.current);
if (response.status === 402) window.location.assign(membershipHref("insufficient-credits"));
throw new Error(payloadMessage(errorPayload, "服务暂时不可用"));
}
if (!response.body) throw new Error("浏览器未收到可读取的回答流");
@@ -2754,8 +2681,6 @@ export default function Home() {
name: profile.name.trim() || account.user.email || "账户",
email: account.user.email || "尚未读取邮箱",
credits: account.credits,
isAdmin: account.isAdmin,
adminUrl: account.adminUrl,
avatar: account.avatar,
initial: profile.name.trim().slice(0, 1)
|| account.user.email?.slice(0, 1).toUpperCase()
@@ -2810,7 +2735,7 @@ export default function Home() {
onNewChat={() => void startNewChat()}
onSelectSession={selectSession}
onOpenProfile={() => openAccountDialog("profile")}
onOpenRedeem={() => openAccountDialog("redeem")}
onOpenRedeem={() => window.location.assign(membershipHref("account-menu", { redeem: true }))}
onOpenLogout={() => openAccountDialog("logout")}
/>
{pendingSessionDeletion ? (
@@ -2847,12 +2772,7 @@ export default function Home() {
evidenceState={reportEvidenceState}
/>
)}
{account.isAdmin && account.adminUrl ? (
<Link className="admin-button" href={account.adminUrl} aria-label="后台管理" title="后台管理">
<ShieldCheck aria-hidden="true" />
</Link>
) : null}
<button className="credit-button" ref={creditTrigger} type="button" onClick={() => openAccountDialog("redeem", creditTrigger.current)} aria-label={account ? `余额 ${account.credits} 点,兑换点数` : accountError || "读取余额中"}>
<button className="credit-button" type="button" onClick={() => window.location.assign(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
<span>{account ? account.credits : "—"}</span>
</button>
@@ -3083,7 +3003,7 @@ export default function Home() {
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
: "请先完成上方资料"
: account.credits === 0 && !account.activeSubscription
? "余额不足,发送时将打开兑换码"
? "余额不足,可前往会员页充值点数或开通会员"
: "例如:未来半年是否适合换工作?"}
rows={1}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
@@ -3272,27 +3192,6 @@ export default function Home() {
</>
)}
{activeAccountDialog === "redeem" && (
<>
<div className="redeem-balance"><span>{account.activeSubscription ? "当前会员" : "当前余额"}</span><strong>{account.activeSubscription ? `${account.activeSubscription.product?.name || account.activeSubscription.productCode} · ${new Date(account.activeSubscription.endsAt).toLocaleDateString("zh-CN")} 到期` : `${account.credits}`}</strong></div>
<form className="redeem-form account-redeem-form" onSubmit={redeem}>
<label htmlFor="redeem-code"></label>
<div>
<input id="redeem-code" ref={redeemInput} autoComplete="off" value={redeemCode} onChange={(event) => { setRedeemCode(event.target.value); setRedeemError(""); setRedeemMessage(""); }} placeholder="输入完整兑换码" />
<button className="button-primary" type="submit" disabled={!redeemCode.trim() || redeeming}>{redeeming ? "兑换中" : "兑换"}</button>
</div>
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
</form>
{paymentEnabled && <div className="payment-packages">
<h3></h3>
{paymentPackages.map((item) => <div className="payment-package" key={item.id}><div><strong>{item.name}</strong><span>{item.description || (item.productType === "credit_pack" ? `${item.credits}` : "会员权益")}</span></div><b>¥{(item.priceCents / 100).toFixed(2)}</b><button className="button-secondary" type="button" onClick={() => void createPayment(item.id)} disabled={Boolean(payingPackageId)}>{payingPackageId === item.id ? "创建中" : "立即支付"}</button></div>)}
{paymentOrder && <div className="payment-status" role="status"><p> {paymentOrder.orderNo}{paymentOrder.status === "paid" ? "支付成功,权益已到账" : "等待支付"}</p></div>}
</div>}
{paymentError && <p className="form-error" role="alert">{paymentError}</p>}
</>
)}
{activeAccountDialog === "logout" && (
<>
<p className="logout-copy">退</p>
@@ -4,6 +4,7 @@ import { ArrowUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import { membershipHref } from "@/lib/membership";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
import { ModelSelector } from "./model-selector";
@@ -152,8 +153,11 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
onProfileIncomplete?.();
return;
}
if (response.status === 402) setError(`咨询点数不足:${message}`);
else if (response.status === 401) setError("请先登录。");
if (response.status === 402) {
window.location.assign(membershipHref("rectification"));
return;
}
if (response.status === 401) setError("请先登录。");
else setError(message);
return;
}
+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" };
}