新增终端大洲分组与移动端卡片 CSS 模块
包含分组标题行、移动端 Tab 隐藏滚动条、信号卡片样式及浅色主题适配。
This commit is contained in:
@@ -94,7 +94,6 @@ SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
SUPABASE_AUTH_CACHE_TTL_SEC=30
|
||||
SUPABASE_SUB_CACHE_TTL_SEC=60
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
POLYWEATHER_SIGNUP_TRIAL_ENABLED=false
|
||||
POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION=decline
|
||||
|
||||
########################################
|
||||
|
||||
@@ -30,10 +30,6 @@ POLYWEATHER_AUTH_ENABLED=false
|
||||
# false: 登录可选,访客可浏览
|
||||
POLYWEATHER_AUTH_REQUIRED=false
|
||||
|
||||
# 可选:分享式看板访问令牌
|
||||
# 设置后,可通过 /?access_token=<token> 打开受保护看板
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
|
||||
# 可选:前端 API Route 转发到后端时附带的共享令牌
|
||||
# 仅当后端启用了 entitlement / 订阅校验时需要
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||
|
||||
export default function AccountErrorPage({
|
||||
function AccountErrorContent({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
useEffect(() => {
|
||||
console.error("Account page error:", error);
|
||||
}, [error]);
|
||||
@@ -38,7 +42,7 @@ export default function AccountErrorPage({
|
||||
color: "var(--color-accent-primary, #4DA3FF)",
|
||||
}}
|
||||
>
|
||||
账户页面出错
|
||||
{isEn ? "Account Page Error" : "账户页面出错"}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
@@ -49,21 +53,9 @@ export default function AccountErrorPage({
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和
|
||||
Rabby)。请尝试关闭其他钱包插件后刷新页面重试。
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--color-text-muted, #7D8FA3)",
|
||||
fontSize: "0.8rem",
|
||||
margin: 0,
|
||||
maxWidth: 420,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
If this happened during payment or wallet binding, the most common cause is
|
||||
conflicting wallet extensions. Try disabling other wallet extensions (e.g.
|
||||
MetaMask + Rabby) and refresh.
|
||||
{isEn
|
||||
? "If this happened during payment or wallet binding, the most common cause is conflicting wallet extensions (e.g. MetaMask and Rabby open at the same time). Try disabling other wallet extensions and refresh."
|
||||
: "如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和 Rabby)。请尝试关闭其他钱包插件后刷新页面重试。"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -84,8 +76,22 @@ export default function AccountErrorPage({
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
重试
|
||||
{isEn ? "Retry" : "重试"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AccountErrorContent error={error} reset={reset} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
export function EntitlementRequiredClient({ nextPath }: { nextPath: string }) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
|
||||
const copy = {
|
||||
title: isEn ? "Sign in required" : "需要登录方可访问此页面",
|
||||
desc: isEn
|
||||
? "This page requires you to sign in. Please sign in and try again."
|
||||
: "本页面需要登录权限。请登录后重试。",
|
||||
signIn: isEn ? "Sign in" : "去登录",
|
||||
backHome: isEn ? "Back to Home" : "返回首页",
|
||||
legacyNote: isEn ? "Legacy token mode still supported" : "传统令牌模式仍支持",
|
||||
requestedPath: isEn ? "Requested path" : "请求路径",
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background:
|
||||
"radial-gradient(circle at 20% 20%, #13264f 0%, #071127 45%, #040812 100%)",
|
||||
color: "#d6e2ff",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<section
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 480,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
background: "rgba(9, 18, 36, 0.88)",
|
||||
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 22,
|
||||
lineHeight: 1.3,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
{copy.title}
|
||||
</h1>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
{copy.desc}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 20,
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, #2563EB, #4F46E5)",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{copy.signIn}
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
background: "rgba(68, 92, 140, 0.2)",
|
||||
color: "#d6e2ff",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{copy.backHome}
|
||||
</Link>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 20,
|
||||
fontSize: 12,
|
||||
color: "#7891b5",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{copy.legacyNote}{" "}
|
||||
<code
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
?access_token=<your-token>
|
||||
</code>
|
||||
<br />
|
||||
<span style={{ marginTop: 4, display: "inline-block" }}>
|
||||
{copy.requestedPath}: <code>{nextPath}</code>
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { EntitlementRequiredClient } from "./EntitlementRequiredClient";
|
||||
import { I18nProvider } from "@/hooks/useI18n";
|
||||
|
||||
type Props = {
|
||||
searchParams?: Promise<{ next?: string }>;
|
||||
@@ -9,98 +10,8 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
|
||||
const nextPath = params.next || "/";
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background:
|
||||
"radial-gradient(circle at 20% 20%, #13264f 0%, #071127 45%, #040812 100%)",
|
||||
color: "#d6e2ff",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<section
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 480,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
background: "rgba(9, 18, 36, 0.88)",
|
||||
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 22,
|
||||
lineHeight: 1.3,
|
||||
fontWeight: 800,
|
||||
}}
|
||||
>
|
||||
需要登录方可访问此页面
|
||||
</h1>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
本页面需要登录权限。请登录后重试。
|
||||
<br />
|
||||
Sign in required to access this page.
|
||||
</p>
|
||||
<div style={{ marginTop: 20, display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap" }}>
|
||||
<Link
|
||||
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(135deg, #2563EB, #4F46E5)",
|
||||
color: "#fff",
|
||||
fontWeight: 700,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
去登录 / Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minHeight: 40,
|
||||
padding: "8px 20px",
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
background: "rgba(68, 92, 140, 0.2)",
|
||||
color: "#d6e2ff",
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
返回首页 / Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 20,
|
||||
fontSize: 12,
|
||||
color: "#7891b5",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
传统令牌模式仍支持 <code style={{ background: "rgba(255,255,255,0.06)", padding: "2px 6px", borderRadius: 4 }}>?access_token=<your-token></code>
|
||||
<br />
|
||||
<span style={{ marginTop: 4, display: "inline-block" }}>
|
||||
请求路径 / Requested path: <code>{nextPath}</code>
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
<I18nProvider>
|
||||
<EntitlementRequiredClient nextPath={nextPath} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -888,7 +888,6 @@ export function AccountCenter() {
|
||||
const joinedAt = formatTime(user?.created_at, locale);
|
||||
const isSubscribed = Boolean(backend?.subscription_active);
|
||||
const planCode = String(backend?.subscription_plan_code || "").trim();
|
||||
const isTrialPlan = /trial/i.test(planCode);
|
||||
const currentExpiryRaw = String(
|
||||
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
|
||||
).trim();
|
||||
@@ -904,7 +903,7 @@ export function AccountCenter() {
|
||||
);
|
||||
const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0);
|
||||
const canAccessPaidTelegramGroup = Boolean(
|
||||
isSubscribed && (!isTrialPlan || hasQueuedExtension),
|
||||
isSubscribed,
|
||||
);
|
||||
const telegramBound = Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
|
||||
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw;
|
||||
@@ -933,7 +932,7 @@ export function AccountCenter() {
|
||||
const paymentFeatureReady = paymentReadyForRecovery;
|
||||
const canOpenCheckoutOverlay = Boolean(
|
||||
paymentFeatureReady &&
|
||||
(!isSubscribed || isTrialPlan || showExpiringSoon || showExpiredReminder),
|
||||
(!isSubscribed || showExpiringSoon || showExpiredReminder),
|
||||
);
|
||||
const subscriptionStatusTitle = showExpiredReminder
|
||||
? copy.proExpiredTitle
|
||||
@@ -2237,7 +2236,7 @@ export function AccountCenter() {
|
||||
>
|
||||
{isSubscribed
|
||||
? copy.proMember
|
||||
: copy.freeTier}
|
||||
: isEn ? "UNSUBSCRIBED" : "未订阅"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-4 font-mono text-sm text-slate-500">
|
||||
|
||||
@@ -129,7 +129,6 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
? "Wallet bound. Creating order and sending payment..."
|
||||
: "钱包已绑定,正在创建订单并发起支付...",
|
||||
proMember: "PRO MEMBER",
|
||||
freeTier: isEn ? "UNSUBSCRIBED" : "未订阅",
|
||||
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
|
||||
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
|
||||
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* ScanTerminalContinent — continent group headers and mobile cards */
|
||||
|
||||
.root {
|
||||
--gap-green: #16a34a;
|
||||
--gap-orange: #ea580c;
|
||||
--gap-slate: #475569;
|
||||
--gap-gray: #94a3b8;
|
||||
--gap-red: #dc2626;
|
||||
}
|
||||
|
||||
/* Group header row */
|
||||
.groupHeader {
|
||||
background: #eef2f6;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
}
|
||||
.groupHeader:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Mobile: hide scrollbar on tab bar */
|
||||
.mobileTabs {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.mobileTabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile card */
|
||||
.mobileCard {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.mobileCard:hover {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
/* Signal badge colors */
|
||||
.signalActive {
|
||||
color: #059669;
|
||||
}
|
||||
.signalWatch {
|
||||
color: #d97706;
|
||||
}
|
||||
.signalClosed {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.signalData {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Light theme overrides — match scan-terminal's .scan-terminal.light scope */
|
||||
:global(.scan-terminal.light) .groupHeader {
|
||||
background: #f8fafc;
|
||||
border-bottom-color: #e2e8f0;
|
||||
}
|
||||
:global(.scan-terminal.light) .groupHeader:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
:global(.scan-terminal.light) .mobileCard {
|
||||
background: #ffffff;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import scanTerminalListStyles from "./ScanTerminalList.module.css";
|
||||
import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css";
|
||||
import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css";
|
||||
import scanTerminalShellStyles from "./ScanTerminalShell.module.css";
|
||||
import scanTerminalContinentStyles from "./ScanTerminalContinent.module.css";
|
||||
import scanTerminalStateStyles from "./ScanTerminalState.module.css";
|
||||
|
||||
export const scanRootClass = clsx(
|
||||
@@ -38,6 +39,7 @@ export const scanRootClass = clsx(
|
||||
scanTerminalStateStyles.root,
|
||||
scanTerminalOpportunityStyles.root,
|
||||
scanTerminalCardStyles.root,
|
||||
scanTerminalContinentStyles.root,
|
||||
scanTerminalMobileStyles.root,
|
||||
detailChromeStyles.root,
|
||||
detailContentStyles.root,
|
||||
|
||||
@@ -1260,18 +1260,6 @@ export function DashboardStoreProvider({
|
||||
});
|
||||
}
|
||||
|
||||
const isTrialPlan = /trial/i.test(
|
||||
String(proAccess.subscriptionPlanCode || ""),
|
||||
);
|
||||
if (
|
||||
isTrialPlan &&
|
||||
markAnalyticsOnce(`signup-completed:${proAccess.userId}`, "local")
|
||||
) {
|
||||
trackAppEvent("signup_completed", {
|
||||
source: "auth_me_trial",
|
||||
subscription_plan_code: proAccess.subscriptionPlanCode,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
proAccess.authenticated,
|
||||
proAccess.loading,
|
||||
|
||||
+1
-50
@@ -5,8 +5,6 @@ import {
|
||||
} from "@/lib/supabase/server";
|
||||
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
|
||||
|
||||
const SESSION_COOKIE = "polyweather_entitlement";
|
||||
|
||||
function readEnvBool(name: string, fallback: boolean) {
|
||||
const raw = process.env[name];
|
||||
if (raw == null) return fallback;
|
||||
@@ -25,7 +23,6 @@ function isPublicPage(pathname: string) {
|
||||
pathname === "/" ||
|
||||
pathname.startsWith("/docs") ||
|
||||
pathname.startsWith("/subscription-help") ||
|
||||
pathname === "/entitlement-required" ||
|
||||
pathname.startsWith("/auth/login") ||
|
||||
pathname.startsWith("/auth/callback")
|
||||
);
|
||||
@@ -93,52 +90,6 @@ async function handleTerminalGate(request: NextRequest): Promise<NextResponse> {
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
function handleLegacyTokenGate(request: NextRequest) {
|
||||
const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim();
|
||||
if (!requiredToken) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const { pathname, searchParams } = request.nextUrl;
|
||||
if (isPublicPage(pathname) || isPublicApi(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const cookieToken = request.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (cookieToken && cookieToken === requiredToken) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const queryToken = searchParams.get("access_token");
|
||||
if (queryToken && queryToken === requiredToken) {
|
||||
const cleanUrl = request.nextUrl.clone();
|
||||
cleanUrl.searchParams.delete("access_token");
|
||||
|
||||
const response = NextResponse.redirect(cleanUrl);
|
||||
response.cookies.set(SESSION_COOKIE, requiredToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: cleanUrl.protocol === "https:",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 12,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized", detail: "Entitlement token required" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const deniedUrl = request.nextUrl.clone();
|
||||
deniedUrl.pathname = "/entitlement-required";
|
||||
deniedUrl.search = "";
|
||||
deniedUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(deniedUrl);
|
||||
}
|
||||
|
||||
async function handleSupabaseAuthGate(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
if (isPublicPage(pathname) || isPublicApi(pathname)) {
|
||||
@@ -224,7 +175,7 @@ export async function middleware(request: NextRequest) {
|
||||
}
|
||||
return handleSupabaseOptionalSession(request);
|
||||
}
|
||||
return handleLegacyTokenGate(request);
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
@@ -65,27 +65,10 @@ class SupabaseEntitlementService:
|
||||
self.timeout_sec = max(3, _env_int("SUPABASE_HTTP_TIMEOUT_SEC", 8))
|
||||
self.cache_ttl_sec = max(5, _env_int("SUPABASE_AUTH_CACHE_TTL_SEC", 30))
|
||||
self.sub_cache_ttl_sec = max(5, _env_int("SUPABASE_SUB_CACHE_TTL_SEC", 60))
|
||||
self.signup_trial_enabled = _env_bool(
|
||||
"POLYWEATHER_SIGNUP_TRIAL_ENABLED",
|
||||
True,
|
||||
)
|
||||
self.signup_trial_days = max(
|
||||
0,
|
||||
_env_int("POLYWEATHER_SIGNUP_TRIAL_DAYS", 3),
|
||||
)
|
||||
self.signup_trial_plan_code = str(
|
||||
os.getenv("POLYWEATHER_SIGNUP_TRIAL_PLAN_CODE") or "signup_trial_3d"
|
||||
).strip() or "signup_trial_3d"
|
||||
self.signup_trial_source = str(
|
||||
os.getenv("POLYWEATHER_SIGNUP_TRIAL_SOURCE") or "signup_trial"
|
||||
).strip() or "signup_trial"
|
||||
|
||||
self._identity_cache: Dict[str, Dict[str, object]] = {}
|
||||
self._identity_cache_lock = threading.Lock()
|
||||
self._sub_cache: Dict[str, Dict[str, object]] = {}
|
||||
self._sub_cache_lock = threading.Lock()
|
||||
self._trial_locks: Dict[str, threading.Lock] = {}
|
||||
self._trial_locks_guard = threading.Lock()
|
||||
|
||||
def invalidate_subscription_cache(self, user_id: str) -> None:
|
||||
key = str(user_id or "").strip()
|
||||
@@ -375,134 +358,6 @@ class SupabaseEntitlementService:
|
||||
return row
|
||||
return None
|
||||
|
||||
def _get_trial_lock(self, user_id: str) -> threading.Lock:
|
||||
key = str(user_id or "").strip()
|
||||
with self._trial_locks_guard:
|
||||
lock = self._trial_locks.get(key)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
self._trial_locks[key] = lock
|
||||
return lock
|
||||
|
||||
def _emit_signup_trial_event(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
starts_at: datetime,
|
||||
expires_at: datetime,
|
||||
) -> None:
|
||||
if not self.service_role_key:
|
||||
return
|
||||
try:
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
requests.post(
|
||||
self._entitlement_events_endpoint(),
|
||||
headers={
|
||||
**self._request_headers_for_service_role(),
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=minimal",
|
||||
},
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"action": "subscription_granted",
|
||||
"reason": "signup_trial",
|
||||
"detail": f"{self.signup_trial_days}d signup trial granted",
|
||||
"payload": {
|
||||
"plan_code": self.signup_trial_plan_code,
|
||||
"source": self.signup_trial_source,
|
||||
"starts_at": starts_at.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
},
|
||||
"created_at": now_iso,
|
||||
},
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase signup trial event insert failed user_id={user_id}: {exc}")
|
||||
|
||||
def ensure_signup_trial(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
created_at: Optional[str] = None,
|
||||
) -> Optional[Dict[str, object]]:
|
||||
normalized_user_id = str(user_id or "").strip()
|
||||
if not normalized_user_id:
|
||||
return None
|
||||
if (
|
||||
not self.signup_trial_enabled
|
||||
or self.signup_trial_days <= 0
|
||||
or not self.service_role_key
|
||||
):
|
||||
return None
|
||||
|
||||
lock = self._get_trial_lock(normalized_user_id)
|
||||
with lock:
|
||||
existing_active = self._query_latest_active_subscription(normalized_user_id)
|
||||
if isinstance(existing_active, dict):
|
||||
return existing_active
|
||||
|
||||
existing_any = self._query_latest_subscription_any_status(normalized_user_id)
|
||||
if isinstance(existing_any, dict):
|
||||
return None
|
||||
|
||||
starts_at = self._parse_iso_datetime(created_at)
|
||||
if starts_at is None:
|
||||
admin_users = self.get_auth_users([normalized_user_id])
|
||||
starts_at = self._parse_iso_datetime(
|
||||
str((admin_users.get(normalized_user_id) or {}).get("created_at") or "")
|
||||
)
|
||||
if starts_at is None:
|
||||
return None
|
||||
|
||||
expires_at = starts_at + timedelta(days=self.signup_trial_days)
|
||||
now = datetime.now(timezone.utc)
|
||||
if expires_at <= now:
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"user_id": normalized_user_id,
|
||||
"plan_code": self.signup_trial_plan_code,
|
||||
"status": "active",
|
||||
"starts_at": starts_at.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"source": self.signup_trial_source,
|
||||
"created_at": now.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
}
|
||||
try:
|
||||
response = requests.post(
|
||||
self._subscription_endpoint(),
|
||||
headers={
|
||||
**self._request_headers_for_service_role(),
|
||||
"Content-Type": "application/json",
|
||||
"Prefer": "return=representation",
|
||||
},
|
||||
json=payload,
|
||||
timeout=self.timeout_sec,
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
logger.warning(
|
||||
"supabase signup trial insert failed user_id={} status={}",
|
||||
normalized_user_id,
|
||||
response.status_code,
|
||||
)
|
||||
return self._query_latest_active_subscription(normalized_user_id)
|
||||
rows = response.json() if response.content else []
|
||||
row = rows[0] if isinstance(rows, list) and rows else None
|
||||
self.invalidate_subscription_cache(normalized_user_id)
|
||||
self._emit_signup_trial_event(
|
||||
user_id=normalized_user_id,
|
||||
starts_at=starts_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
if isinstance(row, dict):
|
||||
return row
|
||||
return self._query_latest_active_subscription(normalized_user_id)
|
||||
except Exception as exc:
|
||||
logger.warning(f"supabase signup trial insert error user_id={normalized_user_id}: {exc}")
|
||||
return self._query_latest_active_subscription(normalized_user_id)
|
||||
|
||||
def _query_active_subscription(self, user_id: str) -> bool:
|
||||
return self._query_latest_active_subscription(user_id) is not None
|
||||
|
||||
|
||||
@@ -14,58 +14,6 @@ class _Response:
|
||||
return self._payload
|
||||
|
||||
|
||||
def test_ensure_signup_trial_grants_three_day_subscription(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
||||
monkeypatch.setenv("POLYWEATHER_SIGNUP_TRIAL_ENABLED", "true")
|
||||
monkeypatch.setenv("POLYWEATHER_SIGNUP_TRIAL_DAYS", "3")
|
||||
|
||||
service = SupabaseEntitlementService()
|
||||
monkeypatch.setattr(service, "_query_latest_active_subscription", lambda user_id: None)
|
||||
monkeypatch.setattr(service, "_query_latest_subscription_any_status", lambda user_id: None)
|
||||
|
||||
captured_posts = []
|
||||
|
||||
def _fake_post(url, headers=None, json=None, timeout=None):
|
||||
captured_posts.append({"url": url, "headers": headers, "json": json, "timeout": timeout})
|
||||
if url.endswith("/rest/v1/subscriptions"):
|
||||
return _Response(
|
||||
201,
|
||||
[
|
||||
{
|
||||
"user_id": json["user_id"],
|
||||
"plan_code": json["plan_code"],
|
||||
"status": json["status"],
|
||||
"starts_at": json["starts_at"],
|
||||
"expires_at": json["expires_at"],
|
||||
"source": json["source"],
|
||||
}
|
||||
],
|
||||
)
|
||||
return _Response(201, {})
|
||||
|
||||
monkeypatch.setattr(entitlement_module.requests, "post", _fake_post)
|
||||
|
||||
starts_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
result = service.ensure_signup_trial(
|
||||
"user-1",
|
||||
created_at=starts_at.isoformat(),
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["plan_code"] == "signup_trial_3d"
|
||||
assert result["status"] == "active"
|
||||
assert result["starts_at"] == starts_at.isoformat()
|
||||
assert result["expires_at"] == (starts_at + timedelta(days=3)).isoformat()
|
||||
|
||||
subscription_insert = next(
|
||||
item for item in captured_posts if item["url"].endswith("/rest/v1/subscriptions")
|
||||
)
|
||||
assert subscription_insert["json"]["user_id"] == "user-1"
|
||||
assert subscription_insert["json"]["source"] == "signup_trial"
|
||||
|
||||
|
||||
def test_latest_active_subscription_ignores_future_start(monkeypatch):
|
||||
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
||||
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
|
||||
|
||||
@@ -31,17 +31,12 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
|
||||
|
||||
if legacy_routes.SUPABASE_ENTITLEMENT.enabled and user_id:
|
||||
try:
|
||||
latest_subscription = legacy_routes.SUPABASE_ENTITLEMENT.ensure_signup_trial(
|
||||
user_id,
|
||||
created_at=getattr(request.state, "auth_created_at", None),
|
||||
)
|
||||
if not latest_subscription:
|
||||
latest_subscription = (
|
||||
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
latest_subscription = (
|
||||
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription(
|
||||
user_id,
|
||||
respect_requirement=False,
|
||||
)
|
||||
)
|
||||
|
||||
latest_known_subscription = latest_subscription
|
||||
if not latest_known_subscription:
|
||||
|
||||
Reference in New Issue
Block a user