add multi language for the website

This commit is contained in:
Puechberty Arthur
2026-04-20 21:32:49 +02:00
parent e0f4e1b8fe
commit 2206c82379
26 changed files with 1409 additions and 136 deletions
@@ -1,4 +1,4 @@
import { DashboardClient } from "../../components/dashboard-client";
import { DashboardClient } from "../../../components/dashboard-client";
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
+90
View File
@@ -0,0 +1,90 @@
import type { Metadata } from "next";
import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google";
import { hasLocale, NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import LanguageSwitcher from "../../components/LanguageSwitcher";
import { routing, type AppLocale } from "../../i18n/routing";
import enMessages from "../../messages/en.json";
import frMessages from "../../messages/fr.json";
import "../globals.css";
const headingFont = Space_Grotesk({
subsets: ["latin"],
variable: "--font-heading",
weight: ["500", "700"],
});
const monoFont = IBM_Plex_Mono({
subsets: ["latin"],
variable: "--font-mono",
weight: ["400", "500"],
});
const metadataByLocale: Record<AppLocale, { title: string; description: string }> = {
en: enMessages.metadata,
fr: frMessages.metadata,
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale: candidateLocale } = await params;
const locale = hasLocale(routing.locales, candidateLocale)
? candidateLocale
: routing.defaultLocale;
const metadata = metadataByLocale[locale];
const languages = routing.locales.reduce<Record<string, string>>(
(accumulator, currentLocale) => {
accumulator[currentLocale] = `/${currentLocale}`;
return accumulator;
},
{},
);
return {
title: metadata.title,
description: metadata.description,
alternates: {
canonical: `/${locale}`,
languages,
},
};
}
export default async function LocaleLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: Promise<{ locale: string }>;
}>) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body className={`${headingFont.variable} ${monoFont.variable}`}>
<NextIntlClientProvider locale={locale} messages={messages}>
<div className="locale-switcher-shell">
<LanguageSwitcher />
</div>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { getT } from "../../../i18n/server";
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
export default async function LoginPage() {
const t = await getT();
return (
<main className="page-shell">
<section className="panel panel-login reveal-up">
<p className="eyebrow">{t("login.eyebrow")}</p>
<h1>{t("login.title")}</h1>
<p>{t("login.description")}</p>
<a className="button-primary" href={`${apiBaseUrl}/auth/discord/login`}>
{t("login.cta")}
</a>
</section>
</main>
);
}
@@ -1,7 +1,7 @@
import Hero from "../components/Hero";
import Features from "../components/Features";
import HowItWorks from "../components/HowItWorks";
import Footer from "../components/Footer";
import Features from "../../components/Features";
import Footer from "../../components/Footer";
import Hero from "../../components/Hero";
import HowItWorks from "../../components/HowItWorks";
export default function HomePage() {
return (
+48
View File
@@ -37,6 +37,43 @@ body {
linear-gradient(140deg, var(--sand-50), #f8f7f4 55%, #fff3e3);
}
.locale-switcher-shell {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 80;
}
.locale-switcher {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(23, 20, 16, 0.74);
padding: 0.2rem 0.55rem;
backdrop-filter: blur(8px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.2);
}
.locale-switcher select {
border: 0;
background: transparent;
color: #fff;
font-size: 0.84rem;
font-weight: 700;
font-family: var(--font-heading), sans-serif;
cursor: pointer;
}
.locale-switcher select:focus {
outline: none;
}
.locale-switcher select:disabled {
opacity: 0.7;
cursor: progress;
}
.page-shell {
min-height: 100vh;
display: grid;
@@ -283,3 +320,14 @@ input:focus {
padding: 2rem;
}
}
@media (max-width: 640px) {
.locale-switcher-shell {
top: 0.7rem;
right: 0.7rem;
}
.locale-switcher select {
font-size: 0.78rem;
}
}
-33
View File
@@ -1,33 +0,0 @@
import type { Metadata } from "next";
import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google";
import "./globals.css";
const headingFont = Space_Grotesk({
subsets: ["latin"],
variable: "--font-heading",
weight: ["500", "700"],
});
const monoFont = IBM_Plex_Mono({
subsets: ["latin"],
variable: "--font-mono",
weight: ["400", "500"],
});
export const metadata: Metadata = {
title: "Discord Bot SaaS",
description: "Multi-tenant Discord bot management platform",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body className={`${headingFont.variable} ${monoFont.variable}`}>{children}</body>
</html>
);
}
-19
View File
@@ -1,19 +0,0 @@
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:4000";
export default function LoginPage() {
return (
<main className="page-shell">
<section className="panel panel-login reveal-up">
<p className="eyebrow">Discord Bot SaaS Platform</p>
<h1>Connecte ton compte Discord</h1>
<p>
Authentifie-toi via OAuth2 pour accéder à ton espace multi-tenant et piloter les bots de ton
organisation.
</p>
<a className="button-primary" href={`${apiBaseUrl}/auth/discord/login`}>
Continuer avec Discord
</a>
</section>
</main>
);
}
+42 -10
View File
@@ -1,11 +1,13 @@
import FeatureCard from "./FeatureCard";
import { getT } from "../i18n/server";
export default async function Features() {
const t = await getT();
export default function Features() {
const items = [
{
title: "Multi-bot",
description:
"Gérez plusieurs bots indépendants depuis un tableau de bord unique — configurations et logs centralisés.",
title: t("features.items.autoCommands.title"),
description: t("features.items.autoCommands.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="text-white">
<path d="M4 7h16v10H4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -14,8 +16,8 @@ export default function Features() {
),
},
{
title: "Gestion simple",
description: "Démarrez, arrêtez, et configurez en quelques clics — interface claire et rapide.",
title: t("features.items.componentsV2.title"),
description: t("features.items.componentsV2.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3v18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -24,8 +26,38 @@ export default function Features() {
),
},
{
title: "Scalable",
description: "Scale automatique des instances pour absorber la charge et garder la disponibilité.",
title: t("features.items.autoHelp.title"),
description: t("features.items.autoHelp.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6 8v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
title: t("features.items.multiLanguage.title"),
description: t("features.items.multiLanguage.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 7h16v10H4z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 11h0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
title: t("features.items.dynamicVariables.title"),
description: t("features.items.dynamicVariables.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3v18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 8h14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
},
{
title: t("features.items.hosting.title"),
description: t("features.items.hosting.description"),
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -37,8 +69,8 @@ export default function Features() {
return (
<section>
<h2 className="text-2xl font-bold text-white">Fonctionnalités principales</h2>
<p className="mt-2 text-gray-400 max-w-2xl">Toutes les fonctionnalités essentielles pour piloter vos bots en production.</p>
<h2 className="text-2xl font-bold text-white">{t("features.title")}</h2>
<p className="mt-2 text-gray-400 max-w-2xl">{t("features.subtitle")}</p>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
{items.map((it, idx) => (
+8 -2
View File
@@ -1,8 +1,14 @@
export default function Footer() {
import { getT } from "../i18n/server";
export default async function Footer() {
const t = await getT();
return (
<footer className="mt-20 border-t border-white/6 bg-gradient-to-t from-black/0 to-black/30">
<div className="container mx-auto px-6 py-8 flex items-center justify-between">
<div className="text-sm text-gray-400">© {new Date().getFullYear()} Shadow</div>
<div className="text-sm text-gray-400">
Copyright {new Date().getFullYear()} Shadow - {t("footer.tagline")}
</div>
<div className="text-sm text-gray-400">
<a href="https://github.com/" target="_blank" rel="noreferrer" className="hover:text-white transition">GitHub</a>
</div>
+23 -16
View File
@@ -1,11 +1,15 @@
import Link from "next/link";
import { Link } from "../i18n/navigation";
import { getT } from "../i18n/server";
import Logo from "./Logo";
export default function Hero() {
export default async function Hero() {
const t = await getT();
const bots = [
{ name: "Moderation", status: "online" },
{ name: "Welcome", status: "stopped" },
{ name: "Analytics", status: "online" },
{ name: t("hero.bots.moderation"), status: "online" as const },
{ name: t("hero.bots.welcome"), status: "stopped" as const },
{ name: t("hero.bots.analytics"), status: "online" as const },
];
return (
@@ -13,28 +17,29 @@ export default function Hero() {
<header className="flex items-center justify-between">
<Logo />
<nav className="flex items-center gap-4">
<Link href="/login" className="text-sm text-gray-400 hover:text-white transition">Se connecter</Link>
<Link href="/login" className="text-sm text-gray-400 hover:text-white transition">
{t("hero.navLogin")}
</Link>
</nav>
</header>
<div className="mt-12 lg:flex lg:items-center lg:justify-between">
<div className="lg:w-7/12">
<h1 className="text-4xl sm:text-5xl font-extrabold leading-tight tracking-tight text-white">
Shadow Gérez vos bots Discord simplement
{t("hero.title")}
</h1>
<p className="mt-4 text-lg text-gray-300 max-w-2xl">
Centralisez, déployez et contrôlez tous vos bots depuis un tableau de bord moderne. Démarrez,
stoppez et scalez en temps réel, en toute simplicité.
{t("hero.description")}
</p>
<div className="mt-8 flex items-center gap-4">
<Link href="/dashboard" className="inline-flex items-center gap-3 px-5 py-3 bg-white text-black rounded-md shadow hover:scale-[1.02] transition transform">
Accéder au dashboard
<Link href="/login" className="inline-flex items-center gap-3 px-5 py-3 bg-white text-black rounded-md shadow hover:scale-[1.02] transition transform">
{t("hero.ctaStart")}
</Link>
<Link href="/login" className="inline-flex items-center gap-2 px-4 py-3 border border-gray-700 text-gray-200 rounded-md hover:bg-gray-800 transition">
Se connecter
{t("hero.ctaLogin")}
</Link>
</div>
</div>
@@ -46,20 +51,22 @@ export default function Hero() {
<div key={i} className="flex items-center justify-between bg-gray-900/40 p-3 rounded-lg">
<div>
<div className="text-sm font-medium text-white">{b.name}</div>
<div className="text-xs text-gray-400">Instances: 1 · CPU 35%</div>
<div className="text-xs text-gray-400">{t("hero.card.instanceMetrics")}</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-2 py-1 text-xs rounded-full ${b.status === "online" ? "bg-green-500/20 text-green-300" : "bg-gray-700 text-gray-300"}`}>
{b.status === "online" ? "Online" : "Stopped"}
{b.status === "online" ? t("hero.status.online") : t("hero.status.stopped")}
</span>
<button className="px-3 py-1 bg-white/10 text-sm rounded-md hover:bg-white/20 transition">Manage</button>
<button className="px-3 py-1 bg-white/10 text-sm rounded-md hover:bg-white/20 transition">
{t("hero.card.manage")}
</button>
</div>
</div>
))}
</div>
<div className="mt-4 text-xs text-gray-400">Aperçu en temps réel du statut de vos bots</div>
<div className="mt-4 text-xs text-gray-400">{t("hero.card.preview")}</div>
</div>
</div>
</div>
+13 -9
View File
@@ -1,23 +1,27 @@
export default function HowItWorks() {
import { getT } from "../i18n/server";
export default async function HowItWorks() {
const t = await getT();
const steps = [
{
title: "Ajouter un bot",
desc: "Connectez votre application Discord via OAuth et enregistrez-la dans Shadow.",
title: t("howItWorks.steps.addBot.title"),
desc: t("howItWorks.steps.addBot.description"),
},
{
title: "Gérer depuis le dashboard",
desc: "Accédez aux commandes, aux logs et aux paramètres depuis une interface centralisée.",
title: t("howItWorks.steps.dashboard.title"),
desc: t("howItWorks.steps.dashboard.description"),
},
{
title: "Contrôler en temps réel",
desc: "Démarrez, arrêtez et scalez vos bots instantanément selon la demande.",
title: t("howItWorks.steps.realtime.title"),
desc: t("howItWorks.steps.realtime.description"),
},
];
return (
<section>
<h2 className="text-2xl font-bold text-white">Comment ça marche</h2>
<p className="mt-2 text-gray-400 max-w-2xl">Trois étapes simples pour prendre le contrôle de vos bots.</p>
<h2 className="text-2xl font-bold text-white">{t("howItWorks.title")}</h2>
<p className="mt-2 text-gray-400 max-w-2xl">{t("howItWorks.subtitle")}</p>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
{steps.map((s, i) => (
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useLocale } from "next-intl";
import { useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useT } from "../i18n/client";
import { usePathname, useRouter } from "../i18n/navigation";
import { routing, type AppLocale } from "../i18n/routing";
export default function LanguageSwitcher() {
const t = useT();
const locale = useLocale() as AppLocale;
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const localeLabels: Record<AppLocale, string> = {
en: t("localeSwitcher.locales.en"),
fr: t("localeSwitcher.locales.fr"),
};
const handleLocaleChange = (nextLocale: AppLocale) => {
const query = Object.fromEntries(searchParams.entries());
startTransition(() => {
router.replace({ pathname, query }, { locale: nextLocale });
});
};
return (
<label className="locale-switcher">
<span className="sr-only">{t("localeSwitcher.label")}</span>
<select
aria-label={t("localeSwitcher.label")}
disabled={isPending}
onChange={(event) => handleLocaleChange(event.target.value as AppLocale)}
value={locale}
>
{routing.locales.map((availableLocale) => (
<option key={availableLocale} value={availableLocale}>
{localeLabels[availableLocale]}
</option>
))}
</select>
</label>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import Link from "next/link";
import { Link } from "../i18n/navigation";
export default function Logo({ className = "" }: { className?: string }) {
return (
+48 -37
View File
@@ -1,6 +1,7 @@
"use client";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useT } from "../i18n/client";
type User = {
id: string;
@@ -27,15 +28,25 @@ type DashboardClientProps = {
apiBaseUrl: string;
};
const statusLabel: Record<BotStatus, string> = {
stopped: "Arrete",
starting: "Demarrage",
running: "En ligne",
stopping: "Arret",
error: "Erreur",
};
type BotAction = "start" | "stop" | "restart";
export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
const t = useT();
const statusLabel: Record<BotStatus, string> = {
stopped: t("dashboard.status.stopped"),
starting: t("dashboard.status.starting"),
running: t("dashboard.status.running"),
stopping: t("dashboard.status.stopping"),
error: t("dashboard.status.error"),
};
const actionLabel: Record<BotAction, string> = {
start: t("dashboard.actions.start"),
stop: t("dashboard.actions.stop"),
restart: t("dashboard.actions.restart"),
};
const [user, setUser] = useState<User | null>(null);
const [bots, setBots] = useState<Bot[]>([]);
const [token, setToken] = useState("");
@@ -61,7 +72,7 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
}
if (!meResponse.ok) {
throw new Error("Impossible de recuperer la session utilisateur");
throw new Error(t("dashboard.errors.fetchSession"));
}
const meJson = await meResponse.json();
@@ -72,18 +83,18 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
});
if (!botsResponse.ok) {
throw new Error("Impossible de recuperer la liste des bots");
throw new Error(t("dashboard.errors.fetchBots"));
}
const botsJson = await botsResponse.json();
setBots((botsJson.bots ?? []) as Bot[]);
} catch (cause) {
const message = cause instanceof Error ? cause.message : "Erreur inattendue";
const message = cause instanceof Error ? cause.message : t("dashboard.errors.unexpected");
setError(message);
} finally {
setLoading(false);
}
}, [apiBaseUrl]);
}, [apiBaseUrl, t]);
useEffect(() => {
void refreshData();
@@ -119,21 +130,21 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
if (!response.ok) {
const errorJson = await response.json().catch(() => ({}));
throw new Error(errorJson.error ?? "Ajout du bot impossible");
throw new Error(errorJson.error ?? t("dashboard.errors.addBot"));
}
setToken("");
setDisplayName("");
await refreshData();
} catch (cause) {
const message = cause instanceof Error ? cause.message : "Erreur inattendue";
const message = cause instanceof Error ? cause.message : t("dashboard.errors.unexpected");
setError(message);
} finally {
setSubmitting(false);
}
};
const triggerAction = async (botId: string, action: "start" | "stop" | "restart") => {
const triggerAction = async (botId: string, action: BotAction) => {
setError(null);
try {
@@ -144,28 +155,28 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
if (!response.ok) {
const errorJson = await response.json().catch(() => ({}));
throw new Error(errorJson.error ?? `Action ${action} impossible`);
throw new Error(errorJson.error ?? t("dashboard.errors.actionFailed", { action: actionLabel[action] }));
}
await refreshData();
} catch (cause) {
const message = cause instanceof Error ? cause.message : "Erreur inattendue";
const message = cause instanceof Error ? cause.message : t("dashboard.errors.unexpected");
setError(message);
}
};
if (loading) {
return <p className="muted">Chargement du dashboard...</p>;
return <p className="muted">{t("dashboard.loading")}</p>;
}
if (!user) {
return (
<div className="stack">
<p className="eyebrow">Session requise</p>
<h1>Connexion necessaire</h1>
<p>Ton dashboard est protege. Connecte-toi via Discord pour administrer tes bots.</p>
<p className="eyebrow">{t("dashboard.sessionRequired")}</p>
<h1>{t("dashboard.loginRequired")}</h1>
<p>{t("dashboard.loginDescription")}</p>
<a className="button-primary" href={`${apiBaseUrl}/auth/discord/login`}>
Se connecter
{t("dashboard.loginCta")}
</a>
</div>
);
@@ -175,12 +186,12 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
<div className="stack">
<header className="dashboard-header">
<div>
<p className="eyebrow">Tenant {user.tenantId}</p>
<p className="eyebrow">{t("dashboard.tenant", { tenantId: user.tenantId })}</p>
<h1>{user.username}</h1>
<p className="muted">Role: {user.role}</p>
<p className="muted">{t("dashboard.role", { role: user.role })}</p>
</div>
<button className="button-ghost" onClick={handleLogout} type="button">
Se deconnecter
{t("dashboard.logout")}
</button>
</header>
@@ -188,11 +199,11 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
<section className="card-grid">
<article className="card add-bot-card">
<h2>Ajouter un bot</h2>
<p>Le token est verifie cote API puis chiffre avant stockage en base.</p>
<h2>{t("dashboard.addBot.title")}</h2>
<p>{t("dashboard.addBot.description")}</p>
<form className="stack-tight" onSubmit={handleAddBot}>
<label>
Token bot Discord
{t("dashboard.addBot.tokenLabel")}
<input
autoComplete="off"
name="token"
@@ -204,7 +215,7 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
/>
</label>
<label>
Nom d'affichage (optionnel)
{t("dashboard.addBot.displayNameLabel")}
<input
name="displayName"
onChange={(event) => setDisplayName(event.target.value)}
@@ -214,40 +225,40 @@ export function DashboardClient({ apiBaseUrl }: DashboardClientProps) {
/>
</label>
<button className="button-primary" disabled={submitting} type="submit">
{submitting ? "Validation en cours..." : "Ajouter le bot"}
{submitting ? t("dashboard.addBot.submitPending") : t("dashboard.addBot.submit")}
</button>
</form>
</article>
<article className="card bots-card">
<div className="row-between">
<h2>Mes bots</h2>
<h2>{t("dashboard.bots.title")}</h2>
<button className="button-ghost" onClick={() => void refreshData()} type="button">
Rafraichir
{t("dashboard.bots.refresh")}
</button>
</div>
{bots.length === 0 ? (
<p className="muted">Aucun bot pour ce tenant.</p>
<p className="muted">{t("dashboard.bots.empty")}</p>
) : (
<ul className="bot-list">
{bots.map((bot) => (
<li className="bot-item" key={bot.id}>
<div className="bot-main">
<p className="bot-name">{bot.displayName}</p>
<p className="muted mono">Discord ID: {bot.discordBotId}</p>
<p className="muted mono">{t("dashboard.bots.discordId", { discordBotId: bot.discordBotId })}</p>
<p className={`status status-${bot.status}`}>{statusLabel[bot.status]}</p>
{bot.lastError ? <p className="error-inline">Derniere erreur: {bot.lastError}</p> : null}
{bot.lastError ? <p className="error-inline">{t("dashboard.bots.lastError", { message: bot.lastError })}</p> : null}
</div>
<div className="actions">
<button onClick={() => void triggerAction(bot.id, "start")} type="button">
Start
{actionLabel.start}
</button>
<button onClick={() => void triggerAction(bot.id, "stop")} type="button">
Stop
{actionLabel.stop}
</button>
<button onClick={() => void triggerAction(bot.id, "restart")} type="button">
Restart
{actionLabel.restart}
</button>
</div>
</li>
+5
View File
@@ -0,0 +1,5 @@
import { useTranslations } from "next-intl";
export function useT() {
return useTranslations();
}
+6
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);
+12
View File
@@ -0,0 +1,12 @@
import "next-intl";
import enMessages from "../messages/en.json";
import { routing } from "./routing";
declare module "next-intl" {
interface AppConfig {
Locale: (typeof routing.locales)[number];
Messages: typeof enMessages;
}
}
+50
View File
@@ -0,0 +1,50 @@
import { hasLocale } from "next-intl";
import { getRequestConfig } from "next-intl/server";
import { routing, type AppLocale } from "./routing";
type Messages = Record<string, unknown>;
const messageLoaders: Record<AppLocale, () => Promise<Messages>> = {
en: async () => (await import("../messages/en.json")).default as Messages,
fr: async () => (await import("../messages/fr.json")).default as Messages,
};
const isObject = (value: unknown): value is Record<string, unknown> => {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
};
const mergeMessages = (base: Messages, override: Messages): Messages => {
const merged: Messages = { ...base };
for (const [key, value] of Object.entries(override)) {
const current = merged[key];
if (isObject(current) && isObject(value)) {
merged[key] = mergeMessages(current, value);
continue;
}
merged[key] = value;
}
return merged;
};
export default getRequestConfig(async ({ requestLocale }) => {
const requestedLocale = await requestLocale;
const locale = hasLocale(routing.locales, requestedLocale)
? requestedLocale
: routing.defaultLocale;
const defaultMessages = await messageLoaders[routing.defaultLocale]();
const localeMessages = await messageLoaders[locale]();
return {
locale,
messages:
locale === routing.defaultLocale
? defaultMessages
: mergeMessages(defaultMessages, localeMessages),
};
});
+9
View File
@@ -0,0 +1,9 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "fr"],
defaultLocale: "en",
localePrefix: "always",
});
export type AppLocale = (typeof routing.locales)[number];
+5
View File
@@ -0,0 +1,5 @@
import { getTranslations } from "next-intl/server";
export async function getT() {
return getTranslations();
}
+135
View File
@@ -0,0 +1,135 @@
{
"metadata": {
"title": "Shadow - Discord bot hosting (EUR 2/month per bot)",
"description": "Multi-tenant SaaS platform to host and manage Discord bots with auto commands, components v2, multi-language support and dynamic variables."
},
"localeSwitcher": {
"label": "Language",
"locales": {
"en": "English",
"fr": "Francais"
}
},
"hero": {
"navLogin": "Sign in",
"title": "Shadow - Host your Discord bots (from EUR 2/month per bot)",
"description": "Host and manage your bots with auto commands (prefix and slash), components v2, automatic help, multi-language support and dynamic variables.",
"ctaStart": "Get started - EUR 2/month",
"ctaLogin": "Sign in",
"bots": {
"moderation": "Moderation",
"welcome": "Welcome",
"analytics": "Analytics"
},
"status": {
"online": "Online",
"stopped": "Stopped"
},
"card": {
"instanceMetrics": "Instances: 1 | CPU 35%",
"manage": "Manage",
"preview": "Real-time preview of your bot status"
}
},
"features": {
"title": "Main features",
"subtitle": "Everything you need to run your bots in production.",
"items": {
"autoCommands": {
"title": "Automatic commands",
"description": "Prefix and slash commands are generated and synced automatically for each bot."
},
"componentsV2": {
"title": "Components v2",
"description": "Full support for components v2 and reliable rich interactions."
},
"autoHelp": {
"title": "Automatic help",
"description": "Help and command documentation generated automatically."
},
"multiLanguage": {
"title": "Multi-language",
"description": "Translate messages and command labels, then enable multiple locales quickly."
},
"dynamicVariables": {
"title": "Dynamic variables",
"description": "Dynamic templates with variables like bot_latency, guild_count and more."
},
"hosting": {
"title": "Hosting and scalability",
"description": "Simple plans at EUR 2/month per bot with monitoring and scalability included."
}
}
},
"howItWorks": {
"title": "How it works",
"subtitle": "Three simple steps to take control of your bots.",
"steps": {
"addBot": {
"title": "Add a bot",
"description": "Connect your Discord application with OAuth and register it in Shadow."
},
"dashboard": {
"title": "Manage from dashboard",
"description": "Access commands, logs and settings from one centralized interface."
},
"realtime": {
"title": "Control in real time",
"description": "Start, stop and scale your bots instantly based on demand."
}
}
},
"footer": {
"tagline": "Discord bot hosting - plans from EUR 2/month per bot"
},
"login": {
"eyebrow": "Shadow - Discord bot hosting",
"title": "Connect your Discord account",
"description": "Authenticate with OAuth2 to access your space and host your bots.",
"cta": "Continue with Discord"
},
"dashboard": {
"loading": "Loading dashboard...",
"sessionRequired": "Session required",
"loginRequired": "Sign in required",
"loginDescription": "Your dashboard is protected. Sign in with Discord to manage your bots.",
"loginCta": "Sign in",
"tenant": "Tenant {tenantId}",
"role": "Role: {role}",
"logout": "Sign out",
"addBot": {
"title": "Add a bot",
"description": "The token is validated by the API and encrypted before it is stored.",
"tokenLabel": "Discord bot token",
"displayNameLabel": "Display name (optional)",
"submit": "Add bot",
"submitPending": "Validating..."
},
"bots": {
"title": "My bots",
"refresh": "Refresh",
"empty": "No bot for this tenant.",
"discordId": "Discord ID: {discordBotId}",
"lastError": "Last error: {message}"
},
"status": {
"stopped": "Stopped",
"starting": "Starting",
"running": "Online",
"stopping": "Stopping",
"error": "Error"
},
"actions": {
"start": "Start",
"stop": "Stop",
"restart": "Restart"
},
"errors": {
"fetchSession": "Unable to fetch user session",
"fetchBots": "Unable to fetch bot list",
"unexpected": "Unexpected error",
"addBot": "Unable to add bot",
"actionFailed": "Action failed: {action}"
}
}
}
+135
View File
@@ -0,0 +1,135 @@
{
"metadata": {
"title": "Shadow - Hebergement de bots Discord (a partir de 2 EUR/mois par bot)",
"description": "Plateforme SaaS multi-tenant pour heberger et gerer vos bots Discord avec commandes automatiques, components v2, multi-langue et variables dynamiques."
},
"localeSwitcher": {
"label": "Langue",
"locales": {
"en": "English",
"fr": "Francais"
}
},
"hero": {
"navLogin": "Se connecter",
"title": "Shadow - Hebergez vos bots Discord (a partir de 2 EUR/mois par bot)",
"description": "Hebergez et gerez vos bots avec commandes automatiques (prefix et slash), components v2, aide automatique, support multi-langue et variables dynamiques.",
"ctaStart": "Commencer - 2 EUR/mois",
"ctaLogin": "Se connecter",
"bots": {
"moderation": "Moderation",
"welcome": "Bienvenue",
"analytics": "Analytics"
},
"status": {
"online": "En ligne",
"stopped": "Arrete"
},
"card": {
"instanceMetrics": "Instances: 1 | CPU 35%",
"manage": "Gerer",
"preview": "Apercu en temps reel du statut de vos bots"
}
},
"features": {
"title": "Fonctionnalites principales",
"subtitle": "Tout ce quil faut pour piloter vos bots en production.",
"items": {
"autoCommands": {
"title": "Commandes automatiques",
"description": "Les commandes prefix et slash sont generees et synchronisees automatiquement pour chaque bot."
},
"componentsV2": {
"title": "Components v2",
"description": "Support complet des components v2 et des interactions riches fiables."
},
"autoHelp": {
"title": "Aide automatique",
"description": "Aide et documentation des commandes generees automatiquement."
},
"multiLanguage": {
"title": "Multi-langue",
"description": "Traduisez les messages et les labels de commandes puis activez plusieurs langues facilement."
},
"dynamicVariables": {
"title": "Variables dynamiques",
"description": "Templates dynamiques avec des variables comme bot_latency, guild_count et plus."
},
"hosting": {
"title": "Hebergement et scalabilite",
"description": "Plans simples a 2 EUR/mois par bot avec monitoring et scalabilite inclus."
}
}
},
"howItWorks": {
"title": "Comment ca marche",
"subtitle": "Trois etapes simples pour prendre le controle de vos bots.",
"steps": {
"addBot": {
"title": "Ajouter un bot",
"description": "Connectez votre application Discord via OAuth et enregistrez-la dans Shadow."
},
"dashboard": {
"title": "Gerer depuis le dashboard",
"description": "Accedez aux commandes, aux logs et aux parametres depuis une interface centralisee."
},
"realtime": {
"title": "Controler en temps reel",
"description": "Demarrez, arretez et scalez vos bots instantanement selon la demande."
}
}
},
"footer": {
"tagline": "Hebergement de bots Discord - plans des 2 EUR/mois par bot"
},
"login": {
"eyebrow": "Shadow - Hebergement de bots Discord",
"title": "Connectez votre compte Discord",
"description": "Authentifiez-vous via OAuth2 pour acceder a votre espace et heberger vos bots.",
"cta": "Continuer avec Discord"
},
"dashboard": {
"loading": "Chargement du dashboard...",
"sessionRequired": "Session requise",
"loginRequired": "Connexion necessaire",
"loginDescription": "Votre dashboard est protege. Connectez-vous via Discord pour administrer vos bots.",
"loginCta": "Se connecter",
"tenant": "Tenant {tenantId}",
"role": "Role: {role}",
"logout": "Se deconnecter",
"addBot": {
"title": "Ajouter un bot",
"description": "Le token est verifie cote API puis chiffre avant stockage.",
"tokenLabel": "Token bot Discord",
"displayNameLabel": "Nom daffichage (optionnel)",
"submit": "Ajouter le bot",
"submitPending": "Validation en cours..."
},
"bots": {
"title": "Mes bots",
"refresh": "Rafraichir",
"empty": "Aucun bot pour ce tenant.",
"discordId": "Discord ID: {discordBotId}",
"lastError": "Derniere erreur: {message}"
},
"status": {
"stopped": "Arrete",
"starting": "Demarrage",
"running": "En ligne",
"stopping": "Arret",
"error": "Erreur"
},
"actions": {
"start": "Demarrer",
"stop": "Arreter",
"restart": "Redemarrer"
},
"errors": {
"fetchSession": "Impossible de recuperer la session utilisateur",
"fetchBots": "Impossible de recuperer la liste des bots",
"unexpected": "Erreur inattendue",
"addBot": "Ajout du bot impossible",
"actionFailed": "Action impossible: {action}"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};
+5 -1
View File
@@ -1,7 +1,11 @@
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: "standalone",
};
export default nextConfig;
export default withNextIntl(nextConfig);
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"next": "15.1.2",
"next-intl": "^4.9.1",
"react": "19.0.0",
"react-dom": "19.0.0"
},
+690 -3
View File
@@ -68,6 +68,7 @@
"version": "1.0.0",
"dependencies": {
"next": "15.1.2",
"next-intl": "^4.9.1",
"react": "19.0.0",
"react-dom": "19.0.0"
},
@@ -681,6 +682,36 @@
"node": ">=18"
}
},
"node_modules/@formatjs/fast-memoize": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.2.tgz",
"integrity": "sha512-vPnriihkfK0lzoQGaXq+qXH23VsYyansRTkTgo2aTG0k1NjLFyZimFVdfj4C9JkSE5dm7CEngcQ5TTc1yAyBfQ==",
"license": "MIT"
},
"node_modules/@formatjs/icu-messageformat-parser": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.4.tgz",
"integrity": "sha512-JVY39ROgLt+pIYngo6piyj4OVfZmXs/2FkC4wLS+ql1Eig/sGJKB7YwDO/5bkJFkfwaFAeIpgEiJc8hiYxNalw==",
"license": "MIT",
"dependencies": {
"@formatjs/icu-skeleton-parser": "2.1.4"
}
},
"node_modules/@formatjs/icu-skeleton-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.4.tgz",
"integrity": "sha512-8bSFZbrlvGX11ywMZxtgkPBt5Q8/etyts7j7j+GWpOVK1g43zwMIH3LZxk43HAtEP7L/jtZ+OZaMiFTOiBj9CA==",
"license": "MIT"
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.3.tgz",
"integrity": "sha512-pHUjWb9NuhnMs8+PxQdzBtZRFJHlGhrURGAbm6Ltwl82BFajeuiIR3jblSa7ia3r62rXe/0YtVpUG3xWr5bFCA==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "3.1.2"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
@@ -1586,6 +1617,313 @@
"node": ">= 8"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.3",
"is-glob": "^4.0.3",
"node-addon-api": "^7.0.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.6",
"@parcel/watcher-darwin-arm64": "2.5.6",
"@parcel/watcher-darwin-x64": "2.5.6",
"@parcel/watcher-freebsd-x64": "2.5.6",
"@parcel/watcher-linux-arm-glibc": "2.5.6",
"@parcel/watcher-linux-arm-musl": "2.5.6",
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
"@parcel/watcher-linux-arm64-musl": "2.5.6",
"@parcel/watcher-linux-x64-glibc": "2.5.6",
"@parcel/watcher-linux-x64-musl": "2.5.6",
"@parcel/watcher-win32-arm64": "2.5.6",
"@parcel/watcher-win32-ia32": "2.5.6",
"@parcel/watcher-win32-x64": "2.5.6"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -1641,6 +1979,204 @@
"npm": ">=7.0.0"
}
},
"node_modules/@schummar/icu-type-parser": {
"version": "1.21.5",
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
"license": "MIT"
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.30.tgz",
"integrity": "sha512-VvpP+vq08HmGYewMWvrdsxh9s2lthz/808zXm8Yu5kaqeR8Yia2b0eYXleHQ3VAjoStUDk6LzTheBW9KXYQdMA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.30.tgz",
"integrity": "sha512-WiJA0hiZI3nwQAO6mu5RqigtWGDtth4Hiq6rbZxAaQyhIcqKIg5IoMRc1Y071lrNJn29eEDMC86Rq58xgUxlDg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.30.tgz",
"integrity": "sha512-YANuFUo48kIT6plJgCD0keae9HFXfjxsbvsgevqc0hr/07X/p7sAWTFOGYEc2SXcASaK7UvuQqzlbW8pr7R79g==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.30.tgz",
"integrity": "sha512-VndG8jaR4ugY6u+iVOT0Q+d2fZd7sLgjPgN8W/Le+3EbZKl+cRfFxV7Eoz4gfLqhmneZPdcIzf9T3LkgkmqNLg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.30.tgz",
"integrity": "sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.30.tgz",
"integrity": "sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.30.tgz",
"integrity": "sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==",
"cpu": [
"s390x"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.30.tgz",
"integrity": "sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.30.tgz",
"integrity": "sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.30.tgz",
"integrity": "sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.30.tgz",
"integrity": "sha512-30VdLeGk6fugiUs/kUdJ/pAg7z/zpvVbR11RH60jZ0Z42WIeIniYx0rLEWN7h/pKJ3CopqsQ3RsogCAkRKiA2g==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.30.tgz",
"integrity": "sha512-4iObHPR+Q4oDY110EF5SF5eIaaVJNpMdG9C0q3Q92BsJ5y467uHz7sYQhP60WYlLFsLQ1el2YrIPUItUAQGOKg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -1656,6 +2192,15 @@
"tslib": "^2.8.0"
}
},
"node_modules/@swc/types": {
"version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2402,7 +2947,6 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -2942,12 +3486,37 @@
"node": ">=0.10.0"
}
},
"node_modules/icu-minify": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.9.1.tgz",
"integrity": "sha512-6NkfF9GHHFouqnz+wuiLjCWQiyxoEyJ5liUv4Jxxo/8wyhV7MY0L0iTEGDAVEa4aAD58WqTxFMa20S5nyMjwNw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^3.4.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/intl-messageformat": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.1.tgz",
"integrity": "sha512-1gAVEUt3wEPvTqML4Fsw9klZV5j0vszQxayP/fi6gUroAc8AUHiNaisBKLWxybL1AdWq1mP07YV1q8v4N92ilQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@formatjs/fast-memoize": "3.1.2",
"@formatjs/icu-messageformat-parser": "3.5.4"
}
},
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
@@ -3021,7 +3590,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3031,7 +3599,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -3304,6 +3871,7 @@
"integrity": "sha512-nLJDV7peNy+0oHlmY2JZjzMfJ8Aj0/dd3jCwSZS8ZiO5nkQfcZRqDrRN3U5rJtqVTQneIOGZzb6LCNrk7trMCQ==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.1.2",
"@swc/counter": "0.1.3",
@@ -3353,12 +3921,104 @@
}
}
},
"node_modules/next-intl": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.9.1.tgz",
"integrity": "sha512-N7ga0CjtYcdxNvaKNIi6eJ2mmatlHK5hp8rt0YO2Omoc1m0gean242/Ukdj6+gJNiReBVcYIjK0HZeNx7CV1ug==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/intl-localematcher": "^0.8.1",
"@parcel/watcher": "^2.4.1",
"@swc/core": "^1.15.2",
"icu-minify": "^4.9.1",
"negotiator": "^1.0.0",
"next-intl-swc-plugin-extractor": "^4.9.1",
"po-parser": "^2.1.1",
"use-intl": "^4.9.1"
},
"peerDependencies": {
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/next-intl-swc-plugin-extractor": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.9.1.tgz",
"integrity": "sha512-8whJJ6oxJz8JqkHarggmmuEDyXgC7nEnaPhZD91CJwEWW4xp0AST3Mw17YxvHyP2vAF3taWfFbs1maD+WWtz3w==",
"license": "MIT"
},
"node_modules/next-intl/node_modules/@swc/core": {
"version": "1.15.30",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.30.tgz",
"integrity": "sha512-R8VQbQY1BZcbIF2p3gjlTCwAQzx1A194ugWfwld5y+WgVVWqVKm7eURGGOVbQVubgKWzidP2agomBbg96rZilQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.30",
"@swc/core-darwin-x64": "1.15.30",
"@swc/core-linux-arm-gnueabihf": "1.15.30",
"@swc/core-linux-arm64-gnu": "1.15.30",
"@swc/core-linux-arm64-musl": "1.15.30",
"@swc/core-linux-ppc64-gnu": "1.15.30",
"@swc/core-linux-s390x-gnu": "1.15.30",
"@swc/core-linux-x64-gnu": "1.15.30",
"@swc/core-linux-x64-musl": "1.15.30",
"@swc/core-win32-arm64-msvc": "1.15.30",
"@swc/core-win32-ia32-msvc": "1.15.30",
"@swc/core-win32-x64-msvc": "1.15.30"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/next-intl/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/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
@@ -3631,6 +4291,12 @@
"node": ">= 6"
}
},
"node_modules/po-parser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
"integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==",
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -4710,6 +5376,27 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-intl": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.9.1.tgz",
"integrity": "sha512-iGVV/xFYlhe3btafRlL8RPLD2Jsuet4yqn9DR6LWWbMhULsJnXgLonDkzDmsAIBIwFtk02oJuX/Ox2vwHKF+UQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "^3.1.0",
"@schummar/icu-type-parser": "1.21.5",
"icu-minify": "^4.9.1",
"intl-messageformat": "^11.1.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",