From 1280844706570e645f9d6c59dc2aed66afddac7e Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:03:50 -0700 Subject: [PATCH] Localization with pages context --- .../FeatureGrid/FeatureGrid.container.tsx | 16 +- app/components/RuleStack/RuleStack.view.tsx | 2 +- app/learn/page.tsx | 21 ++- app/page.tsx | 32 ++-- docs/guides/i18n-translation-workflow.md | 174 ++++++++++++++---- messages/en/components/askOrganizer.json | 6 +- messages/en/components/featureGrid.json | 12 +- messages/en/components/heroBanner.json | 7 +- messages/en/components/numberedCards.json | 18 +- messages/en/components/ruleStack.json | 26 +-- messages/en/index.ts | 6 + messages/en/pages/home.json | 83 +++++++++ messages/en/pages/learn.json | 14 ++ 13 files changed, 283 insertions(+), 134 deletions(-) create mode 100644 messages/en/pages/home.json create mode 100644 messages/en/pages/learn.json diff --git a/app/components/FeatureGrid/FeatureGrid.container.tsx b/app/components/FeatureGrid/FeatureGrid.container.tsx index ba4493e..996e1e5 100644 --- a/app/components/FeatureGrid/FeatureGrid.container.tsx +++ b/app/components/FeatureGrid/FeatureGrid.container.tsx @@ -13,32 +13,32 @@ const FeatureGridContainer = memo( () => [ { backgroundColor: "bg-[var(--color-surface-default-brand-royal)]", - labelLine1: t("featureGrid.features.decisionMaking.labelLine1"), - labelLine2: t("featureGrid.features.decisionMaking.labelLine2"), + labelLine1: t("pages.home.featureGrid.features.decisionMaking.labelLine1"), + labelLine2: t("pages.home.featureGrid.features.decisionMaking.labelLine2"), panelContent: "/assets/Feature_Support.png", ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"), href: "#decision-making", }, { backgroundColor: "bg-[#D1FFE2]", - labelLine1: t("featureGrid.features.valuesAlignment.labelLine1"), - labelLine2: t("featureGrid.features.valuesAlignment.labelLine2"), + labelLine1: t("pages.home.featureGrid.features.valuesAlignment.labelLine1"), + labelLine2: t("pages.home.featureGrid.features.valuesAlignment.labelLine2"), panelContent: "/assets/Feature_Exercises.png", ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"), href: "#values-alignment", }, { backgroundColor: "bg-[#F4CAFF]", - labelLine1: t("featureGrid.features.membershipGuidance.labelLine1"), - labelLine2: t("featureGrid.features.membershipGuidance.labelLine2"), + labelLine1: t("pages.home.featureGrid.features.membershipGuidance.labelLine1"), + labelLine2: t("pages.home.featureGrid.features.membershipGuidance.labelLine2"), panelContent: "/assets/Feature_Guidance.png", ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"), href: "#membership-guidance", }, { backgroundColor: "bg-[#CBDDFF]", - labelLine1: t("featureGrid.features.conflictResolution.labelLine1"), - labelLine2: t("featureGrid.features.conflictResolution.labelLine2"), + labelLine1: t("pages.home.featureGrid.features.conflictResolution.labelLine1"), + labelLine2: t("pages.home.featureGrid.features.conflictResolution.labelLine2"), panelContent: "/assets/Feature_Tools.png", ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"), href: "#conflict-resolution", diff --git a/app/components/RuleStack/RuleStack.view.tsx b/app/components/RuleStack/RuleStack.view.tsx index e0ce92a..671385f 100644 --- a/app/components/RuleStack/RuleStack.view.tsx +++ b/app/components/RuleStack/RuleStack.view.tsx @@ -11,7 +11,7 @@ export function RuleStackView({ className, onTemplateClick, }: RuleStackViewProps) { - const t = useTranslation("ruleStack"); + const t = useTranslation("pages.home.ruleStack"); return (
getTranslation(messages, key); + const contentLockupData = { - title: "Organizing is hard", - subtitle: - "Find answers to your questions and see how other groups have solved similar challenges.", + title: t("pages.learn.contentLockup.title"), + subtitle: t("pages.learn.contentLockup.subtitle"), variant: "learn" as const, alignment: "left" as const, }; const askOrganizerData = { - title: "Still have questions?", - subtitle: "Get answers from an experienced organizer", - description: - "Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.", - buttonText: "Ask an organizer", - buttonHref: "/contact", + title: t("pages.learn.askOrganizer.title"), + subtitle: t("pages.learn.askOrganizer.subtitle"), + description: t("pages.learn.askOrganizer.description"), + buttonText: t("pages.learn.askOrganizer.buttonText"), + buttonHref: t("pages.learn.askOrganizer.buttonHref"), variant: "centered" as const, }; diff --git a/app/page.tsx b/app/page.tsx index da75b41..b85e238 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -46,29 +46,29 @@ export default function Page() { const t = (key: string) => getTranslation(messages, key); const heroBannerData = { - title: t("heroBanner.title"), - subtitle: t("heroBanner.subtitle"), - description: t("heroBanner.description"), - ctaText: t("heroBanner.ctaText"), - ctaHref: t("heroBanner.ctaHref"), + title: t("pages.home.heroBanner.title"), + subtitle: t("pages.home.heroBanner.subtitle"), + description: t("pages.home.heroBanner.description"), + ctaText: t("pages.home.heroBanner.ctaText"), + ctaHref: t("pages.home.heroBanner.ctaHref"), }; const numberedCardsData = { - title: t("numberedCards.title"), - subtitle: t("numberedCards.subtitle"), + title: t("pages.home.numberedCards.title"), + subtitle: t("pages.home.numberedCards.subtitle"), cards: [ { - text: t("numberedCards.cards.card1.text"), + text: t("pages.home.numberedCards.cards.card1.text"), iconShape: "blob", iconColor: "green", }, { - text: t("numberedCards.cards.card2.text"), + text: t("pages.home.numberedCards.cards.card2.text"), iconShape: "gear", iconColor: "purple", }, { - text: t("numberedCards.cards.card3.text"), + text: t("pages.home.numberedCards.cards.card3.text"), iconShape: "star", iconColor: "orange", }, @@ -76,15 +76,15 @@ export default function Page() { }; const featureGridData = { - title: t("featureGrid.title"), - subtitle: t("featureGrid.subtitle"), + title: t("pages.home.featureGrid.title"), + subtitle: t("pages.home.featureGrid.subtitle"), }; const askOrganizerData = { - title: t("askOrganizer.title"), - subtitle: t("askOrganizer.subtitle"), - buttonText: t("askOrganizer.buttonText"), - buttonHref: t("askOrganizer.buttonHref"), + title: t("pages.home.askOrganizer.title"), + subtitle: t("pages.home.askOrganizer.subtitle"), + buttonText: t("pages.home.askOrganizer.buttonText"), + buttonHref: t("pages.home.askOrganizer.buttonHref"), }; return ( diff --git a/docs/guides/i18n-translation-workflow.md b/docs/guides/i18n-translation-workflow.md index 7aef413..b040626 100644 --- a/docs/guides/i18n-translation-workflow.md +++ b/docs/guides/i18n-translation-workflow.md @@ -1,26 +1,87 @@ # i18n Translation Workflow Guide -This guide explains how to work with translations in the CommunityRule application. The app uses `next-intl` for managing UI text content, making it easy for content creators and contributors to update text without modifying component code. +This guide explains how to work with translations in the CommunityRule application. The app uses a hybrid approach combining globalized, shared UI elements with context-aware, localized content pages, making it easy for content creators and contributors to update text without modifying component code. ## Overview -All UI text is stored in JSON files under `messages/en/`. Components reference these translations using keys, allowing content to be edited independently of the codebase. +All UI text is stored in JSON files under `messages/en/`. The structure follows best practices: +- **Page-specific content** lives in `pages/` (varies by page context) +- **Component defaults** live in `components/` (shared across pages) +- **Common strings** live in `common.json` (shared UI elements) + +Components reference these translations using keys, allowing content to be edited independently of the codebase. ## Directory Structure ``` messages/ en/ - common.json # Shared UI strings (buttons, links, labels) + pages/ + home.json # Home page specific content + learn.json # Learn page specific content components/ - heroBanner.json # HeroBanner component translations - numberedCards.json # NumberedCards component translations - askOrganizer.json # AskOrganizer component translations - featureGrid.json # FeatureGrid component translations - footer.json # Footer component translations + heroBanner.json # Component defaults (aria-labels, alt texts) + numberedCards.json # Component defaults + askOrganizer.json # Component defaults + featureGrid.json # Component defaults + footer.json # Shared across pages + header.json # Shared across pages + common.json # Shared UI strings (buttons, links, labels) navigation.json # Navigation items - metadata.json # Page metadata (title, description) - index.ts # Exports all messages + metadata.json # Page metadata (title, description) + index.ts # Exports all messages +``` + +## When to Use `pages/` vs `components/` + +### Use `pages/` for: +- **Page-specific content**: Titles, subtitles, descriptions that vary by page +- **Context-aware text**: Content that changes based on where the component is used +- **User-facing content**: All text that users see on a specific page + +**Example:** The home page hero banner title "Collaborate" goes in `pages/home.json`, not `components/heroBanner.json` + +### Use `components/` for: +- **Component defaults**: Aria-labels, alt text patterns, shared behavior text +- **Shared across pages**: Text that doesn't vary by page context +- **Accessibility text**: Aria-labels and alt texts that are component-level + +**Example:** The hero banner image alt text "Hero illustration" stays in `components/heroBanner.json` because it's the same across all pages + +### Use `common.json` for: +- **Shared UI strings**: Buttons, links, labels used across multiple components +- **Global strings**: Text that appears in many places + +## Page-Specific Translations + +For page-specific content, use the `pages.*` namespace pattern: + +**Server Components:** +```typescript +import messages from "../../messages/en/index"; +import { getTranslation } from "../../lib/i18n/getTranslation"; + +export default function LearnPage() { + const t = (key: string) => getTranslation(messages, key); + + const contentLockupData = { + title: t("pages.learn.contentLockup.title"), + subtitle: t("pages.learn.contentLockup.subtitle"), + }; + + return ; +} +``` + +**Client Components:** +```typescript +"use client"; +import { useTranslation } from "../../contexts/MessagesContext"; + +export default function MyComponent() { + const t = useTranslation("pages.home.heroBanner"); + return

{t("title")}

; +} ``` ## Adding New Translation Keys @@ -67,37 +128,42 @@ Group related translations together: } ``` -### 4. Update the Component +### 4. Update the Component or Page -In your component, use the translation hook: - -**Server Components:** +**For Page Components (Server Components):** ```typescript -import { getTranslations } from "next-intl/server"; +import messages from "../../messages/en/index"; +import { getTranslation } from "../../lib/i18n/getTranslation"; -export default async function MyComponent() { - const t = await getTranslations(); - return

{t("heroBanner.title")}

; +export default function MyPage() { + const t = (key: string) => getTranslation(messages, key); + + // Use page-specific keys + const data = { + title: t("pages.home.heroBanner.title"), + subtitle: t("pages.home.heroBanner.subtitle"), + }; + + return ; } ``` -**Client Components:** +**For Client Components:** ```typescript "use client"; -import { useTranslations } from "next-intl"; +import { useTranslation } from "../../contexts/MessagesContext"; export default function MyComponent() { - const t = useTranslations(); - return

{t("heroBanner.title")}

; + // For page-specific content + const t = useTranslation("pages.home.heroBanner"); + return

{t("title")}

; + + // For component defaults + const tDefault = useTranslation("heroBanner"); + return {tDefault("imageAlt")}; } ``` -**Namespace-specific (recommended for component files):** -```typescript -const t = useTranslations("heroBanner"); -return

{t("title")}

; -``` - ## Translation Key Naming Conventions 1. **Use camelCase** for keys: `buttonText`, `ariaLabel` @@ -155,15 +221,38 @@ export default function HeroBanner() { } ``` +## Adding a New Page + +When creating a new page that needs translations: + +1. **Create a page translation file**: `messages/en/pages/about.json` (for example) +2. **Add page-specific content**: All user-facing text for that page +3. **Import in index.ts**: Add the import and export in `messages/en/index.ts` +4. **Use in page component**: Use `t("pages.about.*")` pattern in your page + +**Example:** +```typescript +// messages/en/pages/about.json +{ + "hero": { + "title": "About Us", + "subtitle": "Learn more about our mission" + } +} + +// app/about/page.tsx +const t = (key: string) => getTranslation(messages, key); +const title = t("pages.about.hero.title"); +``` + ## Adding New Languages (Future) When adding support for a new language: 1. **Create a new locale directory**: `messages/es/` (for Spanish, for example) -2. **Copy the English files** as a starting point +2. **Copy the English files** as a starting point (including `pages/` structure) 3. **Translate all strings** in the JSON files -4. **Update `app/i18n/routing.ts`** to include the new locale -5. **Test thoroughly** to ensure all translations are present +4. **Test thoroughly** to ensure all translations are present ## Testing Translations @@ -242,14 +331,27 @@ If TypeScript complains about translation keys: If text doesn't appear: 1. Check the browser console for errors -2. Verify the component is wrapped in `NextIntlClientProvider` (for client components) -3. Ensure `getMessages()` is called in server components +2. Verify the component is wrapped in `MessagesProvider` (for client components) +3. Ensure `getTranslation()` is called correctly in server components +4. Check if you're using the correct namespace (`pages.*` vs component defaults) + +## Architecture: Hybrid Approach + +This implementation follows the recognized best practice of combining: +- **Globalized, shared UI elements**: Component defaults in `components/` (aria-labels, alt texts) +- **Context-aware, localized content pages**: Page-specific content in `pages/` (titles, descriptions) + +This allows: +- Components to remain flexible and reusable +- Page content to be easily edited without code changes +- Clear separation between shared defaults and page-specific content +- Scalable structure for adding new pages ## Resources -- [next-intl Documentation](https://next-intl.dev/docs) -- [Next.js Internationalization](https://nextjs.org/docs/app/guides/internationalization) -- Component-specific translation files in `messages/en/components/` +- Component defaults in `messages/en/components/` +- Page-specific content in `messages/en/pages/` +- Shared UI strings in `messages/en/common.json` --- diff --git a/messages/en/components/askOrganizer.json b/messages/en/components/askOrganizer.json index 3477ef6..cfa8811 100644 --- a/messages/en/components/askOrganizer.json +++ b/messages/en/components/askOrganizer.json @@ -1,8 +1,4 @@ { - "_comment": "AskOrganizer component translations", - "title": "Still have questions?", - "subtitle": "Get answers from an experienced organizer", - "buttonText": "Ask an organizer", - "buttonHref": "#contact", + "_comment": "AskOrganizer component defaults (shared across pages)", "ariaLabel": "Ask an organizer - Contact an organizer for help" } diff --git a/messages/en/components/featureGrid.json b/messages/en/components/featureGrid.json index 71edc78..56c98f1 100644 --- a/messages/en/components/featureGrid.json +++ b/messages/en/components/featureGrid.json @@ -1,29 +1,19 @@ { - "_comment": "FeatureGrid component translations", - "title": "We've got your back, every step of the way", - "subtitle": "Use our toolkit to improve, document, and evolve your organization.", + "_comment": "FeatureGrid component defaults (shared across pages)", "linkText": "Learn more", "linkHref": "#", "ariaLabel": "Feature tools and services", "features": { "decisionMaking": { - "labelLine1": "Decision-making", - "labelLine2": "support", "ariaLabel": "Decision-making support tools" }, "valuesAlignment": { - "labelLine1": "Values alignment", - "labelLine2": "exercises", "ariaLabel": "Values alignment exercises" }, "membershipGuidance": { - "labelLine1": "Membership", - "labelLine2": "guidance", "ariaLabel": "Membership guidance resources" }, "conflictResolution": { - "labelLine1": "Conflict resolution", - "labelLine2": "tools", "ariaLabel": "Conflict resolution tools" } } diff --git a/messages/en/components/heroBanner.json b/messages/en/components/heroBanner.json index 6499124..db371ef 100644 --- a/messages/en/components/heroBanner.json +++ b/messages/en/components/heroBanner.json @@ -1,9 +1,4 @@ { - "_comment": "HeroBanner component translations", - "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": "#", + "_comment": "HeroBanner component defaults (shared across pages)", "imageAlt": "Hero illustration" } diff --git a/messages/en/components/numberedCards.json b/messages/en/components/numberedCards.json index b6e0ca1..25f6089 100644 --- a/messages/en/components/numberedCards.json +++ b/messages/en/components/numberedCards.json @@ -1,24 +1,8 @@ { - "_comment": "NumberedCards component translations", - "title": "How CommunityRule works", - "subtitle": "Here's a quick overview of the process, from start to finish.", + "_comment": "NumberedCards component defaults (shared across pages)", "titleLg": "How CommunityRule helps", "buttons": { "createCommunityRule": "Create CommunityRule", "seeHowItWorks": "See how it works" - }, - "cards": { - "card1": { - "text": "Document how your community makes decisions", - "_comment": "First step card" - }, - "card2": { - "text": "Build an operating manual for a successful community", - "_comment": "Second step card" - }, - "card3": { - "text": "Get a link to your manual for your group to review and evolve", - "_comment": "Third step card" - } } } diff --git a/messages/en/components/ruleStack.json b/messages/en/components/ruleStack.json index 6dd9516..dd3216b 100644 --- a/messages/en/components/ruleStack.json +++ b/messages/en/components/ruleStack.json @@ -1,28 +1,4 @@ { - "cards": { - "consensusClusters": { - "title": "Consensus clusters", - "description": "Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.", - "iconAlt": "Sociocracy" - }, - "consensus": { - "title": "Consensus", - "description": "Decisions that affect the group collectively should involve participation of all participants.", - "iconAlt": "Consensus" - }, - "electedBoard": { - "title": "Elected Board", - "description": "An elected board determines policies and organizes their implementation.", - "iconAlt": "Elected Board" - }, - "petition": { - "title": "Petition", - "description": "All participants can propose and vote on proposals for the group.", - "iconAlt": "Petition" - } - }, - "button": { - "seeAllTemplates": "See all templates" - }, + "_comment": "RuleStack component defaults (shared across pages)", "ariaLabel": "Learn more about {title} governance pattern" } diff --git a/messages/en/index.ts b/messages/en/index.ts index 27ba107..0d102ce 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -11,6 +11,8 @@ import menuBar from "./components/menuBar.json"; import quoteBlock from "./components/quoteBlock.json"; import ruleCard from "./components/ruleCard.json"; import ruleStack from "./components/ruleStack.json"; +import home from "./pages/home.json"; +import learn from "./pages/learn.json"; import navigation from "./navigation.json"; import metadata from "./metadata.json"; @@ -28,6 +30,10 @@ export default { quoteBlock, ruleCard, ruleStack, + pages: { + home, + learn, + }, navigation, metadata, }; diff --git a/messages/en/pages/home.json b/messages/en/pages/home.json new file mode 100644 index 0000000..61e1913 --- /dev/null +++ b/messages/en/pages/home.json @@ -0,0 +1,83 @@ +{ + "_comment": "Home page specific content", + "heroBanner": { + "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": "#" + }, + "numberedCards": { + "title": "How CommunityRule works", + "subtitle": "Here's a quick overview of the process, from start to finish.", + "cards": { + "card1": { + "text": "Document how your community makes decisions", + "_comment": "First step card" + }, + "card2": { + "text": "Build an operating manual for a successful community", + "_comment": "Second step card" + }, + "card3": { + "text": "Get a link to your manual for your group to review and evolve", + "_comment": "Third step card" + } + } + }, + "featureGrid": { + "title": "We've got your back, every step of the way", + "subtitle": "Use our toolkit to improve, document, and evolve your organization.", + "features": { + "decisionMaking": { + "labelLine1": "Decision-making", + "labelLine2": "support" + }, + "valuesAlignment": { + "labelLine1": "Values alignment", + "labelLine2": "exercises" + }, + "membershipGuidance": { + "labelLine1": "Membership", + "labelLine2": "guidance" + }, + "conflictResolution": { + "labelLine1": "Conflict resolution", + "labelLine2": "tools" + } + } + }, + "askOrganizer": { + "title": "Still have questions?", + "subtitle": "Get answers from an experienced organizer", + "buttonText": "Ask an organizer", + "buttonHref": "#contact" + }, + "ruleStack": { + "cards": { + "consensusClusters": { + "title": "Consensus clusters", + "description": "Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.", + "iconAlt": "Sociocracy" + }, + "consensus": { + "title": "Consensus", + "description": "Decisions that affect the group collectively should involve participation of all participants.", + "iconAlt": "Consensus" + }, + "electedBoard": { + "title": "Elected Board", + "description": "An elected board determines policies and organizes their implementation.", + "iconAlt": "Elected Board" + }, + "petition": { + "title": "Petition", + "description": "All participants can propose and vote on proposals for the group.", + "iconAlt": "Petition" + } + }, + "button": { + "seeAllTemplates": "See all templates" + } + } +} diff --git a/messages/en/pages/learn.json b/messages/en/pages/learn.json new file mode 100644 index 0000000..caa28de --- /dev/null +++ b/messages/en/pages/learn.json @@ -0,0 +1,14 @@ +{ + "_comment": "Learn page specific content", + "contentLockup": { + "title": "Organizing is hard", + "subtitle": "Find answers to your questions and see how other groups have solved similar challenges." + }, + "askOrganizer": { + "title": "Still have questions?", + "subtitle": "Get answers from an experienced organizer", + "description": "Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.", + "buttonText": "Ask an organizer", + "buttonHref": "/contact" + } +}