Rework homepage toward reference dashboard
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock, Search } from "lucide-react";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||||
@@ -62,7 +62,9 @@ export function CitySidebar() {
|
|||||||
const [expandedGroups, setExpandedGroups] = useState<
|
const [expandedGroups, setExpandedGroups] = useState<
|
||||||
Record<RiskGroupKey, boolean>
|
Record<RiskGroupKey, boolean>
|
||||||
>(DEFAULT_EXPANDED_GROUPS);
|
>(DEFAULT_EXPANDED_GROUPS);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const cityItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const cityItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||||
|
|
||||||
const sortedCities = useMemo(
|
const sortedCities = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -91,10 +93,36 @@ export function CitySidebar() {
|
|||||||
other: [],
|
other: [],
|
||||||
};
|
};
|
||||||
sortedCities.forEach((city) => {
|
sortedCities.forEach((city) => {
|
||||||
|
const summary = store.citySummariesByName[city.name];
|
||||||
|
const detail = store.cityDetailsByName[city.name];
|
||||||
|
const localizedName = getLocalizedCityName(
|
||||||
|
city.name,
|
||||||
|
summary?.display_name || detail?.display_name || city.display_name,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
if (normalizedQuery) {
|
||||||
|
const searchCorpus = [
|
||||||
|
city.name,
|
||||||
|
city.display_name,
|
||||||
|
city.airport,
|
||||||
|
city.icao,
|
||||||
|
localizedName,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
if (!searchCorpus.includes(normalizedQuery)) return;
|
||||||
|
}
|
||||||
groups[toPerformanceGroup(city)].push(city);
|
groups[toPerformanceGroup(city)].push(city);
|
||||||
});
|
});
|
||||||
return groups;
|
return groups;
|
||||||
}, [sortedCities]);
|
}, [
|
||||||
|
locale,
|
||||||
|
normalizedQuery,
|
||||||
|
sortedCities,
|
||||||
|
store.cityDetailsByName,
|
||||||
|
store.citySummariesByName,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCity) return;
|
if (!selectedCity) return;
|
||||||
@@ -166,6 +194,17 @@ export function CitySidebar() {
|
|||||||
<span className="city-count">{store.cities.length}</span>
|
<span className="city-count">{store.cities.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="city-search">
|
||||||
|
<Search size={14} strokeWidth={2} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder={locale === "en-US" ? "Search city" : "搜索城市"}
|
||||||
|
aria-label={locale === "en-US" ? "Search city" : "搜索城市"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="city-list-items">
|
<div className="city-list-items">
|
||||||
{groupMeta.map((group) => {
|
{groupMeta.map((group) => {
|
||||||
const citiesInGroup = groupedCities[group.key];
|
const citiesInGroup = groupedCities[group.key];
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
|
|
||||||
/* Spacing — aligned with global layout tokens */
|
/* Spacing — aligned with global layout tokens */
|
||||||
--panel-width: 560px;
|
--panel-width: 560px;
|
||||||
--header-height: 52px;
|
--header-height: 72px;
|
||||||
--sidebar-width: 260px;
|
--sidebar-width: 240px;
|
||||||
|
|
||||||
/* Effects — using global elevation tokens */
|
/* Effects — using global elevation tokens */
|
||||||
--glass-blur: 20px;
|
--glass-blur: 20px;
|
||||||
@@ -69,9 +69,9 @@
|
|||||||
.root :global(.map) {
|
.root :global(.map) {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(var(--header-height) + 16px);
|
top: calc(var(--header-height) + 16px);
|
||||||
right: 418px;
|
right: 392px;
|
||||||
bottom: 128px;
|
bottom: 164px;
|
||||||
left: calc(var(--sidebar-width) + 30px);
|
left: calc(var(--sidebar-width) + 22px);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(34, 211, 238, 0.12);
|
border: 1px solid rgba(34, 211, 238, 0.12);
|
||||||
@@ -82,6 +82,71 @@
|
|||||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.home-map-toolbar) {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--header-height) + 30px);
|
||||||
|
right: 412px;
|
||||||
|
z-index: 930;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-map-modes) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
background: rgba(9, 16, 28, 0.84);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-map-modes button),
|
||||||
|
.root :global(.home-map-expand) {
|
||||||
|
border: none;
|
||||||
|
color: rgba(203, 213, 225, 0.88);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-map-modes button) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-map-modes button.active) {
|
||||||
|
background: linear-gradient(180deg, rgba(28, 100, 242, 0.76), rgba(31, 72, 184, 0.76));
|
||||||
|
color: #f8fafc;
|
||||||
|
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-map-modes button:hover),
|
||||||
|
.root :global(.home-map-expand:hover) {
|
||||||
|
color: #f8fafc;
|
||||||
|
background: rgba(30, 41, 59, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-map-expand) {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
background: rgba(9, 16, 28, 0.84);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.weatherAura {
|
.weatherAura {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -181,12 +246,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 20px;
|
gap: 24px;
|
||||||
background: linear-gradient(
|
padding: 0 16px;
|
||||||
180deg,
|
background: rgba(7, 11, 18, 0.94);
|
||||||
rgba(8, 12, 20, 0.96) 0%,
|
|
||||||
rgba(8, 12, 20, 0.82) 100%
|
|
||||||
);
|
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border-bottom: 1px solid var(--border-glass);
|
border-bottom: 1px solid var(--border-glass);
|
||||||
@@ -194,8 +256,9 @@
|
|||||||
|
|
||||||
.root :global(.brand) {
|
.root :global(.brand) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.brand h1) {
|
.root :global(.brand h1) {
|
||||||
@@ -212,15 +275,48 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
letter-spacing: 0.6px;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.header-nav) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.header-nav-link) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: rgba(203, 213, 225, 0.82);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.header-nav-link:hover) {
|
||||||
|
color: #f8fafc;
|
||||||
|
background: rgba(30, 41, 59, 0.44);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.header-nav-link.active) {
|
||||||
|
color: #f8fafc;
|
||||||
|
background: rgba(8, 145, 178, 0.12);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.header-right) {
|
.root :global(.header-right) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.lang-switch) {
|
.root :global(.lang-switch) {
|
||||||
@@ -344,7 +440,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px 10px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -352,6 +448,32 @@
|
|||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.city-search) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 14px 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
background: rgba(8, 15, 28, 0.72);
|
||||||
|
color: rgba(148, 163, 184, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.city-search input) {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #f8fafc;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.city-search input::placeholder) {
|
||||||
|
color: rgba(148, 163, 184, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.city-count) {
|
.root :global(.city-count) {
|
||||||
background: rgba(99, 102, 241, 0.2);
|
background: rgba(99, 102, 241, 0.2);
|
||||||
color: var(--accent-blue);
|
color: var(--accent-blue);
|
||||||
@@ -366,7 +488,7 @@
|
|||||||
.root :global(.city-list-items) {
|
.root :global(.city-list-items) {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 6px 6px 8px;
|
padding: 0 6px 8px;
|
||||||
}
|
}
|
||||||
.root :global(.city-list-items::-webkit-scrollbar) {
|
.root :global(.city-list-items::-webkit-scrollbar) {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
@@ -599,10 +721,10 @@
|
|||||||
/* ── Homepage Intelligence Layer ── */
|
/* ── Homepage Intelligence Layer ── */
|
||||||
.root :global(.home-intelligence-panel) {
|
.root :global(.home-intelligence-panel) {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(var(--header-height) + 18px);
|
top: calc(var(--header-height) + 16px);
|
||||||
right: 18px;
|
right: 18px;
|
||||||
bottom: 132px;
|
bottom: 20px;
|
||||||
width: min(382px, calc(100vw - var(--sidebar-width) - 64px));
|
width: min(360px, calc(100vw - var(--sidebar-width) - 56px));
|
||||||
z-index: 890;
|
z-index: 890;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -875,7 +997,7 @@
|
|||||||
|
|
||||||
.root :global(.home-intelligence-panel.full) {
|
.root :global(.home-intelligence-panel.full) {
|
||||||
bottom: 18px;
|
bottom: 18px;
|
||||||
width: min(372px, calc(100vw - var(--sidebar-width) - 64px));
|
width: min(360px, calc(100vw - var(--sidebar-width) - 56px));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border-color: rgba(34, 211, 238, 0.34);
|
border-color: rgba(34, 211, 238, 0.34);
|
||||||
@@ -948,10 +1070,10 @@
|
|||||||
|
|
||||||
.root :global(.home-card-titlebar h2) {
|
.root :global(.home-card-titlebar h2) {
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
font-size: 28px;
|
font-size: 22px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: -0.055em;
|
letter-spacing: -0.045em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.home-card-titlebar p),
|
.root :global(.home-card-titlebar p),
|
||||||
@@ -1615,14 +1737,13 @@
|
|||||||
|
|
||||||
.root :global(.home-opportunity-strip) {
|
.root :global(.home-opportunity-strip) {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: calc(var(--sidebar-width) + 30px);
|
left: calc(var(--sidebar-width) + 22px);
|
||||||
right: 418px;
|
right: 392px;
|
||||||
bottom: 18px;
|
bottom: 18px;
|
||||||
z-index: 880;
|
z-index: 880;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 170px 1fr;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
align-items: stretch;
|
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
@@ -1634,12 +1755,53 @@
|
|||||||
animation: opportunity-strip-enter 580ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both;
|
animation: opportunity-strip-enter 580ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-strip-heading) {
|
.root :global(.home-summary-grid) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-summary-card) {
|
||||||
|
min-height: 92px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: space-between;
|
||||||
gap: 7px;
|
padding: 14px 16px;
|
||||||
padding: 4px 8px;
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
|
background: rgba(8, 15, 28, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-summary-card span) {
|
||||||
|
color: rgba(148, 163, 184, 0.88);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-summary-card strong) {
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.home-summary-card.accent-red strong) { color: #fb7185; }
|
||||||
|
.root :global(.home-summary-card.accent-amber strong) { color: #fbbf24; }
|
||||||
|
.root :global(.home-summary-card.accent-green strong) { color: #4ade80; }
|
||||||
|
.root :global(.home-summary-card.accent-cyan strong) { color: #22d3ee; }
|
||||||
|
|
||||||
|
.root :global(.opportunity-strip-heading) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.opportunity-strip-heading > div) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-strip-heading strong) {
|
.root :global(.opportunity-strip-heading strong) {
|
||||||
@@ -1648,28 +1810,36 @@
|
|||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.opportunity-view-all) {
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-card-grid) {
|
.root :global(.opportunity-card-grid) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.opportunity-card-grid.top-opportunities) {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-card) {
|
.root :global(.opportunity-card) {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 10px 1fr;
|
flex-direction: column;
|
||||||
grid-template-areas:
|
gap: 12px;
|
||||||
"dot city"
|
min-height: 122px;
|
||||||
"dot meta"
|
padding: 14px;
|
||||||
"dot hit";
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
gap: 3px 9px;
|
|
||||||
align-content: center;
|
|
||||||
min-height: 76px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: rgba(15, 23, 42, 0.5);
|
background:
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(30, 64, 175, 0.12), transparent 32%),
|
||||||
|
rgba(10, 16, 28, 0.84);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -1680,32 +1850,59 @@
|
|||||||
.root :global(.opportunity-card:hover) {
|
.root :global(.opportunity-card:hover) {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: rgba(34, 211, 238, 0.32);
|
border-color: rgba(34, 211, 238, 0.32);
|
||||||
background: rgba(15, 23, 42, 0.72);
|
background:
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(30, 64, 175, 0.18), transparent 32%),
|
||||||
|
rgba(15, 23, 42, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-risk-dot) {
|
.root :global(.opportunity-card-header),
|
||||||
grid-area: dot;
|
.root :global(.opportunity-card-footer) {
|
||||||
align-self: center;
|
display: flex;
|
||||||
width: 8px;
|
align-items: center;
|
||||||
height: 42px;
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.opportunity-rank) {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(148, 163, 184, 0.55);
|
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-risk-dot.high) {
|
.root :global(.opportunity-pill) {
|
||||||
background: linear-gradient(180deg, #fb7185, #f97316);
|
margin-left: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
color: rgba(226, 232, 240, 0.86);
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-risk-dot.medium) {
|
.root :global(.opportunity-pill.high) {
|
||||||
background: linear-gradient(180deg, #facc15, #f97316);
|
color: #fda4af;
|
||||||
|
border-color: rgba(251, 113, 133, 0.26);
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-risk-dot.low) {
|
.root :global(.opportunity-pill.medium) {
|
||||||
background: linear-gradient(180deg, #34d399, #22d3ee);
|
color: #fbbf24;
|
||||||
|
border-color: rgba(251, 191, 36, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.opportunity-pill.low) {
|
||||||
|
color: #4ade80;
|
||||||
|
border-color: rgba(74, 222, 128, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-city) {
|
.root :global(.opportunity-city) {
|
||||||
grid-area: city;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1715,7 +1912,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-meta) {
|
.root :global(.opportunity-meta) {
|
||||||
grid-area: meta;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: rgba(203, 213, 225, 0.88);
|
color: rgba(203, 213, 225, 0.88);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1723,10 +1919,15 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.root :global(.opportunity-hit) {
|
.root :global(.opportunity-yes) {
|
||||||
grid-area: hit;
|
color: #22d3ee;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.opportunity-edge) {
|
||||||
color: rgba(34, 211, 238, 0.9);
|
color: rgba(34, 211, 238, 0.9);
|
||||||
font-size: 11px;
|
font-size: 18px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
House,
|
House,
|
||||||
|
Bell,
|
||||||
|
BarChart3,
|
||||||
|
CandlestickChart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
@@ -41,8 +44,38 @@ export function HeaderBar({
|
|||||||
const isAuthenticated = store.proAccess.authenticated;
|
const isAuthenticated = store.proAccess.authenticated;
|
||||||
const docsHref = "/docs/intro";
|
const docsHref = "/docs/intro";
|
||||||
const docsActive = pathname?.startsWith("/docs");
|
const docsActive = pathname?.startsWith("/docs");
|
||||||
const homeHref = "/";
|
const navItems = [
|
||||||
const homeActive = pathname === "/";
|
{
|
||||||
|
href: "/",
|
||||||
|
label: locale === "en-US" ? "Dashboard" : "总览",
|
||||||
|
active: pathname === "/",
|
||||||
|
icon: House,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/docs/intraday-signal",
|
||||||
|
label: locale === "en-US" ? "Markets" : "市场",
|
||||||
|
active: pathname?.startsWith("/docs/intraday-signal"),
|
||||||
|
icon: CandlestickChart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/docs/model-stack-deb",
|
||||||
|
label: locale === "en-US" ? "Analytics" : "分析",
|
||||||
|
active: pathname?.startsWith("/docs/model-stack-deb"),
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/docs/history-review",
|
||||||
|
label: locale === "en-US" ? "History" : "历史",
|
||||||
|
active: pathname?.startsWith("/docs/history-review"),
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/docs/alert-playbook",
|
||||||
|
label: locale === "en-US" ? "Alerts" : "提醒",
|
||||||
|
active: pathname?.startsWith("/docs/alert-playbook"),
|
||||||
|
icon: Bell,
|
||||||
|
},
|
||||||
|
];
|
||||||
const trialPromoLabel =
|
const trialPromoLabel =
|
||||||
locale === "en-US"
|
locale === "en-US"
|
||||||
? "New users get 3-day Pro trial"
|
? "New users get 3-day Pro trial"
|
||||||
@@ -109,6 +142,25 @@ export function HeaderBar({
|
|||||||
<span className="subtitle">{t("header.subtitle")}</span>
|
<span className="subtitle">{t("header.subtitle")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
className="header-nav"
|
||||||
|
aria-label={locale === "en-US" ? "Primary" : "主导航"}
|
||||||
|
>
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={clsx("header-nav-link", item.active && "active")}
|
||||||
|
>
|
||||||
|
<Icon size={14} strokeWidth={2} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
<div
|
<div
|
||||||
className="lang-switch"
|
className="lang-switch"
|
||||||
@@ -131,16 +183,6 @@ export function HeaderBar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
|
||||||
href={homeHref}
|
|
||||||
className={clsx("info-btn", homeActive && "active")}
|
|
||||||
title={t("header.homeAria")}
|
|
||||||
aria-label={t("header.homeAria")}
|
|
||||||
>
|
|
||||||
<House size={14} strokeWidth={2} />
|
|
||||||
{t("header.home")}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={docsHref}
|
href={docsHref}
|
||||||
className={clsx("info-btn", docsActive && "active")}
|
className={clsx("info-btn", docsActive && "active")}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
getWeatherSummary,
|
getWeatherSummary,
|
||||||
} from "@/lib/dashboard-utils";
|
} from "@/lib/dashboard-utils";
|
||||||
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
||||||
|
import { Expand, Thermometer, CloudRain, Wind } from "lucide-react";
|
||||||
|
|
||||||
const loadHistoryModal = () =>
|
const loadHistoryModal = () =>
|
||||||
import("@/components/dashboard/HistoryModal").then(
|
import("@/components/dashboard/HistoryModal").then(
|
||||||
@@ -237,7 +238,88 @@ type HomeForecastDay = {
|
|||||||
maxTemp: number;
|
maxTemp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RECENT_OPENED_CITIES_STORAGE_KEY = "polyWeather_recent_opened_cities_v1";
|
type HomeSummaryMetric = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
accent?: "green" | "amber" | "red" | "cyan";
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildDashboardSummaryMetrics(
|
||||||
|
snapshots: CitySnapshot[],
|
||||||
|
locale: string,
|
||||||
|
): HomeSummaryMetric[] {
|
||||||
|
const topTradable = snapshots.filter((snapshot) => snapshot.tradableOpportunity);
|
||||||
|
const highCount = snapshots.filter((snapshot) => snapshot.city.deb_recent_tier === "high").length;
|
||||||
|
const mediumCount = snapshots.filter((snapshot) => snapshot.city.deb_recent_tier === "medium").length;
|
||||||
|
const lowCount = snapshots.filter((snapshot) => snapshot.city.deb_recent_tier === "low").length;
|
||||||
|
const avgEdge = topTradable.length
|
||||||
|
? topTradable.reduce((sum, snapshot) => {
|
||||||
|
const edgeValue = Number(getMarketEdgeValue(snapshot.detail));
|
||||||
|
const normalized =
|
||||||
|
Number.isFinite(edgeValue) && Math.abs(edgeValue) <= 1
|
||||||
|
? edgeValue * 100
|
||||||
|
: edgeValue;
|
||||||
|
return sum + (Number.isFinite(normalized) ? normalized : 0);
|
||||||
|
}, 0) / topTradable.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "high",
|
||||||
|
label: locale === "en-US" ? "High risk" : "高风险",
|
||||||
|
value: String(highCount),
|
||||||
|
accent: "red",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "medium",
|
||||||
|
label: locale === "en-US" ? "Medium risk" : "中风险",
|
||||||
|
value: String(mediumCount),
|
||||||
|
accent: "amber",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "low",
|
||||||
|
label: locale === "en-US" ? "Low risk" : "低风险",
|
||||||
|
value: String(lowCount),
|
||||||
|
accent: "green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "edge",
|
||||||
|
label: locale === "en-US" ? "Avg. edge" : "平均优势",
|
||||||
|
value: formatEdge(avgEdge),
|
||||||
|
accent: "cyan",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeMapToolbar() {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
return (
|
||||||
|
<div className="home-map-toolbar" aria-label={locale === "en-US" ? "Map layer controls" : "地图图层控件"}>
|
||||||
|
<div className="home-map-modes">
|
||||||
|
<button type="button" className="active">
|
||||||
|
<Thermometer size={14} strokeWidth={2} />
|
||||||
|
<span>{locale === "en-US" ? "Temperature" : "温度"}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button">
|
||||||
|
<CloudRain size={14} strokeWidth={2} />
|
||||||
|
<span>{locale === "en-US" ? "Precipitation" : "降水"}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button">
|
||||||
|
<Wind size={14} strokeWidth={2} />
|
||||||
|
<span>{locale === "en-US" ? "Wind" : "风场"}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="home-map-expand"
|
||||||
|
aria-label={locale === "en-US" ? "Expand map" : "放大地图"}
|
||||||
|
>
|
||||||
|
<Expand size={15} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function projectHomeTrendPoint(
|
function projectHomeTrendPoint(
|
||||||
x: number,
|
x: number,
|
||||||
@@ -965,24 +1047,18 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
|
|||||||
|
|
||||||
function OpportunityStrip({
|
function OpportunityStrip({
|
||||||
snapshots,
|
snapshots,
|
||||||
recentCityNames,
|
|
||||||
}: {
|
}: {
|
||||||
snapshots: CitySnapshot[];
|
snapshots: CitySnapshot[];
|
||||||
recentCityNames: string[];
|
|
||||||
}) {
|
}) {
|
||||||
const store = useDashboardStore();
|
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const snapshotByName = useMemo(
|
const store = useDashboardStore();
|
||||||
() => new Map(snapshots.map((snapshot) => [snapshot.city.name, snapshot])),
|
const summaryMetrics = useMemo(
|
||||||
[snapshots],
|
() => buildDashboardSummaryMetrics(snapshots, locale),
|
||||||
|
[locale, snapshots],
|
||||||
);
|
);
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() =>
|
() => snapshots.filter((snapshot) => snapshot.tradableOpportunity).slice(0, 5),
|
||||||
recentCityNames
|
[snapshots],
|
||||||
.map((cityName) => snapshotByName.get(cityName) || null)
|
|
||||||
.filter((snapshot): snapshot is CitySnapshot => snapshot != null)
|
|
||||||
.slice(0, 4),
|
|
||||||
[recentCityNames, snapshotByName],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
@@ -992,14 +1068,28 @@ function OpportunityStrip({
|
|||||||
className="home-opportunity-strip"
|
className="home-opportunity-strip"
|
||||||
aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"}
|
aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"}
|
||||||
>
|
>
|
||||||
<div className="opportunity-strip-heading">
|
<div className="home-summary-grid">
|
||||||
<span>{locale === "en-US" ? "Recently opened" : "最近打开"}</span>
|
{summaryMetrics.map((metric) => (
|
||||||
<strong>
|
<div key={metric.key} className={clsx("home-summary-card", metric.accent && `accent-${metric.accent}`)}>
|
||||||
{locale === "en-US" ? "Recently opened cities" : "最近打开的城市"}
|
<span>{metric.label}</span>
|
||||||
</strong>
|
<strong>{metric.value}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="opportunity-card-grid">
|
|
||||||
{items.map(({ city, detail, summary }) => {
|
<div className="opportunity-strip-heading">
|
||||||
|
<div>
|
||||||
|
<span>{locale === "en-US" ? "Today's Top Opportunities" : "今日最佳机会"}</span>
|
||||||
|
<strong>
|
||||||
|
{locale === "en-US" ? "Sorted by edge and tradability" : "按优势与可交易性排序"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<Link href="/docs/intraday-signal" className="opportunity-view-all">
|
||||||
|
{locale === "en-US" ? "View all" : "查看全部"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="opportunity-card-grid top-opportunities">
|
||||||
|
{items.map(({ city, detail, summary }, index) => {
|
||||||
const symbol = getTempSymbol(city, summary, detail);
|
const symbol = getTempSymbol(city, summary, detail);
|
||||||
const currentTemp = summary?.current?.temp ?? detail?.current?.temp;
|
const currentTemp = summary?.current?.temp ?? detail?.current?.temp;
|
||||||
const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction;
|
const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction;
|
||||||
@@ -1016,16 +1106,21 @@ function OpportunityStrip({
|
|||||||
className="opportunity-card"
|
className="opportunity-card"
|
||||||
onClick={() => void store.focusCity(city.name)}
|
onClick={() => void store.focusCity(city.name)}
|
||||||
>
|
>
|
||||||
<span className={clsx("opportunity-risk-dot", String(tier || "other"))} />
|
<div className="opportunity-card-header">
|
||||||
<span className="opportunity-city">{localizedCityName}</span>
|
<span className="opportunity-rank">{index + 1}</span>
|
||||||
|
<span className="opportunity-city">{localizedCityName}</span>
|
||||||
|
<span className={clsx("opportunity-pill", String(tier || "other"))}>
|
||||||
|
{getRiskCopy(tier as RiskLevel | undefined, locale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span className="opportunity-meta">
|
<span className="opportunity-meta">
|
||||||
{formatTemperature(currentTemp, symbol)} / DEB{" "}
|
> {formatTemperature(debPrediction, symbol)}{" "}
|
||||||
{formatTemperature(debPrediction, symbol)}
|
{locale === "en-US" ? "target" : "目标"}
|
||||||
</span>
|
|
||||||
<span className="opportunity-hit">
|
|
||||||
{locale === "en-US" ? "Hit" : "命中"}{" "}
|
|
||||||
{formatPercent(city.deb_recent_hit_rate)}
|
|
||||||
</span>
|
</span>
|
||||||
|
<div className="opportunity-card-footer">
|
||||||
|
<span className="opportunity-yes">YES {formatCents(detail?.market_scan?.yes_buy)}</span>
|
||||||
|
<span className="opportunity-edge">{formatEdge(getMarketEdgeValue(detail))}</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1039,7 +1134,6 @@ function DashboardScreen() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const didAutoFocusRef = useRef(false);
|
const didAutoFocusRef = useRef(false);
|
||||||
const preloadedOpportunityRef = useRef<Set<string>>(new Set());
|
const preloadedOpportunityRef = useRef<Set<string>>(new Set());
|
||||||
const [recentCityNames, setRecentCityNames] = useState<string[]>([]);
|
|
||||||
const activeSummary = store.selectedCity
|
const activeSummary = store.selectedCity
|
||||||
? store.citySummariesByName[store.selectedCity] || null
|
? store.citySummariesByName[store.selectedCity] || null
|
||||||
: null;
|
: null;
|
||||||
@@ -1098,26 +1192,9 @@ function DashboardScreen() {
|
|||||||
),
|
),
|
||||||
[store.cities, store.cityDetailsByName, store.citySummariesByName],
|
[store.cities, store.cityDetailsByName, store.citySummariesByName],
|
||||||
);
|
);
|
||||||
const fallbackRecentCityNames = useMemo(
|
|
||||||
() => homepageSnapshots.slice(0, 4).map((snapshot) => snapshot.city.name),
|
|
||||||
[homepageSnapshots],
|
|
||||||
);
|
|
||||||
const showHomepageChrome =
|
const showHomepageChrome =
|
||||||
!store.historyState.isOpen && !store.futureModalDate;
|
!store.historyState.isOpen && !store.futureModalDate;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(RECENT_OPENED_CITIES_STORAGE_KEY);
|
|
||||||
if (!raw) return;
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed)) return;
|
|
||||||
setRecentCityNames(
|
|
||||||
parsed.map((item) => String(item || "").trim()).filter(Boolean).slice(0, 8),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (didAutoFocusRef.current) return;
|
if (didAutoFocusRef.current) return;
|
||||||
if (!showHomepageChrome) return;
|
if (!showHomepageChrome) return;
|
||||||
@@ -1141,44 +1218,22 @@ function DashboardScreen() {
|
|||||||
});
|
});
|
||||||
}, [homepageSnapshots, showHomepageChrome, store]);
|
}, [homepageSnapshots, showHomepageChrome, store]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const selectedCity = store.selectedCity;
|
|
||||||
if (!selectedCity) return;
|
|
||||||
setRecentCityNames((current) => {
|
|
||||||
const next = [
|
|
||||||
selectedCity,
|
|
||||||
...current.filter((name) => name !== selectedCity),
|
|
||||||
].slice(0, 8);
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
RECENT_OPENED_CITIES_STORAGE_KEY,
|
|
||||||
JSON.stringify(next),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [store.selectedCity]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
styles.root,
|
styles.root,
|
||||||
detailChromeStyles.root,
|
detailChromeStyles.root,
|
||||||
modalChromeStyles.root,
|
modalChromeStyles.root,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MapCanvas />
|
<MapCanvas />
|
||||||
|
<HomeMapToolbar />
|
||||||
<HeaderBar />
|
<HeaderBar />
|
||||||
<CitySidebar />
|
<CitySidebar />
|
||||||
{showHomepageChrome ? (
|
{showHomepageChrome ? (
|
||||||
<>
|
<>
|
||||||
<HomeIntelligencePanel snapshots={homepageSnapshots} />
|
<HomeIntelligencePanel snapshots={homepageSnapshots} />
|
||||||
<OpportunityStrip
|
<OpportunityStrip snapshots={homepageSnapshots} />
|
||||||
snapshots={homepageSnapshots}
|
|
||||||
recentCityNames={recentCityNames.length ? recentCityNames : fallbackRecentCityNames}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{showCitySyncToast ? (
|
{showCitySyncToast ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user