feat: Implement user account center with authentication, subscription management, and cryptocurrency payment integration.
This commit is contained in:
@@ -40,3 +40,55 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
if (!API_BASE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let payload: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
payload = (await req.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
...auth.headers,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const raw = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||||
|
{ status: res.status },
|
||||||
|
);
|
||||||
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
|
}
|
||||||
|
let data: unknown = { ok: true };
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
data = { ok: true, raw };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = NextResponse.json(data, {
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to unbind wallet", detail: String(error) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { LoginClient } from "@/components/auth/LoginClient";
|
import { LoginClient } from "@/components/auth/LoginClient";
|
||||||
|
import { I18nProvider } from "@/hooks/useI18n";
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
searchParams?: Promise<{ next?: string }>;
|
searchParams?: Promise<{ next?: string }>;
|
||||||
@@ -16,6 +17,9 @@ function normalizeNextPath(input: string | undefined) {
|
|||||||
export default async function LoginPage({ searchParams }: PageProps) {
|
export default async function LoginPage({ searchParams }: PageProps) {
|
||||||
const params = (await searchParams) || {};
|
const params = (await searchParams) || {};
|
||||||
const nextPath = normalizeNextPath(params.next);
|
const nextPath = normalizeNextPath(params.next);
|
||||||
return <LoginClient nextPath={nextPath} />;
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<LoginClient nextPath={nextPath} />
|
||||||
|
</I18nProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
getSupabaseBrowserClient,
|
getSupabaseBrowserClient,
|
||||||
hasSupabasePublicEnv,
|
hasSupabasePublicEnv,
|
||||||
} from "@/lib/supabase/client";
|
} from "@/lib/supabase/client";
|
||||||
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
|
|
||||||
const UnlockProOverlay = dynamic(
|
const UnlockProOverlay = dynamic(
|
||||||
() =>
|
() =>
|
||||||
@@ -489,6 +490,94 @@ function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
|||||||
|
|
||||||
export function AccountCenter() {
|
export function AccountCenter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const isEn = locale === "en-US";
|
||||||
|
const copy = useMemo(
|
||||||
|
() => ({
|
||||||
|
backHome: isEn ? "Back to Home" : "返回首页",
|
||||||
|
accountCenter: isEn ? "Account Center" : "账户中心",
|
||||||
|
loadingAccount: isEn ? "Loading account info..." : "加载账户信息中...",
|
||||||
|
refresh: isEn ? "Refresh" : "刷新",
|
||||||
|
signOut: isEn ? "Sign Out" : "退出",
|
||||||
|
signIn: isEn ? "Sign In" : "登录",
|
||||||
|
upgradePro: isEn ? "Upgrade Pro" : "升级 Pro",
|
||||||
|
guestUser: isEn ? "Guest User" : "游客用户",
|
||||||
|
joinedAt: isEn ? "Joined" : "加入时间",
|
||||||
|
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
|
||||||
|
weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)",
|
||||||
|
weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励",
|
||||||
|
membershipDetails: isEn ? "Membership Details" : "会员权限详情",
|
||||||
|
identityStatus: isEn ? "Identity Status" : "身份状态",
|
||||||
|
authMode: isEn ? "Auth Mode" : "鉴权模式",
|
||||||
|
weatherEngine: isEn ? "Weather Engine" : "气象引擎",
|
||||||
|
intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析",
|
||||||
|
historyFuture:
|
||||||
|
isEn ? "Historical + Future-date Analysis" : "历史对账 + 未来日期分析",
|
||||||
|
smartPush:
|
||||||
|
isEn ? "Cross-platform Smart Weather Push" : "全平台智能气象推送",
|
||||||
|
deepMode:
|
||||||
|
isEn ? "Deep mode (incl. high-temp window)" : "深度版(含高温时段)",
|
||||||
|
compactVisible: isEn ? "Compact visible" : "简版可见",
|
||||||
|
enabled: isEn ? "Enabled" : "已开启",
|
||||||
|
locked: isEn ? "Locked" : "锁定",
|
||||||
|
boundEmail: isEn ? "Bound Email" : "绑定邮箱",
|
||||||
|
loginMethod: isEn ? "Sign-in Method" : "登录方式",
|
||||||
|
renewalDate: isEn ? "Renewal Date" : "续费日期",
|
||||||
|
authResult: isEn ? "Auth Result" : "鉴权结果",
|
||||||
|
passed: isEn ? "Passed" : "通过",
|
||||||
|
restricted: isEn ? "Restricted" : "受限",
|
||||||
|
telegramBind: isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定",
|
||||||
|
telegramHint: isEn
|
||||||
|
? "Send the command below to the polyweather bot to sync notifications and access."
|
||||||
|
: "将下方命令发送给polyweather机器人,实现全平台气象推送与权限同步。",
|
||||||
|
copyCommand: isEn ? "Copy command" : "复制命令",
|
||||||
|
paymentMgmt: isEn ? "Payment Management" : "支付管理",
|
||||||
|
paymentToken: isEn ? "Payment Token" : "支付币种",
|
||||||
|
primary: "Primary",
|
||||||
|
polygonChain: "Polygon Chain",
|
||||||
|
noWallet: isEn ? "No payout wallet bound yet." : "未绑定任何收件钱包",
|
||||||
|
bindExt:
|
||||||
|
isEn ? "Bind Browser Wallet (EVM Extension)" : "绑定浏览器钱包(EVM扩展)",
|
||||||
|
bindQr: isEn ? "Bind via QR (WalletConnect)" : "扫码绑定(WalletConnect)",
|
||||||
|
walletConnectMissing: isEn
|
||||||
|
? "WalletConnect disabled: please configure"
|
||||||
|
: "未启用 WalletConnect:请配置",
|
||||||
|
unbind: isEn ? "Unbind" : "解绑",
|
||||||
|
unbindConfirm: isEn
|
||||||
|
? "Unbind wallet {address}? You can bind it again later."
|
||||||
|
: "确认解绑钱包 {address}?后续可重新绑定。",
|
||||||
|
unbindDone: isEn ? "Wallet unbound." : "钱包已解绑。",
|
||||||
|
unbindDonePrimary: isEn
|
||||||
|
? "Wallet unbound. New primary: {address}"
|
||||||
|
: "钱包已解绑,新的主钱包:{address}",
|
||||||
|
unbindFailed: isEn ? "Failed to unbind wallet" : "解绑钱包失败",
|
||||||
|
payNow: isEn ? "Subscribe & Activate" : "立即订阅并激活服务",
|
||||||
|
connectAndPay: isEn ? "Connect Wallet & Pay" : "连接钱包并支付",
|
||||||
|
loginBeforeBind: isEn
|
||||||
|
? "Please sign in before binding wallet."
|
||||||
|
: "请先登录后再绑定钱包。",
|
||||||
|
loginBeforePay: isEn
|
||||||
|
? "Please sign in before payment."
|
||||||
|
: "请先登录后再支付。",
|
||||||
|
bindFirstBeforePay: isEn
|
||||||
|
? "Please bind a wallet first."
|
||||||
|
: "请先绑定钱包。",
|
||||||
|
payNotReady: isEn
|
||||||
|
? "Payment service is not fully configured."
|
||||||
|
: "支付服务未配置完成。",
|
||||||
|
openBindFlow: isEn
|
||||||
|
? "Please bind a wallet first. Opening bind flow..."
|
||||||
|
: "请先完成钱包绑定,正在拉起绑定流程...",
|
||||||
|
walletBoundCreatingOrder: isEn
|
||||||
|
? "Wallet bound. Creating order and sending payment..."
|
||||||
|
: "钱包已绑定,正在创建订单并发起支付...",
|
||||||
|
proMember: "PRO MEMBER",
|
||||||
|
freeTier: "FREE TIER",
|
||||||
|
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
|
||||||
|
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
|
||||||
|
}),
|
||||||
|
[isEn],
|
||||||
|
);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@@ -522,7 +611,12 @@ export function AccountCenter() {
|
|||||||
* is missing or close to expiry. Throws if the user is not authenticated.
|
* is missing or close to expiry. Throws if the user is not authenticated.
|
||||||
*/
|
*/
|
||||||
const getValidAccessToken = useCallback(async (): Promise<string> => {
|
const getValidAccessToken = useCallback(async (): Promise<string> => {
|
||||||
if (!supabaseReady) throw new Error("Supabase 未配置,无法获取登录凭证。");
|
if (!supabaseReady)
|
||||||
|
throw new Error(
|
||||||
|
isEn
|
||||||
|
? "Supabase is not configured. Unable to get auth token."
|
||||||
|
: "Supabase 未配置,无法获取登录凭证。",
|
||||||
|
);
|
||||||
const client = getSupabaseBrowserClient();
|
const client = getSupabaseBrowserClient();
|
||||||
// First try the cached session.
|
// First try the cached session.
|
||||||
const {
|
const {
|
||||||
@@ -539,10 +633,14 @@ export function AccountCenter() {
|
|||||||
if (refreshedToken) return refreshedToken;
|
if (refreshedToken) return refreshedToken;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
error?.message
|
error?.message
|
||||||
? `登录会话已失效 (${error.message}),请退出后重新登录。`
|
? isEn
|
||||||
: "登录会话已失效,请退出后重新登录。",
|
? `Session expired (${error.message}). Please sign out and sign in again.`
|
||||||
|
: `登录会话已失效 (${error.message}),请退出后重新登录。`
|
||||||
|
: isEn
|
||||||
|
? "Session expired. Please sign out and sign in again."
|
||||||
|
: "登录会话已失效,请退出后重新登录。",
|
||||||
);
|
);
|
||||||
}, [supabaseReady]);
|
}, [isEn, supabaseReady]);
|
||||||
|
|
||||||
const buildAuthedHeaders = useCallback(
|
const buildAuthedHeaders = useCallback(
|
||||||
async (withJson = false): Promise<Record<string, string>> => {
|
async (withJson = false): Promise<Record<string, string>> => {
|
||||||
@@ -764,19 +862,19 @@ export function AccountCenter() {
|
|||||||
const displayName =
|
const displayName =
|
||||||
String(user?.user_metadata?.full_name || "").trim() ||
|
String(user?.user_metadata?.full_name || "").trim() ||
|
||||||
(email ? String(email).split("@")[0] : "") ||
|
(email ? String(email).split("@")[0] : "") ||
|
||||||
"PolyWeather 用户";
|
copy.guestUser;
|
||||||
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
|
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
|
||||||
const joinedAt = formatTime(user?.created_at, "zh-CN");
|
const joinedAt = formatTime(user?.created_at, locale);
|
||||||
const isSubscribed = Boolean(backend?.subscription_active);
|
const isSubscribed = Boolean(backend?.subscription_active);
|
||||||
const expiryRaw = String(
|
const expiryRaw = String(
|
||||||
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
|
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
|
||||||
).trim();
|
).trim();
|
||||||
const expiryFormatted = formatTime(expiryRaw, "zh-CN");
|
const expiryFormatted = formatTime(expiryRaw, locale);
|
||||||
const proExpiry = isSubscribed
|
const proExpiry = isSubscribed
|
||||||
? expiryFormatted !== "--"
|
? expiryFormatted !== "--"
|
||||||
? expiryFormatted
|
? expiryFormatted
|
||||||
: expiryRaw || "已开通(待同步)"
|
: expiryRaw || copy.proPendingSync
|
||||||
: "暂无 Pro 订阅";
|
: copy.noProSubscription;
|
||||||
|
|
||||||
// Points Logic
|
// Points Logic
|
||||||
const backendPointsRaw = Number(backend?.points);
|
const backendPointsRaw = Number(backend?.points);
|
||||||
@@ -1073,7 +1171,7 @@ export function AccountCenter() {
|
|||||||
setPaymentError("");
|
setPaymentError("");
|
||||||
setPaymentInfo("");
|
setPaymentInfo("");
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setPaymentError("请先登录后再绑定钱包。");
|
setPaymentError(copy.loginBeforeBind);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,7 +1199,7 @@ export function AccountCenter() {
|
|||||||
method: "eth_requestAccounts",
|
method: "eth_requestAccounts",
|
||||||
})) as string[];
|
})) as string[];
|
||||||
const address = String(accounts?.[0] || "").toLowerCase();
|
const address = String(accounts?.[0] || "").toLowerCase();
|
||||||
if (!address) throw new Error("钱包账户为空");
|
if (!address) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空");
|
||||||
|
|
||||||
const existingWallet = boundWallets.find(
|
const existingWallet = boundWallets.find(
|
||||||
(w) => String(w.address || "").toLowerCase() === address,
|
(w) => String(w.address || "").toLowerCase() === address,
|
||||||
@@ -1163,16 +1261,77 @@ export function AccountCenter() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUnbindWallet = async (address: string) => {
|
||||||
|
const target = String(address || "").toLowerCase();
|
||||||
|
if (!target) return;
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setPaymentError(copy.loginBeforeBind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
copy.unbindConfirm.replace("{address}", shortAddress(target)),
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setPaymentBusy(true);
|
||||||
|
setPaymentError("");
|
||||||
|
setPaymentInfo("");
|
||||||
|
try {
|
||||||
|
const headers = await buildAuthedHeaders(true);
|
||||||
|
const res = await fetch("/api/payments/wallets", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ address: target }),
|
||||||
|
});
|
||||||
|
const raw = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = raw;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
detail = String(parsed?.detail || parsed?.error || raw);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new Error(detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
data = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
const newPrimary = String(data?.new_primary || "").toLowerCase();
|
||||||
|
if (selectedWallet === target) {
|
||||||
|
setSelectedWallet(newPrimary || "");
|
||||||
|
}
|
||||||
|
if (walletAddress === target) {
|
||||||
|
setWalletAddress(newPrimary || "");
|
||||||
|
}
|
||||||
|
await loadPaymentSnapshot();
|
||||||
|
setPaymentInfo(
|
||||||
|
newPrimary
|
||||||
|
? copy.unbindDonePrimary.replace("{address}", shortAddress(newPrimary))
|
||||||
|
: copy.unbindDone,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = normalizePaymentError(error).message;
|
||||||
|
setPaymentError(`${copy.unbindFailed}: ${message}`);
|
||||||
|
} finally {
|
||||||
|
setPaymentBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createIntentAndPay = async () => {
|
const createIntentAndPay = async () => {
|
||||||
setPaymentError("");
|
setPaymentError("");
|
||||||
setPaymentInfo("");
|
setPaymentInfo("");
|
||||||
setLastTxHash("");
|
setLastTxHash("");
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setPaymentError("请先登录后再支付。");
|
setPaymentError(copy.loginBeforePay);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!paymentConfig?.configured) {
|
if (!paymentConfig?.configured) {
|
||||||
setPaymentError("支付服务未配置完成。");
|
setPaymentError(copy.payNotReady);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1180,7 +1339,7 @@ export function AccountCenter() {
|
|||||||
selectedWallet || walletAddress || boundWallets[0]?.address || "",
|
selectedWallet || walletAddress || boundWallets[0]?.address || "",
|
||||||
).toLowerCase();
|
).toLowerCase();
|
||||||
if (!fallbackWallet) {
|
if (!fallbackWallet) {
|
||||||
setPaymentError("请先绑定钱包。");
|
setPaymentError(copy.bindFirstBeforePay);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1193,7 +1352,7 @@ export function AccountCenter() {
|
|||||||
method: "eth_requestAccounts",
|
method: "eth_requestAccounts",
|
||||||
})) as string[];
|
})) as string[];
|
||||||
const activeAddress = String(activeAccounts?.[0] || "").toLowerCase();
|
const activeAddress = String(activeAccounts?.[0] || "").toLowerCase();
|
||||||
if (!activeAddress) throw new Error("钱包账户为空");
|
if (!activeAddress) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空");
|
||||||
|
|
||||||
const boundAddrSet = new Set(
|
const boundAddrSet = new Set(
|
||||||
boundWallets.map((row) => String(row.address || "").toLowerCase()),
|
boundWallets.map((row) => String(row.address || "").toLowerCase()),
|
||||||
@@ -1408,16 +1567,16 @@ export function AccountCenter() {
|
|||||||
|
|
||||||
const handleOverlayCheckout = async () => {
|
const handleOverlayCheckout = async () => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setPaymentError("请先登录后再支付。");
|
setPaymentError(copy.loginBeforePay);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasPayingWallet) {
|
if (!hasPayingWallet) {
|
||||||
setPaymentInfo("请先完成钱包绑定,正在拉起绑定流程...");
|
setPaymentInfo(copy.openBindFlow);
|
||||||
const bound = await connectAndBindWallet(providerMode, {
|
const bound = await connectAndBindWallet(providerMode, {
|
||||||
openOverlayAfterBind: true,
|
openOverlayAfterBind: true,
|
||||||
});
|
});
|
||||||
if (!bound) return;
|
if (!bound) return;
|
||||||
setPaymentInfo("钱包已绑定,正在创建订单并发起支付...");
|
setPaymentInfo(copy.walletBoundCreatingOrder);
|
||||||
await createIntentAndPay();
|
await createIntentAndPay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1431,7 +1590,7 @@ export function AccountCenter() {
|
|||||||
<div className="flex h-screen w-full items-center justify-center bg-[#0b0f1a]">
|
<div className="flex h-screen w-full items-center justify-center bg-[#0b0f1a]">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-blue-500" />
|
<Loader2 className="h-12 w-12 animate-spin text-blue-500" />
|
||||||
<p className="text-slate-400 font-medium">加载账户信息中...</p>
|
<p className="text-slate-400 font-medium">{copy.loadingAccount}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1449,7 +1608,8 @@ export function AccountCenter() {
|
|||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full text-slate-400 hover:text-white transition-all active:scale-90 group"
|
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full text-slate-400 hover:text-white transition-all active:scale-90 group"
|
||||||
title="返回首页"
|
title={copy.backHome}
|
||||||
|
aria-label={copy.backHome}
|
||||||
>
|
>
|
||||||
<ChevronLeft
|
<ChevronLeft
|
||||||
size={20}
|
size={20}
|
||||||
@@ -1458,7 +1618,7 @@ export function AccountCenter() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||||
账户中心
|
{copy.accountCenter}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1468,7 +1628,7 @@ export function AccountCenter() {
|
|||||||
onClick={() => setShowOverlay(true)}
|
onClick={() => setShowOverlay(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
|
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
|
||||||
>
|
>
|
||||||
<Crown size={16} /> 升级 Pro
|
<Crown size={16} /> {copy.upgradePro}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -1482,21 +1642,21 @@ export function AccountCenter() {
|
|||||||
) : (
|
) : (
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
)}{" "}
|
)}{" "}
|
||||||
刷新
|
{copy.refresh}
|
||||||
</button>
|
</button>
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => void onSignOut()}
|
onClick={() => void onSignOut()}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 text-red-400 rounded-xl text-sm transition-all"
|
className="flex items-center gap-2 px-4 py-2 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 text-red-400 rounded-xl text-sm transition-all"
|
||||||
>
|
>
|
||||||
<LogOut size={16} /> 退出
|
<LogOut size={16} /> {copy.signOut}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href="/auth/login?next=%2Faccount"
|
href="/auth/login?next=%2Faccount"
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 text-blue-400 rounded-xl text-sm transition-all"
|
className="flex items-center gap-2 px-4 py-2 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 text-blue-400 rounded-xl text-sm transition-all"
|
||||||
>
|
>
|
||||||
<LogIn size={16} /> 登录
|
<LogIn size={16} /> {copy.signIn}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1521,11 +1681,11 @@ export function AccountCenter() {
|
|||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-tighter border ${isSubscribed ? "bg-blue-500/20 border-blue-500/40 text-blue-400" : "bg-slate-700/50 border-white/10 text-slate-500"}`}
|
className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-tighter border ${isSubscribed ? "bg-blue-500/20 border-blue-500/40 text-blue-400" : "bg-slate-700/50 border-white/10 text-slate-500"}`}
|
||||||
>
|
>
|
||||||
{isSubscribed ? "PRO MEMBER" : "FREE TIER"}
|
{isSubscribed ? copy.proMember : copy.freeTier}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-500 font-mono text-sm mb-4">
|
<p className="text-slate-500 font-mono text-sm mb-4">
|
||||||
{email || "游客用户"}
|
{email || copy.guestUser}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap justify-center md:justify-start gap-4">
|
<div className="flex flex-wrap justify-center md:justify-start gap-4">
|
||||||
<div className="flex items-center gap-1.5 text-slate-400 text-xs">
|
<div className="flex items-center gap-1.5 text-slate-400 text-xs">
|
||||||
@@ -1535,14 +1695,17 @@ export function AccountCenter() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-slate-400 text-xs">
|
<div className="flex items-center gap-1.5 text-slate-400 text-xs">
|
||||||
<Clock size={14} /> <span>加入时间: {joinedAt}</span>
|
<Clock size={14} />{" "}
|
||||||
|
<span>
|
||||||
|
{copy.joinedAt}: {joinedAt}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="px-6 py-4 bg-black/40 rounded-2xl border border-white/5 text-center min-w-[140px]">
|
<div className="px-6 py-4 bg-black/40 rounded-2xl border border-white/5 text-center min-w-[140px]">
|
||||||
<p className="text-[10px] text-slate-500 uppercase tracking-widest mb-1">
|
<p className="text-[10px] text-slate-500 uppercase tracking-widest mb-1">
|
||||||
总积分 (荣誉)
|
{copy.totalPoints}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
|
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
|
||||||
<Coins size={16} className="text-yellow-500" />{" "}
|
<Coins size={16} className="text-yellow-500" />{" "}
|
||||||
@@ -1551,7 +1714,7 @@ export function AccountCenter() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-4 bg-blue-500/10 rounded-2xl border border-blue-500/20 text-center min-w-[140px]">
|
<div className="px-6 py-4 bg-blue-500/10 rounded-2xl border border-blue-500/20 text-center min-w-[140px]">
|
||||||
<p className="text-[10px] text-blue-400 uppercase tracking-widest mb-1 font-bold">
|
<p className="text-[10px] text-blue-400 uppercase tracking-widest mb-1 font-bold">
|
||||||
周排行 (竞技)
|
{copy.weeklyRank}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
|
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
|
||||||
<Trophy size={16} className="text-amber-400" /> #{weeklyRank}
|
<Trophy size={16} className="text-amber-400" /> #{weeklyRank}
|
||||||
@@ -1564,7 +1727,8 @@ export function AccountCenter() {
|
|||||||
<div className="lg:col-span-4 bg-gradient-to-br from-indigo-600/20 to-purple-600/20 border border-indigo-500/30 rounded-[2.5rem] p-6 flex flex-col justify-between shadow-xl">
|
<div className="lg:col-span-4 bg-gradient-to-br from-indigo-600/20 to-purple-600/20 border border-indigo-500/30 rounded-[2.5rem] p-6 flex flex-col justify-between shadow-xl">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2 text-white mb-6">
|
<h3 className="text-lg font-bold flex items-center gap-2 text-white mb-6">
|
||||||
<Sparkles size={20} className="text-yellow-400" /> 周榜奖励
|
<Sparkles size={20} className="text-yellow-400" />{" "}
|
||||||
|
{copy.weeklyRewards}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
|
||||||
@@ -1617,49 +1781,49 @@ export function AccountCenter() {
|
|||||||
>
|
>
|
||||||
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
|
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
|
||||||
<h3 className="text-sm font-bold text-blue-400 uppercase tracking-widest mb-4">
|
<h3 className="text-sm font-bold text-blue-400 uppercase tracking-widest mb-4">
|
||||||
会员权限详情
|
{copy.membershipDetails}
|
||||||
</h3>
|
</h3>
|
||||||
<InfoRow icon={ShieldCheck} label="鉴权模式" value="Supabase" />
|
<InfoRow icon={ShieldCheck} label={copy.authMode} value="Supabase" />
|
||||||
<InfoRow icon={BarChart3} label="气象引擎" value="DEB + 多模型" />
|
<InfoRow icon={BarChart3} label={copy.weatherEngine} value="DEB + 多模型" />
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
label="今日内分析"
|
label={copy.intradayAnalysis}
|
||||||
value={isSubscribed ? "深度版(含高温时段)" : "简版可见"}
|
value={isSubscribed ? copy.deepMode : copy.compactVisible}
|
||||||
isPrimary={isSubscribed}
|
isPrimary={isSubscribed}
|
||||||
/>
|
/>
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
label="历史对账 + 未来日期分析"
|
label={copy.historyFuture}
|
||||||
value={isSubscribed ? "已开启" : "锁定"}
|
value={isSubscribed ? copy.enabled : copy.locked}
|
||||||
isPrimary={isSubscribed}
|
isPrimary={isSubscribed}
|
||||||
/>
|
/>
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Bot}
|
icon={Bot}
|
||||||
label="全平台智能气象推送"
|
label={copy.smartPush}
|
||||||
value={isSubscribed ? "已开启" : "锁定"}
|
value={isSubscribed ? copy.enabled : copy.locked}
|
||||||
isPrimary={isSubscribed}
|
isPrimary={isSubscribed}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
|
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
|
||||||
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-widest mb-4">
|
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-widest mb-4">
|
||||||
身份状态
|
{copy.identityStatus}
|
||||||
</h3>
|
</h3>
|
||||||
<InfoRow icon={Mail} label="绑定邮箱" value={email || "--"} />
|
<InfoRow icon={Mail} label={copy.boundEmail} value={email || "--"} />
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={LogIn}
|
icon={LogIn}
|
||||||
label="登录方式"
|
label={copy.loginMethod}
|
||||||
value={user?.app_metadata?.provider?.toUpperCase() || "GOOGLE"}
|
value={user?.app_metadata?.provider?.toUpperCase() || "GOOGLE"}
|
||||||
/>
|
/>
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
label="续费日期"
|
label={copy.renewalDate}
|
||||||
value={proExpiry}
|
value={proExpiry}
|
||||||
isPrimary
|
isPrimary
|
||||||
/>
|
/>
|
||||||
<InfoRow
|
<InfoRow
|
||||||
icon={UserCheck}
|
icon={UserCheck}
|
||||||
label="鉴权结果"
|
label={copy.authResult}
|
||||||
value={backend?.authenticated ? "通过" : "受限"}
|
value={backend?.authenticated ? copy.passed : copy.restricted}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -1685,7 +1849,7 @@ export function AccountCenter() {
|
|||||||
onClose={() => setShowOverlay(false)}
|
onClose={() => setShowOverlay(false)}
|
||||||
payBusy={paymentBusy}
|
payBusy={paymentBusy}
|
||||||
payLabel={
|
payLabel={
|
||||||
hasPayingWallet ? "立即订阅并激活服务" : "连接钱包并支付"
|
hasPayingWallet ? copy.payNow : copy.connectAndPay
|
||||||
}
|
}
|
||||||
errorText={paymentError || undefined}
|
errorText={paymentError || undefined}
|
||||||
infoText={paymentInfo || undefined}
|
infoText={paymentInfo || undefined}
|
||||||
@@ -1708,10 +1872,10 @@ export function AccountCenter() {
|
|||||||
/>
|
/>
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<h3 className="text-lg font-bold mb-2 flex items-center gap-2 text-blue-400">
|
<h3 className="text-lg font-bold mb-2 flex items-center gap-2 text-blue-400">
|
||||||
<Bot size={22} /> Telegram Bot 绑定
|
<Bot size={22} /> {copy.telegramBind}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-slate-400 text-sm mb-6">
|
<p className="text-slate-400 text-sm mb-6">
|
||||||
将下方命令发送给polyweather机器人,实现全平台气象推送与权限同步。
|
{copy.telegramHint}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<code className="flex-grow bg-black/40 border border-white/10 p-4 rounded-xl font-mono text-xs text-blue-300 overflow-hidden text-ellipsis whitespace-nowrap">
|
<code className="flex-grow bg-black/40 border border-white/10 p-4 rounded-xl font-mono text-xs text-blue-300 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
@@ -1720,7 +1884,8 @@ export function AccountCenter() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleCopy(bindCommand)}
|
onClick={() => handleCopy(bindCommand)}
|
||||||
className="p-4 bg-blue-600 hover:bg-blue-500 rounded-xl transition-all shadow-lg text-white"
|
className="p-4 bg-blue-600 hover:bg-blue-500 rounded-xl transition-all shadow-lg text-white"
|
||||||
title="复制命令"
|
title={copy.copyCommand}
|
||||||
|
aria-label={copy.copyCommand}
|
||||||
>
|
>
|
||||||
{copied ? <CheckCircle2 size={20} /> : <Copy size={20} />}
|
{copied ? <CheckCircle2 size={20} /> : <Copy size={20} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -1732,12 +1897,12 @@ export function AccountCenter() {
|
|||||||
<section className="w-full md:w-96 bg-white/5 border border-white/10 rounded-[2rem] p-8 flex flex-col justify-between">
|
<section className="w-full md:w-96 bg-white/5 border border-white/10 rounded-[2rem] p-8 flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-blue-400 text-sm font-bold uppercase tracking-widest mb-6 flex items-center gap-2">
|
<h3 className="text-blue-400 text-sm font-bold uppercase tracking-widest mb-6 flex items-center gap-2">
|
||||||
<Wallet size={18} /> 支付管理
|
<Wallet size={18} /> {copy.paymentMgmt}
|
||||||
</h3>
|
</h3>
|
||||||
{availableTokenList.length > 0 && (
|
{availableTokenList.length > 0 && (
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<p className="text-[11px] uppercase tracking-widest text-slate-500 mb-2">
|
<p className="text-[11px] uppercase tracking-widest text-slate-500 mb-2">
|
||||||
支付币种
|
{copy.paymentToken}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{availableTokenList.map((token) => {
|
{availableTokenList.map((token) => {
|
||||||
@@ -1781,17 +1946,28 @@ export function AccountCenter() {
|
|||||||
</span>
|
</span>
|
||||||
{w.is_primary && (
|
{w.is_primary && (
|
||||||
<span className="text-[8px] bg-blue-500 px-1 rounded">
|
<span className="text-[8px] bg-blue-500 px-1 rounded">
|
||||||
Primary
|
{copy.primary}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px]">Polygon Chain</div>
|
<div className="text-[10px]">{copy.polygonChain}</div>
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleUnbindWallet(w.address)}
|
||||||
|
disabled={paymentBusy}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-[10px] font-semibold text-red-300 transition-all hover:bg-red-500/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Minus size={12} />
|
||||||
|
{copy.unbind}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-slate-500 italic">
|
<p className="text-xs text-slate-500 italic">
|
||||||
未绑定任何收件钱包
|
{copy.noWallet}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1805,7 +1981,7 @@ export function AccountCenter() {
|
|||||||
disabled={paymentBusy || !isAuthenticated}
|
disabled={paymentBusy || !isAuthenticated}
|
||||||
className="w-full py-3 border border-white/10 bg-white/5 hover:bg-white/10 rounded-xl text-xs font-bold text-slate-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60"
|
className="w-full py-3 border border-white/10 bg-white/5 hover:bg-white/10 rounded-xl text-xs font-bold text-slate-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<PlusIcon className="w-4 h-4" /> 绑定浏览器钱包(EVM扩展)
|
<PlusIcon className="w-4 h-4" /> {copy.bindExt}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1817,11 +1993,11 @@ export function AccountCenter() {
|
|||||||
}
|
}
|
||||||
className="w-full py-3 border border-cyan-400/30 bg-cyan-500/10 hover:bg-cyan-500/20 rounded-xl text-xs font-bold text-cyan-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60"
|
className="w-full py-3 border border-cyan-400/30 bg-cyan-500/10 hover:bg-cyan-500/20 rounded-xl text-xs font-bold text-cyan-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<CreditCard className="w-4 h-4" /> 扫码绑定(WalletConnect)
|
<CreditCard className="w-4 h-4" /> {copy.bindQr}
|
||||||
</button>
|
</button>
|
||||||
{!walletConnectEnabled && (
|
{!walletConnectEnabled && (
|
||||||
<p className="text-[11px] text-slate-500">
|
<p className="text-[11px] text-slate-500">
|
||||||
未启用 WalletConnect:请配置
|
{copy.walletConnectMissing}
|
||||||
<code className="mx-1">
|
<code className="mx-1">
|
||||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||||
</code>
|
</code>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
getSupabaseBrowserClient,
|
getSupabaseBrowserClient,
|
||||||
hasSupabasePublicEnv,
|
hasSupabasePublicEnv,
|
||||||
} from "@/lib/supabase/client";
|
} from "@/lib/supabase/client";
|
||||||
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
|
|
||||||
type Mode = "login" | "signup";
|
type Mode = "login" | "signup";
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ type LoginClientProps = {
|
|||||||
|
|
||||||
export function LoginClient({ nextPath }: LoginClientProps) {
|
export function LoginClient({ nextPath }: LoginClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { locale } = useI18n();
|
||||||
const [mode, setMode] = useState<Mode>("login");
|
const [mode, setMode] = useState<Mode>("login");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -34,6 +36,42 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
const [infoText, setInfoText] = useState("");
|
const [infoText, setInfoText] = useState("");
|
||||||
|
|
||||||
const supabaseReady = hasSupabasePublicEnv();
|
const supabaseReady = hasSupabasePublicEnv();
|
||||||
|
const isEn = locale === "en-US";
|
||||||
|
const copy = {
|
||||||
|
backHome: isEn ? "Back to Home" : "返回首页",
|
||||||
|
subtitle: isEn
|
||||||
|
? "Explore weather details from every corner of the world"
|
||||||
|
: "探索世界每一个角落的气象细节",
|
||||||
|
googleOneClick: isEn
|
||||||
|
? "Continue with Google"
|
||||||
|
: "使用 Google 账号一键登录",
|
||||||
|
orEmail: isEn ? "Or continue with email" : "或使用邮箱",
|
||||||
|
login: isEn ? "Sign In" : "登录",
|
||||||
|
signup: isEn ? "Sign Up" : "注册",
|
||||||
|
passwordLoginPlaceholder: isEn ? "Enter password" : "输入密码",
|
||||||
|
passwordSignupPlaceholder: isEn
|
||||||
|
? "Set at least 6 characters"
|
||||||
|
: "设置至少 6 位密码",
|
||||||
|
loginSubmit: isEn ? "Start your weather journey" : "开启天气之旅",
|
||||||
|
signupSubmit: isEn ? "Create account now" : "立即创建账号",
|
||||||
|
loginHint: isEn
|
||||||
|
? "After signing in, your homepage will be personalized."
|
||||||
|
: "登录后将为您个性化定制首页数据",
|
||||||
|
signupHint: isEn
|
||||||
|
? "By signing up, you agree to our Terms of Service."
|
||||||
|
: "注册即代表同意我们的服务条款",
|
||||||
|
realtime: isEn ? "Realtime data" : "实时数据",
|
||||||
|
highPrecision: isEn ? "High-precision forecast" : "高精度预测",
|
||||||
|
supabaseMissing: isEn
|
||||||
|
? "Supabase is not configured. Sign-in is unavailable."
|
||||||
|
: "Supabase 未配置,无法使用登录",
|
||||||
|
needEmailPassword: isEn
|
||||||
|
? "Please enter email and password."
|
||||||
|
: "请输入邮箱和密码",
|
||||||
|
signupCheckEmail: isEn
|
||||||
|
? "Sign-up successful. Please verify your email before signing in."
|
||||||
|
: "注册成功,请检查邮箱并完成验证后登录。",
|
||||||
|
} as const;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!supabaseReady) return;
|
if (!supabaseReady) return;
|
||||||
@@ -53,7 +91,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
setErrorText("");
|
setErrorText("");
|
||||||
setInfoText("");
|
setInfoText("");
|
||||||
if (!supabaseReady) {
|
if (!supabaseReady) {
|
||||||
setErrorText("Supabase 未配置,无法使用登录");
|
setErrorText(copy.supabaseMissing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +120,11 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
setErrorText("");
|
setErrorText("");
|
||||||
setInfoText("");
|
setInfoText("");
|
||||||
if (!supabaseReady) {
|
if (!supabaseReady) {
|
||||||
setErrorText("Supabase 未配置,无法使用登录");
|
setErrorText(copy.supabaseMissing);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!email.trim() || !password.trim()) {
|
if (!email.trim() || !password.trim()) {
|
||||||
setErrorText("请输入邮箱和密码");
|
setErrorText(copy.needEmailPassword);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +162,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
router.replace(nextPath);
|
router.replace(nextPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setInfoText("注册成功,请检查邮箱并完成验证后登录。");
|
setInfoText(copy.signupCheckEmail);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -141,18 +179,17 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="group absolute left-6 top-6 rounded-full border border-white/10 bg-white/5 p-2 text-slate-400 transition-all hover:bg-white/10 hover:text-white active:scale-90"
|
className="group absolute left-6 top-6 rounded-full border border-white/10 bg-white/5 p-2 text-slate-400 transition-all hover:bg-white/10 hover:text-white active:scale-90"
|
||||||
title="返回首页"
|
title={copy.backHome}
|
||||||
aria-label="返回首页"
|
aria-label={copy.backHome}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-5 w-5 transition-transform group-hover:-translate-x-0.5" />
|
<ChevronLeft className="h-5 w-5 transition-transform group-hover:-translate-x-0.5" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mb-8 flex flex-col items-center">
|
<div className="mb-8 flex flex-col items-center">
|
||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-tr from-blue-500 to-indigo-400 shadow-lg shadow-blue-500/20">
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-tr from-blue-500 to-indigo-400 shadow-lg shadow-blue-500/20">
|
||||||
<Cloud className="h-10 w-10 text-white" />
|
<Cloud className="h-10 w-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-white">PolyWeather</h1>
|
<h1 className="text-3xl font-bold tracking-tight text-white">PolyWeather</h1>
|
||||||
<p className="mt-2 text-sm text-slate-400">探索世界每一个角落的气象细节</p>
|
<p className="mt-2 text-sm text-slate-400">{copy.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -162,13 +199,13 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
className="mb-6 flex w-full items-center justify-center rounded-xl bg-white px-4 py-3.5 font-semibold text-slate-900 shadow-lg transition-all duration-200 hover:bg-slate-100 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
|
className="mb-6 flex w-full items-center justify-center rounded-xl bg-white px-4 py-3.5 font-semibold text-slate-900 shadow-lg transition-all duration-200 hover:bg-slate-100 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
>
|
>
|
||||||
<Chrome className="mr-3 h-5 w-5" />
|
<Chrome className="mr-3 h-5 w-5" />
|
||||||
使用 Google 账号一键登录
|
{copy.googleOneClick}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="my-6 flex items-center">
|
<div className="my-6 flex items-center">
|
||||||
<div className="h-[1px] flex-grow bg-white/10" />
|
<div className="h-[1px] flex-grow bg-white/10" />
|
||||||
<span className="px-4 text-xs font-medium uppercase tracking-widest text-slate-500">
|
<span className="px-4 text-xs font-medium uppercase tracking-widest text-slate-500">
|
||||||
或使用邮箱
|
{copy.orEmail}
|
||||||
</span>
|
</span>
|
||||||
<div className="h-[1px] flex-grow bg-white/10" />
|
<div className="h-[1px] flex-grow bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +220,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
: "text-slate-400 hover:text-slate-200"
|
: "text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
登录
|
{copy.login}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -194,7 +231,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
: "text-slate-400 hover:text-slate-200"
|
: "text-slate-400 hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
注册
|
{copy.signup}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,7 +255,11 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
minLength={6}
|
minLength={6}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
placeholder={isLogin ? "输入密码" : "设置至少 6 位密码"}
|
placeholder={
|
||||||
|
isLogin
|
||||||
|
? copy.passwordLoginPlaceholder
|
||||||
|
: copy.passwordSignupPlaceholder
|
||||||
|
}
|
||||||
className="w-full rounded-xl border border-white/10 bg-white/5 py-3.5 pl-12 pr-4 text-white placeholder:text-slate-600 transition-all focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
className="w-full rounded-xl border border-white/10 bg-white/5 py-3.5 pl-12 pr-4 text-white placeholder:text-slate-600 transition-all focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +269,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="group mt-8 flex w-full items-center justify-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 py-3.5 font-bold text-white shadow-xl shadow-blue-600/20 transition-all hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
|
className="group mt-8 flex w-full items-center justify-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 py-3.5 font-bold text-white shadow-xl shadow-blue-600/20 transition-all hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{isLogin ? "开启天气之旅" : "立即创建账号"}
|
{isLogin ? copy.loginSubmit : copy.signupSubmit}
|
||||||
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -238,23 +279,21 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
|
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
{isLogin ? "登录后将为您个性化定制首页数据" : "注册即代表同意我们的服务条款"}
|
{isLogin ? copy.loginHint : copy.signupHint}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!supabaseReady ? (
|
{!supabaseReady ? (
|
||||||
<p className="mt-3 text-center text-sm text-rose-300">
|
<p className="mt-3 text-center text-sm text-rose-300">{copy.supabaseMissing}</p>
|
||||||
Supabase 未配置,无法使用登录
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-8 flex items-center gap-4 text-sm text-slate-600">
|
<div className="absolute bottom-8 flex items-center gap-4 text-sm text-slate-600">
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Sun className="mr-1 h-4 w-4" /> 实时数据
|
<Sun className="mr-1 h-4 w-4" /> {copy.realtime}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<CloudRain className="mr-1 h-4 w-4" /> 高精度预测
|
<CloudRain className="mr-1 h-4 w-4" /> {copy.highPrecision}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1119,6 +1119,86 @@ class PaymentContractCheckoutService:
|
|||||||
verified_at=now_iso,
|
verified_at=now_iso,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def unbind_wallet(self, user_id: str, address: str) -> Dict[str, Any]:
|
||||||
|
self._ensure_enabled()
|
||||||
|
normalized = _normalize_address(address)
|
||||||
|
if not normalized:
|
||||||
|
raise PaymentCheckoutError(400, "invalid wallet address")
|
||||||
|
|
||||||
|
# Must be an active wallet owned by current user.
|
||||||
|
self._require_user_wallet(user_id, normalized)
|
||||||
|
|
||||||
|
now_iso = _to_iso(_now_utc())
|
||||||
|
self._rest(
|
||||||
|
"PATCH",
|
||||||
|
"user_wallets",
|
||||||
|
params={
|
||||||
|
"user_id": f"eq.{user_id}",
|
||||||
|
"chain_id": f"eq.{self.chain_id}",
|
||||||
|
"address": f"eq.{normalized}",
|
||||||
|
},
|
||||||
|
payload={
|
||||||
|
"status": "revoked",
|
||||||
|
"is_primary": False,
|
||||||
|
"updated_at": now_iso,
|
||||||
|
},
|
||||||
|
prefer="return=representation",
|
||||||
|
allowed_status=[200],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure there is still an active primary wallet after unbind.
|
||||||
|
active_primary_rows = self._rest(
|
||||||
|
"GET",
|
||||||
|
"user_wallets",
|
||||||
|
params={
|
||||||
|
"select": "id,address",
|
||||||
|
"user_id": f"eq.{user_id}",
|
||||||
|
"chain_id": f"eq.{self.chain_id}",
|
||||||
|
"status": "eq.active",
|
||||||
|
"is_primary": "eq.true",
|
||||||
|
"limit": "1",
|
||||||
|
},
|
||||||
|
allowed_status=[200],
|
||||||
|
)
|
||||||
|
|
||||||
|
new_primary = ""
|
||||||
|
if isinstance(active_primary_rows, list) and active_primary_rows:
|
||||||
|
new_primary = _normalize_address(active_primary_rows[0].get("address") or "")
|
||||||
|
else:
|
||||||
|
active_wallet_rows = self._rest(
|
||||||
|
"GET",
|
||||||
|
"user_wallets",
|
||||||
|
params={
|
||||||
|
"select": "id,address",
|
||||||
|
"user_id": f"eq.{user_id}",
|
||||||
|
"chain_id": f"eq.{self.chain_id}",
|
||||||
|
"status": "eq.active",
|
||||||
|
"order": "verified_at.desc,updated_at.desc",
|
||||||
|
"limit": "1",
|
||||||
|
},
|
||||||
|
allowed_status=[200],
|
||||||
|
)
|
||||||
|
if isinstance(active_wallet_rows, list) and active_wallet_rows:
|
||||||
|
candidate = active_wallet_rows[0]
|
||||||
|
candidate_id = candidate.get("id")
|
||||||
|
candidate_addr = _normalize_address(candidate.get("address") or "")
|
||||||
|
if candidate_id and candidate_addr:
|
||||||
|
self._rest(
|
||||||
|
"PATCH",
|
||||||
|
"user_wallets",
|
||||||
|
params={"id": f"eq.{candidate_id}"},
|
||||||
|
payload={"is_primary": True, "updated_at": now_iso},
|
||||||
|
prefer="return=representation",
|
||||||
|
allowed_status=[200],
|
||||||
|
)
|
||||||
|
new_primary = candidate_addr
|
||||||
|
|
||||||
|
return {
|
||||||
|
"address": normalized,
|
||||||
|
"unbound": True,
|
||||||
|
"new_primary": new_primary or None,
|
||||||
|
}
|
||||||
|
|
||||||
def _select_plan(self, plan_code: str) -> Dict[str, Any]:
|
def _select_plan(self, plan_code: str) -> Dict[str, Any]:
|
||||||
code = str(plan_code or "").strip().lower() or "pro_monthly"
|
code = str(plan_code or "").strip().lower() or "pro_monthly"
|
||||||
row = self.plan_catalog.get(code)
|
row = self.plan_catalog.get(code)
|
||||||
|
|||||||
+17
@@ -261,6 +261,10 @@ class WalletVerifyRequest(BaseModel):
|
|||||||
signature: str = Field(..., min_length=20)
|
signature: str = Field(..., min_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class WalletUnbindRequest(BaseModel):
|
||||||
|
address: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
class CreatePaymentIntentRequest(BaseModel):
|
class CreatePaymentIntentRequest(BaseModel):
|
||||||
plan_code: str = Field(default="pro_monthly", min_length=2)
|
plan_code: str = Field(default="pro_monthly", min_length=2)
|
||||||
payment_mode: str = Field(default="strict")
|
payment_mode: str = Field(default="strict")
|
||||||
@@ -1226,6 +1230,19 @@ async def payment_wallets(request: Request):
|
|||||||
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/payments/wallets")
|
||||||
|
async def payment_wallet_unbind(request: Request, body: WalletUnbindRequest):
|
||||||
|
_assert_entitlement(request)
|
||||||
|
identity = _require_supabase_identity(request)
|
||||||
|
try:
|
||||||
|
return PAYMENT_CHECKOUT.unbind_wallet(
|
||||||
|
user_id=identity["user_id"],
|
||||||
|
address=body.address,
|
||||||
|
)
|
||||||
|
except PaymentCheckoutError as exc:
|
||||||
|
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/payments/wallets/challenge")
|
@app.post("/api/payments/wallets/challenge")
|
||||||
async def payment_wallet_challenge(request: Request, body: WalletChallengeRequest):
|
async def payment_wallet_challenge(request: Request, body: WalletChallengeRequest):
|
||||||
_assert_entitlement(request)
|
_assert_entitlement(request)
|
||||||
|
|||||||
Reference in New Issue
Block a user