) =>
+ mockRouter.replace(...args)
+ );
+
+ usePathname.mockImplementation(() => {
+ const pathname = mockRouter.pathname || `/${i18n.defaultLocale}`;
+ const segments = pathname.split("/");
+ const isLocaleMissing = i18n.locales.every(
+ (locale) =>
+ !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
+ );
+
+ if (isLocaleMissing) {
+ segments[1] = i18n.defaultLocale;
+ }
+
+ return segments.join("/");
+ });
},
};
diff --git a/package-lock.json b/package-lock.json
index 1b3f2cd..f05be0a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,12 @@
"name": "e-learning",
"version": "0.1.0",
"dependencies": {
+ "@formatjs/intl-localematcher": "^0.6.1",
"@phosphor-icons/react": "^2.1.7",
"clsx": "^2.1.1",
+ "negotiator": "^1.0.0",
"next": "15.2.4",
+ "next-router-mock": "^0.9.13",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.2.0"
@@ -30,6 +33,7 @@
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
+ "@types/negotiator": "^0.6.3",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
@@ -2691,6 +2695,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@formatjs/intl-localematcher": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.1.tgz",
+ "integrity": "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -5339,6 +5352,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-JkXTOdKs5MF086b/pt8C3+yVp3iDUwG635L7oCH6HvJvvr6lSUU5oe/gLXnPEfYRROHjJIPgCV6cuAg8gGkntQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "20.17.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz",
@@ -11839,6 +11859,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@@ -11900,6 +11929,16 @@
}
}
},
+ "node_modules/next-router-mock": {
+ "version": "0.9.13",
+ "resolved": "https://registry.npmjs.org/next-router-mock/-/next-router-mock-0.9.13.tgz",
+ "integrity": "sha512-906n2RRaE6Y28PfYJbaz5XZeJ6Tw8Xz1S6E31GGwZ0sXB6/XjldD1/2azn1ZmBmRk5PQRkzjg+n+RHZe5xQzWA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "next": ">=10.0.0",
+ "react": ">=17.0.0"
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
diff --git a/package.json b/package.json
index 623fc62..6504948 100644
--- a/package.json
+++ b/package.json
@@ -14,11 +14,14 @@
"test:e2e": "npx playwright test"
},
"dependencies": {
+ "@formatjs/intl-localematcher": "^0.6.1",
"@phosphor-icons/react": "^2.1.7",
+ "clsx": "^2.1.1",
+ "negotiator": "^1.0.0",
"next": "15.2.4",
+ "next-router-mock": "^0.9.13",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "clsx": "^2.1.1",
"tailwind-merge": "^3.2.0"
},
"devDependencies": {
@@ -37,6 +40,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
+ "@types/negotiator": "^0.6.3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/src/app/favicon.ico b/src/app/[lang]/favicon.ico
similarity index 100%
rename from src/app/favicon.ico
rename to src/app/[lang]/favicon.ico
diff --git a/src/app/globals.css b/src/app/[lang]/globals.css
similarity index 100%
rename from src/app/globals.css
rename to src/app/[lang]/globals.css
diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx
new file mode 100644
index 0000000..5c6dbab
--- /dev/null
+++ b/src/app/[lang]/layout.tsx
@@ -0,0 +1,31 @@
+import { i18n, type Locale } from "@/i18n/i18n-config";
+
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+
+const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
+
+export const metadata: Metadata = {
+ title: "E-Learning",
+};
+
+export async function generateStaticParams() {
+ return i18n.locales.map((locale) => ({ lang: locale }));
+}
+
+export default async function RootLayout({
+ children,
+ params,
+}: Readonly<{
+ children: React.ReactNode;
+ params: Promise<{ lang: Locale }>;
+}>) {
+ const { lang } = await params;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx
new file mode 100644
index 0000000..498a34f
--- /dev/null
+++ b/src/app/[lang]/page.tsx
@@ -0,0 +1,25 @@
+import { getDictionary } from "@/i18n/get-dictionary";
+import { Locale } from "@/i18n/i18n-config";
+import { Header } from "@/components/Header/Header";
+import { Button } from "@/components/Button/Default/Button";
+
+export default async function Home({ params }: { params: { lang: Locale } }) {
+ const { lang } = await params;
+
+ const dictionary = await getDictionary(lang);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
deleted file mode 100644
index b2d238e..0000000
--- a/src/app/layout.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { Metadata } from "next";
-import { Inter } from "next/font/google";
-import "./globals.css";
-
-const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
-
-export const metadata: Metadata = {
- title: "E-Learning",
-};
-
-export default function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
- return (
-
- {children}
-
- );
-}
diff --git a/src/app/page.tsx b/src/app/page.tsx
deleted file mode 100644
index 37bfb2c..0000000
--- a/src/app/page.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Button } from "@/components/Button/Default/Button";
-import { ArrowLeft } from "@phosphor-icons/react";
-
-export default function Home() {
- return (
-
- }
- iconPosition="start"
- onlyIcon
- />
-
- );
-}
diff --git a/src/components/Header/Header.stories.tsx b/src/components/Header/Header.stories.tsx
new file mode 100644
index 0000000..127bb20
--- /dev/null
+++ b/src/components/Header/Header.stories.tsx
@@ -0,0 +1,26 @@
+import { Meta, StoryObj } from "@storybook/react";
+import { Header } from "./Header";
+import { getDictionary } from "@/i18n/get-dictionary";
+import { i18n } from "@/i18n/i18n-config"; // Importa as configurações de idiomas
+
+const meta: Meta = {
+ title: "Components/Header",
+ component: Header,
+ argTypes: {
+ lang: {
+ control: "select",
+ options: i18n.locales,
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ lang: i18n.defaultLocale,
+ dictionary: (await getDictionary(i18n.defaultLocale)).header,
+ },
+};
diff --git a/src/components/Header/Header.test.tsx b/src/components/Header/Header.test.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
new file mode 100644
index 0000000..4bed4ce
--- /dev/null
+++ b/src/components/Header/Header.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import Link from "next/link";
+import { Typography } from "@/components/Typography/Typography";
+import { cn } from "@/lib/utils";
+import { type getDictionary } from "@/i18n/get-dictionary";
+import LocaleSwitcher from "@/components/Header/LocaleSwitcher/LocaleSwitcher";
+
+export const Header = ({
+ dictionary,
+ lang,
+}: {
+ dictionary: Awaited>["header"];
+ lang: string;
+}) => {
+ const pathname = usePathname();
+
+ const navItems = [
+ { redirect: `/${lang}`, name: dictionary.nav.home },
+ { redirect: `/${lang}/courses`, name: dictionary.nav.courses },
+ { redirect: `/${lang}/about`, name: dictionary.nav.about },
+ { redirect: `/${lang}/contact`, name: dictionary.nav.contact },
+ { redirect: `/${lang}/instructor`, name: dictionary.nav.instructor },
+ ];
+
+ const renderNavItems = () => {
+ return navItems.map((item) => {
+ const isActive = pathname.startsWith(item.redirect);
+
+ return (
+
+ {item.name}
+
+ );
+ });
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/Header/LocaleSwitcher/LocaleSwitcher.tsx b/src/components/Header/LocaleSwitcher/LocaleSwitcher.tsx
new file mode 100644
index 0000000..2b12f20
--- /dev/null
+++ b/src/components/Header/LocaleSwitcher/LocaleSwitcher.tsx
@@ -0,0 +1,42 @@
+import { Select } from "@/components/Select/Select";
+import { i18n, type Locale } from "@/i18n/i18n-config";
+import { usePathname, useRouter } from "next/navigation";
+import translations from "@/i18n/locales/index";
+
+export default function LocaleSwitcher() {
+ const pathname = usePathname();
+ const router = useRouter();
+
+ const redirectedPathname = (locale: Locale) => {
+ if (!pathname) return "/";
+ const segments = pathname.split("/");
+ segments[1] = locale;
+ return segments.join("/");
+ };
+
+ const handleLocaleChange = (locale: string) => {
+ const newPath = redirectedPathname(locale as Locale);
+ router.push(newPath);
+ };
+
+ const activeLocale = pathname?.split("/")[1] as Locale;
+
+ const activeLabel = translations[activeLocale]?.header.language;
+
+ const localeOptions = i18n.locales.map((locale) => ({
+ value: locale,
+ label: translations[locale]?.header.language || locale,
+ }));
+
+ console.log(activeLabel);
+
+ return (
+
+ );
+}
diff --git a/src/i18n/get-dictionary.ts b/src/i18n/get-dictionary.ts
new file mode 100644
index 0000000..0212c87
--- /dev/null
+++ b/src/i18n/get-dictionary.ts
@@ -0,0 +1,10 @@
+import "server-only";
+import type { Locale } from "./i18n-config";
+
+const dictionaries = {
+ en: () => import("@/i18n/locales/en.json").then((module) => module.default),
+ pt: () => import("@/i18n/locales/pt.json").then((module) => module.default),
+};
+
+export const getDictionary = async (locale: Locale) =>
+ dictionaries[locale]?.() ?? dictionaries.en();
diff --git a/src/i18n/i18n-config.ts b/src/i18n/i18n-config.ts
new file mode 100644
index 0000000..3c7918b
--- /dev/null
+++ b/src/i18n/i18n-config.ts
@@ -0,0 +1,7 @@
+export const i18n = {
+ defaultLocale: "en",
+ locales: ["en", "pt"],
+ localePrefix: 'never'
+} as const;
+
+export type Locale = (typeof i18n)["locales"][number];
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
new file mode 100644
index 0000000..1764525
--- /dev/null
+++ b/src/i18n/locales/en.json
@@ -0,0 +1,12 @@
+{
+ "header": {
+ "language": "English",
+ "nav": {
+ "home": "Home",
+ "courses": "Courses",
+ "about": "About",
+ "contact": "Contact",
+ "instructor": "Become an instructor"
+ }
+ }
+}
diff --git a/src/i18n/locales/index.ts b/src/i18n/locales/index.ts
new file mode 100644
index 0000000..258d7ee
--- /dev/null
+++ b/src/i18n/locales/index.ts
@@ -0,0 +1,9 @@
+import en from "./en.json";
+import pt from "./pt.json";
+
+const translations = {
+ en,
+ pt,
+};
+
+export default translations;
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
new file mode 100644
index 0000000..ac9639f
--- /dev/null
+++ b/src/i18n/locales/pt.json
@@ -0,0 +1,12 @@
+{
+ "header": {
+ "language": "Português",
+ "nav": {
+ "home": "Início",
+ "courses": "Cursos",
+ "about": "Sobre",
+ "contact": "Contato",
+ "instructor": "Torne-se um instrutor"
+ }
+ }
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index daab5de..a5ef193 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -3,4 +3,4 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
-}
\ No newline at end of file
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..73684ef
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,54 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+import { i18n } from "@/i18n/i18n-config";
+
+import { match as matchLocale } from "@formatjs/intl-localematcher";
+import Negotiator from "negotiator";
+
+function getLocale(request: NextRequest): string | undefined {
+ // Negotiator expects plain object so we need to transform headers
+ const negotiatorHeaders: Record = {};
+ request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
+
+ // @ts-expect-error locales are readonly
+ const locales: string[] = i18n.locales;
+
+ // Use negotiator and intl-localematcher to get best locale
+ const languages = new Negotiator({ headers: negotiatorHeaders }).languages(
+ locales,
+ );
+
+ const locale = matchLocale(languages, locales, i18n.defaultLocale);
+
+ return locale;
+}
+
+export function middleware(request: NextRequest) {
+ const pathname = request.nextUrl.pathname;
+
+ // Check if there is any supported locale in the pathname
+ const pathnameIsMissingLocale = i18n.locales.every(
+ (locale) =>
+ !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
+ );
+
+ // Redirect if there is no locale
+ if (pathnameIsMissingLocale) {
+ const locale = getLocale(request);
+
+ // e.g. incoming request is /products
+ // The new URL is now /en-US/products
+ return NextResponse.redirect(
+ new URL(
+ `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
+ request.url,
+ ),
+ );
+ }
+}
+
+export const config = {
+ // Matcher ignoring `/_next/` and `/api/`
+ matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
+};