Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/[locale]/community/events/_components/EventCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface EventCardProps {
event: EventItem
variant?: "grid" | "highlight"
className?: string
locale?: string
locale: string
showTypeTag?: boolean
customEventOptions?: MatomoEventOptions
}
Expand Down Expand Up @@ -128,7 +128,7 @@ export default function EventCard({
event,
variant,
className,
locale = "en",
locale,
showTypeTag,
customEventOptions,
}: EventCardProps) {
Expand Down
2 changes: 1 addition & 1 deletion app/[locale]/community/events/conferences/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const Page = async (props: { params: Promise<PageParams> }) => {
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(
Expand Down
4 changes: 2 additions & 2 deletions app/[locale]/community/events/meetups/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const Page = async (props: { params: Promise<PageParams> }) => {
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
Expand All @@ -37,7 +37,7 @@ const Page = async (props: { params: Promise<PageParams> }) => {
!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]

Expand Down
4 changes: 2 additions & 2 deletions app/[locale]/community/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const Page = async (props: { params: Promise<PageParams> }) => {
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(
Expand All @@ -82,7 +82,7 @@ const Page = async (props: { params: Promise<PageParams> }) => {
!e.eventTypes?.includes("conference") &&
!e.eventTypes?.includes("hackathon")
)
const meetupGroups = getMeetupGroups()
const meetupGroups = getMeetupGroups(locale)
const meetups = [...apiMeetups, ...meetupGroups]

// Continent labels for tabs
Expand Down
2 changes: 1 addition & 1 deletion app/[locale]/community/events/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Expand Down
17 changes: 11 additions & 6 deletions app/[locale]/community/events/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -25,7 +28,8 @@ export const sanitize = (s: string) =>

export const mapEventTranslations = (
events: EventItem[],
t: ReturnType<typeof useTranslations>
t: ReturnType<typeof useTranslations>,
locale: string
): EventItem[] =>
events.map((event) => {
// Use existing eventTypes if they have values, otherwise compute from tags
Expand All @@ -38,6 +42,7 @@ export const mapEventTranslations = (
...event,
eventTypes,
eventTypesLabels: eventTypes.map((type) => t(`page-events-tag-${type}`)),
location: localizeLocation(event.location, locale),
}
})

Expand All @@ -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}`),
Expand All @@ -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))
}
7 changes: 6 additions & 1 deletion app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
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,
})
}
}

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions src/data-layer/fetchers/fetchEvents.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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),
}
}
Expand Down
89 changes: 88 additions & 1 deletion src/lib/utils/geography.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import countries from "i18n-iso-countries"

import type { Continent } from "@/lib/types"

// Continent → countries mapping for location parsing
Expand Down Expand Up @@ -95,14 +97,99 @@ export const COUNTRY_TO_CONTINENT: Record<string, Continent> = Object.entries(
{} as Record<string, Continent>
)

/**
* 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<string, string> = {
"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}`
}
Loading