From 280d3e7a35af9858256ea059aef8bb7f3963d808 Mon Sep 17 00:00:00 2001 From: Jesse Date: Fri, 7 Aug 2026 18:24:16 +0800 Subject: [PATCH] feat(membership): replace purchase modal with membership page --- .../20260807020000_redeem_security.sql | 131 ++++ frontend/src/app/api/payment/orders/route.ts | 51 ++ frontend/src/app/api/redeem/route.ts | 76 +- frontend/src/app/globals.css | 718 ++++++++++++++++++ frontend/src/app/membership/page.tsx | 702 +++++++++++++++++ frontend/src/app/page.tsx | 157 +--- .../components/rectification-agentic-chat.tsx | 8 +- frontend/src/lib/membership.ts | 155 ++++ frontend/src/lib/payment-orders.ts | 57 ++ frontend/src/lib/redeem-response.ts | 46 ++ .../20260807030000_redeem_security.sql | 131 ++++ frontend/tests/admin-database.test.ts | 2 +- .../tests/consultation-entrypoint.test.ts | 14 + .../tests/database-local-business.test.ts | 3 +- .../tests/database-redeem-security.test.ts | 283 +++++++ frontend/tests/epay-settings.test.ts | 21 +- frontend/tests/membership-page.test.ts | 283 +++++++ frontend/tests/redeem-orders-contract.test.ts | 205 +++++ frontend/tests/sidebar-contract.test.ts | 11 +- frontend/tests/starter-questions.test.ts | 26 +- 20 files changed, 2901 insertions(+), 179 deletions(-) create mode 100644 frontend/db/migrations/20260807020000_redeem_security.sql create mode 100644 frontend/src/app/api/payment/orders/route.ts create mode 100644 frontend/src/app/membership/page.tsx create mode 100644 frontend/src/lib/membership.ts create mode 100644 frontend/src/lib/payment-orders.ts create mode 100644 frontend/src/lib/redeem-response.ts create mode 100644 frontend/supabase/migrations/20260807030000_redeem_security.sql create mode 100644 frontend/tests/database-redeem-security.test.ts create mode 100644 frontend/tests/membership-page.test.ts create mode 100644 frontend/tests/redeem-orders-contract.test.ts diff --git a/frontend/db/migrations/20260807020000_redeem_security.sql b/frontend/db/migrations/20260807020000_redeem_security.sql new file mode 100644 index 00000000..62288541 --- /dev/null +++ b/frontend/db/migrations/20260807020000_redeem_security.sql @@ -0,0 +1,131 @@ +begin; + +-- Account-level, cross-instance rate limiting for consecutive failed +-- redemption attempts. The audit table records only who failed and when; +-- neither the plaintext code nor its hash is ever stored here. It is +-- failure-only: a successful redemption deletes the account's rows, so no +-- permanent success row is kept. +create table if not exists public.redemption_attempts ( + id bigint generated always as identity primary key, + user_id uuid not null references auth.users(id) on delete cascade, + created_at timestamptz not null default now() +); + +create index if not exists redemption_attempts_user_created_idx + on public.redemption_attempts (user_id, created_at desc); + +alter table public.redemption_attempts enable row level security; +revoke all on table public.redemption_attempts from anon, authenticated; +grant select on table public.redemption_attempts to service_role; + +-- CREATE OR REPLACE cannot change a function's return type, so the previous +-- 3-column redeem_code(text) is dropped and rebuilt inside the same +-- transaction, then its revoke/grant ACLs are restored below. +drop function if exists public.redeem_code(text); + +-- redeem_code keeps the redemption-code row lock and the credit_transactions +-- unique constraint. It additionally serializes per-account attempts, counts +-- business failures within a rolling 10-minute window, and returns +-- rate_limited once an account reaches 5 failures. Every business failure +-- writes an audit row; a successful redemption deletes the account's +-- attempts. account_not_eligible is intentionally not fabricated here: there +-- is no account-eligibility restriction model yet. The audit table's +-- identity sequence needs no authenticated USAGE grant because the +-- security-definer function runs as its owner. +create or replace function public.redeem_code(p_code_hash text) +returns table (success boolean, credits integer, awarded_credits integer, error_code text) +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_user_id uuid := auth.uid(); + v_email text := auth.jwt() ->> 'email'; + v_code public.redemption_codes%rowtype; + v_balance integer; + v_failed integer; +begin + if v_user_id is null then + return query select false, null::integer, null::integer, 'unauthorized'::text; + return; + end if; + + perform pg_advisory_xact_lock(hashtextextended('redeem:' || v_user_id::text, 0)); + + delete from public.redemption_attempts + where user_id = v_user_id and created_at < now() - interval '10 minutes'; + + select count(*) into v_failed + from public.redemption_attempts + where user_id = v_user_id; + + if v_failed >= 5 then + return query select false, null::integer, null::integer, 'rate_limited'::text; + return; + end if; + + if p_code_hash is null or p_code_hash !~ '^[0-9a-f]{64}$' then + insert into public.redemption_attempts (user_id) values (v_user_id); + return query select false, null::integer, null::integer, 'invalid_code'::text; + return; + end if; + + select rc.* into v_code + from public.redemption_codes rc + where rc.code_hash = p_code_hash + for update; + if not found then + insert into public.redemption_attempts (user_id) values (v_user_id); + return query select false, null::integer, null::integer, 'invalid_code'::text; + return; + end if; + if v_code.redeemed_by is not null then + insert into public.redemption_attempts (user_id) values (v_user_id); + return query select false, null::integer, null::integer, 'already_redeemed'::text; + return; + end if; + if v_code.revoked_at is not null then + insert into public.redemption_attempts (user_id) values (v_user_id); + return query select false, null::integer, null::integer, 'revoked_code'::text; + return; + end if; + if v_code.expires_at is not null and v_code.expires_at <= now() then + insert into public.redemption_attempts (user_id) values (v_user_id); + return query select false, null::integer, null::integer, 'expired_code'::text; + return; + end if; + + select p.credits into v_balance + from public.profiles p + where p.id = v_user_id + for update; + if not found then + return query select false, null::integer, null::integer, 'profile_missing'::text; + return; + end if; + + update public.redemption_codes rc + set redeemed_by = v_user_id, redeemed_email = v_email, redeemed_at = now() + where rc.id = v_code.id; + + update public.profiles p + set credits = p.credits + v_code.credits, updated_at = now() + where p.id = v_user_id + returning p.credits into v_balance; + + insert into public.credit_transactions ( + user_id, transaction_type, amount, balance_after, request_id, redemption_code_id + ) values ( + v_user_id, 'redeem', v_code.credits, v_balance, v_code.id::text, v_code.id + ); + + delete from public.redemption_attempts where user_id = v_user_id; + + return query select true, v_balance, v_code.credits, null::text; +end; +$$; + +revoke all on function public.redeem_code(text) from public, anon; +grant execute on function public.redeem_code(text) to authenticated; + +commit; diff --git a/frontend/src/app/api/payment/orders/route.ts b/frontend/src/app/api/payment/orders/route.ts new file mode 100644 index 00000000..204b7843 --- /dev/null +++ b/frontend/src/app/api/payment/orders/route.ts @@ -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 }); + } +} diff --git a/frontend/src/app/api/redeem/route.ts b/frontend/src/app/api/redeem/route.ts index c4e9227e..1be0d1eb 100644 --- a/frontend/src/app/api/redeem/route.ts +++ b/frontend/src/app/api/redeem/route.ts @@ -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 = { - 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 }, + ); } } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3fc51b47..3b8fd70a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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; + } +} diff --git a/frontend/src/app/membership/page.tsx b/frontend/src/app/membership/page.tsx new file mode 100644 index 00000000..4acfbbd3 --- /dev/null +++ b/frontend/src/app/membership/page.tsx @@ -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; + const snapshot = (item.product_snapshot ?? item.product ?? {}) as Record; + 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(null); + const [accountError, setAccountError] = useState(""); + const [paymentPackages, setPaymentPackages] = useState([]); + const [paymentEnabled, setPaymentEnabled] = useState(false); + const [packagesError, setPackagesError] = useState(""); + const [orders, setOrders] = useState([]); + 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(null); + const [paymentError, setPaymentError] = useState(""); + const [payingProductId, setPayingProductId] = useState(null); + const [selectedProductId, setSelectedProductId] = useState(null); + const [activeSegment, setActiveSegment] = useState<"membership" | "credits">("membership"); + const [redeemDone, setRedeemDone] = useState(false); + const redeemDialogRef = useRef(null); + const redeemInputRef = useRef(null); + const highlightedCardRef = useRef(null); + const planHighlightScrolled = useRef(false); + + const activeSubscription = account?.activeSubscription ?? null; + const currentProductCode = activeSubscription?.status === "active" + ? activeSubscription.productCode + : null; + + const fetchAccountData = useCallback(async (): Promise => { + 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) { + 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 ( +
+
+
+
+ +

套餐与会员

+
+ +
+ + {sourceNotice &&

{sourceNotice}

} + {highlightedPlan && ( +

已为你定位到{planDisplayLabel(highlightedPlan)}套餐。

+ )} + +
+
+

{account?.user.email ? `账户:${account.user.email}` : "欢迎回来"}

+

+ {activeSubscription ? "保持会员状态,持续获得完整权益" : "开通会员,解锁完整咨询体验"} +

+
+
+ 会员状态 + {activeSubscription ? activeSubscription.product?.name || activeSubscription.productCode : "未开通会员"} + {activeSubscription + ? `${formatMembershipDate(activeSubscription.startsAt)} 至 ${formatMembershipDate(activeSubscription.endsAt)}` + : "体验 / 月卡 / 年卡见下方套餐"} +
+
+ 当前余额 + {account ? `${account.credits} 点` : accountError || "正在读取…"} + 用于咨询与生时校正等服务 +
+
+
+ +
+ +
+
+

订单记录

+ +
+ {ordersState === "loading" &&

正在加载订单…

} + {ordersState === "unavailable" &&

订单记录暂时不可用,请稍后刷新。

} + {ordersState === "ready" && orders.length === 0 &&

暂无订单。

} + {orders.length > 0 && ( +
    + {orders.map((order) => ( +
  • +
    + {order.productName || "订单"} + {order.orderNo} +
    + + {orderStatusLabel(order.status)} + + {formatPrice(order.moneyCents, order.currency)} +
  • + ))} +
+ )} +
+ +
+
+ + +
+ + {paymentError && ( +
+

{paymentError}

+ {selectedProductId && ( + + )} +
+ )} + {packagesError &&

{packagesError}

} + + {activeSegment === "membership" && ( +
+ {!packagesError && !paymentEnabled &&

在线支付暂未开放。

} + {plans.length > 0 && ( +
+ {plans.map((product) => { + const alias = planAlias(product); + const recommended = alias === "monthly"; + const isCurrent = currentProductCode === product.code; + const highlighted = alias === highlightedPlan; + return ( +
+
+
+ {planDisplayLabel(alias ?? "monthly")} +

{product.name}

+
+
+ {recommended && 推荐} + {isCurrent && 当前套餐} +
+
+

+ {formatPrice(product.priceCents, product.currency)} + /{formatProductDuration(product)} +

+

{productAudience(product)}

+
    + {product.entitlements.map((entitlement, index) => ( +
  • {entitlementLabel(entitlement)}
  • + ))} + {product.entitlements.length === 0 &&
  • 购买后按页面说明到账
  • } +
+
+ 详细规则 +
    +
  • {isCurrent + ? `当前有效期至 ${formatMembershipDate(activeSubscription?.endsAt)},续费从到期后顺延,不会覆盖未到期权益。` + : "支付成功后权益立即生效。"}
  • +
  • 订单与到账状态可在本页「订单记录」中查看。
  • +
  • 支付由合作渠道处理;如长时间未到账,请联系支持并出示订单号。
  • +
+
+ +
+ ); + })} +
+ )} + {!packagesError && paymentEnabled && plans.length === 0 &&

会员套餐正在上架中。

} +
+ )} + + {activeSegment === "credits" && ( +
+ {creditPacks.length > 0 && ( +
+ {creditPacks.map((product) => ( +
+
+ {product.credits > 0 ? `${product.credits} 点` : "点数包"} +

{product.name}

+

{product.description || "按需补充咨询点数"}

+
+

{formatPrice(product.priceCents, product.currency)}

+
    + {product.entitlements.map((entitlement, index) => ( +
  • {entitlementLabel(entitlement)}
  • + ))} +
+
+ 详细规则 +
    +
  • 点数充值支付成功后立即到账。
  • +
  • 点数用于咨询、报告与生时校正等服务。
  • +
+
+ +
+ ))} +
+ )} + {!packagesError && paymentEnabled && creditPacks.length === 0 &&

点数包正在上架中。

} +
+ )} +
+ + {paymentOrder && ( +
+ +
+ {paymentOrder.status === "paid" && ( + <> +

支付成功,权益已到账

+

订单号:{paymentOrder.orderNo}

+

余额与会员状态已刷新,可以返回继续使用。

+ + )} + {paymentOrder.status === "failed" && ( + <> +

支付失败

+

订单号:{paymentOrder.orderNo}

+

订单未完成支付,可重新支付或稍后再试。

+ + )} + {paymentOrder.status === "pending" && ( + <> +

等待完成支付

+

订单号:{paymentOrder.orderNo}

+

请在已打开的收银台页面完成支付;本页会自动刷新余额与订单记录。

+ + )} +
+
+ {paymentOrder.status === "failed" && ( + + )} + +
+
+ )} +
+ + {redeemOpen && ( +
+
event.stopPropagation()} + > +
+

兑换点数

+ +
+
+ {activeSubscription ? "当前会员" : "当前余额"} + {activeSubscription + ? `${activeSubscription.product?.name || activeSubscription.productCode} · ${formatMembershipDate(activeSubscription.endsAt)} 到期` + : `${account?.credits ?? 0} 点`} +
+
+ +
+ { + setRedeemCode(event.target.value); + setRedeemError(""); + setRedeemMessage(""); + setRedeemDone(false); + }} + placeholder="输入完整兑换码" + /> + +
+ {redeemError &&

{redeemError}

} + {redeemMessage &&

{redeemMessage}

} + {redeemDone && ( +
+ +
+ )} +
+
+ 兑换规则 +
    +
  • 请完整输入兑换码,系统会按 JYOTISH-XXXX-XXXX 格式校验。
  • +
  • 兑换成功后点数立即到账,余额实时更新。
  • +
  • 每个兑换码仅可使用一次,无法转让或退换。
  • +
+
+
+
+ )} +
+ ); +} + +export default function MembershipPage() { + return ( +
正在加载会员信息…
}> + +
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 297716f9..0da71693 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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; const accountDialogClasses = { profile: "profile-modal", - redeem: "redeem-modal", logout: "logout-modal", } as const satisfies Record; @@ -926,15 +925,6 @@ export default function Home() { const [avatarSaving, setAvatarSaving] = useState(false); const [account, setAccount] = useState(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>([]); - const [paymentOrder, setPaymentOrder] = useState<{ orderNo: string; payUrl: string | null; qrCode: string | null; status: string } | null>(null); - const [paymentError, setPaymentError] = useState(""); - const [payingPackageId, setPayingPackageId] = useState(null); const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); const [pinnedSessionIds, setPinnedSessionIds] = useState([]); @@ -976,10 +966,8 @@ export default function Home() { const conversationEnd = useRef(null); const accountTrigger = useRef(null); const accountDialog = useRef(null); - const creditTrigger = useRef(null); const dialogReturnTarget = useRef(null); const closeButton = useRef(null); - const redeemInput = useRef(null); const composerInput = useRef(null); const starterWorkbench = useRef(null); const pendingConsultation = useRef(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) { - 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 ? ( - -