From 2f37031411df2d611263f8bb9bf044d30b9eae9b Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:17:44 -0700 Subject: [PATCH 1/4] Initial implementation of localization --- .../AskOrganizer/AskOrganizer.container.tsx | 16 +- .../AskOrganizer/AskOrganizer.view.tsx | 10 +- .../FeatureGrid/FeatureGrid.container.tsx | 29 +- .../FeatureGrid/FeatureGrid.view.tsx | 14 +- app/components/Footer.tsx | 47 +- app/components/HeroBanner.tsx | 6 +- .../LanguageSwitcher.container.tsx | 18 + .../LanguageSwitcher.types.ts | 9 + .../LanguageSwitcher.view.tsx | 39 + app/components/LanguageSwitcher/index.ts | 2 + .../NumberedCards/NumberedCards.view.tsx | 11 +- app/contexts/MessagesContext.tsx | 68 + app/layout.tsx | 22 +- app/page.tsx | 40 +- docs/README.md | 11 + docs/guides/i18n-translation-workflow.md | 257 + lib/i18n/getTranslation.ts | 59 + lib/i18n/types.ts | 21 + messages/en/common.json | 15 + messages/en/components/askOrganizer.json | 8 + messages/en/components/featureGrid.json | 30 + messages/en/components/footer.json | 31 + messages/en/components/heroBanner.json | 9 + messages/en/components/numberedCards.json | 24 + messages/en/index.ts | 19 + messages/en/metadata.json | 8 + messages/en/navigation.json | 6 + package-lock.json | 4348 +---------------- package.json | 1 + 29 files changed, 813 insertions(+), 4365 deletions(-) create mode 100644 app/components/LanguageSwitcher/LanguageSwitcher.container.tsx create mode 100644 app/components/LanguageSwitcher/LanguageSwitcher.types.ts create mode 100644 app/components/LanguageSwitcher/LanguageSwitcher.view.tsx create mode 100644 app/components/LanguageSwitcher/index.ts create mode 100644 app/contexts/MessagesContext.tsx create mode 100644 docs/guides/i18n-translation-workflow.md create mode 100644 lib/i18n/getTranslation.ts create mode 100644 lib/i18n/types.ts create mode 100644 messages/en/common.json create mode 100644 messages/en/components/askOrganizer.json create mode 100644 messages/en/components/featureGrid.json create mode 100644 messages/en/components/footer.json create mode 100644 messages/en/components/heroBanner.json create mode 100644 messages/en/components/numberedCards.json create mode 100644 messages/en/index.ts create mode 100644 messages/en/metadata.json create mode 100644 messages/en/navigation.json diff --git a/app/components/AskOrganizer/AskOrganizer.container.tsx b/app/components/AskOrganizer/AskOrganizer.container.tsx index 6614bac..d9c6ac5 100644 --- a/app/components/AskOrganizer/AskOrganizer.container.tsx +++ b/app/components/AskOrganizer/AskOrganizer.container.tsx @@ -1,6 +1,7 @@ "use client"; import { memo } from "react"; +import { useTranslation } from "../../contexts/MessagesContext"; import { useAnalytics } from "../../hooks"; import AskOrganizerView from "./AskOrganizer.view"; import type { @@ -35,12 +36,15 @@ const AskOrganizerContainer = memo( title, subtitle, description, - buttonText = "Ask an organizer", - buttonHref = "#", + buttonText, + buttonHref, className = "", variant = "centered", onContactClick, }) => { + const t = useTranslation(); + const defaultButtonText = buttonText ?? t("askOrganizer.buttonText"); + const defaultButtonHref = buttonHref ?? t("askOrganizer.buttonHref"); const { trackEvent, trackCustomEvent } = useAnalytics(); const resolvedVariant: AskOrganizerVariant = variant ?? "centered"; @@ -74,8 +78,8 @@ const AskOrganizerContainer = memo( { component: "AskOrganizer", variant: resolvedVariant, - buttonText, - buttonHref, + buttonText: defaultButtonText, + buttonHref: defaultButtonHref, }, onContactClick as | ((_data: Record) => void) @@ -92,8 +96,8 @@ const AskOrganizerContainer = memo( title={title} subtitle={subtitle} description={description} - buttonText={buttonText} - buttonHref={buttonHref} + buttonText={defaultButtonText} + buttonHref={defaultButtonHref} className={className} sectionPadding={sectionPadding} contentGap={`${contentGap} ${styles.container}`} diff --git a/app/components/AskOrganizer/AskOrganizer.view.tsx b/app/components/AskOrganizer/AskOrganizer.view.tsx index c16d816..a1f2c28 100644 --- a/app/components/AskOrganizer/AskOrganizer.view.tsx +++ b/app/components/AskOrganizer/AskOrganizer.view.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useTranslation } from "../../contexts/MessagesContext"; import ContentLockup from "../ContentLockup"; import Button from "../Button"; import type { AskOrganizerViewProps } from "./AskOrganizer.types"; @@ -16,11 +19,14 @@ function AskOrganizerView({ labelledBy, onContactClick, }: AskOrganizerViewProps) { + const t = useTranslation(); + const ariaLabel = t("askOrganizer.ariaLabel"); + return (
@@ -42,7 +48,7 @@ function AskOrganizerView({ variant={variant === "inverse" ? "primary" : "default"} className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]" onClick={onContactClick} - ariaLabel={`${buttonText} - Contact an organizer for help`} + ariaLabel={ariaLabel} > {buttonText} diff --git a/app/components/FeatureGrid/FeatureGrid.container.tsx b/app/components/FeatureGrid/FeatureGrid.container.tsx index 1ef87a8..ba4493e 100644 --- a/app/components/FeatureGrid/FeatureGrid.container.tsx +++ b/app/components/FeatureGrid/FeatureGrid.container.tsx @@ -1,47 +1,50 @@ "use client"; import { memo, useMemo } from "react"; +import { useTranslation } from "../../contexts/MessagesContext"; import FeatureGridView from "./FeatureGrid.view"; import type { FeatureGridProps, Feature } from "./FeatureGrid.types"; const FeatureGridContainer = memo( ({ title, subtitle, className = "" }) => { + const t = useTranslation(); + const features: Feature[] = useMemo( () => [ { backgroundColor: "bg-[var(--color-surface-default-brand-royal)]", - labelLine1: "Decision-making", - labelLine2: "support", + labelLine1: t("featureGrid.features.decisionMaking.labelLine1"), + labelLine2: t("featureGrid.features.decisionMaking.labelLine2"), panelContent: "/assets/Feature_Support.png", - ariaLabel: "Decision-making support tools", + ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"), href: "#decision-making", }, { backgroundColor: "bg-[#D1FFE2]", - labelLine1: "Values alignment", - labelLine2: "exercises", + labelLine1: t("featureGrid.features.valuesAlignment.labelLine1"), + labelLine2: t("featureGrid.features.valuesAlignment.labelLine2"), panelContent: "/assets/Feature_Exercises.png", - ariaLabel: "Values alignment exercises", + ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"), href: "#values-alignment", }, { backgroundColor: "bg-[#F4CAFF]", - labelLine1: "Membership", - labelLine2: "guidance", + labelLine1: t("featureGrid.features.membershipGuidance.labelLine1"), + labelLine2: t("featureGrid.features.membershipGuidance.labelLine2"), panelContent: "/assets/Feature_Guidance.png", - ariaLabel: "Membership guidance resources", + ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"), href: "#membership-guidance", }, { backgroundColor: "bg-[#CBDDFF]", - labelLine1: "Conflict resolution", - labelLine2: "tools", + labelLine1: t("featureGrid.features.conflictResolution.labelLine1"), + labelLine2: t("featureGrid.features.conflictResolution.labelLine2"), panelContent: "/assets/Feature_Tools.png", - ariaLabel: "Conflict resolution tools", + ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"), href: "#conflict-resolution", }, ], - [], + [t], ); const labelledBy = title ? "feature-grid-headline" : undefined; diff --git a/app/components/FeatureGrid/FeatureGrid.view.tsx b/app/components/FeatureGrid/FeatureGrid.view.tsx index 4429d93..318865d 100644 --- a/app/components/FeatureGrid/FeatureGrid.view.tsx +++ b/app/components/FeatureGrid/FeatureGrid.view.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useTranslation } from "../../contexts/MessagesContext"; import ContentLockup from "../ContentLockup"; import MiniCard from "../MiniCard"; import type { FeatureGridViewProps } from "./FeatureGrid.types"; @@ -9,11 +12,16 @@ function FeatureGridView({ features, labelledBy, }: FeatureGridViewProps) { + const t = useTranslation(); + const ariaLabel = t("featureGrid.ariaLabel"); + const linkText = t("featureGrid.linkText"); + const linkHref = t("featureGrid.linkHref"); + return (
@@ -23,8 +31,8 @@ function FeatureGridView({ title={title} subtitle={subtitle} variant="feature" - linkText="Learn more" - linkHref="#" + linkText={linkText} + linkHref={linkHref} titleId={labelledBy} />
diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index b2ea3d4..5991759 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,20 +1,25 @@ +"use client"; + import { memo } from "react"; +import { useTranslation } from "../contexts/MessagesContext"; import Link from "next/link"; import Logo from "./Logo"; import Separator from "./Separator"; import { getAssetPath, ASSETS } from "../../lib/assetUtils"; const Footer = memo(() => { + const t = useTranslation("footer"); + // Schema markup for organization information const schemaData = { "@context": "https://schema.org", "@type": "Organization", - name: "Media Economies Design Lab", - email: "medlab@colorado.edu", - url: "https://communityrule.com", + name: t("organization.name"), + email: t("organization.email"), + url: t("organization.url"), sameAs: [ - "https://bsky.app/profile/medlabboulder", - "https://gitlab.com/medlabboulder", + t("social.bluesky.url"), + t("social.gitlab.url"), ], }; @@ -55,22 +60,22 @@ const Footer = memo(() => { {/* Contact info */}
- Media Economies Design Lab + {t("organization.name")}
- medlab@colorado.edu + {t("organization.email")}
{/* Social media links */} @@ -108,19 +113,19 @@ const Footer = memo(() => { href="#" className="text-[var(--color-content-default-primary)] font-inter text-base leading-5 font-medium tracking-[0%] lg:text-2xl lg:leading-7 lg:font-normal hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer" > - Use cases + {t("navigation.useCases")} - Learn + {t("navigation.learn")} - About + {t("navigation.about")}
@@ -133,25 +138,25 @@ const Footer = memo(() => { href="#" className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6 hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer" > - Privacy Policy + {t("legal.privacyPolicy")} - Terms of Service + {t("legal.termsOfService")} - Cookies Settings + {t("legal.cookiesSettings")} {/* Copyright */}
- © All right reserved + {t("copyright")}
diff --git a/app/components/HeroBanner.tsx b/app/components/HeroBanner.tsx index 7cbc8d9..5f3eb36 100644 --- a/app/components/HeroBanner.tsx +++ b/app/components/HeroBanner.tsx @@ -1,6 +1,7 @@ "use client"; import { memo } from "react"; +import { useTranslation } from "../contexts/MessagesContext"; import ContentLockup from "./ContentLockup"; import HeroDecor from "./HeroDecor"; import { getAssetPath } from "../../lib/assetUtils"; @@ -15,6 +16,9 @@ interface HeroBannerProps { const HeroBanner = memo( ({ title, subtitle, description, ctaText, ctaHref }) => { + const t = useTranslation(); + const imageAlt = t("heroBanner.imageAlt"); + return (
@@ -44,7 +48,7 @@ const HeroBanner = memo(
Hero illustration( + ({ className }) => { + // Future: Add language switching logic here + // For now, this is just a UI component + + return ; + }, +); + +LanguageSwitcherContainer.displayName = "LanguageSwitcher"; + +export default LanguageSwitcherContainer; diff --git a/app/components/LanguageSwitcher/LanguageSwitcher.types.ts b/app/components/LanguageSwitcher/LanguageSwitcher.types.ts new file mode 100644 index 0000000..5b6976b --- /dev/null +++ b/app/components/LanguageSwitcher/LanguageSwitcher.types.ts @@ -0,0 +1,9 @@ +export interface LanguageSwitcherProps { + className?: string; +} + +export interface Language { + code: string; + name: string; + nativeName: string; +} diff --git a/app/components/LanguageSwitcher/LanguageSwitcher.view.tsx b/app/components/LanguageSwitcher/LanguageSwitcher.view.tsx new file mode 100644 index 0000000..0c36ddb --- /dev/null +++ b/app/components/LanguageSwitcher/LanguageSwitcher.view.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { memo } from "react"; +import type { LanguageSwitcherProps, Language } from "./LanguageSwitcher.types"; + +const AVAILABLE_LANGUAGES: Language[] = [ + { + code: "en", + name: "English", + nativeName: "English", + }, +]; + +function LanguageSwitcherView({ className = "" }: LanguageSwitcherProps) { + return ( +
+ + +

+ Language switching functionality coming soon +

+
+ ); +} + +export default memo(LanguageSwitcherView); diff --git a/app/components/LanguageSwitcher/index.ts b/app/components/LanguageSwitcher/index.ts new file mode 100644 index 0000000..4ea8270 --- /dev/null +++ b/app/components/LanguageSwitcher/index.ts @@ -0,0 +1,2 @@ +export { default } from "./LanguageSwitcher.container"; +export type { LanguageSwitcherProps, Language } from "./LanguageSwitcher.types"; diff --git a/app/components/NumberedCards/NumberedCards.view.tsx b/app/components/NumberedCards/NumberedCards.view.tsx index 220d109..77489fa 100644 --- a/app/components/NumberedCards/NumberedCards.view.tsx +++ b/app/components/NumberedCards/NumberedCards.view.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useTranslation } from "../../contexts/MessagesContext"; import SectionHeader from "../SectionHeader"; import NumberedCard from "../NumberedCard"; import Button from "../Button"; @@ -9,6 +12,8 @@ function NumberedCardsView({ cards, schemaJson, }: NumberedCardsViewProps) { + const t = useTranslation(); + return ( <>