Complete public brief locale switching
This commit is contained in:
@@ -1,16 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { MethodologyDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import {
|
||||
METHODOLOGY_PAGES,
|
||||
absolutePublicUrl,
|
||||
getMethodologyPage,
|
||||
localizeMethodologyPage,
|
||||
methodologyPath,
|
||||
} from "@/content/public-content";
|
||||
|
||||
type MethodologyPageParams = {
|
||||
slug: string;
|
||||
};
|
||||
type MethodologySearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
async function resolvePublicContentLocale(searchParams: MethodologySearchParams): Promise<LandingLocale> {
|
||||
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 METHODOLOGY_PAGES.map((page) => ({ slug: page.slug }));
|
||||
@@ -18,10 +39,15 @@ export function generateStaticParams() {
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<MethodologyPageParams>;
|
||||
searchParams: MethodologySearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const [{ slug }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const page = getMethodologyPage(slug);
|
||||
|
||||
if (!page) {
|
||||
@@ -29,17 +55,18 @@ export async function generateMetadata({
|
||||
title: "Methodology not found",
|
||||
};
|
||||
}
|
||||
const localizedPage = localizeMethodologyPage(page, locale);
|
||||
|
||||
return {
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
title: localizedPage.title,
|
||||
description: localizedPage.description,
|
||||
alternates: {
|
||||
canonical: methodologyPath(page),
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
title: localizedPage.title,
|
||||
description: localizedPage.description,
|
||||
url: methodologyPath(page),
|
||||
modifiedTime: page.updatedAt,
|
||||
},
|
||||
@@ -48,22 +75,28 @@ export async function generateMetadata({
|
||||
|
||||
export default async function MethodologyDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<MethodologyPageParams>;
|
||||
searchParams: MethodologySearchParams;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const [{ slug }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const page = getMethodologyPage(slug);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const localizedPage = localizeMethodologyPage(page, locale);
|
||||
const pathname = methodologyPath(page);
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "TechArticle",
|
||||
headline: page.title,
|
||||
description: page.description,
|
||||
headline: localizedPage.title,
|
||||
description: localizedPage.description,
|
||||
dateModified: page.updatedAt,
|
||||
mainEntityOfPage: absolutePublicUrl(pathname),
|
||||
author: {
|
||||
@@ -79,7 +112,7 @@ export default async function MethodologyDetailPage({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<MethodologyDetailPageView page={page} />
|
||||
<MethodologyDetailPageView page={page} locale={locale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { MethodologyIndexPageView } 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: "Methodology",
|
||||
description:
|
||||
"PolyWeather public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
|
||||
alternates: {
|
||||
canonical: "/methodology",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Methodology | PolyWeather",
|
||||
description:
|
||||
"Public methodology for DEB forecast blending, settlement-source priority, freshness, and market weather analysis.",
|
||||
url: "/methodology",
|
||||
},
|
||||
};
|
||||
type MethodologySearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default function MethodologyPage() {
|
||||
return <MethodologyIndexPageView />;
|
||||
async function resolvePublicContentLocale(searchParams: MethodologySearchParams): Promise<LandingLocale> {
|
||||
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: MethodologySearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
return {
|
||||
title: copy.methodologyIndexEyebrow,
|
||||
description: copy.methodologyIndexDescription,
|
||||
alternates: {
|
||||
canonical: "/methodology",
|
||||
},
|
||||
openGraph: {
|
||||
title: `${copy.methodologyIndexEyebrow} | PolyWeather`,
|
||||
description: copy.methodologyIndexDescription,
|
||||
url: "/methodology",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function MethodologyPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: MethodologySearchParams;
|
||||
}) {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
return <MethodologyIndexPageView locale={locale} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SourceDetailPageView } from "@/components/public-content/PublicContentPages";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
LANDING_LOCALE_QUERY_PARAM,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
import {
|
||||
SOURCE_PAGES,
|
||||
absolutePublicUrl,
|
||||
getSourcePage,
|
||||
localizeSourcePage,
|
||||
sourcePath,
|
||||
} from "@/content/public-content";
|
||||
|
||||
type SourcePageParams = {
|
||||
slug: string;
|
||||
};
|
||||
type SourceSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
async function resolvePublicContentLocale(searchParams: SourceSearchParams): Promise<LandingLocale> {
|
||||
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 SOURCE_PAGES.map((source) => ({ slug: source.slug }));
|
||||
@@ -18,10 +39,15 @@ export function generateStaticParams() {
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<SourcePageParams>;
|
||||
searchParams: SourceSearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const [{ slug }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const source = getSourcePage(slug);
|
||||
|
||||
if (!source) {
|
||||
@@ -29,17 +55,18 @@ export async function generateMetadata({
|
||||
title: "Source not found",
|
||||
};
|
||||
}
|
||||
const localizedSource = localizeSourcePage(source, locale);
|
||||
|
||||
return {
|
||||
title: source.title,
|
||||
description: source.description,
|
||||
title: localizedSource.title,
|
||||
description: localizedSource.description,
|
||||
alternates: {
|
||||
canonical: sourcePath(source),
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
title: source.title,
|
||||
description: source.description,
|
||||
title: localizedSource.title,
|
||||
description: localizedSource.description,
|
||||
url: sourcePath(source),
|
||||
modifiedTime: source.updatedAt,
|
||||
},
|
||||
@@ -48,22 +75,28 @@ export async function generateMetadata({
|
||||
|
||||
export default async function SourceDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<SourcePageParams>;
|
||||
searchParams: SourceSearchParams;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const [{ slug }, locale] = await Promise.all([
|
||||
params,
|
||||
resolvePublicContentLocale(searchParams),
|
||||
]);
|
||||
const source = getSourcePage(slug);
|
||||
|
||||
if (!source) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const localizedSource = localizeSourcePage(source, locale);
|
||||
const pathname = sourcePath(source);
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Dataset",
|
||||
name: source.title,
|
||||
description: source.description,
|
||||
name: localizedSource.title,
|
||||
description: localizedSource.description,
|
||||
url: absolutePublicUrl(pathname),
|
||||
dateModified: source.updatedAt,
|
||||
creator: {
|
||||
@@ -83,7 +116,7 @@ export default async function SourceDetailPage({
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<SourceDetailPageView source={source} />
|
||||
<SourceDetailPageView source={source} locale={locale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { SourcesIndexPageView } 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 Sources",
|
||||
description:
|
||||
"PolyWeather public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
|
||||
alternates: {
|
||||
canonical: "/sources",
|
||||
},
|
||||
openGraph: {
|
||||
title: "Weather Sources | PolyWeather",
|
||||
description:
|
||||
"Public source notes for MGM, METAR, HKO, NOAA, ECMWF, and settlement-source weather analysis.",
|
||||
url: "/sources",
|
||||
},
|
||||
};
|
||||
type SourcesSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default function SourcesPage() {
|
||||
return <SourcesIndexPageView />;
|
||||
async function resolvePublicContentLocale(searchParams: SourcesSearchParams): Promise<LandingLocale> {
|
||||
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: SourcesSearchParams;
|
||||
}): Promise<Metadata> {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
return {
|
||||
title: copy.sourceIndexEyebrow,
|
||||
description: copy.sourceIndexDescription,
|
||||
alternates: {
|
||||
canonical: "/sources",
|
||||
},
|
||||
openGraph: {
|
||||
title: `${copy.sourceIndexEyebrow} | PolyWeather`,
|
||||
description: copy.sourceIndexDescription,
|
||||
url: "/sources",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SourcesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SourcesSearchParams;
|
||||
}) {
|
||||
const locale = await resolvePublicContentLocale(searchParams);
|
||||
return <SourcesIndexPageView locale={locale} />;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/components/landing/LandingAuthActions";
|
||||
import {
|
||||
LANDING_LOCALE_COOKIE,
|
||||
landingLocaleHref,
|
||||
pickLandingLocale,
|
||||
type LandingLocale,
|
||||
} from "@/components/landing/landingLocale";
|
||||
@@ -324,7 +325,7 @@ function InstitutionalLandingScreen({ locale }: { locale: LandingLocale }) {
|
||||
<Link href="/docs/chart-guide" className="hover:text-slate-950">
|
||||
{isEn ? "Guide" : "读图"}
|
||||
</Link>
|
||||
<Link href="/briefs" className="hover:text-slate-950">
|
||||
<Link href={landingLocaleHref("/briefs", locale)} className="hover:text-slate-950">
|
||||
{isEn ? "Briefs" : "简报"}
|
||||
</Link>
|
||||
<a href="#pricing" className="hover:text-slate-950">
|
||||
|
||||
@@ -104,7 +104,11 @@ export function runTests() {
|
||||
"landing supported cities must render names and station codes from the generated city groups",
|
||||
);
|
||||
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("landingLocaleHref") &&
|
||||
source.includes('href={landingLocaleHref("/briefs", locale)}'),
|
||||
"landing navigation must expose public Weather Market Brief assets while preserving the current locale",
|
||||
);
|
||||
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");
|
||||
|
||||
@@ -38,3 +38,10 @@ export function pickLandingLocale(
|
||||
export function nextLandingLocale(locale: LandingLocale): LandingLocale {
|
||||
return locale === "zh-CN" ? "en-US" : "zh-CN";
|
||||
}
|
||||
|
||||
export function landingLocaleHref(pathname: string, locale: LandingLocale): string {
|
||||
const [pathWithQuery, hash] = pathname.split("#", 2);
|
||||
const separator = pathWithQuery.includes("?") ? "&" : "?";
|
||||
const localizedPath = `${pathWithQuery}${separator}${LANDING_LOCALE_QUERY_PARAM}=${encodeURIComponent(locale)}`;
|
||||
return hash ? `${localizedPath}#${hash}` : localizedPath;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { LandingLocaleToggle } from "@/components/landing/LandingLocaleToggle";
|
||||
import type { LandingLocale } from "@/components/landing/landingLocale";
|
||||
import { landingLocaleHref, type LandingLocale } from "@/components/landing/landingLocale";
|
||||
import {
|
||||
METHODOLOGY_PAGES,
|
||||
PUBLIC_CONTENT_COPY,
|
||||
@@ -44,21 +44,21 @@ function PublicHeader({ locale = "en-US" }: { locale?: LandingLocale }) {
|
||||
return (
|
||||
<header className="border-b border-slate-200 bg-white/90">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
|
||||
<Link className="text-base font-black text-slate-950" href="/">
|
||||
<Link className="text-base font-black text-slate-950" href={landingLocaleHref("/", locale)}>
|
||||
PolyWeather
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
<nav className="flex flex-wrap gap-2 text-sm font-semibold text-slate-700">
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/briefs">
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/briefs", locale)}>
|
||||
{locale === "en-US" ? "Briefs" : "简报"}
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/methodology">
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/methodology", locale)}>
|
||||
{copy.methodology}
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/sources">
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/sources", locale)}>
|
||||
{copy.sources}
|
||||
</Link>
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href="/docs/intro">
|
||||
<Link className="rounded-md px-2.5 py-1.5 hover:bg-slate-100" href={landingLocaleHref("/docs/intro", locale)}>
|
||||
{copy.docs}
|
||||
</Link>
|
||||
</nav>
|
||||
@@ -103,7 +103,7 @@ function SourceLinks({ locale = "en-US", slugs }: { locale?: LandingLocale; slug
|
||||
return (
|
||||
<Link
|
||||
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
|
||||
href={sourcePath(localizedSource)}
|
||||
href={landingLocaleHref(sourcePath(localizedSource), locale)}
|
||||
key={slug}
|
||||
>
|
||||
{localizedSource.title}
|
||||
@@ -124,7 +124,7 @@ function MethodologyLinks({ locale = "en-US", slugs }: { locale?: LandingLocale;
|
||||
return (
|
||||
<Link
|
||||
className="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-300 hover:bg-slate-50"
|
||||
href={methodologyPath(localizedPage)}
|
||||
href={landingLocaleHref(methodologyPath(localizedPage), locale)}
|
||||
key={slug}
|
||||
>
|
||||
{localizedPage.title}
|
||||
@@ -152,7 +152,7 @@ function BriefCard({
|
||||
{brief.cityName} / {brief.date}
|
||||
</p>
|
||||
<h2 className="mt-2 text-xl font-black text-slate-950">
|
||||
<Link href={briefPath(brief)}>{brief.title}</Link>
|
||||
<Link href={landingLocaleHref(briefPath(brief), locale)}>{brief.title}</Link>
|
||||
</h2>
|
||||
</div>
|
||||
<span className="rounded-md bg-emerald-50 px-2.5 py-1 text-xs font-bold text-emerald-800">
|
||||
@@ -170,7 +170,7 @@ function BriefCard({
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link className={secondaryButton} href={briefPath(brief)}>
|
||||
<Link className={secondaryButton} href={landingLocaleHref(briefPath(brief), locale)}>
|
||||
{readBriefLabel}
|
||||
</Link>
|
||||
<SourceLinks locale={locale} slugs={brief.sourceSlugs.slice(0, 2)} />
|
||||
@@ -291,7 +291,7 @@ export function BriefDetailPageView({
|
||||
>
|
||||
{localizedBrief.primaryCtaLabel}
|
||||
</PublicContentCta>
|
||||
<Link className={secondaryButton} href="/briefs">
|
||||
<Link className={secondaryButton} href={landingLocaleHref("/briefs", locale)}>
|
||||
{copy.allPublicBriefs}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -315,7 +315,7 @@ export function BriefDetailPageView({
|
||||
<div className="mt-4">
|
||||
<PublicContentOutboundLink
|
||||
className={secondaryButton}
|
||||
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(localizedBrief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(briefPath(localizedBrief)))}`}
|
||||
href={`https://twitter.com/intent/tweet?text=${encodeURIComponent(localizedBrief.distributionText)}&url=${encodeURIComponent(absolutePublicUrl(landingLocaleHref(briefPath(localizedBrief), locale)))}`}
|
||||
payload={{ city: localizedBrief.city, date: localizedBrief.date, destination: "x_intent" }}
|
||||
>
|
||||
{copy.shareOnX}
|
||||
@@ -365,26 +365,33 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MethodologyIndexPageView() {
|
||||
export function MethodologyIndexPageView({
|
||||
locale = "en-US",
|
||||
}: {
|
||||
locale?: LandingLocale;
|
||||
} = {}) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
const pages = METHODOLOGY_PAGES.map((page) => localizeMethodologyPage(page, locale));
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicHeader />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro
|
||||
description="Public methodology pages explain how PolyWeather handles DEB blending, settlement-source priority, freshness, and source reconciliation for prediction-market weather analysis."
|
||||
eyebrow="Methodology"
|
||||
title="How PolyWeather reads weather markets"
|
||||
description={copy.methodologyIndexDescription}
|
||||
eyebrow={copy.methodologyIndexEyebrow}
|
||||
title={copy.methodologyIndexTitle}
|
||||
/>
|
||||
<div className={contentWrap}>
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
{METHODOLOGY_PAGES.map((page) => (
|
||||
{pages.map((page) => (
|
||||
<article className={`${panel} p-5`} key={page.slug}>
|
||||
<p className={sectionTitle}>{formatDate(page.updatedAt)}</p>
|
||||
<p className={sectionTitle}>{formatDate(page.updatedAt, locale)}</p>
|
||||
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||
<Link href={methodologyPath(page)}>{page.title}</Link>
|
||||
<Link href={landingLocaleHref(methodologyPath(page), locale)}>{page.title}</Link>
|
||||
</h2>
|
||||
<p className={`${bodyText} mt-3`}>{page.description}</p>
|
||||
<Link className={`${secondaryButton} mt-5`} href={methodologyPath(page)}>
|
||||
Read methodology
|
||||
<Link className={`${secondaryButton} mt-5`} href={landingLocaleHref(methodologyPath(page), locale)}>
|
||||
{copy.readMethodology}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
@@ -394,7 +401,16 @@ export function MethodologyIndexPageView() {
|
||||
);
|
||||
}
|
||||
|
||||
export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
|
||||
export function MethodologyDetailPageView({
|
||||
page,
|
||||
locale = "en-US",
|
||||
}: {
|
||||
page: MethodologyPage;
|
||||
locale?: LandingLocale;
|
||||
}) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
const localizedPage = localizeMethodologyPage(page, locale);
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicContentAnalytics
|
||||
@@ -402,13 +418,17 @@ export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
|
||||
onceKey={`methodology:${page.slug}`}
|
||||
payload={{ slug: page.slug, content_type: "methodology" }}
|
||||
/>
|
||||
<PublicHeader />
|
||||
<PageIntro description={page.description} eyebrow="Methodology" title={page.title} />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro
|
||||
description={localizedPage.description}
|
||||
eyebrow={copy.methodologyIndexEyebrow}
|
||||
title={localizedPage.title}
|
||||
/>
|
||||
<div className={contentWrap}>
|
||||
<article className={`${panel} p-5`}>
|
||||
<p className="max-w-3xl text-base leading-7 text-slate-700">{page.summary}</p>
|
||||
<p className="max-w-3xl text-base leading-7 text-slate-700">{localizedPage.summary}</p>
|
||||
<div className="mt-8 grid gap-8">
|
||||
{page.sections.map((section) => (
|
||||
{localizedPage.sections.map((section) => (
|
||||
<section key={section.heading}>
|
||||
<h2 className="text-xl font-black text-slate-950">{section.heading}</h2>
|
||||
<p className={`${bodyText} mt-3`}>{section.body}</p>
|
||||
@@ -428,26 +448,33 @@ export function MethodologyDetailPageView({ page }: { page: MethodologyPage }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SourcesIndexPageView() {
|
||||
export function SourcesIndexPageView({
|
||||
locale = "en-US",
|
||||
}: {
|
||||
locale?: LandingLocale;
|
||||
} = {}) {
|
||||
const copy = PUBLIC_CONTENT_COPY[locale];
|
||||
const sources = SOURCE_PAGES.map((source) => localizeSourcePage(source, locale));
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicHeader />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro
|
||||
description="Source pages separate official observations, airport observations, and model guidance so readers can inspect why PolyWeather prioritizes settlement-relevant evidence."
|
||||
eyebrow="Sources"
|
||||
title="Weather source notes for public audit"
|
||||
description={copy.sourceIndexDescription}
|
||||
eyebrow={copy.sourceIndexEyebrow}
|
||||
title={copy.sourceIndexTitle}
|
||||
/>
|
||||
<div className={contentWrap}>
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
{SOURCE_PAGES.map((source) => (
|
||||
{sources.map((source) => (
|
||||
<article className={`${panel} p-5`} key={source.slug}>
|
||||
<p className={sectionTitle}>{source.operator}</p>
|
||||
<h2 className="mt-2 text-2xl font-black text-slate-950">
|
||||
<Link href={sourcePath(source)}>{source.title}</Link>
|
||||
<Link href={landingLocaleHref(sourcePath(source), locale)}>{source.title}</Link>
|
||||
</h2>
|
||||
<p className={`${bodyText} mt-3`}>{source.description}</p>
|
||||
<Link className={`${secondaryButton} mt-5`} href={sourcePath(source)}>
|
||||
Read source note
|
||||
<Link className={`${secondaryButton} mt-5`} href={landingLocaleHref(sourcePath(source), locale)}>
|
||||
{copy.readSourceNote}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
@@ -457,32 +484,60 @@ export function SourcesIndexPageView() {
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceDetailPageView({ source }: { source: SourcePage }) {
|
||||
export function SourceDetailPageView({
|
||||
source,
|
||||
locale = "en-US",
|
||||
}: {
|
||||
source: SourcePage;
|
||||
locale?: LandingLocale;
|
||||
}) {
|
||||
const localizedSource = localizeSourcePage(source, locale);
|
||||
const labels =
|
||||
locale === "en-US"
|
||||
? {
|
||||
cadence: "Cadence",
|
||||
coverage: "Coverage",
|
||||
operator: "Operator",
|
||||
reliability: "Reliability notes",
|
||||
relatedMethodology: "Related methodology",
|
||||
settlementUse: "Settlement use",
|
||||
sourceNote: "Source note",
|
||||
}
|
||||
: {
|
||||
cadence: "频率",
|
||||
coverage: "覆盖范围",
|
||||
operator: "运营方",
|
||||
reliability: "可靠性备注",
|
||||
relatedMethodology: "相关方法",
|
||||
settlementUse: "结算用途",
|
||||
sourceNote: "来源说明",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={pageShell}>
|
||||
<PublicHeader />
|
||||
<PageIntro description={source.description} eyebrow="Source note" title={source.title} />
|
||||
<PublicHeader locale={locale} />
|
||||
<PageIntro description={localizedSource.description} eyebrow={labels.sourceNote} title={localizedSource.title} />
|
||||
<div className={contentWrap}>
|
||||
<article className={`${panel} grid gap-6 p-5 lg:grid-cols-[0.9fr_1.4fr]`}>
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<InfoRow label="Operator" value={source.operator} />
|
||||
<InfoRow label="Coverage" value={source.coverage} />
|
||||
<InfoRow label="Cadence" value={source.cadence} />
|
||||
<InfoRow label="Settlement use" value={source.settlementUse} />
|
||||
<InfoRow label={labels.operator} value={localizedSource.operator} />
|
||||
<InfoRow label={labels.coverage} value={localizedSource.coverage} />
|
||||
<InfoRow label={labels.cadence} value={localizedSource.cadence} />
|
||||
<InfoRow label={labels.settlementUse} value={localizedSource.settlementUse} />
|
||||
</dl>
|
||||
<div>
|
||||
<p className={sectionTitle}>Reliability notes</p>
|
||||
<p className={sectionTitle}>{labels.reliability}</p>
|
||||
<ul className="mt-4 grid gap-3">
|
||||
{source.reliabilityNotes.map((note) => (
|
||||
{localizedSource.reliabilityNotes.map((note) => (
|
||||
<li className={bodyText} key={note}>
|
||||
{note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6">
|
||||
<p className={sectionTitle}>Related methodology</p>
|
||||
<p className={sectionTitle}>{labels.relatedMethodology}</p>
|
||||
<div className="mt-4">
|
||||
<MethodologyLinks slugs={source.relatedMethodologySlugs} />
|
||||
<MethodologyLinks locale={locale} slugs={localizedSource.relatedMethodologySlugs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,8 +554,8 @@ function formatDateTime(value: string, locale: LandingLocale = "en-US") {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
function formatDate(value: string, locale: LandingLocale = "en-US") {
|
||||
return new Intl.DateTimeFormat(locale === "en-US" ? "en" : "zh-CN", {
|
||||
dateStyle: "medium",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export function runTests() {
|
||||
const analyticsPath = path.join(root, "lib", "app-analytics.ts");
|
||||
const analyticsIslandPath = path.join(root, "components", "public-content", "PublicContentAnalytics.tsx");
|
||||
const publicPagesPath = path.join(root, "components", "public-content", "PublicContentPages.tsx");
|
||||
const localeHelperPath = path.join(root, "components", "landing", "landingLocale.ts");
|
||||
const sitemapPath = path.join(root, "app", "sitemap.ts");
|
||||
|
||||
for (const requiredPath of [
|
||||
@@ -35,11 +36,14 @@ export function runTests() {
|
||||
|
||||
const content = fs.readFileSync(contentPath, "utf8");
|
||||
const briefDetail = fs.readFileSync(briefDetailPath, "utf8");
|
||||
const methodologyIndex = fs.readFileSync(methodologyIndexPath, "utf8");
|
||||
const methodologyDetail = fs.readFileSync(methodologyDetailPath, "utf8");
|
||||
const sourcesIndex = fs.readFileSync(sourcesIndexPath, "utf8");
|
||||
const sourceDetail = fs.readFileSync(sourceDetailPath, "utf8");
|
||||
const analytics = fs.readFileSync(analyticsPath, "utf8");
|
||||
const analyticsIsland = fs.readFileSync(analyticsIslandPath, "utf8");
|
||||
const publicPages = fs.readFileSync(publicPagesPath, "utf8");
|
||||
const localeHelper = fs.readFileSync(localeHelperPath, "utf8");
|
||||
const sitemap = fs.readFileSync(sitemapPath, "utf8");
|
||||
const briefsIndex = fs.readFileSync(briefsIndexPath, "utf8");
|
||||
|
||||
@@ -68,6 +72,13 @@ export function runTests() {
|
||||
content.includes("阅读简报"),
|
||||
"public brief content must provide Chinese and English localized copy",
|
||||
);
|
||||
assert(
|
||||
content.includes("METHODOLOGY_PAGE_LOCALIZATIONS") &&
|
||||
content.includes("SOURCE_PAGE_LOCALIZATIONS") &&
|
||||
content.includes("DEB 不是结算预言机") &&
|
||||
content.includes("官方摘要可能滞后于原始点位观测"),
|
||||
"public methodology and source pages must provide Chinese body-level localization, not title-only translation",
|
||||
);
|
||||
assert(
|
||||
content.includes("24.5°C") &&
|
||||
content.includes("27.1°C") &&
|
||||
@@ -104,6 +115,30 @@ export function runTests() {
|
||||
publicPages.includes('locale === "en-US" ? "Briefs" : "简报"'),
|
||||
"public content pages must render the shared language toggle and localized brief copy",
|
||||
);
|
||||
assert(
|
||||
localeHelper.includes("export function landingLocaleHref") &&
|
||||
localeHelper.includes("LANDING_LOCALE_QUERY_PARAM"),
|
||||
"landing locale helpers must expose a shared href builder for explicit locale navigation",
|
||||
);
|
||||
assert(
|
||||
publicPages.includes("landingLocaleHref") &&
|
||||
publicPages.includes('href={landingLocaleHref("/briefs", locale)}') &&
|
||||
publicPages.includes("href={landingLocaleHref(briefPath(brief), locale)}") &&
|
||||
publicPages.includes("href={landingLocaleHref(methodologyPath(localizedPage), locale)}") &&
|
||||
publicPages.includes("href={landingLocaleHref(sourcePath(localizedSource), locale)}"),
|
||||
"public content brief cards, nav, methodology, and source links must preserve the current locale",
|
||||
);
|
||||
assert(
|
||||
methodologyIndex.includes("resolvePublicContentLocale(searchParams)") &&
|
||||
methodologyIndex.includes("<MethodologyIndexPageView locale={locale} />") &&
|
||||
methodologyDetail.includes("resolvePublicContentLocale(searchParams)") &&
|
||||
methodologyDetail.includes("<MethodologyDetailPageView page={page} locale={locale} />") &&
|
||||
sourcesIndex.includes("resolvePublicContentLocale(searchParams)") &&
|
||||
sourcesIndex.includes("<SourcesIndexPageView locale={locale} />") &&
|
||||
sourceDetail.includes("resolvePublicContentLocale(searchParams)") &&
|
||||
sourceDetail.includes("<SourceDetailPageView source={source} locale={locale} />"),
|
||||
"methodology and source public routes must resolve and pass the same locale as brief routes",
|
||||
);
|
||||
assert(
|
||||
analytics.includes('"brief_view"') &&
|
||||
analytics.includes('"brief_cta_click"') &&
|
||||
|
||||
@@ -661,17 +661,143 @@ const BRIEF_LOCALIZATIONS: Record<string, Partial<PublicBrief>> = {
|
||||
},
|
||||
};
|
||||
|
||||
const METHODOLOGY_TITLE_LOCALIZATIONS: Record<string, string> = {
|
||||
deb: "DEB 预测方法",
|
||||
"settlement-sources": "结算源优先级",
|
||||
const METHODOLOGY_PAGE_LOCALIZATIONS: Record<string, Partial<MethodologyPage>> = {
|
||||
deb: {
|
||||
title: "DEB 预测方法",
|
||||
description:
|
||||
"PolyWeather 如何把 DEB 融合预报用于预测市场温度判断,同时不替代结算源证据。",
|
||||
summary:
|
||||
"DEB 是 PolyWeather 融合预报层的公开名称。它把模型指引、实时观测动量、来源新鲜度和站点上下文放在一起,帮助用户用更少单模型误判来判断最高温区间。",
|
||||
sections: [
|
||||
{
|
||||
heading: "DEB 用来做什么",
|
||||
body:
|
||||
"DEB 不是结算预言机。它是决策辅助层,让实时观测路径和模型分歧更容易对比。",
|
||||
bullets: [
|
||||
"当模型指引与结算源证据冲突时,优先看结算源证据。",
|
||||
"把过期或孤立的来源值视为需要质检的候选点。",
|
||||
"展示预报区间以及区间变宽或收窄的原因。",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "关键输入",
|
||||
body:
|
||||
"融合层有用,是因为它组合了不同证据类别,而不是假设一次模型运行就足够。",
|
||||
bullets: [
|
||||
"最新官方或机场观测及其新鲜度。",
|
||||
"ECMWF、GFS、ICON、GEM 以及可用本地来源之间的模型共识和分歧。",
|
||||
"城市特定来源行为、站点选择和日内最高温时段。",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "如何复盘 DEB 偏差",
|
||||
body:
|
||||
"DEB 偏差应拆开来源新鲜度、模型分歧和结算源修订来复盘。",
|
||||
bullets: [
|
||||
"如果晚到来源 patch 改变了观测高温,将其归类为新鲜度或 replay 问题。",
|
||||
"如果所有来源都新鲜但高温落在区间外,复盘模型权重和本地站点特征。",
|
||||
"如果只有单一来源跳变,先审计相邻观测,再围绕异常点重训。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"settlement-sources": {
|
||||
title: "结算源优先级",
|
||||
description:
|
||||
"为什么 PolyWeather 先展示官方结算相关观测,而不是通用天气 API 数值。",
|
||||
summary:
|
||||
"预测市场用户关心的是合约最终结算数字。因此 PolyWeather 把官方站、机场和运营方特定来源行为放在泛消费者天气均值之上。",
|
||||
sections: [
|
||||
{
|
||||
heading: "为什么通用天气值不够",
|
||||
body:
|
||||
"消费者天气应用经常平滑站点数据,或展示城市级近似值。市场结算可能依赖更窄的官方来源。",
|
||||
bullets: [
|
||||
"一个城市标签可能隐藏多个日高温不同的站点。",
|
||||
"机场 METAR 可能比公开摘要更新更快,但未必是最终结算源。",
|
||||
"官方来源修订往往比平滑的预报曲线更重要。",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "PolyWeather 展示什么",
|
||||
body:
|
||||
"终端会拆开来源标签、观测时间戳、预报模型和新鲜度状态,让用户审计数字形成路径。",
|
||||
bullets: [
|
||||
"图表中的结算源标签和站点上下文。",
|
||||
"来源新鲜度、缓存策略和 SSE patch 可见性。",
|
||||
"DEB 预报上下文与实时观测并列展示,而不是替代观测。",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const SOURCE_TITLE_LOCALIZATIONS: Record<string, string> = {
|
||||
ecmwf: "ECMWF 模型指引",
|
||||
hko: "HKO 官方观测",
|
||||
metar: "METAR 机场观测",
|
||||
mgm: "MGM 天气来源",
|
||||
noaa: "NOAA 天气上下文",
|
||||
const SOURCE_PAGE_LOCALIZATIONS: Record<string, Partial<SourcePage>> = {
|
||||
mgm: {
|
||||
title: "MGM 天气来源",
|
||||
description: "PolyWeather 针对土耳其 MGM 观测的来源说明,用于安卡拉类温度市场分析。",
|
||||
operator: "土耳其国家气象局",
|
||||
coverage: "土耳其官方站网络,包含安卡拉市场上下文。",
|
||||
cadence: "来源频率会随站点和发布路径变化;终端仍需要检查新鲜度。",
|
||||
settlementUse: "当安卡拉市场引用土耳其官方观测时,作为主要官方来源族使用。",
|
||||
reliabilityNotes: [
|
||||
"单点尖峰在接受前应与相邻时间戳比对。",
|
||||
"官方摘要可能滞后于原始点位观测。",
|
||||
"数值突然出现时,应检查缓存和 SSE replay 状态。",
|
||||
],
|
||||
},
|
||||
metar: {
|
||||
title: "METAR 机场观测",
|
||||
description: "PolyWeather 针对机场 METAR 观测的来源说明,用作温度市场工作流中的快速证据。",
|
||||
operator: "机场天气观测网络",
|
||||
coverage: "覆盖受支持市场的机场关联观测。",
|
||||
cadence: "通常为小时级或更短,取决于机场和发布行为。",
|
||||
settlementUse: "适合快速证据和机场关联合约;仍必须与合约的精确结算源校验。",
|
||||
reliabilityNotes: [
|
||||
"METAR 可能比官方日摘要更新更快。",
|
||||
"机场暴露环境可能不同于市区官方站。",
|
||||
"临近收盘的晚些 METAR 周期可能改变最高温判断。",
|
||||
],
|
||||
},
|
||||
ecmwf: {
|
||||
title: "ECMWF 模型指引",
|
||||
description: "PolyWeather 针对 ECMWF 模型指引的来源说明,它是 DEB 融合预报的一个输入。",
|
||||
operator: "欧洲中期天气预报中心",
|
||||
coverage: "全球数值天气预报指引。",
|
||||
cadence: "模型运行频率取决于产品和摄取时间。",
|
||||
settlementUse: "仅用于预报上下文,不替代实时官方观测的结算解释。",
|
||||
reliabilityNotes: [
|
||||
"模型偏暖或偏冷都应由实时观测验证。",
|
||||
"当来源证据尚未稳定时,模型运行间变化可以提供参考。",
|
||||
"模型分歧应与结算源数据并列展示,而不是凌驾其上。",
|
||||
],
|
||||
},
|
||||
hko: {
|
||||
title: "HKO 官方观测",
|
||||
description: "PolyWeather 针对香港天文台观测的来源说明,用于香港市场分析。",
|
||||
operator: "香港天文台",
|
||||
coverage: "香港官方观测网络和站点级上下文。",
|
||||
cadence: "观测产品频率不同;终端仍应检查来源新鲜度。",
|
||||
settlementUse: "作为香港站点和城市市场解释中的官方来源族使用。",
|
||||
reliabilityNotes: [
|
||||
"站点选择可能显著改变最高温读数。",
|
||||
"机场观测应与更广泛的 HKO 网络读数分开解释。",
|
||||
"湿度、风和午后短时日照会影响最终高温。",
|
||||
],
|
||||
},
|
||||
noaa: {
|
||||
title: "NOAA 天气上下文",
|
||||
description: "PolyWeather 针对 NOAA 上下文的来源说明,用于校验美国天气市场观测和摘要。",
|
||||
operator: "美国国家海洋和大气管理局",
|
||||
coverage: "美国官方天气观测、摘要和上下文产品。",
|
||||
cadence: "频率取决于产品族和站点报告行为。",
|
||||
settlementUse: "当合约规则引用 NOAA/NWS 数据时,用于审计和解释美国官方观测。",
|
||||
reliabilityNotes: [
|
||||
"官方摘要可能晚于快速机场观测到达。",
|
||||
"日高温解释应匹配合约的站点和时区规则。",
|
||||
"使用 NOAA 上下文确认机场观测是否具代表性。",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function localizeBrief(brief: PublicBrief, locale: LandingLocale): PublicBrief {
|
||||
@@ -693,17 +819,22 @@ export function localizeBriefs(locale: LandingLocale) {
|
||||
|
||||
export function localizeMethodologyPage(page: MethodologyPage, locale: LandingLocale): MethodologyPage {
|
||||
if (locale === "en-US") return page;
|
||||
const localized = METHODOLOGY_PAGE_LOCALIZATIONS[page.slug] || {};
|
||||
return {
|
||||
...page,
|
||||
title: METHODOLOGY_TITLE_LOCALIZATIONS[page.slug] || page.title,
|
||||
...localized,
|
||||
sections: localized.sections || page.sections,
|
||||
};
|
||||
}
|
||||
|
||||
export function localizeSourcePage(page: SourcePage, locale: LandingLocale): SourcePage {
|
||||
if (locale === "en-US") return page;
|
||||
const localized = SOURCE_PAGE_LOCALIZATIONS[page.slug] || {};
|
||||
return {
|
||||
...page,
|
||||
title: SOURCE_TITLE_LOCALIZATIONS[page.slug] || page.title,
|
||||
...localized,
|
||||
reliabilityNotes: localized.reliabilityNotes || page.reliabilityNotes,
|
||||
relatedMethodologySlugs: localized.relatedMethodologySlugs || page.relatedMethodologySlugs,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user