Add country localization

This commit is contained in:
Codex
2026-05-04 23:33:29 +01:00
parent 102967242e
commit d5c5f4fc17
13 changed files with 1297 additions and 123 deletions

View File

@@ -0,0 +1,80 @@
import type {Metadata} from "next";
import {NextIntlClientProvider, hasLocale} from "next-intl";
import {notFound} from "next/navigation";
import "../globals.css";
import {routing, type Locale} from "@/i18n/routing";
type LocaleLayoutProps = Readonly<{
children: React.ReactNode;
params: Promise<{locale: string}>;
}>;
type LocaleMessages = {
Meta: {
title: string;
description: string;
};
};
const alternates = Object.fromEntries([
...routing.locales.map((locale) => [locale, `/${locale}`]),
["x-default", `/${routing.defaultLocale}`],
]);
async function getLocaleMessages(locale: Locale): Promise<LocaleMessages> {
return (await import(`../../../messages/${locale}.json`)).default;
}
export function generateStaticParams() {
return routing.locales.map((locale) => ({locale}));
}
export async function generateMetadata({
params,
}: Pick<LocaleLayoutProps, "params">): Promise<Metadata> {
const {locale} = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
const messages = await getLocaleMessages(locale);
return {
metadataBase: new URL("https://kairas.io"),
title: messages.Meta.title,
description: messages.Meta.description,
alternates: {
canonical: `/${locale}`,
languages: alternates,
},
openGraph: {
title: messages.Meta.title,
description: messages.Meta.description,
url: `/${locale}`,
siteName: "Kairas",
locale: locale.replace("-", "_"),
type: "website",
},
};
}
export default async function LocaleLayout({
children,
params,
}: LocaleLayoutProps) {
const {locale} = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
return (
<html lang={locale.split("-")[0]}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -1,41 +1,37 @@
"use client";
import { useEffect, useRef } from "react";
import { animate, createTimeline, stagger } from "animejs";
import {useEffect, useRef} from "react";
import {animate, createTimeline, stagger} from "animejs";
import {useLocale, useTranslations} from "next-intl";
import {Link} from "@/i18n/navigation";
import {routing, type Locale} from "@/i18n/routing";
const services = [
{
number: "01",
title: "Brand-led websites",
text: "Identity, UX, interface design, and front-end systems for companies that need their site to carry the weight of the brand.",
},
{
number: "02",
title: "Editorial product pages",
text: "Launch pages, case studies, and content structures that make complex offers feel considered, useful, and easy to move through.",
},
{
number: "03",
title: "Design systems",
text: "Reusable components, visual rules, and interaction patterns that help teams ship new pages without losing quality.",
},
];
type Service = {
number: string;
title: string;
text: string;
};
const work = [
["Atelier North", "Architecture portfolio", "2026"],
["Vellum Labs", "SaaS website", "2025"],
["Morrow House", "Hospitality booking", "2025"],
["Plainform", "Brand system", "2024"],
];
type WorkItem = [string, string, string];
type NoteItem = [string, string, string];
const notes = [
["2026.04.18", "Essay", "Designing quieter conversion paths for premium service brands"],
["2026.03.02", "Studio", "Kairas opens a focused website sprint for early-stage teams"],
["2026.01.14", "Guide", "What belongs above the fold when the work is the proof"],
];
type ProcessStep = {
title: string;
text: string;
};
export default function Home() {
const rootRef = useRef<HTMLElement>(null);
const locale = useLocale() as Locale;
const t = useTranslations("Home");
const heroWords = t.raw("hero.words") as string[];
const tickerItems = t.raw("hero.ticker") as string[];
const marqueeItems = t.raw("marquee") as string[];
const services = t.raw("services") as Service[];
const work = t.raw("work.items") as WorkItem[];
const processSteps = t.raw("process.steps") as ProcessStep[];
const notes = t.raw("notes.items") as NoteItem[];
useEffect(() => {
const root = rootRef.current;
@@ -179,7 +175,7 @@ export default function Home() {
observer.unobserve(target);
});
},
{ threshold: 0.18 },
{threshold: 0.18},
);
root.querySelectorAll("[data-reveal]").forEach((element) => {
@@ -207,7 +203,7 @@ export default function Home() {
observer.disconnect();
animations.forEach((animation) => animation.revert());
};
}, []);
}, [locale]);
return (
<main
@@ -221,40 +217,92 @@ export default function Home() {
<div className="intro-mask bg-[var(--navy)]" />
</div>
<header className="fixed left-0 right-0 top-0 z-20 border-b border-[var(--line)] bg-[rgba(238,228,212,0.86)] backdrop-blur">
<nav className="mx-auto flex max-w-[1500px] items-center justify-between px-5 py-4 text-[13px] uppercase leading-none tracking-[0.12em] sm:px-8 lg:px-12">
<a href="#" className="nav-item text-lg font-semibold tracking-[0.18em]">
<nav className="mx-auto flex max-w-[1500px] items-center justify-between gap-4 px-5 py-4 text-[13px] uppercase leading-none tracking-[0.12em] sm:px-8 lg:px-12">
<Link href="/" className="nav-item text-lg font-semibold tracking-[0.18em]">
Kairas
</a>
</Link>
<div className="hidden items-center gap-8 md:flex">
<a className="nav-item" href="#studio">Studio</a>
<a className="nav-item" href="#services">Services</a>
<a className="nav-item" href="#work">Work</a>
<a className="nav-item" href="#notes">Notes</a>
<a className="nav-item" href="#studio">
{t("nav.studio")}
</a>
<a className="nav-item" href="#services">
{t("nav.services")}
</a>
<a className="nav-item" href="#work">
{t("nav.work")}
</a>
<a className="nav-item" href="#notes">
{t("nav.notes")}
</a>
</div>
<div className="nav-item hidden items-center gap-2 lg:flex">
<span className="sr-only">{t("localeSwitcher.label")}</span>
{routing.locales.map((option) => (
<Link
key={option}
href="/"
locale={option}
aria-current={option === locale ? "page" : undefined}
style={option === locale ? {color: "var(--paper)"} : undefined}
className={`border px-3 py-2 transition ${
option === locale
? "border-[var(--navy)] bg-[var(--navy)] text-[var(--paper)]"
: "border-[var(--line)] hover:border-[var(--navy)]"
}`}
>
{option.split("-")[1].toUpperCase()}
</Link>
))}
</div>
<a
href="mailto:hello@kairas.io"
className="nav-item border border-[var(--navy)] px-4 py-3 transition hover:bg-[var(--navy)] hover:text-[var(--paper)]"
>
hello@kairas.io
{t("nav.contact")}
</a>
</nav>
<div className="flex items-center justify-center gap-2 border-t border-[var(--line)] px-5 py-2 text-[11px] uppercase leading-none tracking-[0.12em] lg:hidden">
<span className="text-[var(--ink-muted)]">
{t("localeSwitcher.label")}
</span>
{routing.locales.map((option) => (
<Link
key={option}
href="/"
locale={option}
aria-current={option === locale ? "page" : undefined}
style={option === locale ? {color: "var(--paper)"} : undefined}
className={`border px-3 py-2 transition ${
option === locale
? "border-[var(--navy)] bg-[var(--navy)] text-[var(--paper)]"
: "border-[var(--line)] hover:border-[var(--navy)]"
}`}
>
{option.split("-")[1].toUpperCase()}
</Link>
))}
</div>
</header>
<section className="relative z-10 mx-auto grid min-h-screen max-w-[1500px] content-end gap-12 px-5 pb-12 pt-28 sm:px-8 md:pt-36 lg:grid-cols-[1.05fr_0.95fr] lg:px-12">
<section className="relative z-10 mx-auto grid min-h-screen max-w-[1500px] content-end gap-12 px-5 pb-12 pt-40 sm:px-8 md:pt-36 lg:grid-cols-[1.05fr_0.95fr] lg:px-12">
<div className="flex flex-col justify-end gap-8">
<div className="hero-kicker flex max-w-sm flex-wrap items-baseline gap-x-3 gap-y-1 text-sm leading-6 text-[var(--navy-soft)]">
<span className="uppercase tracking-[0.14em]">Web design firm</span>
<span className="uppercase tracking-[0.14em]">
{t("hero.kicker")}
</span>
<a
href="https://kairas.io"
className="font-semibold italic tracking-normal"
>
kairas.io
{t("hero.siteUrl")}
</a>
</div>
<h1 className="max-w-5xl overflow-hidden text-7xl font-semibold leading-[0.86] tracking-normal sm:text-8xl md:text-9xl lg:text-[10rem] xl:text-[12rem]">
<span className="hero-word motion-hidden">Websites</span>
<span className="hero-word motion-hidden">with quiet</span>
<span className="hero-word motion-hidden">force.</span>
{heroWords.map((word) => (
<span key={word} className="hero-word motion-hidden">
{word}
</span>
))}
</h1>
</div>
@@ -262,18 +310,20 @@ export default function Home() {
<div className="hero-card motion-hidden relative min-h-[420px] overflow-hidden border border-[var(--line)] bg-[var(--paper)] p-4 shadow-[0_18px_60px_rgba(8,27,51,0.08)]">
<div className="grid h-full min-h-[388px] grid-rows-[auto_1fr_auto] border border-[var(--line)] bg-[var(--beige)]">
<div className="flex items-center justify-between border-b border-[var(--line)] px-4 py-3 text-xs uppercase tracking-[0.16em]">
<span>Selected interface</span>
<span>{t("hero.interfaceLabel")}</span>
<span>01 / 04</span>
</div>
<div className="grid grid-cols-[1fr_0.72fr] gap-3 p-4">
<div className="float-piece flex flex-col justify-between bg-[var(--navy)] p-5 text-[var(--paper)]">
<span className="text-xs uppercase tracking-[0.18em] text-[#d9ccb8]">
Brand system
{t("hero.brandLabel")}
</span>
<div>
<p className="spin-mark inline-block text-5xl font-semibold leading-none">KA</p>
<p className="spin-mark inline-block text-5xl font-semibold leading-none">
KA
</p>
<p className="mt-3 max-w-[14rem] text-sm leading-6 text-[#d9ccb8]">
Layouts tuned for calm reading and decisive action.
{t("hero.interfaceCopy")}
</p>
</div>
</div>
@@ -290,33 +340,27 @@ export default function Home() {
</div>
</div>
<div className="flex items-center justify-between border-t border-[var(--line)] px-4 py-3 text-xs uppercase tracking-[0.16em]">
<span className="ticker-item">Strategy</span>
<span className="ticker-item">Design</span>
<span className="ticker-item">Build</span>
{tickerItems.map((item) => (
<span key={item} className="ticker-item">
{item}
</span>
))}
</div>
</div>
</div>
<p className="hero-copy motion-hidden max-w-xl text-xl leading-9 text-[var(--navy-soft)]">
Kairas designs and builds refined websites for founders, studios,
and service brands that care about taste, clarity, and commercial
performance.
{t("hero.copy")}
</p>
</div>
</section>
<div className="marquee relative z-10 border-y border-[var(--line)] bg-[var(--navy)] py-5 text-[var(--paper)]">
<div className="marquee-track text-4xl italic leading-none sm:text-6xl">
{Array.from({ length: 2 }).map((_, group) => (
{Array.from({length: 2}).map((_, group) => (
<div key={group} className="flex shrink-0 items-center gap-8 pr-8">
<span>Strategy</span>
<span>/</span>
<span>Design</span>
<span>/</span>
<span>Development</span>
<span>/</span>
<span>Systems</span>
<span>/</span>
<span>Launch</span>
{marqueeItems.map((item) => (
<span key={`${group}-${item}`}>{item}</span>
))}
<span>/</span>
</div>
))}
@@ -329,24 +373,29 @@ export default function Home() {
className="motion-hidden relative z-10 border-y border-[var(--line)] bg-[var(--paper)]"
>
<div className="mx-auto grid max-w-[1500px] gap-10 px-5 py-20 sm:px-8 lg:grid-cols-[0.7fr_1.3fr] lg:px-12 lg:py-28">
<p className="text-sm uppercase tracking-[0.16em]">About us</p>
<p className="text-sm uppercase tracking-[0.16em]">
{t("studio.eyebrow")}
</p>
<div>
<h2 className="max-w-4xl text-4xl font-semibold leading-tight sm:text-6xl">
We shape digital homes for brands that need to feel exact,
intentional, and easy to trust.
{t("studio.title")}
</h2>
<p className="mt-8 max-w-2xl text-lg leading-8 text-[var(--ink-muted)]">
Our work sits between design studio and front-end craft. We use
strong typography, generous pacing, and disciplined systems to
make every page feel composed without becoming static.
{t("studio.copy")}
</p>
</div>
</div>
</section>
<section id="services" data-reveal className="motion-hidden relative z-10 mx-auto max-w-[1500px] px-5 py-20 sm:px-8 lg:px-12 lg:py-28">
<section
id="services"
data-reveal
className="motion-hidden relative z-10 mx-auto max-w-[1500px] px-5 py-20 sm:px-8 lg:px-12 lg:py-28"
>
<div className="mb-12 flex items-end justify-between border-b border-[var(--line)] pb-6">
<h2 className="text-5xl font-semibold sm:text-7xl">Services</h2>
<h2 className="text-5xl font-semibold sm:text-7xl">
{t("servicesTitle")}
</h2>
<span className="text-sm uppercase tracking-[0.16em]">03</span>
</div>
<div className="grid gap-0 border-t border-[var(--line)]">
@@ -355,7 +404,9 @@ export default function Home() {
key={service.title}
className="grid gap-6 border-b border-[var(--line)] py-9 md:grid-cols-[0.22fr_0.78fr] lg:grid-cols-[0.18fr_0.38fr_0.44fr]"
>
<span className="text-sm tracking-[0.16em]">{service.number}</span>
<span className="text-sm tracking-[0.16em]">
{service.number}
</span>
<h3 className="text-3xl font-semibold">{service.title}</h3>
<p className="max-w-2xl text-lg leading-8 text-[var(--ink-muted)]">
{service.text}
@@ -366,14 +417,18 @@ export default function Home() {
</div>
</section>
<section id="work" data-reveal className="motion-hidden relative z-10 bg-[var(--navy)] text-[var(--paper)]">
<section
id="work"
data-reveal
className="motion-hidden relative z-10 bg-[var(--navy)] text-[var(--paper)]"
>
<div className="mx-auto grid max-w-[1500px] gap-12 px-5 py-20 sm:px-8 lg:grid-cols-[0.42fr_0.58fr] lg:px-12 lg:py-28">
<div>
<p className="text-sm uppercase tracking-[0.16em] text-[#d9ccb8]">
Selected work
{t("work.eyebrow")}
</p>
<h2 className="mt-6 max-w-lg text-5xl font-semibold leading-none sm:text-7xl">
Places where the brand can breathe.
{t("work.title")}
</h2>
</div>
<div className="grid content-start border-t border-[rgba(247,241,231,0.24)]">
@@ -396,34 +451,40 @@ export default function Home() {
</div>
</section>
<section data-reveal className="motion-hidden relative z-10 mx-auto grid max-w-[1500px] gap-10 px-5 py-20 sm:px-8 lg:grid-cols-[0.35fr_0.65fr] lg:px-12 lg:py-28">
<h2 className="text-5xl font-semibold sm:text-7xl">Process</h2>
<section
data-reveal
className="motion-hidden relative z-10 mx-auto grid max-w-[1500px] gap-10 px-5 py-20 sm:px-8 lg:grid-cols-[0.35fr_0.65fr] lg:px-12 lg:py-28"
>
<h2 className="text-5xl font-semibold sm:text-7xl">
{t("process.title")}
</h2>
<div className="grid gap-8 md:grid-cols-3">
{["Read", "Compose", "Ship"].map((step, index) => (
<div key={step} className="border-t border-[var(--line)] pt-5">
{processSteps.map((step, index) => (
<div key={step.title} className="border-t border-[var(--line)] pt-5">
<span className="text-sm tracking-[0.16em]">
{String(index + 1).padStart(2, "0")}
</span>
<h3 className="mt-8 text-3xl font-semibold">{step}</h3>
<h3 className="mt-8 text-3xl font-semibold">{step.title}</h3>
<p className="mt-4 leading-7 text-[var(--ink-muted)]">
{index === 0 &&
"We clarify the offer, audience, proof, and moments where a visitor needs confidence."}
{index === 1 &&
"We design the system: type, pacing, components, motion notes, and content hierarchy."}
{index === 2 &&
"We build responsive pages with practical handoff, analytics, and room to grow."}
{step.text}
</p>
</div>
))}
</div>
</section>
<section id="notes" data-reveal className="motion-hidden relative z-10 border-y border-[var(--line)] bg-[var(--paper)]">
<section
id="notes"
data-reveal
className="motion-hidden relative z-10 border-y border-[var(--line)] bg-[var(--paper)]"
>
<div className="mx-auto grid max-w-[1500px] gap-10 px-5 py-20 sm:px-8 lg:grid-cols-[0.35fr_0.65fr] lg:px-12 lg:py-28">
<div>
<h2 className="text-5xl font-semibold sm:text-7xl">Notes</h2>
<h2 className="text-5xl font-semibold sm:text-7xl">
{t("notes.title")}
</h2>
<p className="mt-5 text-sm uppercase tracking-[0.16em] text-[var(--ink-muted)]">
Studio journal
{t("notes.eyebrow")}
</p>
</div>
<div className="border-t border-[var(--line)]">
@@ -450,16 +511,18 @@ export default function Home() {
className="motion-hidden relative z-10 mx-auto grid max-w-[1500px] gap-12 px-5 py-16 sm:px-8 lg:grid-cols-[1fr_auto] lg:px-12"
>
<div>
<p className="text-sm uppercase tracking-[0.16em]">Kairas</p>
<p className="text-sm uppercase tracking-[0.16em]">
{t("footer.brand")}
</p>
<h2 className="mt-5 max-w-4xl text-5xl font-semibold leading-none sm:text-8xl">
Build the site your brand has been waiting for.
{t("footer.headline")}
</h2>
</div>
<div className="grid content-between gap-10 text-sm uppercase tracking-[0.14em] lg:text-right">
<a href="https://kairas.io">kairas.io</a>
<a href="mailto:hello@kairas.io">hello@kairas.io</a>
<p>Strategy / Design / Development</p>
<p>© 2026 Kairas</p>
<a href="https://kairas.io">{t("footer.siteUrl")}</a>
<a href="mailto:hello@kairas.io">{t("footer.email")}</a>
<p>{t("footer.services")}</p>
<p>{t("footer.copyright")}</p>
</div>
</footer>
</main>

View File

@@ -1,21 +0,0 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
metadataBase: new URL("https://kairas.io"),
title: "Kairas | Web design studio",
description:
"Kairas is a web design firm crafting refined websites, brand systems, and digital products.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

6
src/i18n/navigation.ts Normal file
View File

@@ -0,0 +1,6 @@
import {createNavigation} from "next-intl/navigation";
import {routing} from "./routing";
export const {Link, redirect, usePathname, useRouter, getPathname} =
createNavigation(routing);

16
src/i18n/request.ts Normal file
View File

@@ -0,0 +1,16 @@
import {getRequestConfig} from "next-intl/server";
import {hasLocale} from "next-intl";
import {routing} from "./routing";
export default getRequestConfig(async ({requestLocale}) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

11
src/i18n/routing.ts Normal file
View File

@@ -0,0 +1,11 @@
import {defineRouting} from "next-intl/routing";
export const routing = defineRouting({
locales: ["en-ie", "en-gb", "en-us"],
defaultLocale: "en-ie",
localePrefix: "always",
localeDetection: true,
});
export type Locale = (typeof routing.locales)[number];