Add trial and referral subscription program

This commit is contained in:
2569718930@qq.com
2026-05-29 19:24:46 +08:00
parent f8f5035225
commit 522e35de7f
20 changed files with 1629 additions and 54 deletions
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import {
buildProxyExceptionResponse,
buildUpstreamErrorResponse,
} from "@/lib/api-proxy";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const body = await req.text();
const res = await fetch(`${API_BASE}/api/auth/referral/apply`, {
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = buildUpstreamErrorResponse(res.status, raw);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to apply referral code",
});
}
}
+207 -3
View File
@@ -84,6 +84,8 @@ export function AccountCenter() {
const [updatedAt, setUpdatedAt] = useState<string>("");
const [user, setUser] = useState<User | null>(null);
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
const [referralCodeInput, setReferralCodeInput] = useState("");
const [referralApplying, setReferralApplying] = useState(false);
const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
@@ -135,6 +137,7 @@ export function AccountCenter() {
// Shared setters
setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet,
setSelectedInjectedProviderKey,
setProviderMode,
@@ -299,8 +302,16 @@ export function AccountCenter() {
backend.authenticated === false),
);
const isSubscribed = backend?.subscription_active === true;
const subscriptionSource = String(backend?.subscription_source || "").trim();
const isTrialSubscription = Boolean(
backend?.subscription_is_trial === true ||
String(backend?.subscription_plan_code || "").toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
const subscriptionStatusLabel = isSubscribed
? copy.proMember
? isTrialSubscription
? copy.trialBadge
: copy.proMember
: isSubscriptionUnknown
? copy.subscriptionChecking
: isEn
@@ -325,7 +336,7 @@ export function AccountCenter() {
const hasQueuedExtension = Boolean(
isSubscribed && queuedExtensionDays > 0,
);
const canAccessPaidTelegramGroup = Boolean(isSubscribed);
const canAccessPaidTelegramGroup = Boolean(isSubscribed && !isTrialSubscription);
const telegramBound =
Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
const displayExpiryRaw = isSubscribed
@@ -384,6 +395,21 @@ export function AccountCenter() {
const expiryLabel = hasQueuedExtension
? copy.accessUntil
: copy.renewalDate;
const displayPlanList = effectivePlanList.length
? effectivePlanList
: [
{ plan_code: "pro_monthly", plan_id: 101, amount_usdc: "29.9", duration_days: 30 },
{ plan_code: "pro_quarterly", plan_id: 102, amount_usdc: "79.9", duration_days: 90 },
];
const referral = backend?.referral;
const referralCode = String(referral?.code || "").trim();
const appliedReferralCode = String(referral?.applied_code || "").trim();
const canApplyReferralCode = Boolean(
isAuthenticated &&
!isSubscribed &&
!appliedReferralCode &&
referralCodeInput.trim(),
);
// ── Payment overlay tracking effect ──────────────────────
useEffect(() => {
@@ -432,6 +458,55 @@ export function AccountCenter() {
});
};
const applyReferralCode = useCallback(async () => {
const code = referralCodeInput.trim();
if (!code || referralApplying) return;
setReferralApplying(true);
setPaymentError("");
setPaymentInfo("");
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (supabaseReady) {
const {
data: { session },
} = await getSupabaseBrowserClient().auth.getSession();
const token = String(session?.access_token || "").trim();
if (token) headers.Authorization = `Bearer ${token}`;
}
const res = await fetch("/api/auth/referral/apply", {
method: "POST",
headers,
body: JSON.stringify({ code }),
});
if (!res.ok) {
const raw = (await res.text()).slice(0, 240);
throw new Error(raw || copy.referralApplyFailed);
}
setPaymentInfo(copy.referralApplied);
setReferralCodeInput("");
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(
error instanceof Error ? error.message : copy.referralApplyFailed,
);
} finally {
setReferralApplying(false);
}
}, [
copy.referralApplied,
copy.referralApplyFailed,
loadPaymentSnapshot,
loadSnapshot,
referralApplying,
referralCodeInput,
setPaymentError,
setPaymentInfo,
supabaseReady,
]);
// ── Render ────────────────────────────────────────────
if (loading && !refreshing) {
@@ -826,6 +901,31 @@ export function AccountCenter() {
{/* Telegram Bot Section & Payment Details */}
{showSecondarySections ? (
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6">
{isTrialSubscription && (
<section className="group relative flex-1 overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 p-8 shadow-sm">
<Bot
size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-amber-100"
/>
<div className="relative z-10">
<h3 className="mb-2 flex items-center gap-2 text-lg font-bold text-amber-800">
<Bot size={22} /> {copy.telegramBind}
</h3>
<p className="text-sm leading-6 text-amber-900">
{copy.trialPaidGroupLocked}
</p>
<button
type="button"
onClick={() => setShowOverlay(true)}
disabled={!canOpenCheckoutOverlay}
className="mt-5 inline-flex items-center gap-2 rounded-xl border border-amber-700 bg-amber-600 px-4 py-3 text-xs font-bold text-white hover:bg-amber-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Crown size={14} />
{copy.upgradePro}
</button>
</div>
</section>
)}
{canAccessPaidTelegramGroup && (
<section className="group relative flex-1 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Bot
@@ -907,7 +1007,7 @@ export function AccountCenter() {
{/* Payment Details / Wallet Management */}
<section
className={`flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-8 shadow-sm ${
canAccessPaidTelegramGroup ? "w-full md:w-96" : "w-full"
canAccessPaidTelegramGroup || isTrialSubscription ? "w-full md:w-96" : "w-full"
}`}
>
<div>
@@ -943,6 +1043,110 @@ export function AccountCenter() {
)}
</div>
) : null}
<div className="mb-5">
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.proPlan}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{displayPlanList.map((plan) => {
const code = String(plan.plan_code || "");
const active = code === selectedPlanCode;
const isQuarterly = code === "pro_quarterly";
return (
<button
type="button"
key={code}
onClick={() => setSelectedPlanCode(code)}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-3 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="flex items-center justify-between gap-2 text-xs font-bold">
<span>
{isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan}
</span>
<span>{plan.amount_usdc} USDC</span>
</div>
<div className="mt-1 text-[10px] opacity-80">
{code} · {plan.duration_days}
</div>
</button>
);
})}
</div>
{appliedReferralCode && selectedPlanCode === "pro_monthly" ? (
<p className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] text-emerald-800">
{copy.referralDiscountHint}
</p>
) : null}
</div>
<div className="mb-5 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-bold uppercase text-slate-700">
{copy.referralTitle}
</p>
<span className="text-[10px] text-slate-500">
{copy.referralInviteLimit}
</span>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralMyCode}
</p>
<div className="flex gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-blue-700">
{referralCode || "--"}
</code>
{referralCode ? (
<button
type="button"
onClick={() => handleCopy(referralCode)}
className="rounded-xl border border-blue-700 bg-blue-600 px-3 text-xs font-bold text-white hover:bg-blue-700"
>
{copied ? <CheckCircle2 size={15} /> : <Copy size={15} />}
</button>
) : null}
</div>
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralRewardHint}
</p>
</div>
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralApplyLabel}
</p>
{appliedReferralCode ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800">
{appliedReferralCode}
</div>
) : (
<div className="flex gap-2">
<input
value={referralCodeInput}
onChange={(event) => setReferralCodeInput(event.target.value)}
placeholder={copy.referralApplyPlaceholder}
className="min-w-0 flex-1 rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs text-slate-950 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
<button
type="button"
onClick={() => void applyReferralCode()}
disabled={!canApplyReferralCode || referralApplying}
className="rounded-xl border border-slate-900 bg-slate-900 px-3 text-xs font-bold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{referralApplying ? "..." : copy.referralApplyButton}
</button>
</div>
)}
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralDiscountHint}
</p>
</div>
</div>
</div>
<div className="mb-5 space-y-3">
<InfoRow
icon={Mail}
@@ -0,0 +1,60 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const accountCenter = fs.readFileSync(
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
"utf8",
);
const accountCopy = fs.readFileSync(
path.join(projectRoot, "components", "account", "account-copy.ts"),
"utf8",
);
const useAccountPayment = fs.readFileSync(
path.join(projectRoot, "components", "account", "useAccountPayment.ts"),
"utf8",
);
const usePaymentFlow = fs.readFileSync(
path.join(projectRoot, "components", "account", "usePaymentFlow.ts"),
"utf8",
);
const types = fs.readFileSync(
path.join(projectRoot, "components", "account", "types.ts"),
"utf8",
);
assert(
accountCopy.includes("3天试用") &&
accountCopy.includes("付费 Telegram 群") &&
accountCopy.includes("邀请码"),
"account copy must describe trial limits and referral code UI",
);
assert(
accountCenter.includes("copy.trialPaidGroupLocked") &&
accountCenter.includes("copy.referralInviteLimit") &&
accountCenter.includes("applyReferralCode"),
"account center must expose trial paid-group gating and referral controls",
);
assert(
accountCenter.includes("pro_quarterly") &&
accountCenter.includes("79.9") &&
accountCenter.includes("29.9"),
"account center must show monthly and quarterly Pro prices",
);
assert(
!useAccountPayment.includes("monthlyPlanList") &&
!usePaymentFlow.includes("monthlyPlanList"),
"payment hooks must not filter checkout plans down to monthly only",
);
assert(
types.includes("ReferralSummary") &&
types.includes("referral?: ReferralSummary | null") &&
types.includes("duration_days: number"),
"account auth and payment types must include referral summary and plan durations",
);
}
+27 -2
View File
@@ -57,6 +57,31 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
: "城市实测温度群",
copyCommand: isEn ? "Copy fallback command" : "复制兜底命令",
paymentMgmt: isEn ? "Payment Management" : "支付管理",
proPlan: isEn ? "Pro Plan" : "Pro 套餐",
monthlyPlan: isEn ? "Monthly" : "月付",
quarterlyPlan: isEn ? "Quarterly" : "季度",
trialBadge: isEn ? "3-day trial" : "3天试用",
trialPaidGroupLocked: isEn
? "Trial users can use the core product experience, but the paid Telegram group is available to full Pro subscriptions only."
: "3天试用用户可体验核心产品,但无法进入付费 Telegram 群;付费群仅对正式 Pro 开放。",
referralTitle: isEn ? "Referral Code" : "邀请码",
referralMyCode: isEn ? "My invite code" : "我的邀请码",
referralApplyLabel: isEn ? "Use invite code" : "使用邀请码",
referralApplyPlaceholder: isEn ? "Enter invite code" : "输入邀请码",
referralApplyButton: isEn ? "Apply" : "应用",
referralApplied: isEn
? "Referral code applied. Your first monthly payment is now discounted."
: "邀请码已应用,首月 Pro 将按邀请价结算。",
referralApplyFailed: isEn ? "Failed to apply referral code" : "邀请码应用失败",
referralDiscountHint: isEn
? "Invite discount: first monthly Pro is 26.9 USDC."
: "邀请首月价:Pro 月付 26.9 USDC。",
referralRewardHint: isEn
? "When an invited user pays for Pro, you receive +3 days Pro."
: "被邀请人成功付费后,邀请人获得 +3 天 Pro。",
referralInviteLimit: isEn
? "Monthly referral reward cap: 10 paid invites, up to +30 days Pro."
: "每月最多 10 个有效付费邀请奖励,最高 +30 天 Pro。",
paymentToken: isEn ? "Payment Token" : "支付币种",
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
paymentWallet: isEn ? "Paying Wallet" : "付款钱包",
@@ -246,8 +271,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
verifyUnknown: isEn ? "Unknown error" : "未知错误",
// ── Telegram bind messages ────────────────────────────────────────
telegramVerifySuccess: isEn
? "Telegram group membership verified. Current membership price: {amount} U."
: "Telegram 群成员验证成功,当前会员价 {amount}U。",
? "Telegram group membership verified. Checkout follows the selected Pro plan."
: "Telegram 群成员验证成功,结算金额以当前选择的 Pro 套餐为准。",
telegramBindClickHint: isEn
? "Open the Telegram Bot, click Start, and confirm binding. Then refresh this page to request group entry."
: "已打开 Telegram Bot,请在 Bot 内点击 Start 并确认绑定;完成后刷新本页再申请入群。",
+16
View File
@@ -10,12 +10,28 @@ export type AuthMeResponse = {
subscription_required?: boolean;
subscription_active?: boolean | null;
subscription_plan_code?: string | null;
subscription_source?: string | null;
subscription_is_trial?: boolean | null;
subscription_starts_at?: string | null;
subscription_expires_at?: string | null;
subscription_total_expires_at?: string | null;
subscription_queued_days?: number | null;
subscription_queued_count?: number | null;
telegram_pricing?: TelegramPricing | null;
referral?: ReferralSummary | null;
};
export type ReferralSummary = {
code?: string;
discount_usdc?: string;
discounted_monthly_amount_usdc?: string;
reward_days?: number;
monthly_reward_limit?: number;
monthly_reward_days_limit?: number;
monthly_reward_count?: number;
monthly_reward_days?: number;
applied_code?: string;
attribution_status?: string;
};
export type TelegramPricing = {
@@ -320,10 +320,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
]);
// ── Selected plan (derived, shared across sub-hooks) ───
const monthlyPlanList = (paymentConfig?.plans || []).filter(
(p) => String(p.plan_code || "").trim().toLowerCase() === "pro_monthly",
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : (paymentConfig?.plans || []);
const effectivePlanList = paymentConfig?.plans || [];
const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0];
// ── useWalletBind ──────────────────────────────────────
@@ -496,6 +493,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
// Setters for shared state
setSelectedTokenAddress,
setSelectedPaymentChainId,
setSelectedPlanCode,
setSelectedWallet,
setSelectedInjectedProviderKey,
setProviderMode,
+30 -4
View File
@@ -116,10 +116,31 @@ export function useBilling(params: UseBillingParams) {
// ── Billing ──────────────────────────────────────────────
const billing = useMemo(() => {
const parsedPlanAmount = Number(
backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 10,
const listAmountRaw = Number(selectedPlan?.amount_usdc ?? 29.9);
const listAmount =
Number.isFinite(listAmountRaw) && listAmountRaw > 0 ? listAmountRaw : 29.9;
const selectedPlanCode = String(selectedPlan?.plan_code || "").toLowerCase();
const referral = backend?.referral;
const referralPending = Boolean(
referral?.applied_code ||
String(referral?.attribution_status || "").toLowerCase() === "pending",
);
const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount : 10;
const referralDiscountRaw = Number(referral?.discount_usdc ?? 0);
const referralDiscount = Number.isFinite(referralDiscountRaw)
? Math.max(0, referralDiscountRaw)
: 0;
const discountedMonthlyRaw = Number(
referral?.discounted_monthly_amount_usdc ?? 0,
);
const referralApplies =
selectedPlanCode === "pro_monthly" &&
referralPending &&
backend?.subscription_active !== true;
const planAmount = referralApplies
? Number.isFinite(discountedMonthlyRaw) && discountedMonthlyRaw > 0
? discountedMonthlyRaw
: Math.max(0, listAmount - referralDiscount)
: listAmount;
const pointsCfg = paymentConfig?.points_redemption || {};
const pointsEnabled = pointsCfg.enabled !== false;
@@ -145,6 +166,9 @@ export function useBilling(params: UseBillingParams) {
return {
planAmount,
listAmount,
referralApplied: referralApplies,
referralDiscountAmount: referralApplies ? listAmount - planAmount : 0,
pointsEnabled,
pointsPerUsdc,
maxDiscountUsdc,
@@ -155,7 +179,9 @@ export function useBilling(params: UseBillingParams) {
};
}, [
paymentConfig?.points_redemption,
backend?.telegram_pricing?.amount_usdc,
backend?.referral,
backend?.subscription_active,
selectedPlan?.plan_code,
selectedPlan?.amount_usdc,
totalPoints,
usePoints,
@@ -170,10 +170,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
// ── Derived payment values ──────────────────────────────
const planList = paymentConfig?.plans || [];
const monthlyPlanList = planList.filter(
(plan) => String(plan.plan_code || "").trim().toLowerCase() === "pro_monthly",
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
const effectivePlanList = planList;
const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0];
const availableChainList: PaymentChainOption[] = useMemo(() => {
@@ -319,35 +319,58 @@ function InstitutionalLandingScreen() {
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base text-slate-500">
{isEn
? "One plan. Full access. No hidden fees."
: "一个方案,全部功能,无隐藏费用。"}
? "Start with a 3-day trial, then choose monthly or quarterly Pro access."
: "新用户可先领 3 天免费试用,再选择月付或季度 Pro。"}
</p>
<div className="mx-auto mt-16 max-w-lg">
<div className="relative flex flex-col rounded-3xl border-2 border-blue-500/80 bg-white p-8 shadow-[0_20px_60px_rgba(37,99,235,0.12)] text-left animate-fade-up opacity-0 transition-transform hover:-translate-y-1 hover:shadow-[0_30px_80px_rgba(37,99,235,0.2)] duration-500" style={{ animationDelay: "500ms", animationFillMode: "forwards" }}>
<div className="mx-auto mt-16 grid max-w-5xl gap-4 text-left md:grid-cols-3">
<div className="relative flex flex-col rounded-2xl border border-slate-200 bg-slate-50 p-6 shadow-sm animate-fade-up opacity-0" style={{ animationDelay: "420ms", animationFillMode: "forwards" }}>
<div className="mb-4 inline-flex w-fit rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
{isEn ? "Trial" : "试用"}
</div>
<h3 className="text-2xl font-black text-slate-900">
{isEn ? "3-day free trial" : "3 天免费试用"}
</h3>
<p className="mt-3 text-sm leading-relaxed text-slate-500">
{isEn
? "New users receive one signup trial for core product workflows. Paid Telegram group, high-frequency refresh, batch alerts, and API access require Pro."
: "新用户首次注册/登录后自动开通一次 3 天免费试用,可体验核心产品;付费 Telegram 群、高频刷新、批量提醒与 API 需 Pro。"}
</p>
<Link
href="/auth/login?next=%2Fterminal&mode=signup"
className="mt-auto inline-flex items-center justify-center gap-2 rounded-xl border border-emerald-700 bg-emerald-600 px-4 py-3 text-sm font-bold text-white transition hover:bg-emerald-700"
>
{isEn ? "Start trial" : "开始试用"}
<ArrowRight size={15} />
</Link>
</div>
<div className="relative flex flex-col rounded-2xl border-2 border-blue-500/80 bg-white p-6 shadow-[0_20px_60px_rgba(37,99,235,0.12)] animate-fade-up opacity-0 transition-transform hover:-translate-y-1 hover:shadow-[0_30px_80px_rgba(37,99,235,0.18)] duration-500" style={{ animationDelay: "500ms", animationFillMode: "forwards" }}>
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-gradient-to-r from-blue-600 to-indigo-600 px-5 py-1.5 text-xs font-bold uppercase tracking-widest text-white shadow-md">
{isEn ? "Pro Workspace" : "专业决策分析台"}
</div>
<div>
<h3 className="text-2xl font-black text-slate-900 tracking-tight">
PolyWeather Pro
{isEn ? "Pro Monthly" : "Pro 月付"}
</h3>
<p className="mt-3 text-sm text-slate-500 leading-relaxed">
{isEn
? "Full access to the institutional weather intelligence workspace. Live METAR, DEB forecasts, probability distribution, realtime terminal charts, and alerts."
: "完整访问机构级天气决策分析台。实时 METAR、DEB 预报、概率分布、实时终端图表、实时通知。"}
? "Full Pro access for 30 days, including paid Telegram group eligibility."
: "完整 Pro 权限 30 天,包含付费 Telegram 群准入资格。"}
</p>
<div className="mt-6 flex items-baseline">
<span className="text-5xl font-black tracking-tight text-slate-900">
$10
29.9
</span>
<span className="ml-1 text-sm font-semibold text-slate-500">
/ {isEn ? "month" : "月"}
USDC / 30
</span>
</div>
<p className="mt-1 text-xs text-slate-400">
{isEn ? "Billed monthly. Cancel anytime." : "按月计费,随时可取消。"}
{isEn
? "Referral first-month price: 26.9 USDC."
: "使用邀请码首月 26.9 USDC。"}
</p>
<div className="mt-8 border-t border-slate-100 pt-6">
@@ -369,7 +392,7 @@ function InstitutionalLandingScreen() {
href="/account"
className="group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-slate-900 to-slate-800 py-3.5 text-center text-sm font-bold text-white shadow-lg shadow-slate-900/20 transition-all hover:scale-[1.02] hover:shadow-slate-900/30 hover:from-blue-600 hover:to-indigo-600 duration-300 active:scale-[0.98]"
>
<span>{isEn ? "Subscribe for $10/month" : "立即订阅 $10/月"}</span>
<span>{isEn ? "Subscribe monthly" : "订阅月付 Pro"}</span>
<ArrowRight size={16} className="transition-transform group-hover:translate-x-1" />
</Link>
<p className="mt-4 text-center text-xs text-slate-400">
@@ -379,6 +402,40 @@ function InstitutionalLandingScreen() {
</p>
</div>
</div>
<div className="relative flex flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm animate-fade-up opacity-0" style={{ animationDelay: "580ms", animationFillMode: "forwards" }}>
<div className="mb-4 inline-flex w-fit rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-bold text-indigo-700">
{isEn ? "Quarterly" : "季度"}
</div>
<h3 className="text-2xl font-black text-slate-900">
{isEn ? "Pro Quarterly" : "Pro 季度"}
</h3>
<p className="mt-3 text-sm leading-relaxed text-slate-500">
{isEn
? "90 days of Pro access at a lower effective monthly cost."
: "90 天 Pro 权限,适合稳定使用,折算月成本更低。"}
</p>
<div className="mt-6 flex items-baseline">
<span className="text-5xl font-black tracking-tight text-slate-900">
79.9
</span>
<span className="ml-1 text-sm font-semibold text-slate-500">
USDC / 90
</span>
</div>
<div className="mt-6 rounded-xl border border-violet-200 bg-violet-50 px-4 py-3 text-sm font-semibold text-violet-800">
{isEn
? "Invite reward: invited user pays Pro, referrer gets +3 days Pro."
: "邀请奖励:被邀请人成功付费后,邀请人 +3 天 Pro。"}
</div>
<Link
href="/account"
className="mt-auto inline-flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 py-3 text-sm font-bold text-slate-800 transition hover:border-slate-400 hover:bg-slate-50"
>
{isEn ? "Choose quarterly" : "选择季度 Pro"}
<ArrowRight size={15} />
</Link>
</div>
</div>
</div>
</section>
@@ -0,0 +1,23 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const source = fs.readFileSync(
path.join(projectRoot(), "components", "landing", "InstitutionalLandingPage.tsx"),
"utf8",
);
assert(source.includes("3 天免费试用"), "landing page must advertise the 3-day trial");
assert(source.includes("29.9") && source.includes("30 天"), "landing page must show monthly Pro pricing");
assert(source.includes("79.9") && source.includes("90 天"), "landing page must show quarterly Pro pricing");
assert(source.includes("26.9") && source.includes("+3 天 Pro"), "landing page must describe referral discount and reward");
assert(!source.includes("$10"), "legacy $10/month pricing must be removed from landing page");
}
function projectRoot() {
return process.cwd();
}