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