469 lines
15 KiB
TypeScript
469 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import {
|
|
ArrowRight,
|
|
BellRing,
|
|
CheckCircle2,
|
|
Coins,
|
|
Crown,
|
|
ExternalLink,
|
|
Loader2,
|
|
Lock,
|
|
MessageSquare,
|
|
Radar,
|
|
Send,
|
|
Sparkles,
|
|
TrendingUp,
|
|
Wallet,
|
|
X,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import s from "./UnlockProOverlay.module.css";
|
|
|
|
const DEFAULT_FAQ_HREF = "/subscription-help";
|
|
const DEFAULT_TELEGRAM_GROUP_URL = "";
|
|
|
|
export type UnlockProBilling = {
|
|
pointsEnabled: boolean;
|
|
isEligible: boolean;
|
|
pointsUsed: number;
|
|
discountAmount: number;
|
|
finalPrice: number;
|
|
maxDiscountUsd: number;
|
|
pointsPerUsd: number;
|
|
};
|
|
|
|
type UnlockProOverlayProps = {
|
|
points: number;
|
|
planPriceUsd: number;
|
|
planLabel?: string;
|
|
periodLabel?: string;
|
|
usePoints: boolean;
|
|
billing: UnlockProBilling;
|
|
onToggleUsePoints: () => void;
|
|
onPay: () => void;
|
|
onManualPay?: () => void;
|
|
onClose?: () => void;
|
|
payBusy?: boolean;
|
|
payLabel?: string;
|
|
manualPayLabel?: string;
|
|
locale?: "zh-CN" | "en-US";
|
|
errorText?: string;
|
|
infoText?: string;
|
|
faqHref?: string;
|
|
telegramGroupUrl?: string;
|
|
txHash?: string;
|
|
chainId?: number;
|
|
paymentTokenLabel?: string;
|
|
};
|
|
|
|
function formatUsdcAmount(value: number) {
|
|
if (!Number.isFinite(value)) return "0";
|
|
return value.toFixed(2).replace(/\.?0+$/, "");
|
|
}
|
|
|
|
const FEATURES = {
|
|
"zh-CN": [
|
|
"Terminal:市场温度与城市机会扫描",
|
|
"机场 / 跑道实测:METAR + 结算源优先",
|
|
"DEB 路径与高温时段复核",
|
|
"Telegram 提醒与账户权益同步",
|
|
],
|
|
"en-US": [
|
|
"Terminal: market temperature and city signal scanning",
|
|
"Airport / runway observations: METAR + settlement-source-first",
|
|
"DEB paths and peak-temperature window review",
|
|
"Telegram alerts and entitlement sync",
|
|
],
|
|
};
|
|
|
|
export function UnlockProOverlay({
|
|
points,
|
|
planPriceUsd,
|
|
planLabel,
|
|
periodLabel,
|
|
usePoints,
|
|
billing,
|
|
onToggleUsePoints,
|
|
onPay,
|
|
onManualPay,
|
|
onClose,
|
|
payBusy = false,
|
|
payLabel,
|
|
manualPayLabel,
|
|
locale = "zh-CN",
|
|
errorText,
|
|
infoText,
|
|
faqHref = DEFAULT_FAQ_HREF,
|
|
telegramGroupUrl = DEFAULT_TELEGRAM_GROUP_URL,
|
|
txHash,
|
|
chainId = 137,
|
|
paymentTokenLabel,
|
|
}: UnlockProOverlayProps) {
|
|
const isEn = locale === "en-US";
|
|
const canUsePoints = billing.pointsEnabled && billing.isEligible;
|
|
const featureList = FEATURES[locale] ?? FEATURES["zh-CN"];
|
|
const currencyLabel = "USDC";
|
|
const formattedPlanPrice = formatUsdcAmount(planPriceUsd);
|
|
const formattedFinalPrice = formatUsdcAmount(billing.finalPrice);
|
|
const formattedDiscount = formatUsdcAmount(billing.discountAmount);
|
|
const formattedMaxDiscount = formatUsdcAmount(billing.maxDiscountUsd);
|
|
const resolvedPlanLabel = planLabel || "Standard Pro";
|
|
const resolvedPeriodLabel = periodLabel || (isEn ? "/ 30 days" : "/ 30 天");
|
|
const finalPayLabel =
|
|
payLabel || (isEn ? "Subscribe & Activate" : "立即订阅并激活服务");
|
|
|
|
const maxDiscountUsdInt = Math.max(1, Math.floor(billing.maxDiscountUsd || 0));
|
|
const maxPointsForFullDiscount = Math.max(
|
|
billing.pointsPerUsd,
|
|
billing.pointsPerUsd * maxDiscountUsdInt,
|
|
);
|
|
const redeemableUsdNow = billing.pointsEnabled
|
|
? Math.min(maxDiscountUsdInt, Math.floor(points / Math.max(1, billing.pointsPerUsd)))
|
|
: 0;
|
|
const formattedRedeemableNow = formatUsdcAmount(redeemableUsdNow);
|
|
const formattedMaxDiscountInt = formatUsdcAmount(maxDiscountUsdInt);
|
|
const progressPct = billing.pointsEnabled
|
|
? Math.min(100, Math.round((points / maxPointsForFullDiscount) * 100))
|
|
: 0;
|
|
const resolvedTelegramGroupUrl = String(telegramGroupUrl || "").trim();
|
|
const txHref =
|
|
txHash && txHash.startsWith("0x")
|
|
? `${chainId === 137 ? "https://polygonscan.com" : "https://etherscan.io"}/tx/${txHash}`
|
|
: "";
|
|
|
|
return (
|
|
<div className={s.modal}>
|
|
{/* Ambient glows */}
|
|
<div className={s.glowLeft} />
|
|
<div className={s.glowRight} />
|
|
<div className={s.topLine} />
|
|
|
|
{/* Close button */}
|
|
{onClose && (
|
|
<button
|
|
onClick={onClose}
|
|
className={s.closeBtn}
|
|
title={isEn ? "Close" : "关闭"}
|
|
>
|
|
<X size={15} />
|
|
</button>
|
|
)}
|
|
|
|
{/* ── Header ── */}
|
|
<div className={s.header}>
|
|
<div className={s.badge}>
|
|
<Crown size={12} style={{ color: "#fbbf24" }} />
|
|
<span className={s.badgeText}>Pro</span>
|
|
</div>
|
|
<h2 className={s.title}>
|
|
{isEn ? "Activate PolyWeather Pro" : "确认开通 PolyWeather Pro"}
|
|
</h2>
|
|
<p className={s.subtitle}>
|
|
{isEn
|
|
? "Confirm your subscription for the settlement-source-first terminal: observations, DEB paths, city charts, and alerts."
|
|
: "确认订阅结算源优先的天气决策终端:实测观测、DEB 路径、城市图表和提醒。"}
|
|
</p>
|
|
</div>
|
|
|
|
{/* ── Cards ── */}
|
|
<div className={s.grid}>
|
|
{/* Left: Plan card */}
|
|
<div className={s.planCard}>
|
|
<span className={s.planChip}>
|
|
<Zap size={10} />
|
|
{resolvedPlanLabel}
|
|
</span>
|
|
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "baseline",
|
|
gap: 4,
|
|
marginTop: 12,
|
|
}}
|
|
>
|
|
<span className={s.price}>{formattedPlanPrice}</span>
|
|
<span className={s.priceSuffix}>{currencyLabel} {resolvedPeriodLabel}</span>
|
|
</div>
|
|
|
|
<ul className={s.featureList}>
|
|
{featureList.map((item, i) => (
|
|
<li key={i} className={s.featureItem}>
|
|
<span className={s.featureIcon}>
|
|
<CheckCircle2 size={11} />
|
|
</span>
|
|
{item}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Right: Points card */}
|
|
{canUsePoints ? (
|
|
<div
|
|
className={`${s.pointsCard} ${usePoints ? s.pointsCardActive : ""}`}
|
|
>
|
|
{/* Header row */}
|
|
<div className={s.pointsHeader}>
|
|
<div className={s.pointsLabelRow}>
|
|
<div
|
|
className={`${s.pointsIconBox} ${usePoints ? s.pointsIconBoxActive : ""}`}
|
|
>
|
|
<Coins size={13} />
|
|
</div>
|
|
<span
|
|
className={`${s.pointsLabel} ${usePoints ? s.pointsLabelActive : ""}`}
|
|
>
|
|
{isEn ? "Points Credit" : "积分抵扣"}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={onToggleUsePoints}
|
|
className={`${s.toggle} ${usePoints ? s.toggleActive : ""}`}
|
|
title={isEn ? "Toggle points" : "切换积分"}
|
|
>
|
|
<div
|
|
className={`${s.toggleThumb} ${usePoints ? s.toggleThumbActive : ""}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Discount amount */}
|
|
<div style={{ display: "flex", alignItems: "baseline" }}>
|
|
<span
|
|
className={`${s.discount} ${usePoints ? s.discountActive : ""}`}
|
|
>
|
|
-{formattedDiscount}
|
|
</span>
|
|
<span className={s.discountSuffix}>{currencyLabel}</span>
|
|
</div>
|
|
|
|
<p className={s.pointsNote}>
|
|
{usePoints
|
|
? isEn
|
|
? `Using ${billing.pointsUsed} pts · saves ${formattedDiscount} ${currencyLabel}`
|
|
: `已消耗 ${billing.pointsUsed} 积分 · 省 ${formattedDiscount} ${currencyLabel}`
|
|
: isEn
|
|
? `Toggle to save up to ${formattedMaxDiscount} ${currencyLabel}`
|
|
: `开启可最多抵扣 ${formattedMaxDiscount} ${currencyLabel}`}
|
|
</p>
|
|
<p className={s.pointsNote}>
|
|
{isEn
|
|
? `Now redeemable: ${formattedRedeemableNow} / ${formattedMaxDiscountInt} ${currencyLabel}`
|
|
: `当前可抵:${formattedRedeemableNow} / ${formattedMaxDiscountInt} ${currencyLabel}`}
|
|
</p>
|
|
|
|
<div className={s.pointsBalance}>
|
|
<Sparkles size={11} />
|
|
<span>
|
|
{isEn ? "Balance:" : "当前积分:"}{" "}
|
|
<span
|
|
className={`${s.balanceNum} ${usePoints ? s.balanceNumActive : ""}`}
|
|
>
|
|
{points}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Not eligible */
|
|
<div className={s.pointsUnavailableCard}>
|
|
<div className={s.unavailChip}>
|
|
<div className={s.unavailChipIcon}>
|
|
<Coins size={12} />
|
|
</div>
|
|
<span className={s.unavailChipLabel}>
|
|
{!billing.pointsEnabled
|
|
? isEn
|
|
? "Points Disabled"
|
|
: "积分未开启"
|
|
: isEn
|
|
? "Insufficient Points"
|
|
: "积分不足"}
|
|
</span>
|
|
</div>
|
|
|
|
<h4 className={s.unavailTitle}>
|
|
{isEn ? "Earn Points & Save" : "赚取积分,抵扣订阅"}
|
|
</h4>
|
|
<p className={s.unavailDesc}>
|
|
{!billing.pointsEnabled
|
|
? isEn
|
|
? "Points redemption is unavailable for this plan."
|
|
: "当前套餐暂不支持积分抵扣。"
|
|
: isEn
|
|
? `Starts at ${billing.pointsPerUsd} pts, ${billing.pointsPerUsd} pts per 1 ${currencyLabel}, up to ${formattedMaxDiscountInt} ${currencyLabel}. You have: ${points}`
|
|
: `满 ${billing.pointsPerUsd} 分起兑,每 ${billing.pointsPerUsd} 分抵 1 ${currencyLabel},最多抵 ${formattedMaxDiscountInt} ${currencyLabel}。当前 ${points} 分`}
|
|
</p>
|
|
|
|
{billing.pointsEnabled && (
|
|
<div className={s.progressWrap}>
|
|
<div className={s.progressHeader}>
|
|
<span>
|
|
{points} / {maxPointsForFullDiscount}
|
|
</span>
|
|
<span>
|
|
{isEn
|
|
? `${formattedRedeemableNow} / ${formattedMaxDiscountInt} ${currencyLabel}`
|
|
: `${formattedRedeemableNow} / ${formattedMaxDiscountInt} ${currencyLabel}`}
|
|
</span>
|
|
</div>
|
|
<div className={s.progressTrack}>
|
|
<div
|
|
className={s.progressFill}
|
|
style={{ width: `${progressPct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginTop: "auto", paddingTop: 16 }}>
|
|
{resolvedTelegramGroupUrl ? (
|
|
<Link
|
|
href={resolvedTelegramGroupUrl}
|
|
target="_blank"
|
|
className={s.unavailCta}
|
|
>
|
|
<MessageSquare size={12} />
|
|
{isEn
|
|
? "Invite paid users to earn points"
|
|
: "邀请付费用户即可获得积分"}
|
|
<ArrowRight size={11} />
|
|
</Link>
|
|
) : (
|
|
<span className={s.unavailCta} style={{ cursor: "default" }}>
|
|
<MessageSquare size={12} />
|
|
{isEn
|
|
? "Invite paid users to earn points"
|
|
: "邀请付费用户即可获得积分"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Payment summary ── */}
|
|
<div className={s.summaryBox}>
|
|
<div className={s.summaryRow}>
|
|
<div>
|
|
<p className={s.summaryLabel}>
|
|
{isEn ? "Total Due Today" : "今日应付"}
|
|
</p>
|
|
{paymentTokenLabel && (
|
|
<p className={s.summaryOriginal}>
|
|
{isEn ? "Token" : "支付币种"}: {paymentTokenLabel}
|
|
</p>
|
|
)}
|
|
{billing.discountAmount > 0 && usePoints && (
|
|
<p className={s.summaryOriginal}>
|
|
{formattedPlanPrice} {currencyLabel}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className={s.summaryAmount}>
|
|
<span className={s.summaryPrice}>
|
|
{formattedFinalPrice}
|
|
</span>
|
|
<span className={s.summaryUnit}>{currencyLabel}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={s.summaryDivider} />
|
|
|
|
<div className={s.ctaWrap}>
|
|
<button onClick={onPay} disabled={payBusy} className={s.ctaBtn}>
|
|
{payBusy ? (
|
|
<Loader2 size={18} className="animate-spin" />
|
|
) : (
|
|
<>
|
|
<Wallet size={17} />
|
|
{finalPayLabel}
|
|
<ArrowRight size={17} />
|
|
</>
|
|
)}
|
|
</button>
|
|
{onManualPay ? (
|
|
<button
|
|
onClick={onManualPay}
|
|
disabled={payBusy}
|
|
className={s.ctaBtn}
|
|
style={{
|
|
marginTop: 10,
|
|
background: "rgba(15, 23, 42, 0.78)",
|
|
border: "1px solid rgba(148, 163, 184, 0.28)",
|
|
}}
|
|
>
|
|
{payBusy ? (
|
|
<Loader2 size={18} className="animate-spin" />
|
|
) : (
|
|
<>
|
|
<Wallet size={17} />
|
|
{manualPayLabel || (isEn ? "Manual transfer" : "手动转账")}
|
|
<ArrowRight size={17} />
|
|
</>
|
|
)}
|
|
</button>
|
|
) : null}
|
|
{onManualPay ? (
|
|
<p
|
|
style={{
|
|
marginTop: 10,
|
|
color: "rgba(203, 213, 225, 0.72)",
|
|
fontSize: 12,
|
|
lineHeight: 1.5,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
{isEn
|
|
? "Choose one payment method only. Do not pay again after the order is completed."
|
|
: "请只选择一种支付方式;订单完成后请勿重复付款。"}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Footer ── */}
|
|
<div className={s.footer}>
|
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
|
|
<Lock size={11} />
|
|
{isEn ? "Secure Payment" : "安全付款"}
|
|
</span>
|
|
<span style={{ color: "#0f172a" }}>·</span>
|
|
<Link href={faqHref} className={s.footerLink}>
|
|
<BellRing size={11} />
|
|
{isEn ? "Subscription FAQ" : "订阅说明"}
|
|
</Link>
|
|
</div>
|
|
|
|
{/* ── Error / Info ── */}
|
|
{errorText && (
|
|
<div className={s.alertError}>
|
|
<div className={`${s.alertIconBox} ${s.alertIconError}`}>
|
|
<X size={10} />
|
|
</div>
|
|
{errorText}
|
|
</div>
|
|
)}
|
|
{infoText && (
|
|
<div className={s.alertInfo}>
|
|
<div className={`${s.alertIconBox} ${s.alertIconInfo}`}>
|
|
<CheckCircle2 size={10} />
|
|
</div>
|
|
<div>
|
|
<div>{infoText}</div>
|
|
{txHref && (
|
|
<Link href={txHref} target="_blank" className={s.txLink}>
|
|
{isEn ? "View on-chain transaction" : "查看链上交易"}
|
|
<ExternalLink size={11} />
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|