diff --git a/app/[locale]/community/events/_components/EventCard.tsx b/app/[locale]/community/events/_components/EventCard.tsx index e0d945b0f7a..bf1f02b3cd3 100644 --- a/app/[locale]/community/events/_components/EventCard.tsx +++ b/app/[locale]/community/events/_components/EventCard.tsx @@ -15,7 +15,7 @@ interface EventCardProps { event: EventItem variant?: "grid" | "highlight" className?: string - locale?: string + locale: string showTypeTag?: boolean customEventOptions?: MatomoEventOptions } @@ -128,7 +128,7 @@ export default function EventCard({ event, variant, className, - locale = "en", + locale, showTypeTag, customEventOptions, }: EventCardProps) { 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/sitemap.ts b/app/sitemap.ts index 3774a866a63..3875ecd5771 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -67,7 +67,12 @@ export default async function sitemap(): Promise { const url = getFullUrl(locale, videoSlug) if (seenUrls.has(url)) continue seenUrls.add(url) - entries.push({ url, alternates, changeFrequency: "monthly", priority: 0.6 }) + entries.push({ + url, + alternates, + changeFrequency: "monthly", + priority: 0.6, + }) } } diff --git a/package.json b/package.json index 8fc353e0c3a..05a93a9f308 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,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 7d58a604f46..cbdf08f955a 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 @@ -6882,6 +6885,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==} @@ -7978,6 +7984,10 @@ packages: engines: {node: '>=18'} hasBin: true + i18n-iso-countries@7.14.0: + resolution: {integrity: sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==} + engines: {node: '>= 12'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -20701,6 +20711,8 @@ snapshots: dependencies: dequal: 2.0.3 + diacritics@1.3.0: {} + didyoumean@1.2.2: {} diff@4.0.2: {} @@ -22190,6 +22202,10 @@ snapshots: husky@9.1.7: {} + i18n-iso-countries@7.14.0: + dependencies: + diacritics: 1.3.0 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 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 053e5a1af71..efb53a57bd5 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 @@ -95,14 +97,99 @@ 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 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 + + // 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 +} + +/** + * 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 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) + * + * @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 (isOnlineLocation(location)) 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}` +}