feat: implement weather dashboard components, state management, and API routes for terminal scanning and assistant chat
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -565,6 +566,16 @@ async function generateWithGroq(params: {
|
||||
|
||||
async function ensureAssistantAccess(request: NextRequest) {
|
||||
const auth = await buildBackendRequestHeaders(request);
|
||||
const requestHost =
|
||||
request.headers.get("x-forwarded-host") ||
|
||||
request.headers.get("host") ||
|
||||
request.nextUrl.host;
|
||||
if (
|
||||
isLocalFullAccessHost(requestHost) ||
|
||||
isLocalFullAccessHost(request.nextUrl.hostname)
|
||||
) {
|
||||
return { allowed: true, auth };
|
||||
}
|
||||
if (process.env.POLYWEATHER_AI_ALLOW_FREE === "true") {
|
||||
return { allowed: true, auth };
|
||||
}
|
||||
|
||||
@@ -3,10 +3,25 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
getLocalDevAuthPayload,
|
||||
isLocalFullAccessHost,
|
||||
} from "@/lib/local-dev-access";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const requestHost =
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
|
||||
if (
|
||||
isLocalFullAccessHost(requestHost) ||
|
||||
isLocalFullAccessHost(req.nextUrl.hostname)
|
||||
) {
|
||||
return NextResponse.json(getLocalDevAuthPayload(), {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
|
||||
@@ -10014,6 +10014,25 @@
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-strength.approve) {
|
||||
color: #71f7c8;
|
||||
border-color: rgba(45, 212, 191, 0.34);
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-strength.downgrade),
|
||||
.root :global(.scan-opportunity-strength.watchlist) {
|
||||
color: #ffd166;
|
||||
border-color: rgba(245, 158, 11, 0.32);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-strength.veto) {
|
||||
color: #ff8585;
|
||||
border-color: rgba(248, 113, 113, 0.34);
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-expand) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -10129,6 +10148,367 @@
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-group) {
|
||||
overflow: visible;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-items) {
|
||||
gap: 10px;
|
||||
padding: 10px 12px 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-item) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
border-color: rgba(89, 128, 178, 0.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(6, 18, 32, 0.72);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-item.expanded) {
|
||||
border-color: rgba(23, 217, 139, 0.42);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(8, 29, 43, 0.94), rgba(5, 17, 31, 0.96)),
|
||||
radial-gradient(circle at 12% 0%, rgba(20, 184, 166, 0.12), transparent 36%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(125, 251, 231, 0.09),
|
||||
0 18px 42px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-summary-row) {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
4px minmax(128px, 180px) minmax(360px, 1fr)
|
||||
max-content max-content;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-branch) {
|
||||
align-self: stretch;
|
||||
width: 4px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(23, 217, 139, 0.92), rgba(34, 211, 238, 0.28));
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-branch::before),
|
||||
.root :global(.scan-opportunity-branch i) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-metrics) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(96px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-stat) {
|
||||
display: grid;
|
||||
align-items: start;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-color: rgba(90, 123, 166, 0.14);
|
||||
background: rgba(8, 25, 43, 0.62);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-stat.edge) {
|
||||
justify-self: stretch;
|
||||
justify-content: stretch;
|
||||
justify-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-trade) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-action) {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-ai) {
|
||||
display: grid;
|
||||
grid-column: auto;
|
||||
gap: 4px;
|
||||
margin: 0 14px;
|
||||
padding: 11px 13px;
|
||||
border-color: rgba(64, 104, 168, 0.16);
|
||||
background: rgba(5, 15, 27, 0.64);
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-ai small) {
|
||||
overflow: visible;
|
||||
color: #a9bdd8;
|
||||
white-space: normal;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis) {
|
||||
grid-column: auto;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0 14px 14px;
|
||||
padding: 14px;
|
||||
border-color: rgba(56, 189, 248, 0.18);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
|
||||
radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.11), transparent 28%);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis section) {
|
||||
padding: 13px;
|
||||
border-color: rgba(92, 132, 188, 0.14);
|
||||
background: rgba(10, 25, 43, 0.7);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis p),
|
||||
.root :global(.scan-v4-analysis ul) {
|
||||
color: #b8c9e3;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis .scan-v4-evidence) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis .scan-v4-decision) {
|
||||
grid-column: 1 / -1;
|
||||
border-color: rgba(45, 212, 191, 0.24);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(6, 31, 43, 0.86), rgba(8, 23, 38, 0.86)),
|
||||
radial-gradient(circle at 0 0, rgba(20, 184, 166, 0.12), transparent 32%);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis .scan-v4-decision.downgrade),
|
||||
.root :global(.scan-v4-analysis .scan-v4-decision.watchlist) {
|
||||
border-color: rgba(245, 158, 11, 0.24);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis .scan-v4-decision.veto) {
|
||||
border-color: rgba(248, 113, 113, 0.28);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-decision p b) {
|
||||
color: #ecfeff;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-metar-summary) {
|
||||
margin-top: 9px;
|
||||
color: #8fb3d8;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-evidence > div),
|
||||
.root :global(.scan-v4-model-sources) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 13px;
|
||||
margin: 0 14px 14px;
|
||||
padding: 15px 16px 16px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.16);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
|
||||
radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.08), transparent 30%);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis section),
|
||||
.root :global(.scan-v4-analysis section:first-child),
|
||||
.root :global(.scan-v4-analysis .scan-v4-evidence) {
|
||||
grid-column: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-heading) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(90, 123, 166, 0.14);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-heading > div) {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-heading strong),
|
||||
.root :global(.scan-v4-current b),
|
||||
.root :global(.scan-v4-brief-grid strong) {
|
||||
color: #72f3d1;
|
||||
font-size: 12px;
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-heading p) {
|
||||
margin: 0;
|
||||
color: #d2e1f4;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-decision-pill) {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(45, 212, 191, 0.3);
|
||||
border-radius: 999px;
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
color: #7dfbe7;
|
||||
font-size: 12px;
|
||||
font-weight: 950;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-decision-pill.downgrade),
|
||||
.root :global(.scan-v4-decision-pill.watchlist) {
|
||||
border-color: rgba(245, 158, 11, 0.34);
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #ffd166;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-decision-pill.veto) {
|
||||
border-color: rgba(248, 113, 113, 0.34);
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: #ff8585;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-current) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-current span) {
|
||||
color: #c8d8ec;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-current small) {
|
||||
color: #8fb3d8;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-brief-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(260px, 0.85fr);
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-brief-grid section:first-child) {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-brief-grid ul) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-brief-grid li) {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
color: #aebfd8;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-brief-grid li::before) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0.65em;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: #2dd4bf;
|
||||
box-shadow: 0 0 10px rgba(45, 212, 191, 0.45);
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-brief-grid section:nth-child(2) li::before) {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 10px rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
|
||||
.root :global(details.scan-v4-evidence) {
|
||||
padding-top: 11px;
|
||||
border-top: 1px solid rgba(90, 123, 166, 0.12);
|
||||
}
|
||||
|
||||
.root :global(details.scan-v4-evidence summary) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 30px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(65, 118, 216, 0.28);
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 30, 58, 0.52);
|
||||
color: #8fbdff;
|
||||
font-size: 12px;
|
||||
font-weight: 950;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.root :global(details.scan-v4-evidence summary::-webkit-details-marker) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(details.scan-v4-evidence summary::after) {
|
||||
content: "v";
|
||||
color: #8fbdff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.root :global(details.scan-v4-evidence[open] summary::after) {
|
||||
content: "^";
|
||||
}
|
||||
|
||||
.root :global(details.scan-v4-evidence > div) {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-body::-webkit-scrollbar),
|
||||
.root :global(.scan-calendar-view::-webkit-scrollbar),
|
||||
.root :global(.scan-detail-panel::-webkit-scrollbar) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -831,6 +831,7 @@ export function ProbabilityDistribution({
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
const view = getProbabilityView(detail, targetDate);
|
||||
const modelView = getModelView(detail, targetDate);
|
||||
const marketYesPrice = getMarketYesPrice(marketScan);
|
||||
const marketYesText = toPercent(marketYesPrice);
|
||||
const isToday = !targetDate || targetDate === detail.local_date;
|
||||
@@ -1216,34 +1217,26 @@ export function ProbabilityDistribution({
|
||||
<div className="prob-price-card">
|
||||
<div className="prob-price-head">
|
||||
<span>
|
||||
{locale === "en-US" ? "Probability x Price" : "概率 x 价格联动"}
|
||||
{locale === "en-US" ? "Win-rate reference" : "胜率参考"}
|
||||
</span>
|
||||
<strong>
|
||||
{!marketScan
|
||||
? locale === "en-US"
|
||||
? "Waiting for market layer"
|
||||
: "等待市场层"
|
||||
? "Waiting for market context"
|
||||
: "等待市场参照"
|
||||
: !marketScan.available
|
||||
? locale === "en-US"
|
||||
? "No matched active market"
|
||||
: "未匹配到活跃盘口"
|
||||
: linkedMarketBucket
|
||||
? locale === "en-US"
|
||||
? `${actionText}: ${linkedContractLabel || "contract bucket"}`
|
||||
: `${actionText}:${linkedContractLabel || "合约桶"}`
|
||||
: preferredPriceView?.edge != null
|
||||
? locale === "en-US"
|
||||
? `${actionText} · edge ${formatSignedPercent(preferredPriceView.edge)}`
|
||||
: `${actionText} · 优势 ${formatSignedPercent(preferredPriceView.edge)}`
|
||||
: locale === "en-US"
|
||||
? "Waiting for executable quote"
|
||||
: "等待可执行报价"}
|
||||
: locale === "en-US"
|
||||
? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}`
|
||||
: `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="prob-price-grid">
|
||||
<div>
|
||||
<span>
|
||||
{locale === "en-US" ? "Contract bucket" : "合约桶口径"}
|
||||
{locale === "en-US" ? "Bucket" : "温度桶"}
|
||||
</span>
|
||||
<strong>
|
||||
{linkedContractLabel || topProbabilityLabel || "--"}
|
||||
@@ -1253,33 +1246,29 @@ export function ProbabilityDistribution({
|
||||
</em>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Candidate" : "可关注"}</span>
|
||||
<strong>{actionText}</strong>
|
||||
<em>{actionNote}</em>
|
||||
<span>{locale === "en-US" ? "DEB" : "DEB"}</span>
|
||||
<strong>
|
||||
{modelView.deb != null && Number.isFinite(Number(modelView.deb))
|
||||
? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}`
|
||||
: "--"}
|
||||
</strong>
|
||||
<em>{locale === "en-US" ? "final fused forecast" : "最终融合预测"}</em>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "YES price" : "YES 价格"}</span>
|
||||
<strong>{toPriceCents(yesDisplayPrice) || "--"}</strong>
|
||||
<em>
|
||||
{locale === "en-US"
|
||||
? `edge ${formatSignedPercent(yesDisplayEdge)}`
|
||||
: `优势 ${formatSignedPercent(yesDisplayEdge)}`}
|
||||
</em>
|
||||
<span>{locale === "en-US" ? "Model support" : "模型支持"}</span>
|
||||
<strong>{modelVoteHint || "--"}</strong>
|
||||
<em>{locale === "en-US" ? "raw model agreement" : "原始模型一致性"}</em>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "NO price" : "NO 价格"}</span>
|
||||
<strong>{toPriceCents(noDisplayPrice) || "--"}</strong>
|
||||
<em>
|
||||
{locale === "en-US"
|
||||
? `edge ${formatSignedPercent(noDisplayEdge)}`
|
||||
: `优势 ${formatSignedPercent(noDisplayEdge)}`}
|
||||
</em>
|
||||
<span>{locale === "en-US" ? "Market role" : "盘口角色"}</span>
|
||||
<strong>{locale === "en-US" ? "Reference only" : "仅作参考"}</strong>
|
||||
<em>{quoteSourceLabel}</em>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
{locale === "en-US"
|
||||
? `Read-only comparison between model probability and executable ask; it does not place orders. Source: ${quoteSourceLabel}${lockAvailable ? ` · lock ${formatSignedPercent(lockEdge)}` : ""}.`
|
||||
: `只比较模型概率与可执行买价;系统不会下单。来源:${quoteSourceLabel}${lockAvailable ? ` · 锁价 ${formatSignedPercent(lockEdge)}` : ""}。`}
|
||||
? "This card follows the same rule as the opportunity list: DEB first, model agreement second, METAR conflict check before settlement."
|
||||
: "该卡片与机会列表口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -701,7 +701,7 @@ function ScanTerminalScreen() {
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||
const [activeView, setActiveView] = useState<ContentView>("list");
|
||||
const [activeView, setActiveView] = useState<ContentView>("map");
|
||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
||||
const [aiLogs, setAiLogs] = useState<ScanAiLogEntry[]>([]);
|
||||
@@ -1000,7 +1000,7 @@ function ScanTerminalScreen() {
|
||||
.slice(0, 12);
|
||||
|
||||
cities.forEach((cityName) => {
|
||||
void store.ensureCityDetail(cityName, true, "market").catch(() => {});
|
||||
void store.ensureCityDetail(cityName, false, "market").catch(() => {});
|
||||
});
|
||||
}, [
|
||||
isPro,
|
||||
@@ -1086,12 +1086,26 @@ function ScanTerminalScreen() {
|
||||
setMapSelectedCityName(cityName);
|
||||
const matchedRow = findRowForCity(timeSortedRows, cityName);
|
||||
setSelectedRowId(matchedRow?.id || null);
|
||||
void store.selectCity(cityName);
|
||||
}, [store, timeSortedRows]);
|
||||
}, [timeSortedRows]);
|
||||
|
||||
const handleSelectRow = useCallback((row: ScanOpportunityRow) => {
|
||||
const cityName = row.city || row.city_display_name || row.display_name || "";
|
||||
if (!cityName) return;
|
||||
setSelectedRowId(row.id);
|
||||
void store.selectCity(row.city);
|
||||
const selectedCityKey = normalizeCityKey(store.selectedCity);
|
||||
const rowCityKey = normalizeCityKey(cityName);
|
||||
const hasCachedDetail =
|
||||
Boolean(store.cityDetailsByName[cityName]) ||
|
||||
Object.values(store.cityDetailsByName).some((detail) =>
|
||||
rowMatchesCity(row, detail?.name || detail?.display_name || ""),
|
||||
);
|
||||
if (store.isPanelOpen && selectedCityKey === rowCityKey) {
|
||||
if (!hasCachedDetail) {
|
||||
void store.ensureCityDetail(cityName, false, "panel").catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
void store.selectCity(cityName);
|
||||
}, [store]);
|
||||
|
||||
const openScanPaywall = useCallback(() => {
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
getSupabaseBrowserClient,
|
||||
hasSupabasePublicEnv,
|
||||
} from "@/lib/supabase/client";
|
||||
import {
|
||||
getLocalDevProAccessState,
|
||||
isBrowserLocalFullAccess,
|
||||
} from "@/lib/local-dev-access";
|
||||
import {
|
||||
CityDetail,
|
||||
CityListItem,
|
||||
@@ -94,6 +98,9 @@ function getInitialHistoryState(): HistoryState {
|
||||
}
|
||||
|
||||
function getInitialProAccessState(): ProAccessState {
|
||||
if (isBrowserLocalFullAccess()) {
|
||||
return getLocalDevProAccessState();
|
||||
}
|
||||
return {
|
||||
loading: true,
|
||||
authenticated: false,
|
||||
@@ -705,6 +712,10 @@ export function DashboardStoreProvider({
|
||||
};
|
||||
|
||||
const refreshProAccess = async () => {
|
||||
if (isBrowserLocalFullAccess()) {
|
||||
setProAccess(getLocalDevProAccessState());
|
||||
return;
|
||||
}
|
||||
setProAccess((current) => ({
|
||||
...current,
|
||||
loading: true,
|
||||
@@ -870,10 +881,14 @@ export function DashboardStoreProvider({
|
||||
]);
|
||||
|
||||
const selectCity = async (cityName: string) => {
|
||||
const wasSelectedCity = selectedCityRef.current === cityName;
|
||||
const cached = cityDetailsByName[cityName];
|
||||
selectedCityRef.current = cityName;
|
||||
setSelectedCity(cityName);
|
||||
setIsPanelOpen(true);
|
||||
setSelectedForecastDate(null);
|
||||
setSelectedForecastDate(
|
||||
cached?.local_date || (wasSelectedCity ? selectedForecastDate : null),
|
||||
);
|
||||
setFutureModalDate(null);
|
||||
setForecastModalMode(null);
|
||||
|
||||
@@ -898,9 +913,10 @@ export function DashboardStoreProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
const needsDetailRefresh = true;
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
const detailPromise = ensureCityDetail(cityName, needsDetailRefresh, "panel");
|
||||
if (!cached) {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||
}
|
||||
const detailPromise = ensureCityDetail(cityName, false, "panel");
|
||||
void Promise.allSettled([summaryPromise, detailPromise])
|
||||
.then(([, detail]) => {
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
@@ -910,7 +926,9 @@ export function DashboardStoreProvider({
|
||||
})
|
||||
.finally(() => {
|
||||
if (selectedCityRef.current !== cityName) return;
|
||||
setLoadingState((current) => ({ ...current, cityDetail: false }));
|
||||
if (!cached) {
|
||||
setLoadingState((current) => ({ ...current, cityDetail: false }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -455,6 +455,43 @@ export interface ScanOpportunityRow {
|
||||
temp_symbol?: string | null;
|
||||
current_temp?: number | null;
|
||||
current_max_so_far?: number | null;
|
||||
metar_context?: {
|
||||
source?: string | null;
|
||||
station?: string | null;
|
||||
station_label?: string | null;
|
||||
obs_count?: number | null;
|
||||
last_time?: string | null;
|
||||
last_temp?: number | null;
|
||||
max_temp?: number | null;
|
||||
max_time?: string | null;
|
||||
trend_delta?: number | null;
|
||||
stale_for_today?: boolean;
|
||||
available_for_today?: boolean;
|
||||
last_observation_time?: string | null;
|
||||
airport_current_temp?: number | null;
|
||||
airport_max_so_far?: number | null;
|
||||
airport_obs_time?: string | null;
|
||||
airport_report_time?: string | null;
|
||||
airport_raw_metar?: string | null;
|
||||
airport_wx_desc?: string | null;
|
||||
airport_cloud_desc?: string | null;
|
||||
airport_visibility_mi?: number | null;
|
||||
airport_wind_speed_kt?: number | null;
|
||||
airport_wind_dir?: number | null;
|
||||
airport_humidity?: number | null;
|
||||
today_obs?: Array<{ time?: string; temp?: number | null }>;
|
||||
recent_obs?: Array<{ time?: string; temp?: number | null }>;
|
||||
settlement_today_obs?: Array<{ time?: string; temp?: number | null }>;
|
||||
} | null;
|
||||
metar_recent_obs?: Array<{ time?: string; temp?: number | null }>;
|
||||
metar_today_obs?: Array<{ time?: string; temp?: number | null }>;
|
||||
settlement_today_obs?: Array<{ time?: string; temp?: number | null }>;
|
||||
metar_status?: {
|
||||
available_for_today?: boolean;
|
||||
stale_for_today?: boolean;
|
||||
last_observation_time?: string | null;
|
||||
last_temp?: number | null;
|
||||
} | null;
|
||||
deb_prediction?: number | null;
|
||||
airport?: string | null;
|
||||
risk_level?: RiskLevel | null;
|
||||
@@ -559,8 +596,25 @@ export interface ScanOpportunityRow {
|
||||
ai_city_thesis_en?: string | null;
|
||||
ai_city_confidence?: string | null;
|
||||
ai_city_model_cluster_note?: string | null;
|
||||
ai_predicted_max?: number | null;
|
||||
ai_predicted_low?: number | null;
|
||||
ai_predicted_high?: number | null;
|
||||
ai_forecast_unit?: string | null;
|
||||
ai_forecast_confidence?: string | null;
|
||||
ai_peak_window_zh?: string | null;
|
||||
ai_peak_window_en?: string | null;
|
||||
ai_airport_metar_read_zh?: string | null;
|
||||
ai_airport_metar_read_en?: string | null;
|
||||
ai_forecast_reason_zh?: string | null;
|
||||
ai_forecast_reason_en?: string | null;
|
||||
ai_forecast_match?: "core" | "edge" | "outside" | "watch" | string | null;
|
||||
ai_forecast_match_reason_zh?: string | null;
|
||||
ai_forecast_match_reason_en?: string | null;
|
||||
ai_watchlist_reason_zh?: string | null;
|
||||
ai_watchlist_reason_en?: string | null;
|
||||
v4_metar_decision?: "approve" | "veto" | "downgrade" | "watchlist" | string | null;
|
||||
v4_metar_reason_zh?: string | null;
|
||||
v4_metar_reason_en?: string | null;
|
||||
}
|
||||
|
||||
export interface PrimarySignal extends ScanOpportunityRow {}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ProAccessState } from "@/lib/dashboard-types";
|
||||
|
||||
const LOCAL_DEV_EXPIRY = "2099-12-31T23:59:59Z";
|
||||
const LOCAL_DEV_POINTS = 999_999;
|
||||
|
||||
function readLocalAccessFlag() {
|
||||
const raw =
|
||||
process.env.NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS ??
|
||||
process.env.POLYWEATHER_LOCAL_FULL_ACCESS;
|
||||
if (raw == null) return true;
|
||||
return !/^(0|false|off|no)$/i.test(String(raw).trim());
|
||||
}
|
||||
|
||||
export function isLocalHostname(value?: string | null) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\[/, "")
|
||||
.replace(/\]$/, "");
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized === "127.0.0.1" ||
|
||||
normalized === "::1"
|
||||
);
|
||||
}
|
||||
|
||||
export function extractHostname(value?: string | null) {
|
||||
const firstHost = String(value || "")
|
||||
.split(",")[0]
|
||||
.trim();
|
||||
if (!firstHost) return "";
|
||||
try {
|
||||
return new URL(
|
||||
firstHost.includes("://") ? firstHost : `http://${firstHost}`,
|
||||
).hostname;
|
||||
} catch {
|
||||
if (firstHost.startsWith("[")) {
|
||||
const endIndex = firstHost.indexOf("]");
|
||||
return endIndex > 0 ? firstHost.slice(1, endIndex) : firstHost;
|
||||
}
|
||||
return firstHost.split(":")[0] || firstHost;
|
||||
}
|
||||
}
|
||||
|
||||
export function isLocalFullAccessHost(value?: string | null) {
|
||||
return readLocalAccessFlag() && isLocalHostname(extractHostname(value));
|
||||
}
|
||||
|
||||
export function isBrowserLocalFullAccess() {
|
||||
if (typeof window === "undefined") return false;
|
||||
return readLocalAccessFlag() && isLocalHostname(window.location.hostname);
|
||||
}
|
||||
|
||||
export function getLocalDevAuthPayload() {
|
||||
return {
|
||||
authenticated: true,
|
||||
user_id: "local-dev",
|
||||
email: "local-dev@polyweather.local",
|
||||
subscription_active: true,
|
||||
subscription_plan_code: "local-full-access",
|
||||
subscription_expires_at: LOCAL_DEV_EXPIRY,
|
||||
subscription_total_expires_at: LOCAL_DEV_EXPIRY,
|
||||
subscription_queued_days: 0,
|
||||
subscription_queued_count: 0,
|
||||
points: LOCAL_DEV_POINTS,
|
||||
local_dev_full_access: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLocalDevProAccessState(): ProAccessState {
|
||||
return {
|
||||
loading: false,
|
||||
authenticated: true,
|
||||
userId: "local-dev",
|
||||
subscriptionActive: true,
|
||||
subscriptionPlanCode: "local-full-access",
|
||||
subscriptionExpiresAt: LOCAL_DEV_EXPIRY,
|
||||
subscriptionTotalExpiresAt: LOCAL_DEV_EXPIRY,
|
||||
subscriptionQueuedDays: 0,
|
||||
points: LOCAL_DEV_POINTS,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createSupabaseMiddlewareClient,
|
||||
hasSupabaseServerEnv,
|
||||
} from "@/lib/supabase/server";
|
||||
import { isLocalFullAccessHost } from "@/lib/local-dev-access";
|
||||
|
||||
const SESSION_COOKIE = "polyweather_entitlement";
|
||||
|
||||
@@ -181,6 +182,16 @@ export async function middleware(request: NextRequest) {
|
||||
if (isStaticAsset(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
const requestHost =
|
||||
request.headers.get("x-forwarded-host") ||
|
||||
request.headers.get("host") ||
|
||||
request.nextUrl.host;
|
||||
if (
|
||||
isLocalFullAccessHost(requestHost) ||
|
||||
isLocalFullAccessHost(request.nextUrl.hostname)
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (SUPABASE_AUTH_ENABLED && hasSupabaseServerEnv()) {
|
||||
if (SUPABASE_AUTH_REQUIRED) {
|
||||
|
||||
Reference in New Issue
Block a user