diff --git a/frontend/app/briefs/[city]/[date]/page.tsx b/frontend/app/briefs/[city]/[date]/page.tsx index 75c5bbf8..909f8f01 100644 --- a/frontend/app/briefs/[city]/[date]/page.tsx +++ b/frontend/app/briefs/[city]/[date]/page.tsx @@ -1,17 +1,38 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { cookies, headers } from "next/headers"; import { BriefDetailPageView } from "@/components/public-content/PublicContentPages"; +import { + LANDING_LOCALE_COOKIE, + LANDING_LOCALE_QUERY_PARAM, + pickLandingLocale, + type LandingLocale, +} from "@/components/landing/landingLocale"; import { PUBLIC_BRIEFS, absolutePublicUrl, briefPath, getBrief, + localizeBrief, } from "@/content/public-content"; type BriefPageParams = { city: string; date: string; }; +type BriefSearchParams = Promise>; + +async function resolvePublicContentLocale(searchParams: BriefSearchParams): Promise { + const params = await searchParams; + const rawLocale = params[LANDING_LOCALE_QUERY_PARAM]; + const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale; + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]); + return pickLandingLocale( + queryLocale, + cookieStore.get(LANDING_LOCALE_COOKIE)?.value, + headerStore.get("accept-language"), + ); +} export function generateStaticParams() { return PUBLIC_BRIEFS.map((brief) => ({ @@ -22,10 +43,15 @@ export function generateStaticParams() { export async function generateMetadata({ params, + searchParams, }: { params: Promise; + searchParams: BriefSearchParams; }): Promise { - const { city, date } = await params; + const [{ city, date }, locale] = await Promise.all([ + params, + resolvePublicContentLocale(searchParams), + ]); const brief = getBrief(city, date); if (!brief) { @@ -34,51 +60,58 @@ export async function generateMetadata({ }; } - const pathname = briefPath(brief); + const localizedBrief = localizeBrief(brief, locale); + const pathname = briefPath(localizedBrief); return { - title: brief.title, - description: brief.description, + title: localizedBrief.title, + description: localizedBrief.description, alternates: { canonical: pathname, }, openGraph: { type: "article", - title: brief.title, - description: brief.description, + title: localizedBrief.title, + description: localizedBrief.description, url: pathname, - publishedTime: brief.publishedAt, - modifiedTime: brief.updatedAt, + publishedTime: localizedBrief.publishedAt, + modifiedTime: localizedBrief.updatedAt, }, twitter: { card: "summary", - title: brief.title, - description: brief.description, + title: localizedBrief.title, + description: localizedBrief.description, }, }; } export default async function BriefDetailPage({ params, + searchParams, }: { params: Promise; + searchParams: BriefSearchParams; }) { - const { city, date } = await params; + const [{ city, date }, locale] = await Promise.all([ + params, + resolvePublicContentLocale(searchParams), + ]); const brief = getBrief(city, date); if (!brief) { notFound(); } - const pathname = briefPath(brief); + const localizedBrief = localizeBrief(brief, locale); + const pathname = briefPath(localizedBrief); const jsonLd = [ { "@context": "https://schema.org", "@type": "Article", - headline: brief.title, - description: brief.description, - datePublished: brief.publishedAt, - dateModified: brief.updatedAt, + headline: localizedBrief.title, + description: localizedBrief.description, + datePublished: localizedBrief.publishedAt, + dateModified: localizedBrief.updatedAt, mainEntityOfPage: absolutePublicUrl(pathname), author: { "@type": "Organization", @@ -91,9 +124,9 @@ export default async function BriefDetailPage({ url: "https://polyweather.top", }, about: [ - brief.cityName, - brief.market, - brief.settlementSource, + localizedBrief.cityName, + localizedBrief.market, + localizedBrief.settlementSource, "DEB forecast methodology", ], }, @@ -110,7 +143,7 @@ export default async function BriefDetailPage({ { "@type": "ListItem", position: 2, - name: brief.cityName, + name: localizedBrief.cityName, item: absolutePublicUrl(pathname), }, ], @@ -123,7 +156,7 @@ export default async function BriefDetailPage({ type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> - + ); } diff --git a/frontend/app/briefs/page.tsx b/frontend/app/briefs/page.tsx index 3dcf2e2a..a06c57fe 100644 --- a/frontend/app/briefs/page.tsx +++ b/frontend/app/briefs/page.tsx @@ -1,21 +1,54 @@ import type { Metadata } from "next"; +import { cookies, headers } from "next/headers"; import { BriefsIndexPageView } from "@/components/public-content/PublicContentPages"; +import { + LANDING_LOCALE_COOKIE, + LANDING_LOCALE_QUERY_PARAM, + pickLandingLocale, + type LandingLocale, +} from "@/components/landing/landingLocale"; +import { PUBLIC_CONTENT_COPY } from "@/content/public-content"; -export const metadata: Metadata = { - title: "Weather Market Brief", - description: - "Public PolyWeather market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.", - alternates: { - canonical: "/briefs", - }, - openGraph: { - title: "Weather Market Brief | PolyWeather", - description: - "Public market briefs for temperature judgment, settlement-source checks, DEB context, and source freshness notes.", - url: "/briefs", - }, -}; +type BriefsSearchParams = Promise>; -export default function BriefsPage() { - return ; +async function resolvePublicContentLocale(searchParams: BriefsSearchParams): Promise { + const params = await searchParams; + const rawLocale = params[LANDING_LOCALE_QUERY_PARAM]; + const queryLocale = Array.isArray(rawLocale) ? rawLocale[0] : rawLocale; + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]); + return pickLandingLocale( + queryLocale, + cookieStore.get(LANDING_LOCALE_COOKIE)?.value, + headerStore.get("accept-language"), + ); +} + +export async function generateMetadata({ + searchParams, +}: { + searchParams: BriefsSearchParams; +}): Promise { + const locale = await resolvePublicContentLocale(searchParams); + const copy = PUBLIC_CONTENT_COPY[locale]; + return { + title: copy.briefIndexEyebrow, + description: copy.briefIndexDescription, + alternates: { + canonical: "/briefs", + }, + openGraph: { + title: `Weather Market Brief | PolyWeather`, + description: copy.briefIndexDescription, + url: "/briefs", + }, + }; +} + +export default async function BriefsPage({ + searchParams, +}: { + searchParams: BriefsSearchParams; +}) { + const locale = await resolvePublicContentLocale(searchParams); + return ; } diff --git a/frontend/components/landing/InstitutionalLandingPage.tsx b/frontend/components/landing/InstitutionalLandingPage.tsx index 0d1fbfbc..db64ae02 100644 --- a/frontend/components/landing/InstitutionalLandingPage.tsx +++ b/frontend/components/landing/InstitutionalLandingPage.tsx @@ -325,7 +325,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) { {isEn ? "Guide" : "读图"} - Weather Market Brief + {isEn ? "Briefs" : "简报"} {isEn ? "Pricing" : "定价"} diff --git a/frontend/components/landing/__tests__/landingPricingReferral.test.ts b/frontend/components/landing/__tests__/landingPricingReferral.test.ts index 5ccfebda..791e8bdf 100644 --- a/frontend/components/landing/__tests__/landingPricingReferral.test.ts +++ b/frontend/components/landing/__tests__/landingPricingReferral.test.ts @@ -105,7 +105,7 @@ export function runTests() { ); assert(source.includes("#contact"), "landing navigation must expose the contact section"); assert(source.includes('href="/briefs"'), "landing navigation must expose public Weather Market Brief assets"); - assert(source.includes("Weather Market Brief"), "landing page must label the public brief entry clearly"); + assert(source.includes('isEn ? "Briefs" : "简报"'), "landing page must label the public brief entry in both languages"); assert(source.includes('id="contact"'), "landing page must include a contact section"); assert(source.includes("yhrsc30@gmail.com"), "landing page must show the operator contact email"); assert(source.includes("mailto:${CONTACT_EMAIL}"), "landing contact email must be clickable"); diff --git a/frontend/components/public-content/PublicContentPages.tsx b/frontend/components/public-content/PublicContentPages.tsx index a5d8878e..f48bda08 100644 --- a/frontend/components/public-content/PublicContentPages.tsx +++ b/frontend/components/public-content/PublicContentPages.tsx @@ -1,12 +1,19 @@ import Link from "next/link"; +import { LandingLocaleToggle } from "@/components/landing/LandingLocaleToggle"; +import type { LandingLocale } from "@/components/landing/landingLocale"; import { METHODOLOGY_PAGES, + PUBLIC_CONTENT_COPY, PUBLIC_BRIEFS, SOURCE_PAGES, absolutePublicUrl, briefPath, methodologyPath, sourcePath, + localizeBrief, + localizeBriefs, + localizeMethodologyPage, + localizeSourcePage, type MethodologyPage, type PublicBrief, type SourcePage, @@ -31,27 +38,32 @@ const primaryButton = const secondaryButton = "inline-flex min-h-10 items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 transition hover:border-slate-400 hover:bg-slate-50"; -function PublicHeader() { +function PublicHeader({ locale = "en-US" }: { locale?: LandingLocale }) { + const copy = PUBLIC_CONTENT_COPY[locale]; + return (
PolyWeather - +
+ + +
); @@ -81,19 +93,20 @@ function PageIntro({ ); } -function SourceLinks({ slugs }: { slugs: string[] }) { +function SourceLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) { return (
{slugs.map((slug) => { const source = SOURCE_PAGES.find((entry) => entry.slug === slug); if (!source) return null; + const localizedSource = localizeSourcePage(source, locale); return ( - {source.title} + {localizedSource.title} ); })} @@ -101,19 +114,20 @@ function SourceLinks({ slugs }: { slugs: string[] }) { ); } -function MethodologyLinks({ slugs }: { slugs: string[] }) { +function MethodologyLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slugs: string[] }) { return (
{slugs.map((slug) => { const page = METHODOLOGY_PAGES.find((entry) => entry.slug === slug); if (!page) return null; + const localizedPage = localizeMethodologyPage(page, locale); return ( - {page.title} + {localizedPage.title} ); })} @@ -121,7 +135,15 @@ function MethodologyLinks({ slugs }: { slugs: string[] }) { ); } -function BriefCard({ brief }: { brief: PublicBrief }) { +function BriefCard({ + brief, + locale = "en-US", + readBriefLabel, +}: { + brief: PublicBrief; + locale?: LandingLocale; + readBriefLabel: string; +}) { return (
@@ -149,52 +171,60 @@ function BriefCard({ brief }: { brief: PublicBrief }) {
- Read brief + {readBriefLabel} - +
); } -export function BriefsIndexPageView() { +export function BriefsIndexPageView({ locale = "en-US" }: { locale?: LandingLocale }) { + const copy = PUBLIC_CONTENT_COPY[locale]; + const briefs = localizeBriefs(locale); + return (
- +
- {PUBLIC_BRIEFS.map((brief) => ( - + {briefs.map((brief) => ( + ))}
-

Methodology

+

{copy.methodology}

- How the public read is produced + {copy.methodologyPanelTitle}

- Briefs cross-link to the DEB methodology and settlement-source priority pages so readers can audit why PolyWeather does not treat generic city forecasts as market truth. + {copy.methodologyPanelBody}

- +
-

Source notes

+

{copy.sourceNotes}

- Official-source context + {copy.sourcePanelTitle}

- Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows. + {copy.sourcePanelBody}

- +
@@ -203,25 +233,34 @@ export function BriefsIndexPageView() { ); } -export function BriefDetailPageView({ brief }: { brief: PublicBrief }) { +export function BriefDetailPageView({ + brief, + locale = "en-US", +}: { + brief: PublicBrief; + locale?: LandingLocale; +}) { + const copy = PUBLIC_CONTENT_COPY[locale]; + const localizedBrief = localizeBrief(brief, locale); + return (
- +
- {brief.signals.map((signal) => ( + {localizedBrief.signals.map((signal) => (

{signal.label}

{signal.value}

@@ -230,30 +269,30 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) { ))}
- - - - + + + +
@@ -261,9 +300,9 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
-

Checks before acting

+

{copy.checksBeforeActing}

    - {brief.checkpoints.map((checkpoint) => ( + {localizedBrief.checkpoints.map((checkpoint) => (
  • {checkpoint}
  • @@ -271,15 +310,15 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
-

Distribution copy

-

{brief.distributionText}

+

{copy.distributionCopy}

+

{localizedBrief.distributionText}

- Share on X + {copy.shareOnX}
@@ -287,21 +326,21 @@ export function BriefDetailPageView({ brief }: { brief: PublicBrief }) {
-

Methodology links

+

{copy.methodologyLinks}

- +
-

Source links

+

{copy.sourceLinks}

- +

- {brief.notFinancialAdvice} + {localizedBrief.notFinancialAdvice}

@@ -453,8 +492,8 @@ export function SourceDetailPageView({ source }: { source: SourcePage }) { ); } -function formatDateTime(value: string) { - return new Intl.DateTimeFormat("en", { +function formatDateTime(value: string, locale: LandingLocale = "en-US") { + return new Intl.DateTimeFormat(locale === "en-US" ? "en" : "zh-CN", { dateStyle: "medium", timeStyle: "short", }).format(new Date(value)); diff --git a/frontend/components/public-content/__tests__/publicContentAssets.test.ts b/frontend/components/public-content/__tests__/publicContentAssets.test.ts index f7044369..6904cf61 100644 --- a/frontend/components/public-content/__tests__/publicContentAssets.test.ts +++ b/frontend/components/public-content/__tests__/publicContentAssets.test.ts @@ -59,6 +59,15 @@ export function runTests() { content.includes("distributionText"), "public briefs must carry disclaimer, freshness, settlement source, and shareable distribution copy", ); + assert( + content.includes("PUBLIC_CONTENT_COPY") && + content.includes('"zh-CN"') && + content.includes('"en-US"') && + content.includes("公开天气市场简报") && + content.includes("安卡拉") && + content.includes("阅读简报"), + "public brief content must provide Chinese and English localized copy", + ); assert( briefDetail.includes("generateStaticParams") && briefDetail.includes("generateMetadata") && @@ -77,11 +86,18 @@ export function runTests() { "methodology and source detail routes must expose structured data for GEO/SEO", ); assert( - publicPages.includes('MethodologyLinks slugs={["deb", "settlement-sources"]}') && - publicPages.includes('SourceLinks slugs={["mgm", "metar", "hko", "noaa"]}') && + publicPages.includes('MethodologyLinks locale={locale} slugs={["deb", "settlement-sources"]}') && + publicPages.includes('SourceLinks locale={locale} slugs={["mgm", "metar", "hko", "noaa"]}') && briefsIndex.includes("Weather Market Brief"), "brief index must cross-link to DEB methodology and settlement source pages", ); + assert( + publicPages.includes("LandingLocaleToggle") && + publicPages.includes("localizeBrief") && + publicPages.includes("PUBLIC_CONTENT_COPY") && + publicPages.includes('locale === "en-US" ? "Briefs" : "简报"'), + "public content pages must render the shared language toggle and localized brief copy", + ); assert( analytics.includes('"brief_view"') && analytics.includes('"brief_cta_click"') && diff --git a/frontend/content/public-content.ts b/frontend/content/public-content.ts index e03d4f59..4897d53a 100644 --- a/frontend/content/public-content.ts +++ b/frontend/content/public-content.ts @@ -1,3 +1,5 @@ +import type { LandingLocale } from "@/components/landing/landingLocale"; + export const PUBLIC_CONTENT_BASE_URL = "https://polyweather.top"; export type PublicBriefSignal = { @@ -57,6 +59,140 @@ export type SourcePage = { relatedMethodologySlugs: string[]; }; +export type PublicContentCopy = { + allPublicBriefs: string; + briefIndexDescription: string; + briefIndexEyebrow: string; + briefIndexTitle: string; + checksBeforeActing: string; + distributionCopy: string; + docs: string; + freshness: string; + market: string; + methodology: string; + methodologyIndexDescription: string; + methodologyIndexEyebrow: string; + methodologyIndexTitle: string; + methodologyLinks: string; + methodologyPanelBody: string; + methodologyPanelTitle: string; + openLiveTerminal: string; + readBrief: string; + readMethodology: string; + readSourceNote: string; + settlementSource: string; + shareOnX: string; + snapshot: string; + sourceIndexDescription: string; + sourceIndexEyebrow: string; + sourceIndexTitle: string; + sourceLinks: string; + sourceNotes: string; + sourcePanelBody: string; + sourcePanelTitle: string; + sources: string; + updated: string; + detailLabels: { + debRead: string; + modelContext: string; + riskNotes: string; + settlementSourceRead: string; + }; +}; + +export const PUBLIC_CONTENT_COPY: Record = { + "zh-CN": { + allPublicBriefs: "全部公开简报", + briefIndexDescription: + "公开天气市场简报把选定城市的市场读数整理为可索引证据:结算源、DEB 背景、模型分歧、新鲜度备注,以及明确的研究免责声明。", + briefIndexEyebrow: "天气市场简报", + briefIndexTitle: "公开天气市场简报", + checksBeforeActing: "行动前检查", + distributionCopy: "分发文案", + docs: "文档", + freshness: "新鲜度", + market: "市场", + methodology: "方法", + methodologyIndexDescription: + "公开方法页解释 PolyWeather 如何处理 DEB 融合、结算源优先级、新鲜度和来源校验。", + methodologyIndexEyebrow: "方法", + methodologyIndexTitle: "PolyWeather 如何读取天气市场", + methodologyLinks: "方法链接", + methodologyPanelBody: + "简报会交叉链接 DEB 方法和结算源优先级页面,让读者能审计为什么 PolyWeather 不把通用城市预报直接当作市场真实值。", + methodologyPanelTitle: "公开读数如何产生", + openLiveTerminal: "打开实时终端", + readBrief: "阅读简报", + readMethodology: "阅读方法", + readSourceNote: "阅读来源说明", + settlementSource: "结算源", + shareOnX: "分享到 X", + snapshot: "快照", + sourceIndexDescription: + "来源页把官方观测、机场观测和模型指引分开展示,方便读者审计 PolyWeather 为什么优先使用与结算相关的证据。", + sourceIndexEyebrow: "来源", + sourceIndexTitle: "用于公开审计的天气来源说明", + sourceLinks: "来源链接", + sourceNotes: "来源说明", + sourcePanelBody: + "来源页解释为什么 MGM、METAR、HKO、NOAA 和模型指引会在 PolyWeather 工作流中分开展示。", + sourcePanelTitle: "官方来源上下文", + sources: "来源", + updated: "更新", + detailLabels: { + debRead: "DEB 读数", + modelContext: "模型上下文", + riskNotes: "风险备注", + settlementSourceRead: "结算源读数", + }, + }, + "en-US": { + allPublicBriefs: "All public briefs", + briefIndexDescription: + "Public Weather Market Brief pages turn selected city-market reads into indexable evidence: settlement source, DEB context, model disagreement, freshness notes, and a clear research disclaimer.", + briefIndexEyebrow: "Weather Market Brief", + briefIndexTitle: "Public market briefs for temperature judgment", + checksBeforeActing: "Checks before acting", + distributionCopy: "Distribution copy", + docs: "Docs", + freshness: "Freshness", + market: "Market", + methodology: "Methodology", + methodologyIndexDescription: + "Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis.", + methodologyIndexEyebrow: "Methodology", + methodologyIndexTitle: "How PolyWeather reads weather markets", + methodologyLinks: "Methodology links", + methodologyPanelBody: + "Briefs cross-link to the DEB methodology and settlement-source priority pages so readers can audit why PolyWeather does not treat generic city forecasts as market truth.", + methodologyPanelTitle: "How the public read is produced", + openLiveTerminal: "Open live terminal", + readBrief: "Read brief", + readMethodology: "Read methodology", + readSourceNote: "Read source note", + settlementSource: "Settlement source", + shareOnX: "Share on X", + snapshot: "Snapshot", + sourceIndexDescription: + "Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence.", + sourceIndexEyebrow: "Sources", + sourceIndexTitle: "Weather source notes for public audit", + sourceLinks: "Source links", + sourceNotes: "Source notes", + sourcePanelBody: + "Source pages explain why MGM, METAR, HKO, NOAA, and model guidance are displayed separately in PolyWeather workflows.", + sourcePanelTitle: "Official-source context", + sources: "Sources", + updated: "Updated", + detailLabels: { + debRead: "DEB read", + modelContext: "Model context", + riskNotes: "Risk notes", + settlementSourceRead: "Settlement-source read", + }, + }, +}; + export const PUBLIC_BRIEFS: PublicBrief[] = [ { city: "ankara", @@ -384,6 +520,193 @@ export const SOURCE_PAGES: SourcePage[] = [ }, ]; +const BRIEF_LOCALIZATIONS: Record> = { + "ankara:2026-06-24": { + cityName: "安卡拉", + countryName: "土耳其", + title: "安卡拉天气市场简报 - 2026年6月24日", + description: + "安卡拉最高温公开简报,聚焦 MGM 结算源行为、DEB 融合预报背景和异常值复核。", + market: "当日最高温判断", + settlementSource: "MGM 官方站", + dataFreshness: + "静态公开快照。付费终端用户行动前应核对最新官方观测和 SSE replay 状态。", + debRead: + "DEB 将日内最高温读数压在孤立 MGM 尖峰下方,更接近已观测的官方区间。", + sourceRead: + "MGM 被视为主要结算参考。单个 27.1 C 点位在接受为新高前,需要与相邻官方读数比对。", + modelRead: + "ECMWF 在午后窗口比 DEB 融合更暖,但公开简报把官方观测置于纯模型移动之上。", + riskRead: + "主要风险是晚些时候官方更新或来源侧修正,导致公开快照后的认可高温发生变化。", + notFinancialAdvice: + "本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。", + distributionText: + "安卡拉 2026-06-24 公开天气市场简报:MGM 官方读数更支持 24.5 C 已观测高温,而非孤立 27.1 C 尖峰;DEB 仍低于异常点。非金融建议。", + primaryCtaLabel: "打开实时终端", + signals: [ + { + label: "目前官方高温", + value: "24.5 C", + detail: "用于对比任何孤立更高点的官方来源值。", + }, + { + label: "异常点待复核", + value: "27.1 C", + detail: "突然出现的单源值需要相邻时间验证。", + }, + { + label: "DEB 公开读数", + value: "低于尖峰", + detail: "融合路径仍更接近已验证观测带。", + }, + ], + checkpoints: [ + "检查疑似尖峰是否进入官方高温摘要。", + "在把单点视为结算相关之前,先比较相邻 MGM 观测。", + "市场关闭前查看付费终端的实时图表 patch 和来源新鲜度。", + ], + }, + "hong-kong:2026-06-24": { + cityName: "香港", + countryName: "香港", + title: "香港天气市场简报 - 2026年6月24日", + description: + "公开简报展示 PolyWeather 如何把 HKO/长洲/机场观测与 DEB、模型分歧放在一起解释最高温市场。", + market: "城市与机场最高温判断", + settlementSource: "HKO 官方网络", + dataFreshness: + "静态公开快照。HKO 和站点缓存刷新后,实时终端数值可能不同。", + debRead: + "到午后后段,实况观测与模型分歧趋于一致,DEB 支持较窄的高温窗口。", + sourceRead: + "解释机场单独移动前,应先把 HKO 网络观测作为需要校验的来源族。", + modelRead: + "模型分歧有限,因此来源新鲜度和站点选择比大尺度天气不确定性更重要。", + riskRead: + "主要风险是午后后段特定站点保温,或官方摘要出现较晚修订。", + notFinancialAdvice: + "本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。", + distributionText: + "香港 2026-06-24 公开天气市场简报:来源选择和 HKO 新鲜度比模型分歧更关键。非金融建议。", + primaryCtaLabel: "打开实时终端", + signals: [ + { + label: "来源族", + value: "HKO", + detail: "先使用官方站上下文,再解释机场读数。", + }, + { + label: "模型分歧", + value: "低", + detail: "晚间不确定性主要来自站点行为。", + }, + { + label: "终端需要", + value: "新鲜度", + detail: "实时来源时间戳决定公开快照是否仍有用。", + }, + ], + checkpoints: [ + "比较市场温度桶前先确认 HKO 站点时间戳。", + "把机场 METAR 观测与 HKO 网络读数分开解释。", + "如果公开快照已超过一个刷新周期,检查终端来源健康状态。", + ], + }, + "new-york:2026-06-24": { + cityName: "纽约", + countryName: "美国", + title: "纽约天气市场简报 - 2026年6月24日", + description: + "纽约温度市场公开简报,连接 METAR、NOAA 上下文、DEB 融合和日内尾段风险检查。", + market: "机场关联最高温判断", + settlementSource: "METAR 与 NOAA 站点上下文", + dataFreshness: + "静态公开快照。付费终端用户应检查当前 METAR 观测和官方摘要。", + debRead: + "DEB 用最新观测趋势校验偏暖模型指引,而不是直接追随原始模型高温。", + sourceRead: + "METAR 提供快速机场证据,NOAA 上下文帮助确认机场读数是否具代表性。", + modelRead: + "偏暖模型指引只有在和机场实况、云量/风场上下文校验后才有价值。", + riskRead: + "主要风险是日内尾段云层短暂打开,将机场观测推入更高温度桶。", + notFinancialAdvice: + "本简报是用于预测市场准备的天气研究内容,不构成金融建议,也不保证结算结果。", + distributionText: + "纽约 2026-06-24 公开天气市场简报:DEB 将偏暖模型指引与机场实时证据融合。非金融建议。", + primaryCtaLabel: "打开实时终端", + signals: [ + { + label: "快速证据", + value: "METAR", + detail: "机场观测定义短周期读数。", + }, + { + label: "复核", + value: "NOAA", + detail: "官方上下文帮助审计最终最高温解释。", + }, + { + label: "DEB 立场", + value: "融合", + detail: "没有实况确认,不追随偏暖模型运行。", + }, + ], + checkpoints: [ + "在每日高温窗口结束前观察最后两个 METAR 周期。", + "检查模型偏暖是否得到云量和风场观测支持。", + "最终解释结算前先使用官方来源上下文。", + ], + }, +}; + +const METHODOLOGY_TITLE_LOCALIZATIONS: Record = { + deb: "DEB 预测方法", + "settlement-sources": "结算源优先级", +}; + +const SOURCE_TITLE_LOCALIZATIONS: Record = { + ecmwf: "ECMWF 模型指引", + hko: "HKO 官方观测", + metar: "METAR 机场观测", + mgm: "MGM 天气来源", + noaa: "NOAA 天气上下文", +}; + +export function localizeBrief(brief: PublicBrief, locale: LandingLocale): PublicBrief { + if (locale === "en-US") return brief; + const localized = BRIEF_LOCALIZATIONS[`${brief.city}:${brief.date}`] || {}; + return { + ...brief, + ...localized, + checkpoints: localized.checkpoints || brief.checkpoints, + methodologySlugs: localized.methodologySlugs || brief.methodologySlugs, + signals: localized.signals || brief.signals, + sourceSlugs: localized.sourceSlugs || brief.sourceSlugs, + }; +} + +export function localizeBriefs(locale: LandingLocale) { + return PUBLIC_BRIEFS.map((brief) => localizeBrief(brief, locale)); +} + +export function localizeMethodologyPage(page: MethodologyPage, locale: LandingLocale): MethodologyPage { + if (locale === "en-US") return page; + return { + ...page, + title: METHODOLOGY_TITLE_LOCALIZATIONS[page.slug] || page.title, + }; +} + +export function localizeSourcePage(page: SourcePage, locale: LandingLocale): SourcePage { + if (locale === "en-US") return page; + return { + ...page, + title: SOURCE_TITLE_LOCALIZATIONS[page.slug] || page.title, + }; +} + export function briefPath(brief: PublicBrief) { return `/briefs/${brief.city}/${brief.date}`; }