Merge pull request 'adilallo/feature/TextLocalization' (#30) from adilallo/feature/TextLocalization into main
Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import { useAnalytics } from "../../hooks";
|
import { useAnalytics } from "../../hooks";
|
||||||
import AskOrganizerView from "./AskOrganizer.view";
|
import AskOrganizerView from "./AskOrganizer.view";
|
||||||
import type {
|
import type {
|
||||||
@@ -35,12 +36,15 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
|||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
description,
|
description,
|
||||||
buttonText = "Ask an organizer",
|
buttonText,
|
||||||
buttonHref = "#",
|
buttonHref,
|
||||||
className = "",
|
className = "",
|
||||||
variant = "centered",
|
variant = "centered",
|
||||||
onContactClick,
|
onContactClick,
|
||||||
}) => {
|
}) => {
|
||||||
|
const t = useTranslation();
|
||||||
|
const defaultButtonText = buttonText ?? t("askOrganizer.buttonText");
|
||||||
|
const defaultButtonHref = buttonHref ?? t("askOrganizer.buttonHref");
|
||||||
const { trackEvent, trackCustomEvent } = useAnalytics();
|
const { trackEvent, trackCustomEvent } = useAnalytics();
|
||||||
|
|
||||||
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
|
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
|
||||||
@@ -74,8 +78,8 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
|||||||
{
|
{
|
||||||
component: "AskOrganizer",
|
component: "AskOrganizer",
|
||||||
variant: resolvedVariant,
|
variant: resolvedVariant,
|
||||||
buttonText,
|
buttonText: defaultButtonText,
|
||||||
buttonHref,
|
buttonHref: defaultButtonHref,
|
||||||
},
|
},
|
||||||
onContactClick as
|
onContactClick as
|
||||||
| ((_data: Record<string, unknown>) => void)
|
| ((_data: Record<string, unknown>) => void)
|
||||||
@@ -92,8 +96,8 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
|
|||||||
title={title}
|
title={title}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
description={description}
|
description={description}
|
||||||
buttonText={buttonText}
|
buttonText={defaultButtonText}
|
||||||
buttonHref={buttonHref}
|
buttonHref={defaultButtonHref}
|
||||||
className={className}
|
className={className}
|
||||||
sectionPadding={sectionPadding}
|
sectionPadding={sectionPadding}
|
||||||
contentGap={`${contentGap} ${styles.container}`}
|
contentGap={`${contentGap} ${styles.container}`}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import ContentLockup from "../ContentLockup";
|
import ContentLockup from "../ContentLockup";
|
||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
import type { AskOrganizerViewProps } from "./AskOrganizer.types";
|
import type { AskOrganizerViewProps } from "./AskOrganizer.types";
|
||||||
@@ -16,11 +19,14 @@ function AskOrganizerView({
|
|||||||
labelledBy,
|
labelledBy,
|
||||||
onContactClick,
|
onContactClick,
|
||||||
}: AskOrganizerViewProps) {
|
}: AskOrganizerViewProps) {
|
||||||
|
const t = useTranslation();
|
||||||
|
const ariaLabel = t("askOrganizer.ariaLabel");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`${sectionPadding} ${className}`}
|
className={`${sectionPadding} ${className}`}
|
||||||
aria-labelledby={labelledBy}
|
aria-labelledby={labelledBy}
|
||||||
aria-label={labelledBy ? undefined : "Ask an organizer"}
|
aria-label={labelledBy ? undefined : ariaLabel}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className={`flex flex-col ${contentGap}`}>
|
<div className={`flex flex-col ${contentGap}`}>
|
||||||
@@ -42,7 +48,7 @@ function AskOrganizerView({
|
|||||||
variant={variant === "inverse" ? "primary" : "default"}
|
variant={variant === "inverse" ? "primary" : "default"}
|
||||||
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
||||||
onClick={onContactClick}
|
onClick={onContactClick}
|
||||||
ariaLabel={`${buttonText} - Contact an organizer for help`}
|
ariaLabel={ariaLabel}
|
||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,47 +1,66 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import FeatureGridView from "./FeatureGrid.view";
|
import FeatureGridView from "./FeatureGrid.view";
|
||||||
import type { FeatureGridProps, Feature } from "./FeatureGrid.types";
|
import type { FeatureGridProps, Feature } from "./FeatureGrid.types";
|
||||||
|
|
||||||
const FeatureGridContainer = memo<FeatureGridProps>(
|
const FeatureGridContainer = memo<FeatureGridProps>(
|
||||||
({ title, subtitle, className = "" }) => {
|
({ title, subtitle, className = "" }) => {
|
||||||
|
const t = useTranslation();
|
||||||
|
|
||||||
const features: Feature[] = useMemo(
|
const features: Feature[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
|
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
|
||||||
labelLine1: "Decision-making",
|
labelLine1: t(
|
||||||
labelLine2: "support",
|
"pages.home.featureGrid.features.decisionMaking.labelLine1",
|
||||||
|
),
|
||||||
|
labelLine2: t(
|
||||||
|
"pages.home.featureGrid.features.decisionMaking.labelLine2",
|
||||||
|
),
|
||||||
panelContent: "/assets/Feature_Support.png",
|
panelContent: "/assets/Feature_Support.png",
|
||||||
ariaLabel: "Decision-making support tools",
|
ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"),
|
||||||
href: "#decision-making",
|
href: "#decision-making",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[#D1FFE2]",
|
backgroundColor: "bg-[#D1FFE2]",
|
||||||
labelLine1: "Values alignment",
|
labelLine1: t(
|
||||||
labelLine2: "exercises",
|
"pages.home.featureGrid.features.valuesAlignment.labelLine1",
|
||||||
|
),
|
||||||
|
labelLine2: t(
|
||||||
|
"pages.home.featureGrid.features.valuesAlignment.labelLine2",
|
||||||
|
),
|
||||||
panelContent: "/assets/Feature_Exercises.png",
|
panelContent: "/assets/Feature_Exercises.png",
|
||||||
ariaLabel: "Values alignment exercises",
|
ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"),
|
||||||
href: "#values-alignment",
|
href: "#values-alignment",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[#F4CAFF]",
|
backgroundColor: "bg-[#F4CAFF]",
|
||||||
labelLine1: "Membership",
|
labelLine1: t(
|
||||||
labelLine2: "guidance",
|
"pages.home.featureGrid.features.membershipGuidance.labelLine1",
|
||||||
|
),
|
||||||
|
labelLine2: t(
|
||||||
|
"pages.home.featureGrid.features.membershipGuidance.labelLine2",
|
||||||
|
),
|
||||||
panelContent: "/assets/Feature_Guidance.png",
|
panelContent: "/assets/Feature_Guidance.png",
|
||||||
ariaLabel: "Membership guidance resources",
|
ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"),
|
||||||
href: "#membership-guidance",
|
href: "#membership-guidance",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: "bg-[#CBDDFF]",
|
backgroundColor: "bg-[#CBDDFF]",
|
||||||
labelLine1: "Conflict resolution",
|
labelLine1: t(
|
||||||
labelLine2: "tools",
|
"pages.home.featureGrid.features.conflictResolution.labelLine1",
|
||||||
|
),
|
||||||
|
labelLine2: t(
|
||||||
|
"pages.home.featureGrid.features.conflictResolution.labelLine2",
|
||||||
|
),
|
||||||
panelContent: "/assets/Feature_Tools.png",
|
panelContent: "/assets/Feature_Tools.png",
|
||||||
ariaLabel: "Conflict resolution tools",
|
ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"),
|
||||||
href: "#conflict-resolution",
|
href: "#conflict-resolution",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const labelledBy = title ? "feature-grid-headline" : undefined;
|
const labelledBy = title ? "feature-grid-headline" : undefined;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import ContentLockup from "../ContentLockup";
|
import ContentLockup from "../ContentLockup";
|
||||||
import MiniCard from "../MiniCard";
|
import MiniCard from "../MiniCard";
|
||||||
import type { FeatureGridViewProps } from "./FeatureGrid.types";
|
import type { FeatureGridViewProps } from "./FeatureGrid.types";
|
||||||
@@ -9,11 +12,16 @@ function FeatureGridView({
|
|||||||
features,
|
features,
|
||||||
labelledBy,
|
labelledBy,
|
||||||
}: FeatureGridViewProps) {
|
}: FeatureGridViewProps) {
|
||||||
|
const t = useTranslation();
|
||||||
|
const ariaLabel = t("featureGrid.ariaLabel");
|
||||||
|
const linkText = t("featureGrid.linkText");
|
||||||
|
const linkHref = t("featureGrid.linkHref");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
|
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
|
||||||
aria-labelledby={labelledBy}
|
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="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]">
|
<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}
|
title={title}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
variant="feature"
|
variant="feature"
|
||||||
linkText="Learn more"
|
linkText={linkText}
|
||||||
linkHref="#"
|
linkHref={linkHref}
|
||||||
titleId={labelledBy}
|
titleId={labelledBy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+25
-23
@@ -1,21 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import { useTranslation } from "../contexts/MessagesContext";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
import Separator from "./Separator";
|
import Separator from "./Separator";
|
||||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
||||||
|
|
||||||
const Footer = memo(() => {
|
const Footer = memo(() => {
|
||||||
|
const t = useTranslation("footer");
|
||||||
|
|
||||||
// Schema markup for organization information
|
// Schema markup for organization information
|
||||||
const schemaData = {
|
const schemaData = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: "Media Economies Design Lab",
|
name: t("organization.name"),
|
||||||
email: "medlab@colorado.edu",
|
email: t("organization.email"),
|
||||||
url: "https://communityrule.com",
|
url: t("organization.url"),
|
||||||
sameAs: [
|
sameAs: [t("social.bluesky.url"), t("social.gitlab.url")],
|
||||||
"https://bsky.app/profile/medlabboulder",
|
|
||||||
"https://gitlab.com/medlabboulder",
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,22 +57,22 @@ const Footer = memo(() => {
|
|||||||
{/* Contact info */}
|
{/* Contact info */}
|
||||||
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)]">
|
<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">
|
<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>
|
</div>
|
||||||
<a
|
<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"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Social media links */}
|
{/* Social media links */}
|
||||||
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)]">
|
<div className="flex flex-col items-start gap-[var(--spacing-measures-spacing-016,16px)]">
|
||||||
<a
|
<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"
|
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
|
<img
|
||||||
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
|
src={getAssetPath(ASSETS.BLUESKY_LOGO)}
|
||||||
@@ -80,13 +82,13 @@ const Footer = memo(() => {
|
|||||||
className="flex-shrink-0 group-hover:scale-110 transition-transform"
|
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">
|
<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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<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"
|
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
|
<img
|
||||||
src={getAssetPath(ASSETS.GITLAB_ICON)}
|
src={getAssetPath(ASSETS.GITLAB_ICON)}
|
||||||
@@ -96,7 +98,7 @@ const Footer = memo(() => {
|
|||||||
className="flex-shrink-0 grayscale group-hover:scale-110 transition-transform"
|
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">
|
<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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,19 +110,19 @@ const Footer = memo(() => {
|
|||||||
href="#"
|
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"
|
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>
|
||||||
<Link
|
<Link
|
||||||
href="/learn"
|
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"
|
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>
|
||||||
<Link
|
<Link
|
||||||
href="#"
|
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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,25 +135,25 @@ const Footer = memo(() => {
|
|||||||
href="#"
|
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"
|
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>
|
||||||
<Link
|
<Link
|
||||||
href="#"
|
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"
|
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>
|
||||||
<Link
|
<Link
|
||||||
href="#"
|
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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Copyright */}
|
{/* 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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import MenuBarItem from "../MenuBarItem";
|
import MenuBarItem from "../MenuBarItem";
|
||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
import AvatarContainer from "../AvatarContainer";
|
import AvatarContainer from "../AvatarContainer";
|
||||||
@@ -11,13 +12,6 @@ import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
|
|||||||
import { HeaderView } from "./Header.view";
|
import { HeaderView } from "./Header.view";
|
||||||
import type { HeaderProps, NavSize } from "./Header.types";
|
import type { HeaderProps, NavSize } from "./Header.types";
|
||||||
|
|
||||||
// Configuration data for testing
|
|
||||||
export const navigationItems = [
|
|
||||||
{ href: "#", text: "Use cases", extraPadding: true },
|
|
||||||
{ href: "/learn", text: "Learn" },
|
|
||||||
{ href: "#", text: "About" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const avatarImages = [
|
export const avatarImages = [
|
||||||
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
||||||
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
||||||
@@ -46,6 +40,7 @@ export const logoConfig = [
|
|||||||
|
|
||||||
const HeaderContainer = memo<HeaderProps>(() => {
|
const HeaderContainer = memo<HeaderProps>(() => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const t = useTranslation("header");
|
||||||
|
|
||||||
// Schema markup for site navigation
|
// Schema markup for site navigation
|
||||||
const schemaData = {
|
const schemaData = {
|
||||||
@@ -60,6 +55,13 @@ const HeaderContainer = memo<HeaderProps>(() => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Navigation items with translations
|
||||||
|
const navigationItems = [
|
||||||
|
{ href: "#", text: t("navigation.useCases"), extraPadding: true },
|
||||||
|
{ href: "/learn", text: t("navigation.learn") },
|
||||||
|
{ href: "#", text: t("navigation.about") },
|
||||||
|
];
|
||||||
|
|
||||||
const renderNavigationItems = (size: NavSize) => {
|
const renderNavigationItems = (size: NavSize) => {
|
||||||
return navigationItems.map((item, index) => (
|
return navigationItems.map((item, index) => (
|
||||||
<MenuBarItem
|
<MenuBarItem
|
||||||
@@ -67,7 +69,7 @@ const HeaderContainer = memo<HeaderProps>(() => {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
size={item.extraPadding && size === "xsmall" ? "xsmallUseCases" : size}
|
size={item.extraPadding && size === "xsmall" ? "xsmallUseCases" : size}
|
||||||
isActive={pathname === item.href}
|
isActive={pathname === item.href}
|
||||||
ariaLabel={`Navigate to ${item.text} page`}
|
ariaLabel={t("ariaLabels.navigateToPage").replace("{text}", item.text)}
|
||||||
>
|
>
|
||||||
{item.text}
|
{item.text}
|
||||||
</MenuBarItem>
|
</MenuBarItem>
|
||||||
@@ -94,8 +96,12 @@ const HeaderContainer = memo<HeaderProps>(() => {
|
|||||||
|
|
||||||
const renderLoginButton = (size: NavSize) => {
|
const renderLoginButton = (size: NavSize) => {
|
||||||
return (
|
return (
|
||||||
<MenuBarItem href="#" size={size} ariaLabel="Log in to your account">
|
<MenuBarItem
|
||||||
Log in
|
href="#"
|
||||||
|
size={size}
|
||||||
|
ariaLabel={t("ariaLabels.logInToAccount")}
|
||||||
|
>
|
||||||
|
{t("buttons.logIn")}
|
||||||
</MenuBarItem>
|
</MenuBarItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -106,12 +112,9 @@ const HeaderContainer = memo<HeaderProps>(() => {
|
|||||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button size={buttonSize} ariaLabel={t("ariaLabels.createNewRule")}>
|
||||||
size={buttonSize}
|
|
||||||
ariaLabel="Create a new rule with avatar decoration"
|
|
||||||
>
|
|
||||||
{renderAvatarGroup(containerSize, avatarSize)}
|
{renderAvatarGroup(containerSize, avatarSize)}
|
||||||
<span>Create rule</span>
|
<span>{t("buttons.createRule")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import MenuBar from "../MenuBar";
|
import MenuBar from "../MenuBar";
|
||||||
import type { HeaderViewProps } from "./Header.types";
|
import type { HeaderViewProps } from "./Header.types";
|
||||||
|
|
||||||
@@ -9,6 +12,8 @@ export function HeaderView({
|
|||||||
renderCreateRuleButton,
|
renderCreateRuleButton,
|
||||||
renderLogo,
|
renderLogo,
|
||||||
}: HeaderViewProps) {
|
}: HeaderViewProps) {
|
||||||
|
const t = useTranslation("header");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
<script
|
||||||
@@ -18,12 +23,12 @@ export function HeaderView({
|
|||||||
<header
|
<header
|
||||||
className="sticky top-0 z-50 bg-[var(--color-surface-default-primary)] w-full border-b border-[var(--border-color-default-tertiary)]"
|
className="sticky top-0 z-50 bg-[var(--color-surface-default-primary)] w-full border-b border-[var(--border-color-default-tertiary)]"
|
||||||
role="banner"
|
role="banner"
|
||||||
aria-label="Main navigation header"
|
aria-label={t("ariaLabels.mainNavigationHeader")}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
className="flex items-center justify-between mx-auto h-[40px] lg:h-[84px] xl:h-[88px] px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-008)] lg:px-[var(--spacing-measures-spacing-64,64px)] lg:py-[var(--spacing-measures-spacing-016,16px)]"
|
className="flex items-center justify-between mx-auto h-[40px] lg:h-[84px] xl:h-[88px] px-[var(--spacing-measures-spacing-016)] py-[var(--spacing-measures-spacing-008)] lg:px-[var(--spacing-measures-spacing-64,64px)] lg:py-[var(--spacing-measures-spacing-016,16px)]"
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label="Main navigation"
|
aria-label={t("ariaLabels.mainNavigation")}
|
||||||
>
|
>
|
||||||
{/* Logo - Consistent left positioning across all breakpoints */}
|
{/* Logo - Consistent left positioning across all breakpoints */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { default } from "./Header.container";
|
export { default } from "./Header.container";
|
||||||
export type { HeaderProps } from "./Header.types";
|
export type { HeaderProps } from "./Header.types";
|
||||||
export { navigationItems, avatarImages, logoConfig } from "./Header.container";
|
export { avatarImages, logoConfig } from "./Header.container";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import { useTranslation } from "../contexts/MessagesContext";
|
||||||
import ContentLockup from "./ContentLockup";
|
import ContentLockup from "./ContentLockup";
|
||||||
import HeroDecor from "./HeroDecor";
|
import HeroDecor from "./HeroDecor";
|
||||||
import { getAssetPath } from "../../lib/assetUtils";
|
import { getAssetPath } from "../../lib/assetUtils";
|
||||||
@@ -15,6 +16,9 @@ interface HeroBannerProps {
|
|||||||
|
|
||||||
const HeroBanner = memo<HeroBannerProps>(
|
const HeroBanner = memo<HeroBannerProps>(
|
||||||
({ title, subtitle, description, ctaText, ctaHref }) => {
|
({ title, subtitle, description, ctaText, ctaHref }) => {
|
||||||
|
const t = useTranslation();
|
||||||
|
const imageAlt = t("heroBanner.imageAlt");
|
||||||
|
|
||||||
return (
|
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)]">
|
<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)]">
|
<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">
|
<div className="w-full h-full md:flex-1 rounded-[8px] overflow-hidden relative z-10 flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={getAssetPath("assets/HeroImage.png")}
|
src={getAssetPath("assets/HeroImage.png")}
|
||||||
alt="Hero illustration"
|
alt={imageAlt}
|
||||||
className="w-full h-auto"
|
className="w-full h-auto"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchPriority="high"
|
fetchPriority="high"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import MenuBarItem from "../MenuBarItem";
|
import MenuBarItem from "../MenuBarItem";
|
||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
import AvatarContainer from "../AvatarContainer";
|
import AvatarContainer from "../AvatarContainer";
|
||||||
@@ -11,13 +12,6 @@ import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
|
|||||||
import HomeHeaderView from "./HomeHeader.view";
|
import HomeHeaderView from "./HomeHeader.view";
|
||||||
import type { HomeHeaderProps, NavSize } from "./HomeHeader.types";
|
import type { HomeHeaderProps, NavSize } from "./HomeHeader.types";
|
||||||
|
|
||||||
// Configuration data for testing
|
|
||||||
export const navigationItems = [
|
|
||||||
{ href: "#", text: "Use cases", extraPadding: true },
|
|
||||||
{ href: "/learn", text: "Learn" },
|
|
||||||
{ href: "#", text: "About" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const avatarImages = [
|
export const avatarImages = [
|
||||||
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
||||||
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
||||||
@@ -54,6 +48,7 @@ export const logoConfig = [
|
|||||||
|
|
||||||
const HomeHeaderContainer = memo<HomeHeaderProps>(() => {
|
const HomeHeaderContainer = memo<HomeHeaderProps>(() => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const t = useTranslation("header");
|
||||||
|
|
||||||
// Schema markup for site navigation (home page specific)
|
// Schema markup for site navigation (home page specific)
|
||||||
const schemaData = {
|
const schemaData = {
|
||||||
@@ -69,6 +64,13 @@ const HomeHeaderContainer = memo<HomeHeaderProps>(() => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Navigation items with translations
|
||||||
|
const navigationItems = [
|
||||||
|
{ href: "#", text: t("navigation.useCases"), extraPadding: true },
|
||||||
|
{ href: "/learn", text: t("navigation.learn") },
|
||||||
|
{ href: "#", text: t("navigation.about") },
|
||||||
|
];
|
||||||
|
|
||||||
const renderNavigationItems = (size: NavSize) => {
|
const renderNavigationItems = (size: NavSize) => {
|
||||||
return navigationItems.map((item, index) => (
|
return navigationItems.map((item, index) => (
|
||||||
<MenuBarItem
|
<MenuBarItem
|
||||||
@@ -102,7 +104,7 @@ const HomeHeaderContainer = memo<HomeHeaderProps>(() => {
|
|||||||
: "default"
|
: "default"
|
||||||
}
|
}
|
||||||
isActive={pathname === item.href}
|
isActive={pathname === item.href}
|
||||||
ariaLabel={`Navigate to ${item.text} page`}
|
ariaLabel={t("ariaLabels.navigateToPage").replace("{text}", item.text)}
|
||||||
>
|
>
|
||||||
{item.text}
|
{item.text}
|
||||||
</MenuBarItem>
|
</MenuBarItem>
|
||||||
@@ -133,9 +135,9 @@ const HomeHeaderContainer = memo<HomeHeaderProps>(() => {
|
|||||||
href="#"
|
href="#"
|
||||||
size={size}
|
size={size}
|
||||||
variant={size === "xsmall" || size === "default" ? "home" : "default"}
|
variant={size === "xsmall" || size === "default" ? "home" : "default"}
|
||||||
ariaLabel="Log in to your account"
|
ariaLabel={t("ariaLabels.logInToAccount")}
|
||||||
>
|
>
|
||||||
Log in
|
{t("buttons.logIn")}
|
||||||
</MenuBarItem>
|
</MenuBarItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -149,10 +151,10 @@ const HomeHeaderContainer = memo<HomeHeaderProps>(() => {
|
|||||||
<Button
|
<Button
|
||||||
size={buttonSize}
|
size={buttonSize}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
ariaLabel="Create a new rule with avatar decoration"
|
ariaLabel={t("ariaLabels.createNewRule")}
|
||||||
>
|
>
|
||||||
{renderAvatarGroup(containerSize, avatarSize)}
|
{renderAvatarGroup(containerSize, avatarSize)}
|
||||||
<span>Create rule</span>
|
<span>{t("buttons.createRule")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import HeaderTab from "../HeaderTab";
|
import HeaderTab from "../HeaderTab";
|
||||||
import MenuBar from "../MenuBar";
|
import MenuBar from "../MenuBar";
|
||||||
import type { HomeHeaderViewProps } from "./HomeHeader.types";
|
import type { HomeHeaderViewProps } from "./HomeHeader.types";
|
||||||
@@ -14,6 +15,8 @@ function HomeHeaderView({
|
|||||||
renderCreateRuleButton,
|
renderCreateRuleButton,
|
||||||
renderLogo,
|
renderLogo,
|
||||||
}: HomeHeaderViewProps) {
|
}: HomeHeaderViewProps) {
|
||||||
|
const t = useTranslation("homeHeader");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Script
|
<Script
|
||||||
@@ -24,12 +27,12 @@ function HomeHeaderView({
|
|||||||
<header
|
<header
|
||||||
className="w-full bg-transparent overflow-hidden"
|
className="w-full bg-transparent overflow-hidden"
|
||||||
role="banner"
|
role="banner"
|
||||||
aria-label="Home page navigation header"
|
aria-label={t("ariaLabels.homePageNavigationHeader")}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
className="relative flex items-center justify-between mx-auto h-[50px] sm:h-[62px] md:h-[68px] lg:h-[68px] xl:h-[88px] px-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:pl-[var(--spacing-scale-024)] lg:pt-[var(--spacing-scale-016)] lg:pr-[var(--spacing-scale-056)] xl:pl-[var(--spacing-scale-048)] xl:pt-[var(--spacing-scale-024)] xl:pr-[var(--spacing-scale-056)]"
|
className="relative flex items-center justify-between mx-auto h-[50px] sm:h-[62px] md:h-[68px] lg:h-[68px] xl:h-[88px] px-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:pl-[var(--spacing-scale-024)] lg:pt-[var(--spacing-scale-016)] lg:pr-[var(--spacing-scale-056)] xl:pl-[var(--spacing-scale-048)] xl:pt-[var(--spacing-scale-024)] xl:pr-[var(--spacing-scale-056)]"
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label="Main navigation"
|
aria-label={t("ariaLabels.mainNavigation")}
|
||||||
>
|
>
|
||||||
<HeaderTab className="flex items-center self-end" stretch={true}>
|
<HeaderTab className="flex items-center self-end" stretch={true}>
|
||||||
{/* Logo - Consistent left positioning within HeaderTab */}
|
{/* Logo - Consistent left positioning within HeaderTab */}
|
||||||
|
|||||||
@@ -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,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
|
import type { LanguageSwitcherProps, Language } from "./LanguageSwitcher.types";
|
||||||
|
|
||||||
|
function LanguageSwitcherView({ className = "" }: LanguageSwitcherProps) {
|
||||||
|
const t = useTranslation("languageSwitcher");
|
||||||
|
|
||||||
|
const AVAILABLE_LANGUAGES: Language[] = [
|
||||||
|
{
|
||||||
|
code: "en",
|
||||||
|
name: t("languages.english.name"),
|
||||||
|
nativeName: t("languages.english.nativeName"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label htmlFor="language-select" className="sr-only">
|
||||||
|
{t("label")}
|
||||||
|
</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={t("ariaLabel")}
|
||||||
|
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)]">
|
||||||
|
{t("comingSoonMessage")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(LanguageSwitcherView);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./LanguageSwitcher.container";
|
||||||
|
export type { LanguageSwitcherProps, Language } from "./LanguageSwitcher.types";
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import { useTranslation } from "../contexts/MessagesContext";
|
||||||
|
|
||||||
interface MenuBarProps extends React.HTMLAttributes<HTMLElement> {
|
interface MenuBarProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -8,6 +11,7 @@ interface MenuBarProps extends React.HTMLAttributes<HTMLElement> {
|
|||||||
|
|
||||||
const MenuBar = memo<MenuBarProps>(
|
const MenuBar = memo<MenuBarProps>(
|
||||||
({ children, className = "", size = "default", ...props }) => {
|
({ children, className = "", size = "default", ...props }) => {
|
||||||
|
const t = useTranslation("menuBar");
|
||||||
const sizeStyles: Record<string, string> = {
|
const sizeStyles: Record<string, string> = {
|
||||||
xsmall:
|
xsmall:
|
||||||
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]",
|
"px-[var(--spacing-scale-004)] py-[var(--spacing-scale-004)] gap-[var(--spacing-scale-001)] rounded-[4px]",
|
||||||
@@ -25,7 +29,7 @@ const MenuBar = memo<MenuBarProps>(
|
|||||||
<nav
|
<nav
|
||||||
className={baseStyles}
|
className={baseStyles}
|
||||||
role="menubar"
|
role="menubar"
|
||||||
aria-label="Main navigation menu"
|
aria-label={t("ariaLabel")}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import SectionHeader from "../SectionHeader";
|
import SectionHeader from "../SectionHeader";
|
||||||
import NumberedCard from "../NumberedCard";
|
import NumberedCard from "../NumberedCard";
|
||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
@@ -9,6 +12,8 @@ function NumberedCardsView({
|
|||||||
cards,
|
cards,
|
||||||
schemaJson,
|
schemaJson,
|
||||||
}: NumberedCardsViewProps) {
|
}: NumberedCardsViewProps) {
|
||||||
|
const t = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
<script
|
||||||
@@ -23,7 +28,7 @@ function NumberedCardsView({
|
|||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={title}
|
title={title}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
titleLg="How CommunityRule helps"
|
titleLg={t("numberedCards.titleLg")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,13 +50,13 @@ function NumberedCardsView({
|
|||||||
{/* Default button for xsm and sm breakpoints */}
|
{/* Default button for xsm and sm breakpoints */}
|
||||||
<div className="block lg:hidden">
|
<div className="block lg:hidden">
|
||||||
<Button variant="default" size="large">
|
<Button variant="default" size="large">
|
||||||
Create CommunityRule
|
{t("numberedCards.buttons.createCommunityRule")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/* Outlined button for lg and xlg breakpoints */}
|
{/* Outlined button for lg and xlg breakpoints */}
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<Button variant="outlined" size="large">
|
<Button variant="outlined" size="large">
|
||||||
See how it works
|
{t("numberedCards.buttons.seeHowItWorks")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import QuoteDecor from "../QuoteDecor";
|
import QuoteDecor from "../QuoteDecor";
|
||||||
import type { QuoteBlockViewProps } from "./QuoteBlock.types";
|
import type { QuoteBlockViewProps } from "./QuoteBlock.types";
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ function QuoteBlockView({
|
|||||||
onImageLoad,
|
onImageLoad,
|
||||||
onImageError,
|
onImageError,
|
||||||
}: QuoteBlockViewProps) {
|
}: QuoteBlockViewProps) {
|
||||||
|
const t = useTranslation("quoteBlock");
|
||||||
|
const avatarAlt = t("avatarAlt").replace("{author}", author);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`${config.container} ${className}`}
|
className={`${config.container} ${className}`}
|
||||||
@@ -54,7 +58,7 @@ function QuoteBlockView({
|
|||||||
{!imageError ? (
|
{!imageError ? (
|
||||||
<Image
|
<Image
|
||||||
src={currentAvatarSrc}
|
src={currentAvatarSrc}
|
||||||
alt={`Portrait of ${author}`}
|
alt={avatarAlt}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className={`filter sepia ${
|
className={`filter sepia ${
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import type { RuleCardViewProps } from "./RuleCard.types";
|
import type { RuleCardViewProps } from "./RuleCard.types";
|
||||||
|
|
||||||
export function RuleCardView({
|
export function RuleCardView({
|
||||||
@@ -9,12 +12,15 @@ export function RuleCardView({
|
|||||||
onClick,
|
onClick,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
}: RuleCardViewProps) {
|
}: RuleCardViewProps) {
|
||||||
|
const t = useTranslation("ruleCard");
|
||||||
|
const ariaLabel = t("ariaLabel").replace("{title}", title);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
|
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={`Learn more about ${title} governance pattern`}
|
aria-label={ariaLabel}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import RuleCard from "../RuleCard";
|
import RuleCard from "../RuleCard";
|
||||||
import Button from "../Button";
|
import Button from "../Button";
|
||||||
import { getAssetPath } from "../../../lib/assetUtils";
|
import { getAssetPath } from "../../../lib/assetUtils";
|
||||||
@@ -8,77 +11,79 @@ export function RuleStackView({
|
|||||||
className,
|
className,
|
||||||
onTemplateClick,
|
onTemplateClick,
|
||||||
}: RuleStackViewProps) {
|
}: RuleStackViewProps) {
|
||||||
|
const t = useTranslation("pages.home.ruleStack");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`w-full bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-032)] xmd:py-[var(--spacing-scale-056)] xmd:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-096)] flex flex-col gap-[var(--spacing-scale-024)] xmd:gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] ${className}`}
|
className={`w-full bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-032)] xmd:py-[var(--spacing-scale-056)] xmd:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-096)] flex flex-col gap-[var(--spacing-scale-024)] xmd:gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] ${className}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-[18px] xmd:grid xmd:grid-cols-2 lg:gap-[var(--spacing-scale-024)]">
|
<div className="flex flex-col gap-[18px] xmd:grid xmd:grid-cols-2 lg:gap-[var(--spacing-scale-024)]">
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Consensus clusters"
|
title={t("cards.consensusClusters.title")}
|
||||||
description="Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council."
|
description={t("cards.consensusClusters.description")}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<Image
|
||||||
src={getAssetPath("assets/Icon_Sociocracy.svg")}
|
src={getAssetPath("assets/Icon_Sociocracy.svg")}
|
||||||
alt="Sociocracy"
|
alt={t("cards.consensusClusters.iconAlt")}
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
|
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-lime)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-lime)]"
|
||||||
onClick={() => onTemplateClick("Consensus clusters")}
|
onClick={() => onTemplateClick(t("cards.consensusClusters.title"))}
|
||||||
/>
|
/>
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Consensus"
|
title={t("cards.consensus.title")}
|
||||||
description="Decisions that affect the group collectively should involve participation of all participants."
|
description={t("cards.consensus.description")}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<Image
|
||||||
src={getAssetPath("assets/Icon_Consensus.svg")}
|
src={getAssetPath("assets/Icon_Consensus.svg")}
|
||||||
alt="Consensus"
|
alt={t("cards.consensus.iconAlt")}
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
|
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
|
||||||
onClick={() => onTemplateClick("Consensus")}
|
onClick={() => onTemplateClick(t("cards.consensus.title"))}
|
||||||
/>
|
/>
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Elected Board"
|
title={t("cards.electedBoard.title")}
|
||||||
description="An elected board determines policies and organizes their implementation."
|
description={t("cards.electedBoard.description")}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<Image
|
||||||
src={getAssetPath("assets/Icon_ElectedBoard.svg")}
|
src={getAssetPath("assets/Icon_ElectedBoard.svg")}
|
||||||
alt="Elected Board"
|
alt={t("cards.electedBoard.iconAlt")}
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
|
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-red)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-red)]"
|
||||||
onClick={() => onTemplateClick("Elected Board")}
|
onClick={() => onTemplateClick(t("cards.electedBoard.title"))}
|
||||||
/>
|
/>
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Petition"
|
title={t("cards.petition.title")}
|
||||||
description="All participants can propose and vote on proposals for the group."
|
description={t("cards.petition.description")}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<Image
|
||||||
src={getAssetPath("assets/Icon_Petition.svg")}
|
src={getAssetPath("assets/Icon_Petition.svg")}
|
||||||
alt="Petition"
|
alt={t("cards.petition.iconAlt")}
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
|
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-teal)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-teal)]"
|
||||||
onClick={() => onTemplateClick("Petition")}
|
onClick={() => onTemplateClick(t("cards.petition.title"))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* See all templates button */}
|
{/* See all templates button */}
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Button variant="outlined" size="large">
|
<Button variant="outlined" size="large">
|
||||||
See all templates
|
{t("button.seeAllTemplates")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"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);
|
||||||
|
};
|
||||||
|
}
|
||||||
+11
-5
@@ -2,6 +2,8 @@ import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { MessagesProvider } from "./contexts/MessagesContext";
|
||||||
|
import messages from "../messages/en/index";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import ConditionalHeader from "./components/ConditionalHeader";
|
import ConditionalHeader from "./components/ConditionalHeader";
|
||||||
|
|
||||||
@@ -86,6 +88,8 @@ 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 (
|
return (
|
||||||
<html lang="en" className="font-sans">
|
<html lang="en" className="font-sans">
|
||||||
<head>
|
<head>
|
||||||
@@ -101,11 +105,13 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<body
|
<body
|
||||||
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
|
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable}`}
|
||||||
>
|
>
|
||||||
<div className="min-h-screen flex flex-col">
|
<MessagesProvider messages={messages}>
|
||||||
<ConditionalHeader />
|
<div className="min-h-screen flex flex-col">
|
||||||
<main className="flex-1">{children}</main>
|
<ConditionalHeader />
|
||||||
<Footer />
|
<main className="flex-1">{children}</main>
|
||||||
</div>
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</MessagesProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-9
@@ -1,3 +1,5 @@
|
|||||||
|
import messages from "../../messages/en/index";
|
||||||
|
import { getTranslation } from "../../lib/i18n/getTranslation";
|
||||||
import ContentThumbnailTemplate from "../components/ContentThumbnailTemplate";
|
import ContentThumbnailTemplate from "../components/ContentThumbnailTemplate";
|
||||||
import ContentLockup from "../components/ContentLockup";
|
import ContentLockup from "../components/ContentLockup";
|
||||||
import AskOrganizer from "../components/AskOrganizer";
|
import AskOrganizer from "../components/AskOrganizer";
|
||||||
@@ -7,21 +9,22 @@ export default function LearnPage() {
|
|||||||
// Get real blog posts from the content system
|
// Get real blog posts from the content system
|
||||||
const allPosts = getAllBlogPosts();
|
const allPosts = getAllBlogPosts();
|
||||||
|
|
||||||
|
// Use direct message access for server components
|
||||||
|
const t = (key: string) => getTranslation(messages, key);
|
||||||
|
|
||||||
const contentLockupData = {
|
const contentLockupData = {
|
||||||
title: "Organizing is hard",
|
title: t("pages.learn.contentLockup.title"),
|
||||||
subtitle:
|
subtitle: t("pages.learn.contentLockup.subtitle"),
|
||||||
"Find answers to your questions and see how other groups have solved similar challenges.",
|
|
||||||
variant: "learn" as const,
|
variant: "learn" as const,
|
||||||
alignment: "left" as const,
|
alignment: "left" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
const askOrganizerData = {
|
const askOrganizerData = {
|
||||||
title: "Still have questions?",
|
title: t("pages.learn.askOrganizer.title"),
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: t("pages.learn.askOrganizer.subtitle"),
|
||||||
description:
|
description: t("pages.learn.askOrganizer.description"),
|
||||||
"Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.",
|
buttonText: t("pages.learn.askOrganizer.buttonText"),
|
||||||
buttonText: "Ask an organizer",
|
buttonHref: t("pages.learn.askOrganizer.buttonHref"),
|
||||||
buttonHref: "/contact",
|
|
||||||
variant: "centered" as const,
|
variant: "centered" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+22
-18
@@ -1,4 +1,6 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import messages from "../messages/en/index";
|
||||||
|
import { getTranslation } from "../lib/i18n/getTranslation";
|
||||||
import HeroBanner from "./components/HeroBanner";
|
import HeroBanner from "./components/HeroBanner";
|
||||||
import AskOrganizer from "./components/AskOrganizer";
|
import AskOrganizer from "./components/AskOrganizer";
|
||||||
|
|
||||||
@@ -39,31 +41,34 @@ const QuoteBlock = dynamic(() => import("./components/QuoteBlock"), {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function Page() {
|
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 = {
|
const heroBannerData = {
|
||||||
title: "Collaborate",
|
title: t("pages.home.heroBanner.title"),
|
||||||
subtitle: "with clarity",
|
subtitle: t("pages.home.heroBanner.subtitle"),
|
||||||
description:
|
description: t("pages.home.heroBanner.description"),
|
||||||
"Help your community make important decisions in a way that reflects its unique values.",
|
ctaText: t("pages.home.heroBanner.ctaText"),
|
||||||
ctaText: "Learn how CommunityRule works",
|
ctaHref: t("pages.home.heroBanner.ctaHref"),
|
||||||
ctaHref: "#",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const numberedCardsData = {
|
const numberedCardsData = {
|
||||||
title: "How CommunityRule works",
|
title: t("pages.home.numberedCards.title"),
|
||||||
subtitle: "Here's a quick overview of the process, from start to finish.",
|
subtitle: t("pages.home.numberedCards.subtitle"),
|
||||||
cards: [
|
cards: [
|
||||||
{
|
{
|
||||||
text: "Document how your community makes decisions",
|
text: t("pages.home.numberedCards.cards.card1.text"),
|
||||||
iconShape: "blob",
|
iconShape: "blob",
|
||||||
iconColor: "green",
|
iconColor: "green",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Build an operating manual for a successful community",
|
text: t("pages.home.numberedCards.cards.card2.text"),
|
||||||
iconShape: "gear",
|
iconShape: "gear",
|
||||||
iconColor: "purple",
|
iconColor: "purple",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Get a link to your manual for your group to review and evolve",
|
text: t("pages.home.numberedCards.cards.card3.text"),
|
||||||
iconShape: "star",
|
iconShape: "star",
|
||||||
iconColor: "orange",
|
iconColor: "orange",
|
||||||
},
|
},
|
||||||
@@ -71,16 +76,15 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const featureGridData = {
|
const featureGridData = {
|
||||||
title: "We've got your back, every step of the way",
|
title: t("pages.home.featureGrid.title"),
|
||||||
subtitle:
|
subtitle: t("pages.home.featureGrid.subtitle"),
|
||||||
"Use our toolkit to improve, document, and evolve your organization.",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const askOrganizerData = {
|
const askOrganizerData = {
|
||||||
title: "Still have questions?",
|
title: t("pages.home.askOrganizer.title"),
|
||||||
subtitle: "Get answers from an experienced organizer",
|
subtitle: t("pages.home.askOrganizer.subtitle"),
|
||||||
buttonText: "Ask an organizer",
|
buttonText: t("pages.home.askOrganizer.buttonText"),
|
||||||
buttonHref: "#contact",
|
buttonHref: t("pages.home.askOrganizer.buttonHref"),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ This directory contains project documentation organized by topic.
|
|||||||
|
|
||||||
- **[container-presentation-pattern.md](./guides/container-presentation-pattern.md)** - Container/Presentation pattern guide for component architecture
|
- **[container-presentation-pattern.md](./guides/container-presentation-pattern.md)** - Container/Presentation pattern guide for component architecture
|
||||||
- **[content-creation.md](./guides/content-creation.md)** - Guide for creating blog content
|
- **[content-creation.md](./guides/content-creation.md)** - Guide for creating blog content
|
||||||
|
- **[i18n-translation-workflow.md](./guides/i18n-translation-workflow.md)** - Guide for working with translations and UI content management
|
||||||
|
|
||||||
## Quick Navigation
|
## Quick Navigation
|
||||||
|
|
||||||
@@ -51,6 +52,16 @@ See **[content-creation.md](./guides/content-creation.md)** for:
|
|||||||
- Content guidelines
|
- Content guidelines
|
||||||
- Contribution workflow
|
- Contribution workflow
|
||||||
|
|
||||||
|
### For Translations and UI Content
|
||||||
|
|
||||||
|
See **[i18n-translation-workflow.md](./guides/i18n-translation-workflow.md)** for:
|
||||||
|
|
||||||
|
- How to update UI text content
|
||||||
|
- Translation key structure and naming conventions
|
||||||
|
- How to extract strings from components
|
||||||
|
- How to add new translation keys
|
||||||
|
- Best practices for content creators and developers
|
||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
- **Vitest Documentation**: https://vitest.dev/
|
- **Vitest Documentation**: https://vitest.dev/
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
# i18n Translation Workflow Guide
|
||||||
|
|
||||||
|
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/`. 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/
|
||||||
|
pages/
|
||||||
|
home.json # Home page specific content
|
||||||
|
learn.json # Learn page specific content
|
||||||
|
components/
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 <ContentLockup {...contentLockupData} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client Components:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
|
|
||||||
|
export default function MyComponent() {
|
||||||
|
const t = useTranslation("pages.home.heroBanner");
|
||||||
|
return <h1>{t("title")}</h1>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Translation Keys
|
||||||
|
|
||||||
|
### 1. Identify the Component
|
||||||
|
|
||||||
|
Determine which component needs the translation. If it's a shared string (like a button label), add it to `common.json`. Otherwise, add it to the component-specific file.
|
||||||
|
|
||||||
|
### 2. Add the Key to the JSON File
|
||||||
|
|
||||||
|
Open the appropriate JSON file and add your translation key. Use descriptive, semantic keys:
|
||||||
|
|
||||||
|
**Good:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"heroBanner": {
|
||||||
|
"title": "Collaborate",
|
||||||
|
"subtitle": "with clarity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bad:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text1": "Collaborate",
|
||||||
|
"text2": "with clarity"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Nested Objects for Organization
|
||||||
|
|
||||||
|
Group related translations together:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"numberedCards": {
|
||||||
|
"title": "How CommunityRule works",
|
||||||
|
"buttons": {
|
||||||
|
"createCommunityRule": "Create CommunityRule",
|
||||||
|
"seeHowItWorks": "See how it works"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update the Component or Page
|
||||||
|
|
||||||
|
**For Page Components (Server Components):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import messages from "../../messages/en/index";
|
||||||
|
import { getTranslation } from "../../lib/i18n/getTranslation";
|
||||||
|
|
||||||
|
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 <HeroBanner {...data} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Client Components:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
|
|
||||||
|
export default function MyComponent() {
|
||||||
|
// For page-specific content
|
||||||
|
const t = useTranslation("pages.home.heroBanner");
|
||||||
|
return <h1>{t("title")}</h1>;
|
||||||
|
|
||||||
|
// For component defaults
|
||||||
|
const tDefault = useTranslation("heroBanner");
|
||||||
|
return <img alt={tDefault("imageAlt")} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Translation Key Naming Conventions
|
||||||
|
|
||||||
|
1. **Use camelCase** for keys: `buttonText`, `ariaLabel`
|
||||||
|
2. **Use descriptive names**: `createCommunityRule` not `btn1`
|
||||||
|
3. **Group by component**: Each component has its own namespace
|
||||||
|
4. **Use nested objects** for related strings: `buttons.createCommunityRule`
|
||||||
|
5. **Include context in comments**: Use `_comment` fields for clarity
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_comment": "HeroBanner component translations",
|
||||||
|
"title": "Collaborate",
|
||||||
|
"subtitle": "with clarity",
|
||||||
|
"description": "Help your community make important decisions..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extracting Strings from Components
|
||||||
|
|
||||||
|
When migrating a component to use translations:
|
||||||
|
|
||||||
|
1. **Identify hardcoded strings** in the component
|
||||||
|
2. **Create translation keys** in the appropriate JSON file
|
||||||
|
3. **Replace hardcoded strings** with `t("key.path")` calls
|
||||||
|
4. **Test the component** to ensure translations load correctly
|
||||||
|
|
||||||
|
### Example Migration
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default function HeroBanner() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Collaborate</h1>
|
||||||
|
<p>with clarity</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function HeroBanner() {
|
||||||
|
const t = useTranslations("heroBanner");
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{t("title")}</h1>
|
||||||
|
<p>{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 (including `pages/` structure)
|
||||||
|
3. **Translate all strings** in the JSON files
|
||||||
|
4. **Test thoroughly** to ensure all translations are present
|
||||||
|
|
||||||
|
## Testing Translations
|
||||||
|
|
||||||
|
1. **Check for missing keys**: Ensure all translation keys used in components exist in the JSON files
|
||||||
|
2. **Verify type safety**: TypeScript will catch typos in translation keys at compile time
|
||||||
|
3. **Test in browser**: Run the dev server and verify text displays correctly
|
||||||
|
4. **Check for fallbacks**: Missing translations will show the key path (e.g., `heroBanner.title`)
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### For Content Creators
|
||||||
|
|
||||||
|
- **Edit JSON files directly**: No need to understand React or TypeScript
|
||||||
|
- **Use descriptive comments**: Add `_comment` fields to explain context
|
||||||
|
- **Maintain consistency**: Use the same terminology across components
|
||||||
|
- **Test changes**: Run the dev server to see your changes immediately
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
- **Use TypeScript**: Translation keys are type-safe
|
||||||
|
- **Namespace when possible**: Use `useTranslations("namespace")` for better organization
|
||||||
|
- **Server components first**: Prefer server-side translations for better performance
|
||||||
|
- **Extract incrementally**: Migrate components one at a time
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Buttons and CTAs
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"createCommunityRule": "Create CommunityRule",
|
||||||
|
"seeHowItWorks": "See how it works"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aria Labels
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ariaLabels": {
|
||||||
|
"followBluesky": "Follow us on Bluesky",
|
||||||
|
"followGitlab": "Follow us on GitLab"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Content
|
||||||
|
|
||||||
|
For content that varies (like card text), use arrays or numbered keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cards": {
|
||||||
|
"card1": { "text": "First step" },
|
||||||
|
"card2": { "text": "Second step" },
|
||||||
|
"card3": { "text": "Third step" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Translation Key Not Found
|
||||||
|
|
||||||
|
If you see a key path like `heroBanner.title` instead of the text:
|
||||||
|
|
||||||
|
1. Check the JSON file exists and has the key
|
||||||
|
2. Verify the key path matches exactly (case-sensitive)
|
||||||
|
3. Restart the dev server if you just added the key
|
||||||
|
|
||||||
|
### TypeScript Errors
|
||||||
|
|
||||||
|
If TypeScript complains about translation keys:
|
||||||
|
|
||||||
|
1. Ensure the key exists in the JSON file
|
||||||
|
2. Check for typos in the key path
|
||||||
|
3. Verify the namespace is correct if using `useTranslations("namespace")`
|
||||||
|
|
||||||
|
### Missing Translations
|
||||||
|
|
||||||
|
If text doesn't appear:
|
||||||
|
|
||||||
|
1. Check the browser console for errors
|
||||||
|
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
|
||||||
|
|
||||||
|
- Component defaults in `messages/en/components/`
|
||||||
|
- Page-specific content in `messages/en/pages/`
|
||||||
|
- Shared UI strings in `messages/en/common.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: January 2025
|
||||||
|
**Maintained by**: CommunityRule Development Team
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type messages from "../../messages/en/index";
|
||||||
|
|
||||||
|
type Messages = typeof messages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to access nested translation keys from messages object
|
||||||
|
* This provides a type-safe way to access translations in server components
|
||||||
|
* without requiring the next-intl plugin configuration.
|
||||||
|
*
|
||||||
|
* @param messages - The messages object from messages/en/index
|
||||||
|
* @param key - Dot-separated key path (e.g., "heroBanner.title")
|
||||||
|
* @returns The translation string or the key if not found
|
||||||
|
*/
|
||||||
|
export function getTranslation(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 {
|
||||||
|
// Fallback to key if path not found
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === "string" ? value : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe helper to get nested values from messages
|
||||||
|
* Usage: getNested(messages, "heroBanner", "title")
|
||||||
|
*/
|
||||||
|
export function getNested<T extends keyof Messages>(
|
||||||
|
messages: Messages,
|
||||||
|
namespace: T,
|
||||||
|
key: string,
|
||||||
|
): string {
|
||||||
|
const namespaceObj = messages[namespace];
|
||||||
|
if (!namespaceObj || typeof namespaceObj !== "object") {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = key.split(".");
|
||||||
|
let value: any = namespaceObj;
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
if (value && typeof value === "object" && k in value) {
|
||||||
|
value = value[k];
|
||||||
|
} else {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === "string" ? value : key;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Type definitions for translation keys
|
||||||
|
*
|
||||||
|
* These types provide type safety when accessing translation keys.
|
||||||
|
* The actual types are inferred from the JSON files in messages/en/
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Import the message structure to ensure type safety
|
||||||
|
import type messages from "../../messages/en/index";
|
||||||
|
|
||||||
|
export type Messages = typeof messages;
|
||||||
|
|
||||||
|
// Helper type for nested key paths
|
||||||
|
export type NestedKeyOf<ObjectType extends object> = {
|
||||||
|
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
|
||||||
|
? `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
|
||||||
|
: `${Key}`;
|
||||||
|
}[keyof ObjectType & (string | number)];
|
||||||
|
|
||||||
|
// Type for all possible translation keys
|
||||||
|
export type TranslationKey = NestedKeyOf<Messages>;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Common UI strings shared across components",
|
||||||
|
"buttons": {
|
||||||
|
"createCommunityRule": "Create CommunityRule",
|
||||||
|
"seeHowItWorks": "See how it works",
|
||||||
|
"learnMore": "Learn more",
|
||||||
|
"askOrganizer": "Ask an organizer"
|
||||||
|
},
|
||||||
|
"ariaLabels": {
|
||||||
|
"followBluesky": "Follow us on Bluesky",
|
||||||
|
"followGitlab": "Follow us on GitLab",
|
||||||
|
"featureToolsAndServices": "Feature tools and services",
|
||||||
|
"askOrganizerContact": "Ask an organizer - Contact an organizer for help"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"_comment": "AskOrganizer component defaults (shared across pages)",
|
||||||
|
"ariaLabel": "Ask an organizer - Contact an organizer for help"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"_comment": "FeatureGrid component defaults (shared across pages)",
|
||||||
|
"linkText": "Learn more",
|
||||||
|
"linkHref": "#",
|
||||||
|
"ariaLabel": "Feature tools and services",
|
||||||
|
"features": {
|
||||||
|
"decisionMaking": {
|
||||||
|
"ariaLabel": "Decision-making support tools"
|
||||||
|
},
|
||||||
|
"valuesAlignment": {
|
||||||
|
"ariaLabel": "Values alignment exercises"
|
||||||
|
},
|
||||||
|
"membershipGuidance": {
|
||||||
|
"ariaLabel": "Membership guidance resources"
|
||||||
|
},
|
||||||
|
"conflictResolution": {
|
||||||
|
"ariaLabel": "Conflict resolution tools"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Footer component translations",
|
||||||
|
"organization": {
|
||||||
|
"name": "Media Economies Design Lab",
|
||||||
|
"email": "medlab@colorado.edu",
|
||||||
|
"url": "https://communityrule.com"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"bluesky": {
|
||||||
|
"handle": "medlabboulder",
|
||||||
|
"ariaLabel": "Follow us on Bluesky",
|
||||||
|
"url": "https://bsky.app/profile/medlabboulder"
|
||||||
|
},
|
||||||
|
"gitlab": {
|
||||||
|
"handle": "medlabboulder",
|
||||||
|
"ariaLabel": "Follow us on GitLab",
|
||||||
|
"url": "https://gitlab.com/medlabboulder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"useCases": "Use cases",
|
||||||
|
"learn": "Learn",
|
||||||
|
"about": "About"
|
||||||
|
},
|
||||||
|
"legal": {
|
||||||
|
"privacyPolicy": "Privacy Policy",
|
||||||
|
"termsOfService": "Terms of Service",
|
||||||
|
"cookiesSettings": "Cookies Settings"
|
||||||
|
},
|
||||||
|
"copyright": "© All right reserved"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"navigation": {
|
||||||
|
"useCases": "Use cases",
|
||||||
|
"learn": "Learn",
|
||||||
|
"about": "About"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"logIn": "Log in",
|
||||||
|
"createRule": "Create rule"
|
||||||
|
},
|
||||||
|
"ariaLabels": {
|
||||||
|
"mainNavigationHeader": "Main navigation header",
|
||||||
|
"mainNavigation": "Main navigation",
|
||||||
|
"navigateToPage": "Navigate to {text} page",
|
||||||
|
"logInToAccount": "Log in to your account",
|
||||||
|
"createNewRule": "Create a new rule with avatar decoration"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"_comment": "HeroBanner component defaults (shared across pages)",
|
||||||
|
"imageAlt": "Hero illustration"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ariaLabels": {
|
||||||
|
"homePageNavigationHeader": "Home page navigation header",
|
||||||
|
"mainNavigation": "Main navigation"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"label": "Select language",
|
||||||
|
"ariaLabel": "Select language",
|
||||||
|
"comingSoonMessage": "Language switching functionality coming soon",
|
||||||
|
"languages": {
|
||||||
|
"english": {
|
||||||
|
"name": "English",
|
||||||
|
"nativeName": "English"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"ariaLabel": "Main navigation menu"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"_comment": "NumberedCards component defaults (shared across pages)",
|
||||||
|
"titleLg": "How CommunityRule helps",
|
||||||
|
"buttons": {
|
||||||
|
"createCommunityRule": "Create CommunityRule",
|
||||||
|
"seeHowItWorks": "See how it works"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"avatarAlt": "Portrait of {author}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"ariaLabel": "Learn more about {title} governance pattern"
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"_comment": "RuleStack component defaults (shared across pages)",
|
||||||
|
"ariaLabel": "Learn more about {title} governance pattern"
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import common from "./common.json";
|
||||||
|
import heroBanner from "./components/heroBanner.json";
|
||||||
|
import numberedCards from "./components/numberedCards.json";
|
||||||
|
import askOrganizer from "./components/askOrganizer.json";
|
||||||
|
import featureGrid from "./components/featureGrid.json";
|
||||||
|
import footer from "./components/footer.json";
|
||||||
|
import header from "./components/header.json";
|
||||||
|
import homeHeader from "./components/homeHeader.json";
|
||||||
|
import languageSwitcher from "./components/languageSwitcher.json";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
common,
|
||||||
|
heroBanner,
|
||||||
|
numberedCards,
|
||||||
|
askOrganizer,
|
||||||
|
featureGrid,
|
||||||
|
footer,
|
||||||
|
header,
|
||||||
|
homeHeader,
|
||||||
|
languageSwitcher,
|
||||||
|
menuBar,
|
||||||
|
quoteBlock,
|
||||||
|
ruleCard,
|
||||||
|
ruleStack,
|
||||||
|
pages: {
|
||||||
|
home,
|
||||||
|
learn,
|
||||||
|
},
|
||||||
|
navigation,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Page metadata translations",
|
||||||
|
"home": {
|
||||||
|
"title": "CommunityRule - Build operating manuals for successful communities",
|
||||||
|
"description": "Help your community make important decisions in a way that reflects its unique values.",
|
||||||
|
"keywords": [
|
||||||
|
"community",
|
||||||
|
"governance",
|
||||||
|
"decision-making",
|
||||||
|
"operating manual"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Navigation items",
|
||||||
|
"useCases": "Use cases",
|
||||||
|
"learn": "Learn",
|
||||||
|
"about": "About"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+56
-4292
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,7 @@
|
|||||||
"critters": "^0.0.23",
|
"critters": "^0.0.23",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"next": "^16.0.0",
|
"next": "^16.0.0",
|
||||||
|
"next-intl": "^3.26.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import AskOrganizer from "../../app/components/AskOrganizer";
|
import AskOrganizer from "../../app/components/AskOrganizer";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import FeatureGrid from "../../app/components/FeatureGrid";
|
import FeatureGrid from "../../app/components/FeatureGrid";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import Footer from "../../app/components/Footer";
|
import Footer from "../../app/components/Footer";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import HeroBanner from "../../app/components/HeroBanner";
|
import HeroBanner from "../../app/components/HeroBanner";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, test, expect } from "vitest";
|
import { describe, test, expect } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "../utils/test-utils";
|
||||||
import Page from "../../app/page";
|
import Page from "../../app/page";
|
||||||
|
|
||||||
describe("Page", () => {
|
describe("Page", () => {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
cleanup,
|
||||||
|
waitFor,
|
||||||
|
} from "../utils/test-utils";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { vi, describe, test, expect, afterEach } from "vitest";
|
import { vi, describe, test, expect, afterEach } from "vitest";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
cleanup,
|
||||||
|
waitFor,
|
||||||
|
} from "../utils/test-utils";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { vi, describe, test, expect, afterEach } from "vitest";
|
import { vi, describe, test, expect, afterEach } from "vitest";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { render, screen, cleanup } from "@testing-library/react";
|
import {
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
cleanup,
|
||||||
|
} from "../utils/test-utils";
|
||||||
import { describe, test, expect, afterEach } from "vitest";
|
import { describe, test, expect, afterEach } from "vitest";
|
||||||
import NumberedCards from "../../app/components/NumberedCards";
|
import NumberedCards from "../../app/components/NumberedCards";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { render, screen, cleanup } from "@testing-library/react";
|
import {
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
cleanup,
|
||||||
|
} from "../utils/test-utils";
|
||||||
import { vi, describe, test, expect, afterEach } from "vitest";
|
import { vi, describe, test, expect, afterEach } from "vitest";
|
||||||
import QuoteBlock from "../../app/components/QuoteBlock";
|
import QuoteBlock from "../../app/components/QuoteBlock";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import {
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
} from "../utils/test-utils";
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import RuleCard from "../../app/components/RuleCard";
|
import RuleCard from "../../app/components/RuleCard";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { render, screen, cleanup } from "@testing-library/react";
|
import {
|
||||||
|
renderWithProviders as render,
|
||||||
|
screen,
|
||||||
|
cleanup,
|
||||||
|
} from "../utils/test-utils";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { vi, describe, test, expect, afterEach } from "vitest";
|
import { vi, describe, test, expect, afterEach } from "vitest";
|
||||||
import { logger } from "../../lib/logger";
|
import { logger } from "../../lib/logger";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { axe } from "jest-axe";
|
import { axe } from "jest-axe";
|
||||||
|
import { renderWithProviders as render } from "./test-utils";
|
||||||
|
|
||||||
type TestCases = {
|
type TestCases = {
|
||||||
renders?: boolean;
|
renders?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React, { type ReactElement } from "react";
|
||||||
|
import { render, type RenderOptions } from "@testing-library/react";
|
||||||
|
import { MessagesProvider } from "../../app/contexts/MessagesContext";
|
||||||
|
import messages from "../../messages/en/index";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom render function that wraps components with MessagesProvider
|
||||||
|
* Use this instead of the default render from @testing-library/react
|
||||||
|
* for components that use useTranslation hook
|
||||||
|
*/
|
||||||
|
export function renderWithProviders(
|
||||||
|
ui: ReactElement,
|
||||||
|
options?: Omit<RenderOptions, "wrapper">,
|
||||||
|
) {
|
||||||
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return <MessagesProvider messages={messages}>{children}</MessagesProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(ui, { wrapper: Wrapper, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export everything from @testing-library/react for convenience
|
||||||
|
export * from "@testing-library/react";
|
||||||
Reference in New Issue
Block a user