From 0c391a8ac075ff7bfb3d6305e5f087e9a98b7b5e Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:49:21 +0000 Subject: [PATCH 1/3] feat(i18n): localize event location country names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add i18n-iso-countries package and country translation utilities to geography.ts. Event locations on community events pages and homepage now display translated country names (e.g., "Denver, USA" -> "Denver, アメリカ合衆国"). - getCountryTranslation(): reusable country name lookup - localizeLocation(): parses "City, Country" and translates - Updated EventCard, ContinentTabs, and homepage events - City names remain in Latin script (country-only MVP) Co-Authored-By: Claude Opus 4.6 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- .../events/_components/ContinentTabs.tsx | 5 +- .../events/_components/EventCard.tsx | 9 +-- app/[locale]/page.tsx | 3 +- package.json | 1 + pnpm-lock.yaml | 16 +++++ src/lib/utils/geography.ts | 72 +++++++++++++++++++ 6 files changed, 100 insertions(+), 6 deletions(-) diff --git a/app/[locale]/community/events/_components/ContinentTabs.tsx b/app/[locale]/community/events/_components/ContinentTabs.tsx index a63b383b471..652bd86dc6c 100644 --- a/app/[locale]/community/events/_components/ContinentTabs.tsx +++ b/app/[locale]/community/events/_components/ContinentTabs.tsx @@ -22,6 +22,7 @@ import { Tag } from "@/components/ui/tag" import { cn } from "@/lib/utils/cn" import { formatDateRange } from "@/lib/utils/date" +import { localizeLocation } from "@/lib/utils/geography" import { TAG_STATUS_MAPPING } from "../utils" @@ -174,7 +175,9 @@ export default function ContinentTabs({ {event.title}

-

{event.location}

+

+ {localizeLocation(event.location, locale)} +

diff --git a/app/[locale]/community/events/_components/EventCard.tsx b/app/[locale]/community/events/_components/EventCard.tsx index e0d945b0f7a..7d67283efe0 100644 --- a/app/[locale]/community/events/_components/EventCard.tsx +++ b/app/[locale]/community/events/_components/EventCard.tsx @@ -8,6 +8,7 @@ import { Tag } from "@/components/ui/tag" import { cn } from "@/lib/utils/cn" import { formatDate, formatDateRange } from "@/lib/utils/date" +import { localizeLocation } from "@/lib/utils/geography" import { TAG_STATUS_MAPPING } from "../utils" @@ -15,7 +16,7 @@ interface EventCardProps { event: EventItem variant?: "grid" | "highlight" className?: string - locale?: string + locale: string showTypeTag?: boolean customEventOptions?: MatomoEventOptions } @@ -72,7 +73,7 @@ function EventCardGrid({ {event.title}

{formattedDate &&

{formattedDate}

} -

{event.location}

+

{localizeLocation(event.location, locale)}

@@ -113,7 +114,7 @@ function EventCardHighlight({

{event.title}

-

{event.location}

+

{localizeLocation(event.location, locale)}

{formatDateRange(event.startTime, event.endTime, locale)}

@@ -128,7 +129,7 @@ export default function EventCard({ event, variant, className, - locale = "en", + locale, showTypeTag, customEventOptions, }: EventCardProps) { diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 36bf989131f..fa6fe8ccd28 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -59,6 +59,7 @@ import { parseAppsOfTheWeek } from "@/lib/utils/apps" import { cn } from "@/lib/utils/cn" import { formatDateRange } from "@/lib/utils/date" import { getDirection } from "@/lib/utils/direction" +import { localizeLocation } from "@/lib/utils/geography" import { getMetadata } from "@/lib/utils/metadata" import { formatPriceUSD } from "@/lib/utils/numbers" import { polishRSSList } from "@/lib/utils/rss" @@ -850,7 +851,7 @@ const Page = async (props: { params: Promise }) => { })} - {location} + {localizeLocation(location, locale)} diff --git a/package.json b/package.json index 990f516f8ec..2aa0bfe5efe 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "howler": "^2.2.4", "html-react-parser": "^5.2.17", "humanize-duration": "^3.33.1", + "i18n-iso-countries": "^7.14.0", "lodash": "^4.18.1", "lucide-react": "^0.516.0", "motion": "^12.36.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48188d95bc8..dd86676d46d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ importers: humanize-duration: specifier: ^3.33.1 version: 3.33.1 + i18n-iso-countries: + specifier: ^7.14.0 + version: 7.14.0 lodash: specifier: ^4.18.1 version: 4.18.1 @@ -6474,6 +6477,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diacritics@1.3.0: + resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -7456,6 +7462,10 @@ packages: engines: {node: '>=18'} hasBin: true + i18n-iso-countries@7.14.0: + resolution: {integrity: sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==} + engines: {node: '>= 12'} + icss-utils@5.1.0: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} @@ -19379,6 +19389,8 @@ snapshots: dependencies: dequal: 2.0.3 + diacritics@1.3.0: {} + didyoumean@1.2.2: {} diff@4.0.2: {} @@ -20691,6 +20703,10 @@ snapshots: husky@9.1.7: {} + i18n-iso-countries@7.14.0: + dependencies: + diacritics: 1.3.0 + icss-utils@5.1.0(postcss@8.5.4): dependencies: postcss: 8.5.4 diff --git a/src/lib/utils/geography.ts b/src/lib/utils/geography.ts index 053e5a1af71..31e4b8ee073 100644 --- a/src/lib/utils/geography.ts +++ b/src/lib/utils/geography.ts @@ -1,3 +1,5 @@ +import countries from "i18n-iso-countries" + import type { Continent } from "@/lib/types" // Continent → countries mapping for location parsing @@ -106,3 +108,73 @@ export function parseLocationToContinent(location: string): Continent | null { if (!country) return null return COUNTRY_TO_CONTINENT[country] || null } + +/** + * Aliases for country names that i18n-iso-countries doesn't recognize. + * Maps free-text names from external data to forms the package understands. + */ +const COUNTRY_NAME_ALIASES: Record = { + "Hong Kong SAR": "Hong Kong", + // Add more here as needed +} + +/** + * Translate an English country name into the target locale. + * + * Accepts informal English country names (e.g., "USA", "United States", + * "Hong Kong SAR") and returns the localized name. + * + * @param country - English country name (informal forms accepted) + * @param locale - Target locale code (e.g., "ja", "es", "ar") + * @returns Localized country name, or the original string if not recognized + */ +export function getCountryTranslation( + country: string, + locale: string +): string { + if (!country) return country + + const normalized = COUNTRY_NAME_ALIASES[country] ?? country + const code = countries.getAlpha2Code(normalized, "en") + if (!code) return country + + return countries.getName(code, locale) ?? country +} + +/** + * Localize an event location string by translating the country portion. + * + * Parses "City, Country" format, translates the country into the target + * locale, and reassembles. City names are left in their original script. + * + * Returns the original string unchanged if: + * - The location is "Online" (handled separately by i18n keys) + * - The country cannot be identified or translated + * - The locale is "en" (no translation needed) + * + * @param location - Raw location string (e.g., "Denver, USA") + * @param locale - Target locale code (e.g., "ja", "es", "ar") + * @returns Localized location string (e.g., "Denver, アメリカ合衆国") + */ +export function localizeLocation(location: string, locale: string): string { + if (!location || locale === "en") return location + + if (location.toLowerCase() === "online") return location + + const parts = location.split(/,\s*/) + const rawCountry = parts[parts.length - 1].trim() + + const localizedCountry = getCountryTranslation(rawCountry, locale) + + // Country wasn't translated -- return original + if (localizedCountry === rawCountry) return location + + if (parts.length === 1) { + // No comma -- the whole string was the country (e.g., "Hong Kong SAR") + return localizedCountry + } + + // Reassemble: "City, TranslatedCountry" + const city = parts.slice(0, -1).join(", ") + return `${city}, ${localizedCountry}` +} From 99d6cd63d95fa50a0be482053e792ec1387f2e63 Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:45:21 -0700 Subject: [PATCH 2/3] fix: localize event locations server-side Pre-translate the event location field at the server level (in mapEventTranslations and getMeetupGroups) rather than calling localizeLocation inside client components. Client-side i18n-iso-countries needs locale data registered, which would bloat the bundle. Doing translation server-side keeps client components dumb and keeps the bundle lean. Fixes untranslated locations on /community/events/ search results and /community/events/meetups/. Co-Authored-By: Claude Opus 4.6 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- .../events/_components/ContinentTabs.tsx | 5 +---- .../community/events/_components/EventCard.tsx | 5 ++--- .../community/events/conferences/page.tsx | 2 +- app/[locale]/community/events/meetups/page.tsx | 4 ++-- app/[locale]/community/events/page.tsx | 4 ++-- app/[locale]/community/events/search/page.tsx | 2 +- app/[locale]/community/events/utils.ts | 17 +++++++++++------ app/[locale]/page.tsx | 9 ++++++--- src/lib/utils/geography.ts | 10 +++++----- 9 files changed, 31 insertions(+), 27 deletions(-) diff --git a/app/[locale]/community/events/_components/ContinentTabs.tsx b/app/[locale]/community/events/_components/ContinentTabs.tsx index 652bd86dc6c..a63b383b471 100644 --- a/app/[locale]/community/events/_components/ContinentTabs.tsx +++ b/app/[locale]/community/events/_components/ContinentTabs.tsx @@ -22,7 +22,6 @@ import { Tag } from "@/components/ui/tag" import { cn } from "@/lib/utils/cn" import { formatDateRange } from "@/lib/utils/date" -import { localizeLocation } from "@/lib/utils/geography" import { TAG_STATUS_MAPPING } from "../utils" @@ -175,9 +174,7 @@ export default function ContinentTabs({ {event.title}

-

- {localizeLocation(event.location, locale)} -

+

{event.location}

diff --git a/app/[locale]/community/events/_components/EventCard.tsx b/app/[locale]/community/events/_components/EventCard.tsx index 7d67283efe0..bf1f02b3cd3 100644 --- a/app/[locale]/community/events/_components/EventCard.tsx +++ b/app/[locale]/community/events/_components/EventCard.tsx @@ -8,7 +8,6 @@ import { Tag } from "@/components/ui/tag" import { cn } from "@/lib/utils/cn" import { formatDate, formatDateRange } from "@/lib/utils/date" -import { localizeLocation } from "@/lib/utils/geography" import { TAG_STATUS_MAPPING } from "../utils" @@ -73,7 +72,7 @@ function EventCardGrid({ {event.title}

{formattedDate &&

{formattedDate}

} -

{localizeLocation(event.location, locale)}

+

{event.location}

@@ -114,7 +113,7 @@ function EventCardHighlight({

{event.title}

-

{localizeLocation(event.location, locale)}

+

{event.location}

{formatDateRange(event.startTime, event.endTime, locale)}

diff --git a/app/[locale]/community/events/conferences/page.tsx b/app/[locale]/community/events/conferences/page.tsx index 3ec263e3f29..c1a9032ea5b 100644 --- a/app/[locale]/community/events/conferences/page.tsx +++ b/app/[locale]/community/events/conferences/page.tsx @@ -32,7 +32,7 @@ const Page = async (props: { params: Promise }) => { const t = await getTranslations("page-community-events") // Apply translations and compute eventTypes from tags if missing - const events = mapEventTranslations(_events, t) + const events = mapEventTranslations(_events, t, locale) // Filter to conferences only (includes hackathons as they're often conference-adjacent) const conferences = events.filter( diff --git a/app/[locale]/community/events/meetups/page.tsx b/app/[locale]/community/events/meetups/page.tsx index b00fde43761..8b74eded2c6 100644 --- a/app/[locale]/community/events/meetups/page.tsx +++ b/app/[locale]/community/events/meetups/page.tsx @@ -28,7 +28,7 @@ const Page = async (props: { params: Promise }) => { const t = await getTranslations("page-community-events") // Apply translations and compute eventTypes from tags if missing - const events = mapEventTranslations(_events, t) + const events = mapEventTranslations(_events, t, locale) // Combine API meetup events with legacy meetup groups // Exclude conferences and hackathons - they have their own section @@ -37,7 +37,7 @@ const Page = async (props: { params: Promise }) => { !e.eventTypes?.includes("conference") && !e.eventTypes?.includes("hackathon") ) - const meetupGroups = getMeetupGroups() + const meetupGroups = getMeetupGroups(locale) // Show API meetups first (sorted by date), then groups (sorted alphabetically) const meetups = [...apiMeetups, ...meetupGroups] diff --git a/app/[locale]/community/events/page.tsx b/app/[locale]/community/events/page.tsx index 00be50ac273..75611ec68f2 100644 --- a/app/[locale]/community/events/page.tsx +++ b/app/[locale]/community/events/page.tsx @@ -61,7 +61,7 @@ const Page = async (props: { params: Promise }) => { locale as Lang ) - const events = mapEventTranslations(_events, t) + const events = mapEventTranslations(_events, t, locale) // Get highlighted conferences (with highlight flag or first 3) const conferences = events.filter( @@ -82,7 +82,7 @@ const Page = async (props: { params: Promise }) => { !e.eventTypes?.includes("conference") && !e.eventTypes?.includes("hackathon") ) - const meetupGroups = getMeetupGroups() + const meetupGroups = getMeetupGroups(locale) const meetups = [...apiMeetups, ...meetupGroups] // Continent labels for tabs diff --git a/app/[locale]/community/events/search/page.tsx b/app/[locale]/community/events/search/page.tsx index 9aff9dd4761..db1e543dc8d 100644 --- a/app/[locale]/community/events/search/page.tsx +++ b/app/[locale]/community/events/search/page.tsx @@ -41,7 +41,7 @@ const Page = async (props: { const t = await getTranslations("page-community-events") const tCommon = await getTranslations("common") - const events = mapEventTranslations(_events, t) + const events = mapEventTranslations(_events, t, locale) const filteredEvents = ((): EventItem[] => { if (!q) return [] diff --git a/app/[locale]/community/events/utils.ts b/app/[locale]/community/events/utils.ts index 2d686fd6169..543fcbfb8a0 100644 --- a/app/[locale]/community/events/utils.ts +++ b/app/[locale]/community/events/utils.ts @@ -4,7 +4,10 @@ import type { EventItem, EventType } from "@/lib/types" import { TagProps } from "@/components/ui/tag" -import { parseLocationToContinent } from "@/lib/utils/geography" +import { + localizeLocation, + parseLocationToContinent, +} from "@/lib/utils/geography" import { slugify } from "@/lib/utils/url" import communityMeetups from "@/data/community-meetups.json" @@ -25,7 +28,8 @@ export const sanitize = (s: string) => export const mapEventTranslations = ( events: EventItem[], - t: ReturnType + t: ReturnType, + locale: string ): EventItem[] => events.map((event) => { // Use existing eventTypes if they have values, otherwise compute from tags @@ -38,6 +42,7 @@ export const mapEventTranslations = ( ...event, eventTypes, eventTypesLabels: eventTypes.map((type) => t(`page-events-tag-${type}`)), + location: localizeLocation(event.location, locale), } }) @@ -50,14 +55,14 @@ interface MeetupGroup { bannerImage?: string } -function transformMeetupGroup(group: MeetupGroup): EventItem { +function transformMeetupGroup(group: MeetupGroup, locale: string): EventItem { return { title: group.title, logoImage: group.logoImage || "", bannerImage: group.bannerImage || "", startTime: "", endTime: null, - location: group.location, + location: localizeLocation(group.location, locale), link: group.link, tags: ["meetup"], id: slugify(`${group.title}-${group.location}`), @@ -71,8 +76,8 @@ function transformMeetupGroup(group: MeetupGroup): EventItem { * Get meetup groups from community-meetups.json * These are ongoing community groups (not individual events with dates) */ -export function getMeetupGroups(): EventItem[] { +export function getMeetupGroups(locale: string): EventItem[] { return (communityMeetups as MeetupGroup[]) - .map(transformMeetupGroup) + .map((group) => transformMeetupGroup(group, locale)) .sort((a, b) => a.title.localeCompare(b.title)) } diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index d54110b9eab..2d18e28b453 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -161,8 +161,11 @@ const Page = async (props: { params: Promise }) => { // Extract totalEthStaked from beaconchainData const { totalEthStaked } = beaconchainData - // Events - use empty array as fallback - const upcomingEvents = (eventsData ?? []).slice(0, 3) + // Events - use empty array as fallback; localize location country names + const upcomingEvents = (eventsData ?? []).slice(0, 3).map((event) => ({ + ...event, + location: localizeLocation(event.location, locale), + })) const appsOfTheWeek = parseAppsOfTheWeek(appsData) @@ -861,7 +864,7 @@ const Page = async (props: { params: Promise }) => { })} - {localizeLocation(location, locale)} + {location} diff --git a/src/lib/utils/geography.ts b/src/lib/utils/geography.ts index 31e4b8ee073..12a757dd647 100644 --- a/src/lib/utils/geography.ts +++ b/src/lib/utils/geography.ts @@ -128,17 +128,17 @@ const COUNTRY_NAME_ALIASES: Record = { * @param locale - Target locale code (e.g., "ja", "es", "ar") * @returns Localized country name, or the original string if not recognized */ -export function getCountryTranslation( - country: string, - locale: string -): string { +export function getCountryTranslation(country: string, locale: string): string { if (!country) return country const normalized = COUNTRY_NAME_ALIASES[country] ?? country const code = countries.getAlpha2Code(normalized, "en") if (!code) return country - return countries.getName(code, locale) ?? country + // Strip region suffix: "pt-br" -> "pt", "zh-tw" -> "zh" + // (i18n-iso-countries uses base language codes) + const baseLocale = locale.split("-")[0] + return countries.getName(code, baseLocale) ?? country } /** From 3e15f96befa383b9ff0dbe17fe9a6695100066db Mon Sep 17 00:00:00 2001 From: myelinated-wackerow <263208946+myelinated-wackerow@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:00:47 -0700 Subject: [PATCH 3/3] fix: treat "Remote" location as online event External event data sometimes uses "Remote" instead of "Online" for virtual events. Normalize both to the same isOnline flag so they render with the same translated "Online" tag in the UI. - Added isOnlineLocation() helper with a set of sentinel values - Used in fetchEvents, parseLocationToContinent, localizeLocation Co-Authored-By: Claude Opus 4.6 Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com> --- src/data-layer/fetchers/fetchEvents.ts | 7 +++++-- src/lib/utils/geography.ts | 21 ++++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/data-layer/fetchers/fetchEvents.ts b/src/data-layer/fetchers/fetchEvents.ts index 87144bacb02..10b120eea97 100644 --- a/src/data-layer/fetchers/fetchEvents.ts +++ b/src/data-layer/fetchers/fetchEvents.ts @@ -1,6 +1,9 @@ import type { EventItem, EventType, GeodeApiEventItem } from "@/lib/types" -import { parseLocationToContinent } from "@/lib/utils/geography" +import { + isOnlineLocation, + parseLocationToContinent, +} from "@/lib/utils/geography" import { slugify } from "@/lib/utils/url" import { uploadToS3 } from "../s3" @@ -46,7 +49,7 @@ function transformEvent(event: GeodeApiEventItem): EventItem { ...event, id: slugify(event.title), eventTypes: getEventTypes(event.tags), - isOnline: event.location.toLowerCase() === "online", + isOnline: isOnlineLocation(event.location), continent: parseLocationToContinent(event.location), } } diff --git a/src/lib/utils/geography.ts b/src/lib/utils/geography.ts index 12a757dd647..efb53a57bd5 100644 --- a/src/lib/utils/geography.ts +++ b/src/lib/utils/geography.ts @@ -97,12 +97,26 @@ export const COUNTRY_TO_CONTINENT: Record = Object.entries( {} as Record ) +/** + * Sentinel location strings that indicate a virtual/remote event rather + * than a physical location. Matched case-insensitively. + */ +const ONLINE_LOCATION_VALUES = new Set(["online", "remote"]) + +/** + * Returns true if the location string represents a virtual event + * (e.g., "Online", "Remote") rather than a physical location. + */ +export function isOnlineLocation(location: string): boolean { + return ONLINE_LOCATION_VALUES.has(location?.trim().toLowerCase() ?? "") +} + /** * Parse a location string (e.g., "Berlin, Germany") to its continent. * Returns null for online events or unrecognized locations. */ export function parseLocationToContinent(location: string): Continent | null { - if (!location || location.toLowerCase() === "online") return null + if (!location || isOnlineLocation(location)) return null const parts = location.split(/,\s*/) const country = parts[parts.length - 1]?.trim() if (!country) return null @@ -148,7 +162,8 @@ export function getCountryTranslation(country: string, locale: string): string { * locale, and reassembles. City names are left in their original script. * * Returns the original string unchanged if: - * - The location is "Online" (handled separately by i18n keys) + * - The location is a virtual event sentinel (e.g., "Online", "Remote") -- + * these are handled separately by i18n keys via the isOnline flag * - The country cannot be identified or translated * - The locale is "en" (no translation needed) * @@ -159,7 +174,7 @@ export function getCountryTranslation(country: string, locale: string): string { export function localizeLocation(location: string, locale: string): string { if (!location || locale === "en") return location - if (location.toLowerCase() === "online") return location + if (isOnlineLocation(location)) return location const parts = location.split(/,\s*/) const rawCountry = parts[parts.length - 1].trim()