Add trial and referral subscription program
This commit is contained in:
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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 并确认绑定;完成后刷新本页再申请入群。",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user