Initial implementation of localization

This commit is contained in:
adilallo
2026-01-29 22:17:44 -07:00
parent 1714e7f930
commit 2f37031411
29 changed files with 813 additions and 4365 deletions
@@ -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<AskOrganizerProps>(
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<AskOrganizerProps>(
{
component: "AskOrganizer",
variant: resolvedVariant,
buttonText,
buttonHref,
buttonText: defaultButtonText,
buttonHref: defaultButtonHref,
},
onContactClick as
| ((_data: Record<string, unknown>) => void)
@@ -92,8 +96,8 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
title={title}
subtitle={subtitle}
description={description}
buttonText={buttonText}
buttonHref={buttonHref}
buttonText={defaultButtonText}
buttonHref={defaultButtonHref}
className={className}
sectionPadding={sectionPadding}
contentGap={`${contentGap} ${styles.container}`}
@@ -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 (
<section
className={`${sectionPadding} ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Ask an organizer"}
aria-label={labelledBy ? undefined : ariaLabel}
tabIndex={-1}
>
<div className={`flex flex-col ${contentGap}`}>
@@ -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}
</Button>
@@ -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<FeatureGridProps>(
({ 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;
@@ -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 (
<section
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : "Feature tools and services"}
aria-label={labelledBy ? undefined : ariaLabel}
>
<div className="py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:pt-[var(--spacing-scale-076)] md:pb-[var(--spacing-scale-048)] lg:pb-[var(--spacing-scale-076)] md:px-[var(--spacing-scale-048)] bg-[#171717] rounded-[var(--radius-measures-radius-xlarge)] focus-within:ring-2 focus-within:ring-[var(--color-surface-default-brand-royal)] focus-within:ring-offset-2">
<div className="w-full mx-auto gap-[var(--spacing-scale-048)] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)] [container-type:inline-size]">
@@ -23,8 +31,8 @@ function FeatureGridView({
title={title}
subtitle={subtitle}
variant="feature"
linkText="Learn more"
linkHref="#"
linkText={linkText}
linkHref={linkHref}
titleId={labelledBy}
/>
</div>
+26 -21
View File
@@ -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 */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)]">
<div 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">
Media Economies Design Lab
{t("organization.name")}
</div>
<a
href="mailto:medlab@colorado.edu"
href={`mailto:${t("organization.email")}`}
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"
>
medlab@colorado.edu
{t("organization.email")}
</a>
</div>
{/* Social media links */}
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)]">
<a
href="#"
href={t("social.bluesky.url")}
className="flex items-center gap-[var(--spacing-measures-spacing-06,6px)] 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 group"
aria-label="Follow us on Bluesky"
aria-label={t("social.bluesky.ariaLabel")}
>
<img
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
@@ -80,13 +85,13 @@ const Footer = memo(() => {
className="flex-shrink-0 group-hover:scale-110 transition-transform"
/>
<div 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">
medlabboulder
{t("social.bluesky.handle")}
</div>
</a>
<a
href="#"
href={t("social.gitlab.url")}
className="flex items-center gap-[var(--spacing-measures-spacing-06,6px)] 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 group"
aria-label="Follow us on GitLab"
aria-label={t("social.gitlab.ariaLabel")}
>
<img
src={getAssetPath(ASSETS.GITLAB_ICON)}
@@ -96,7 +101,7 @@ const Footer = memo(() => {
className="flex-shrink-0 grayscale group-hover:scale-110 transition-transform"
/>
<div 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">
medlabboulder
{t("social.gitlab.handle")}
</div>
</a>
</div>
@@ -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")}
</Link>
<Link
href="/learn"
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"
>
Learn
{t("navigation.learn")}
</Link>
<Link
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"
>
About
{t("navigation.about")}
</Link>
</div>
</div>
@@ -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")}
</Link>
<Link
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"
>
Terms of Service
{t("legal.termsOfService")}
</Link>
<Link
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"
>
Cookies Settings
{t("legal.cookiesSettings")}
</Link>
</div>
{/* Copyright */}
<div className="text-[var(--color-content-default-secondary)] font-inter text-sm leading-5 font-normal tracking-[0%] lg:text-base lg:leading-6">
© All right reserved
{t("copyright")}
</div>
</div>
</footer>
+5 -1
View File
@@ -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<HeroBannerProps>(
({ title, subtitle, description, ctaText, ctaHref }) => {
const t = useTranslation();
const imageAlt = t("heroBanner.imageAlt");
return (
<section className="bg-transparent px-[var(--spacing-scale-008)] sm:px-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] lg:px-[var(--spacing-scale-024)] xl:px-[var(--spacing-scale-048)]">
<div className="flex flex-col gap-[var(--spacing-scale-010)]">
@@ -44,7 +48,7 @@ const HeroBanner = memo<HeroBannerProps>(
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
<img
src={getAssetPath("assets/HeroImage.png")}
alt="Hero illustration"
alt={imageAlt}
className="w-full h-auto"
loading="eager"
fetchPriority="high"
@@ -0,0 +1,18 @@
"use client";
import { memo } from "react";
import LanguageSwitcherView from "./LanguageSwitcher.view";
import type { LanguageSwitcherProps } from "./LanguageSwitcher.types";
const LanguageSwitcherContainer = memo<LanguageSwitcherProps>(
({ className }) => {
// Future: Add language switching logic here
// For now, this is just a UI component
return <LanguageSwitcherView className={className} />;
},
);
LanguageSwitcherContainer.displayName = "LanguageSwitcher";
export default LanguageSwitcherContainer;
@@ -0,0 +1,9 @@
export interface LanguageSwitcherProps {
className?: string;
}
export interface Language {
code: string;
name: string;
nativeName: string;
}
@@ -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 (
<div className={className}>
<label htmlFor="language-select" className="sr-only">
Select language
</label>
<select
id="language-select"
className="bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] font-inter text-sm leading-5 font-normal border border-[var(--color-surface-default-secondary)] rounded-[var(--radius-measures-radius-small)] px-[var(--spacing-scale-012)] py-[var(--spacing-scale-008)] focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 cursor-pointer"
aria-label="Select language"
disabled
>
{AVAILABLE_LANGUAGES.map((language) => (
<option key={language.code} value={language.code}>
{language.nativeName}
</option>
))}
</select>
<p className="text-[var(--color-content-default-secondary)] font-inter text-xs leading-4 font-normal mt-[var(--spacing-scale-008)]">
Language switching functionality coming soon
</p>
</div>
);
}
export default memo(LanguageSwitcherView);
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./LanguageSwitcher.container";
export type { LanguageSwitcherProps, Language } from "./LanguageSwitcher.types";
@@ -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 (
<>
<script
@@ -23,7 +28,7 @@ function NumberedCardsView({
<SectionHeader
title={title}
subtitle={subtitle}
titleLg="How CommunityRule helps"
titleLg={t("numberedCards.titleLg")}
/>
</div>
@@ -45,13 +50,13 @@ function NumberedCardsView({
{/* Default button for xsm and sm breakpoints */}
<div className="block lg:hidden">
<Button variant="default" size="large">
Create CommunityRule
{t("numberedCards.buttons.createCommunityRule")}
</Button>
</div>
{/* Outlined button for lg and xlg breakpoints */}
<div className="hidden lg:block">
<Button variant="outlined" size="large">
See how it works
{t("numberedCards.buttons.seeHowItWorks")}
</Button>
</div>
</div>
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import type messages from "../../messages/en/index";
type Messages = typeof messages;
const MessagesContext = createContext<Messages | null>(null);
interface MessagesProviderProps {
messages: Messages;
children: ReactNode;
}
export function MessagesProvider({ messages, children }: MessagesProviderProps) {
return (
<MessagesContext.Provider value={messages}>
{children}
</MessagesContext.Provider>
);
}
export function useMessages(): Messages {
const messages = useContext(MessagesContext);
if (!messages) {
throw new Error("useMessages must be used within MessagesProvider");
}
return messages;
}
/**
* Get a translation value from messages using dot notation
* @param messages - The messages object
* @param key - Dot-separated key path (e.g., "heroBanner.title")
* @returns The translation string or the key if not found
*/
function getTranslationValue(messages: Messages, key: string): string {
const keys = key.split(".");
let value: any = messages;
for (const k of keys) {
if (value && typeof value === "object" && k in value) {
value = value[k as keyof typeof value];
} else {
return key; // Fallback to key if path not found
}
}
return typeof value === "string" ? value : key;
}
/**
* Custom translation hook for client components
* @param namespace - Optional namespace to scope translations (e.g., "footer")
* @returns Translation function that accepts a key and returns the translated string
*/
export function useTranslation(namespace?: string) {
const messages = useMessages();
return (key: string): string => {
if (namespace) {
// If namespace is provided, prepend it to the key
const fullKey = `${namespace}.${key}`;
return getTranslationValue(messages, fullKey);
}
return getTranslationValue(messages, key);
};
}
+16 -6
View File
@@ -2,6 +2,8 @@ import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import dynamic from "next/dynamic";
import { MessagesProvider } from "./contexts/MessagesContext";
import messages from "../messages/en/index";
import "./globals.css";
import ConditionalHeader from "./components/ConditionalHeader";
@@ -85,7 +87,13 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({ children }: { children: ReactNode }) {
export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
// Load messages for the default locale (single locale setup)
return (
<html lang="en" className="font-sans">
<head>
@@ -101,11 +109,13 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
>
<div className="min-h-screen flex flex-col">
<ConditionalHeader />
<main className="flex-1">{children}</main>
<Footer />
</div>
<MessagesProvider messages={messages}>
<div className="min-h-screen flex flex-col">
<ConditionalHeader />
<main className="flex-1">{children}</main>
<Footer />
</div>
</MessagesProvider>
</body>
</html>
);
+22 -18
View File
@@ -1,4 +1,6 @@
import dynamic from "next/dynamic";
import messages from "../messages/en/index";
import { getTranslation } from "../lib/i18n/getTranslation";
import HeroBanner from "./components/HeroBanner";
import AskOrganizer from "./components/AskOrganizer";
@@ -39,31 +41,34 @@ const QuoteBlock = dynamic(() => import("./components/QuoteBlock"), {
});
export default function Page() {
// Use direct message access for server components
// This maintains type safety without requiring external i18n libraries
const t = (key: string) => getTranslation(messages, key);
const heroBannerData = {
title: "Collaborate",
subtitle: "with clarity",
description:
"Help your community make important decisions in a way that reflects its unique values.",
ctaText: "Learn how CommunityRule works",
ctaHref: "#",
title: t("heroBanner.title"),
subtitle: t("heroBanner.subtitle"),
description: t("heroBanner.description"),
ctaText: t("heroBanner.ctaText"),
ctaHref: t("heroBanner.ctaHref"),
};
const numberedCardsData = {
title: "How CommunityRule works",
subtitle: "Here's a quick overview of the process, from start to finish.",
title: t("numberedCards.title"),
subtitle: t("numberedCards.subtitle"),
cards: [
{
text: "Document how your community makes decisions",
text: t("numberedCards.cards.card1.text"),
iconShape: "blob",
iconColor: "green",
},
{
text: "Build an operating manual for a successful community",
text: t("numberedCards.cards.card2.text"),
iconShape: "gear",
iconColor: "purple",
},
{
text: "Get a link to your manual for your group to review and evolve",
text: t("numberedCards.cards.card3.text"),
iconShape: "star",
iconColor: "orange",
},
@@ -71,16 +76,15 @@ export default function Page() {
};
const featureGridData = {
title: "We've got your back, every step of the way",
subtitle:
"Use our toolkit to improve, document, and evolve your organization.",
title: t("featureGrid.title"),
subtitle: t("featureGrid.subtitle"),
};
const askOrganizerData = {
title: "Still have questions?",
subtitle: "Get answers from an experienced organizer",
buttonText: "Ask an organizer",
buttonHref: "#contact",
title: t("askOrganizer.title"),
subtitle: t("askOrganizer.subtitle"),
buttonText: t("askOrganizer.buttonText"),
buttonHref: t("askOrganizer.buttonHref"),
};
return (