新增终端大洲分组与移动端卡片 CSS 模块

包含分组标题行、移动端 Tab 隐藏滚动条、信号卡片样式及浅色主题适配。
This commit is contained in:
2569718930@qq.com
2026-05-25 01:55:55 +08:00
parent 0b47f4b487
commit 9a49ff3f5e
14 changed files with 237 additions and 392 deletions
-1
View File
@@ -94,7 +94,6 @@ SUPABASE_HTTP_TIMEOUT_SEC=8
SUPABASE_AUTH_CACHE_TTL_SEC=30 SUPABASE_AUTH_CACHE_TTL_SEC=30
SUPABASE_SUB_CACHE_TTL_SEC=60 SUPABASE_SUB_CACHE_TTL_SEC=60
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
POLYWEATHER_SIGNUP_TRIAL_ENABLED=false
POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION=decline POLYWEATHER_TELEGRAM_JOIN_INELIGIBLE_ACTION=decline
######################################## ########################################
-4
View File
@@ -30,10 +30,6 @@ POLYWEATHER_AUTH_ENABLED=false
# false: 登录可选,访客可浏览 # false: 登录可选,访客可浏览
POLYWEATHER_AUTH_REQUIRED=false POLYWEATHER_AUTH_REQUIRED=false
# 可选:分享式看板访问令牌
# 设置后,可通过 /?access_token=<token> 打开受保护看板
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
# 可选:前端 API Route 转发到后端时附带的共享令牌 # 可选:前端 API Route 转发到后端时附带的共享令牌
# 仅当后端启用了 entitlement / 订阅校验时需要 # 仅当后端启用了 entitlement / 订阅校验时需要
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
+24 -18
View File
@@ -2,14 +2,18 @@
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { I18nProvider, useI18n } from "@/hooks/useI18n";
export default function AccountErrorPage({ function AccountErrorContent({
error, error,
reset, reset,
}: { }: {
error: Error & { digest?: string }; error: Error & { digest?: string };
reset: () => void; reset: () => void;
}) { }) {
const { locale } = useI18n();
const isEn = locale === "en-US";
useEffect(() => { useEffect(() => {
console.error("Account page error:", error); console.error("Account page error:", error);
}, [error]); }, [error]);
@@ -38,7 +42,7 @@ export default function AccountErrorPage({
color: "var(--color-accent-primary, #4DA3FF)", color: "var(--color-accent-primary, #4DA3FF)",
}} }}
> >
{isEn ? "Account Page Error" : "账户页面出错"}
</h1> </h1>
<p <p
style={{ style={{
@@ -49,21 +53,9 @@ export default function AccountErrorPage({
lineHeight: 1.7, lineHeight: 1.7,
}} }}
> >
MetaMask {isEn
Rabby ? "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."
</p> : "如果是在支付或绑定钱包时出现此问题,常见原因是钱包插件冲突(例如同时开启了 MetaMask 和 Rabby)。请尝试关闭其他钱包插件后刷新页面重试。"}
<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.
</p> </p>
<button <button
type="button" type="button"
@@ -84,8 +76,22 @@ export default function AccountErrorPage({
}} }}
> >
<RefreshCw size={14} /> <RefreshCw size={14} />
{isEn ? "Retry" : "重试"}
</button> </button>
</div> </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=&lt;your-token&gt;
</code>
<br />
<span style={{ marginTop: 4, display: "inline-block" }}>
{copy.requestedPath}: <code>{nextPath}</code>
</span>
</p>
</section>
</main>
);
}
+5 -94
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import { EntitlementRequiredClient } from "./EntitlementRequiredClient";
import { I18nProvider } from "@/hooks/useI18n";
type Props = { type Props = {
searchParams?: Promise<{ next?: string }>; searchParams?: Promise<{ next?: string }>;
@@ -9,98 +10,8 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
const nextPath = params.next || "/"; const nextPath = params.next || "/";
return ( return (
<main <I18nProvider>
style={{ <EntitlementRequiredClient nextPath={nextPath} />
minHeight: "100vh", </I18nProvider>
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=&lt;your-token&gt;</code>
<br />
<span style={{ marginTop: 4, display: "inline-block" }}>
/ Requested path: <code>{nextPath}</code>
</span>
</p>
</section>
</main>
); );
} }
@@ -888,7 +888,6 @@ export function AccountCenter() {
const joinedAt = formatTime(user?.created_at, locale); const joinedAt = formatTime(user?.created_at, locale);
const isSubscribed = Boolean(backend?.subscription_active); const isSubscribed = Boolean(backend?.subscription_active);
const planCode = String(backend?.subscription_plan_code || "").trim(); const planCode = String(backend?.subscription_plan_code || "").trim();
const isTrialPlan = /trial/i.test(planCode);
const currentExpiryRaw = String( const currentExpiryRaw = String(
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "", backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
).trim(); ).trim();
@@ -904,7 +903,7 @@ export function AccountCenter() {
); );
const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0); const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0);
const canAccessPaidTelegramGroup = Boolean( const canAccessPaidTelegramGroup = Boolean(
isSubscribed && (!isTrialPlan || hasQueuedExtension), isSubscribed,
); );
const telegramBound = Number(backend?.telegram_pricing?.telegram_id || 0) > 0; const telegramBound = Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw; const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw;
@@ -933,7 +932,7 @@ export function AccountCenter() {
const paymentFeatureReady = paymentReadyForRecovery; const paymentFeatureReady = paymentReadyForRecovery;
const canOpenCheckoutOverlay = Boolean( const canOpenCheckoutOverlay = Boolean(
paymentFeatureReady && paymentFeatureReady &&
(!isSubscribed || isTrialPlan || showExpiringSoon || showExpiredReminder), (!isSubscribed || showExpiringSoon || showExpiredReminder),
); );
const subscriptionStatusTitle = showExpiredReminder const subscriptionStatusTitle = showExpiredReminder
? copy.proExpiredTitle ? copy.proExpiredTitle
@@ -2237,7 +2236,7 @@ export function AccountCenter() {
> >
{isSubscribed {isSubscribed
? copy.proMember ? copy.proMember
: copy.freeTier} : isEn ? "UNSUBSCRIBED" : "未订阅"}
</span> </span>
</div> </div>
<p className="mb-4 font-mono text-sm text-slate-500"> <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..." ? "Wallet bound. Creating order and sending payment..."
: "钱包已绑定,正在创建订单并发起支付...", : "钱包已绑定,正在创建订单并发起支付...",
proMember: "PRO MEMBER", proMember: "PRO MEMBER",
freeTier: isEn ? "UNSUBSCRIBED" : "未订阅",
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)", proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅", noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "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 scanTerminalMobileStyles from "./ScanTerminalMobile.module.css";
import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css"; import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css";
import scanTerminalShellStyles from "./ScanTerminalShell.module.css"; import scanTerminalShellStyles from "./ScanTerminalShell.module.css";
import scanTerminalContinentStyles from "./ScanTerminalContinent.module.css";
import scanTerminalStateStyles from "./ScanTerminalState.module.css"; import scanTerminalStateStyles from "./ScanTerminalState.module.css";
export const scanRootClass = clsx( export const scanRootClass = clsx(
@@ -38,6 +39,7 @@ export const scanRootClass = clsx(
scanTerminalStateStyles.root, scanTerminalStateStyles.root,
scanTerminalOpportunityStyles.root, scanTerminalOpportunityStyles.root,
scanTerminalCardStyles.root, scanTerminalCardStyles.root,
scanTerminalContinentStyles.root,
scanTerminalMobileStyles.root, scanTerminalMobileStyles.root,
detailChromeStyles.root, detailChromeStyles.root,
detailContentStyles.root, detailContentStyles.root,
-12
View File
@@ -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.authenticated,
proAccess.loading, proAccess.loading,
+1 -50
View File
@@ -5,8 +5,6 @@ import {
} from "@/lib/supabase/server"; } from "@/lib/supabase/server";
import { isLocalFullAccessHost } from "@/lib/local-dev-access"; import { isLocalFullAccessHost } from "@/lib/local-dev-access";
const SESSION_COOKIE = "polyweather_entitlement";
function readEnvBool(name: string, fallback: boolean) { function readEnvBool(name: string, fallback: boolean) {
const raw = process.env[name]; const raw = process.env[name];
if (raw == null) return fallback; if (raw == null) return fallback;
@@ -25,7 +23,6 @@ function isPublicPage(pathname: string) {
pathname === "/" || pathname === "/" ||
pathname.startsWith("/docs") || pathname.startsWith("/docs") ||
pathname.startsWith("/subscription-help") || pathname.startsWith("/subscription-help") ||
pathname === "/entitlement-required" ||
pathname.startsWith("/auth/login") || pathname.startsWith("/auth/login") ||
pathname.startsWith("/auth/callback") pathname.startsWith("/auth/callback")
); );
@@ -93,52 +90,6 @@ async function handleTerminalGate(request: NextRequest): Promise<NextResponse> {
return NextResponse.redirect(loginUrl); 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) { async function handleSupabaseAuthGate(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
if (isPublicPage(pathname) || isPublicApi(pathname)) { if (isPublicPage(pathname) || isPublicApi(pathname)) {
@@ -224,7 +175,7 @@ export async function middleware(request: NextRequest) {
} }
return handleSupabaseOptionalSession(request); return handleSupabaseOptionalSession(request);
} }
return handleLegacyTokenGate(request); return NextResponse.next();
} }
export const config = { export const config = {
+1 -146
View File
@@ -4,7 +4,7 @@ import os
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
import requests import requests
@@ -65,27 +65,10 @@ class SupabaseEntitlementService:
self.timeout_sec = max(3, _env_int("SUPABASE_HTTP_TIMEOUT_SEC", 8)) 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.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.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: Dict[str, Dict[str, object]] = {}
self._identity_cache_lock = threading.Lock() self._identity_cache_lock = threading.Lock()
self._sub_cache: Dict[str, Dict[str, object]] = {} self._sub_cache: Dict[str, Dict[str, object]] = {}
self._sub_cache_lock = threading.Lock() 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: def invalidate_subscription_cache(self, user_id: str) -> None:
key = str(user_id or "").strip() key = str(user_id or "").strip()
@@ -375,134 +358,6 @@ class SupabaseEntitlementService:
return row return row
return None 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: def _query_active_subscription(self, user_id: str) -> bool:
return self._query_latest_active_subscription(user_id) is not None return self._query_latest_active_subscription(user_id) is not None
-52
View File
@@ -14,58 +14,6 @@ class _Response:
return self._payload 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): def test_latest_active_subscription_ignores_future_start(monkeypatch):
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co") monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key") monkeypatch.setenv("SUPABASE_ANON_KEY", "anon-key")
+5 -10
View File
@@ -31,17 +31,12 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
if legacy_routes.SUPABASE_ENTITLEMENT.enabled and user_id: if legacy_routes.SUPABASE_ENTITLEMENT.enabled and user_id:
try: try:
latest_subscription = legacy_routes.SUPABASE_ENTITLEMENT.ensure_signup_trial( latest_subscription = (
user_id, legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription(
created_at=getattr(request.state, "auth_created_at", None), user_id,
) respect_requirement=False,
if not latest_subscription:
latest_subscription = (
legacy_routes.SUPABASE_ENTITLEMENT.get_latest_active_subscription(
user_id,
respect_requirement=False,
)
) )
)
latest_known_subscription = latest_subscription latest_known_subscription = latest_subscription
if not latest_known_subscription: if not latest_known_subscription: