Merge pull request #184 from ArnasDon/feat/i18n-next-intl-v2

feat(i18n): re-attempt next-intl with hardened artifact tracing
This commit is contained in:
Arnas Donauskas 2026-05-31 20:03:58 +02:00 committed by GitHub
commit d9d5569494
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1603 additions and 245 deletions

145
messages/en.json Normal file
View File

@ -0,0 +1,145 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"back": "Back",
"next": "Next",
"loading": "Loading...",
"submit": "Submit",
"retry": "Try again",
"ok": "OK"
},
"appName": "wacrm",
"appTagline": "CRM Template for WhatsApp",
"nav": {
"dashboard": "Dashboard",
"inbox": "Inbox",
"contacts": "Contacts",
"pipelines": "Pipelines",
"broadcasts": "Broadcasts",
"automations": "Automations",
"flows": "Flows",
"settings": "Settings",
"beta": "Beta",
"betaFeatureAria": "Beta feature",
"unreadAria": "{count, plural, =1 {# unread conversation} other {# unread conversations}}",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"primaryAria": "Primary",
"openAccountMenu": "Open account menu",
"avatarAlt": "Avatar",
"userFallback": "User",
"profile": "Profile",
"signOut": "Sign out"
},
"roles": {
"owner": "Owner",
"admin": "Admin",
"agent": "Agent",
"viewer": "Viewer"
},
"auth": {
"login": {
"title": "Welcome back",
"titleInvite": "Sign in to accept",
"subtitle": "Sign in to your account",
"subtitleInvite": "Sign in and we'll take you to the invitation.",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter your password",
"forgotPassword": "Forgot password?",
"submit": "Sign in",
"submitting": "Signing in...",
"noAccount": "Don't have an account?",
"createAccount": "Create account"
},
"signup": {
"title": "Create account",
"titleInvite": "Create account & join",
"subtitle": "Get started with CRM Template for WhatsApp",
"subtitleInvite": "Verify your email, then accept the invitation to join your team.",
"fullNameLabel": "Full name",
"fullNamePlaceholder": "John Doe",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"passwordLabel": "Password",
"passwordPlaceholder": "At least 6 characters",
"confirmPasswordLabel": "Confirm password",
"confirmPasswordPlaceholder": "Repeat your password",
"submit": "Create account",
"submitting": "Creating account...",
"haveAccount": "Already have an account?",
"signIn": "Sign in",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters",
"checkEmailTitle": "Check your email",
"checkEmailBody": "We've sent a confirmation link to <highlight>{email}</highlight>. Please check your inbox and click the link to verify your account.",
"backToSignIn": "Back to sign in"
},
"forgotPassword": {
"title": "Reset password",
"subtitle": "Enter your email and we'll send you a reset link",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"submit": "Send reset link",
"submitting": "Sending...",
"checkEmailTitle": "Check your email",
"checkEmailBody": "We've sent a password reset link to <highlight>{email}</highlight>. Please check your inbox.",
"backToSignIn": "Back to sign in"
}
},
"join": {
"verifying": "Verifying invitation…",
"invitedToTitle": "You're invited to <highlight>{accountName}</highlight>",
"invitedAs": "You'll join as <role>{role}</role>. Link valid until {date, date, medium}.",
"acceptInvitation": "Accept invitation",
"accepting": "Accepting…",
"acceptFooter": "Accepting moves your login into <highlight>{accountName}</highlight>. Your empty personal account from signup will be cleaned up.",
"createAccountAndJoin": "Create account & join",
"alreadyHaveAccount": "I already have an account",
"conflictTitle": "Can't join {accountName} with this account",
"conflictBody": "To join <highlight>{accountName}</highlight>, sign out and sign up again with a different email address. The invite link stays valid as long as it hasn't expired.",
"staySignedIn": "Stay signed in",
"signOutAndRetry": "Sign out & use a different email",
"signingOut": "Signing out…",
"createNewAccountInstead": "Create a new account instead",
"signIn": "Sign in",
"tryAgain": "Try again",
"redeemConflictFallback": "You are already in another account. Sign in with a different email to join this one.",
"redeemFailedFallback": "Failed to accept invitation",
"redeemNetworkError": "Could not reach the server",
"signOutError": "Could not sign out. Try refreshing the page.",
"welcomeToast": "Welcome to the team",
"fail": {
"notFound": {
"title": "Invite not found",
"body": "This link doesn't match a valid invitation. Double-check the URL or ask the person who invited you to send a new one."
},
"used": {
"title": "Invite already used",
"body": "This invitation has already been accepted. If that wasn't you, ask the account admin to send a fresh link."
},
"expired": {
"title": "Invite expired",
"body": "This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate."
},
"serverError": {
"title": "Something went wrong",
"body": "We couldn't verify this invitation right now. Try refreshing the page in a moment."
}
}
},
"localeSwitcher": {
"label": "Language",
"ariaLabel": "Change language",
"locales": {
"en": "English",
"es": "Español",
"lt": "Lietuvių"
}
}
}

145
messages/es.json Normal file
View File

@ -0,0 +1,145 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"back": "Back",
"next": "Next",
"loading": "Loading...",
"submit": "Submit",
"retry": "Try again",
"ok": "OK"
},
"appName": "wacrm",
"appTagline": "CRM Template for WhatsApp",
"nav": {
"dashboard": "Dashboard",
"inbox": "Inbox",
"contacts": "Contacts",
"pipelines": "Pipelines",
"broadcasts": "Broadcasts",
"automations": "Automations",
"flows": "Flows",
"settings": "Settings",
"beta": "Beta",
"betaFeatureAria": "Beta feature",
"unreadAria": "{count, plural, =1 {# unread conversation} other {# unread conversations}}",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"primaryAria": "Primary",
"openAccountMenu": "Open account menu",
"avatarAlt": "Avatar",
"userFallback": "User",
"profile": "Profile",
"signOut": "Sign out"
},
"roles": {
"owner": "Owner",
"admin": "Admin",
"agent": "Agent",
"viewer": "Viewer"
},
"auth": {
"login": {
"title": "Welcome back",
"titleInvite": "Sign in to accept",
"subtitle": "Sign in to your account",
"subtitleInvite": "Sign in and we'll take you to the invitation.",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter your password",
"forgotPassword": "Forgot password?",
"submit": "Sign in",
"submitting": "Signing in...",
"noAccount": "Don't have an account?",
"createAccount": "Create account"
},
"signup": {
"title": "Create account",
"titleInvite": "Create account & join",
"subtitle": "Get started with CRM Template for WhatsApp",
"subtitleInvite": "Verify your email, then accept the invitation to join your team.",
"fullNameLabel": "Full name",
"fullNamePlaceholder": "John Doe",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"passwordLabel": "Password",
"passwordPlaceholder": "At least 6 characters",
"confirmPasswordLabel": "Confirm password",
"confirmPasswordPlaceholder": "Repeat your password",
"submit": "Create account",
"submitting": "Creating account...",
"haveAccount": "Already have an account?",
"signIn": "Sign in",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters",
"checkEmailTitle": "Check your email",
"checkEmailBody": "We've sent a confirmation link to <highlight>{email}</highlight>. Please check your inbox and click the link to verify your account.",
"backToSignIn": "Back to sign in"
},
"forgotPassword": {
"title": "Reset password",
"subtitle": "Enter your email and we'll send you a reset link",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"submit": "Send reset link",
"submitting": "Sending...",
"checkEmailTitle": "Check your email",
"checkEmailBody": "We've sent a password reset link to <highlight>{email}</highlight>. Please check your inbox.",
"backToSignIn": "Back to sign in"
}
},
"join": {
"verifying": "Verifying invitation…",
"invitedToTitle": "You're invited to <highlight>{accountName}</highlight>",
"invitedAs": "You'll join as <role>{role}</role>. Link valid until {date, date, medium}.",
"acceptInvitation": "Accept invitation",
"accepting": "Accepting…",
"acceptFooter": "Accepting moves your login into <highlight>{accountName}</highlight>. Your empty personal account from signup will be cleaned up.",
"createAccountAndJoin": "Create account & join",
"alreadyHaveAccount": "I already have an account",
"conflictTitle": "Can't join {accountName} with this account",
"conflictBody": "To join <highlight>{accountName}</highlight>, sign out and sign up again with a different email address. The invite link stays valid as long as it hasn't expired.",
"staySignedIn": "Stay signed in",
"signOutAndRetry": "Sign out & use a different email",
"signingOut": "Signing out…",
"createNewAccountInstead": "Create a new account instead",
"signIn": "Sign in",
"tryAgain": "Try again",
"redeemConflictFallback": "You are already in another account. Sign in with a different email to join this one.",
"redeemFailedFallback": "Failed to accept invitation",
"redeemNetworkError": "Could not reach the server",
"signOutError": "Could not sign out. Try refreshing the page.",
"welcomeToast": "Welcome to the team",
"fail": {
"notFound": {
"title": "Invite not found",
"body": "This link doesn't match a valid invitation. Double-check the URL or ask the person who invited you to send a new one."
},
"used": {
"title": "Invite already used",
"body": "This invitation has already been accepted. If that wasn't you, ask the account admin to send a fresh link."
},
"expired": {
"title": "Invite expired",
"body": "This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate."
},
"serverError": {
"title": "Something went wrong",
"body": "We couldn't verify this invitation right now. Try refreshing the page in a moment."
}
}
},
"localeSwitcher": {
"label": "Language",
"ariaLabel": "Change language",
"locales": {
"en": "English",
"es": "Español",
"lt": "Lietuvių"
}
}
}

145
messages/lt.json Normal file
View File

@ -0,0 +1,145 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"back": "Back",
"next": "Next",
"loading": "Loading...",
"submit": "Submit",
"retry": "Try again",
"ok": "OK"
},
"appName": "wacrm",
"appTagline": "CRM Template for WhatsApp",
"nav": {
"dashboard": "Dashboard",
"inbox": "Inbox",
"contacts": "Contacts",
"pipelines": "Pipelines",
"broadcasts": "Broadcasts",
"automations": "Automations",
"flows": "Flows",
"settings": "Settings",
"beta": "Beta",
"betaFeatureAria": "Beta feature",
"unreadAria": "{count, plural, =1 {# unread conversation} other {# unread conversations}}",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"primaryAria": "Primary",
"openAccountMenu": "Open account menu",
"avatarAlt": "Avatar",
"userFallback": "User",
"profile": "Profile",
"signOut": "Sign out"
},
"roles": {
"owner": "Owner",
"admin": "Admin",
"agent": "Agent",
"viewer": "Viewer"
},
"auth": {
"login": {
"title": "Welcome back",
"titleInvite": "Sign in to accept",
"subtitle": "Sign in to your account",
"subtitleInvite": "Sign in and we'll take you to the invitation.",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter your password",
"forgotPassword": "Forgot password?",
"submit": "Sign in",
"submitting": "Signing in...",
"noAccount": "Don't have an account?",
"createAccount": "Create account"
},
"signup": {
"title": "Create account",
"titleInvite": "Create account & join",
"subtitle": "Get started with CRM Template for WhatsApp",
"subtitleInvite": "Verify your email, then accept the invitation to join your team.",
"fullNameLabel": "Full name",
"fullNamePlaceholder": "John Doe",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"passwordLabel": "Password",
"passwordPlaceholder": "At least 6 characters",
"confirmPasswordLabel": "Confirm password",
"confirmPasswordPlaceholder": "Repeat your password",
"submit": "Create account",
"submitting": "Creating account...",
"haveAccount": "Already have an account?",
"signIn": "Sign in",
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters",
"checkEmailTitle": "Check your email",
"checkEmailBody": "We've sent a confirmation link to <highlight>{email}</highlight>. Please check your inbox and click the link to verify your account.",
"backToSignIn": "Back to sign in"
},
"forgotPassword": {
"title": "Reset password",
"subtitle": "Enter your email and we'll send you a reset link",
"emailLabel": "Email",
"emailPlaceholder": "you@example.com",
"submit": "Send reset link",
"submitting": "Sending...",
"checkEmailTitle": "Check your email",
"checkEmailBody": "We've sent a password reset link to <highlight>{email}</highlight>. Please check your inbox.",
"backToSignIn": "Back to sign in"
}
},
"join": {
"verifying": "Verifying invitation…",
"invitedToTitle": "You're invited to <highlight>{accountName}</highlight>",
"invitedAs": "You'll join as <role>{role}</role>. Link valid until {date, date, medium}.",
"acceptInvitation": "Accept invitation",
"accepting": "Accepting…",
"acceptFooter": "Accepting moves your login into <highlight>{accountName}</highlight>. Your empty personal account from signup will be cleaned up.",
"createAccountAndJoin": "Create account & join",
"alreadyHaveAccount": "I already have an account",
"conflictTitle": "Can't join {accountName} with this account",
"conflictBody": "To join <highlight>{accountName}</highlight>, sign out and sign up again with a different email address. The invite link stays valid as long as it hasn't expired.",
"staySignedIn": "Stay signed in",
"signOutAndRetry": "Sign out & use a different email",
"signingOut": "Signing out…",
"createNewAccountInstead": "Create a new account instead",
"signIn": "Sign in",
"tryAgain": "Try again",
"redeemConflictFallback": "You are already in another account. Sign in with a different email to join this one.",
"redeemFailedFallback": "Failed to accept invitation",
"redeemNetworkError": "Could not reach the server",
"signOutError": "Could not sign out. Try refreshing the page.",
"welcomeToast": "Welcome to the team",
"fail": {
"notFound": {
"title": "Invite not found",
"body": "This link doesn't match a valid invitation. Double-check the URL or ask the person who invited you to send a new one."
},
"used": {
"title": "Invite already used",
"body": "This invitation has already been accepted. If that wasn't you, ask the account admin to send a fresh link."
},
"expired": {
"title": "Invite expired",
"body": "This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate."
},
"serverError": {
"title": "Something went wrong",
"body": "We couldn't verify this invitation right now. Try refreshing the page in a moment."
}
}
},
"localeSwitcher": {
"label": "Language",
"ariaLabel": "Change language",
"locales": {
"en": "English",
"es": "Español",
"lt": "Lietuvių"
}
}
}

View File

@ -1,5 +1,9 @@
import path from "node:path";
import createNextIntlPlugin from "next-intl/plugin";
import type { NextConfig } from "next";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
/**
* Baseline security headers applied to every response.
*
@ -54,6 +58,20 @@ const SECURITY_HEADERS = [
] as const;
const nextConfig: NextConfig = {
// Pin file-tracing to this directory so Next.js doesn't ascend to the
// parent GHWebhosting folder (which has its own lockfile) and either
// bundle the wrong stuff or skip ours. Was visible as a workspace-root
// warning during builds. Also see `outputFileTracingIncludes` below —
// a wrong root makes the includes match incorrectly.
outputFileTracingRoot: path.join(__dirname),
// Force the production artifact to include the i18n message dictionaries.
// `src/i18n/request.ts` loads them via a static import map but the
// tracer can still miss JSON-only files that no code statically
// imports relative to this entry point. Belt-and-suspenders.
outputFileTracingIncludes: {
"/**/*": ["./messages/**/*.json"],
},
/**
* Cache-Control policy.
*
@ -127,4 +145,4 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
export default withNextIntl(nextConfig);

695
package-lock.json generated
View File

@ -22,6 +22,7 @@
"date-fns": "^4.3.0",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"next-intl": "^4.13.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
@ -44,7 +45,7 @@
"vitest": "^4.1.7"
},
"engines": {
"node": ">=20.0.0"
"node": ">=20.10.0"
}
},
"node_modules/@alloc/quick-lru": {
@ -1015,6 +1016,36 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@formatjs/fast-memoize": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.5.tgz",
"integrity": "sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==",
"license": "MIT"
},
"node_modules/@formatjs/icu-messageformat-parser": {
"version": "3.5.10",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.10.tgz",
"integrity": "sha512-XeJihYLy1lCe19xfK1KWKG/betBOK2rB0luL8lSkjfvJj0zP+LTJvkC+RKd0jsFI8mWxN71LrarHSrEXE8xxOQ==",
"license": "MIT",
"dependencies": {
"@formatjs/icu-skeleton-parser": "2.1.9"
}
},
"node_modules/@formatjs/icu-skeleton-parser": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.9.tgz",
"integrity": "sha512-rsxswgHMfU1zUgB2byc08fesf83wLGjFnzLCEtuf00mx2doiqc6pYrf67raI37XqdRcGUviQepk2UKGqpng74Q==",
"license": "MIT"
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.8.9",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.9.tgz",
"integrity": "sha512-GmB0F/gYh4Hdl4rLWjgDsgT+x4pB54fkJeRh8kAZ4XFzKeCK8dGs+SBJWXO42QZtOUni+IDWKNuCw6wiL4lTvw==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "3.1.5"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@ -2025,6 +2056,313 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"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/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
@ -2351,6 +2689,12 @@
"dev": true,
"license": "MIT"
},
"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/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
@ -2490,15 +2834,222 @@
"node": ">=20.0.0"
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz",
"integrity": "sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz",
"integrity": "sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==",
"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.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz",
"integrity": "sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz",
"integrity": "sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==",
"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.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz",
"integrity": "sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==",
"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.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz",
"integrity": "sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==",
"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.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz",
"integrity": "sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==",
"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.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz",
"integrity": "sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==",
"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.40",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz",
"integrity": "sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==",
"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.40",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz",
"integrity": "sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==",
"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.40",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz",
"integrity": "sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==",
"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.40",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz",
"integrity": "sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==",
"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",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"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/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
@ -5133,7 +5684,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@ -6760,6 +7310,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/icu-minify": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.0.tgz",
"integrity": "sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/amannn"
}
],
"license": "MIT",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^3.4.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -6835,6 +7400,16 @@
"node": ">=12"
}
},
"node_modules/intl-messageformat": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.7.tgz",
"integrity": "sha512-+q6Ktg119nULZEpZ8YTuGOst9MyEzFtjD63FTGBlN1mLz0Z/MOUYDIvnpVKwq17eezIEh+cfJIebfJoCetpiNw==",
"license": "BSD-3-Clause",
"dependencies": {
"@formatjs/fast-memoize": "3.1.5",
"@formatjs/icu-messageformat-parser": "3.5.10"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
@ -8302,6 +8877,89 @@
}
}
},
"node_modules/next-intl": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.0.tgz",
"integrity": "sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==",
"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.13.0",
"negotiator": "^1.0.0",
"next-intl-swc-plugin-extractor": "^4.13.0",
"po-parser": "^2.1.1",
"use-intl": "^4.13.0"
},
"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.13.0",
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz",
"integrity": "sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==",
"license": "MIT"
},
"node_modules/next-intl/node_modules/@swc/core": {
"version": "1.15.40",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz",
"integrity": "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==",
"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.40",
"@swc/core-darwin-x64": "1.15.40",
"@swc/core-linux-arm-gnueabihf": "1.15.40",
"@swc/core-linux-arm64-gnu": "1.15.40",
"@swc/core-linux-arm64-musl": "1.15.40",
"@swc/core-linux-ppc64-gnu": "1.15.40",
"@swc/core-linux-s390x-gnu": "1.15.40",
"@swc/core-linux-x64-gnu": "1.15.40",
"@swc/core-linux-x64-musl": "1.15.40",
"@swc/core-win32-arm64-msvc": "1.15.40",
"@swc/core-win32-ia32-msvc": "1.15.40",
"@swc/core-win32-x64-msvc": "1.15.40"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"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-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@ -8822,6 +9480,12 @@
"node": ">=16.20.0"
}
},
"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/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -10802,6 +11466,27 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-intl": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.0.tgz",
"integrity": "sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==",
"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.13.0",
"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/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",

View File

@ -25,7 +25,7 @@
"template"
],
"engines": {
"node": ">=20.0.0"
"node": ">=20.10.0"
},
"scripts": {
"dev": "next dev",
@ -52,6 +52,7 @@
"date-fns": "^4.3.0",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"next-intl": "^4.13.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
@ -77,6 +78,7 @@
"postcss": "^8.5.10",
"ip-address": "^10.1.1",
"fast-uri": "^3.1.2",
"hono": "^4.12.18"
"hono": "^4.12.18",
"@swc/helpers": "^0.5.17"
}
}

View File

@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -16,6 +17,7 @@ import {
import { MessageSquare, CheckCircle, ArrowLeft } from "lucide-react";
export default function ForgotPasswordPage() {
const t = useTranslations("auth.forgotPassword");
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@ -50,12 +52,15 @@ export default function ForgotPasswordPage() {
<CheckCircle className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-white">
Check your email
{t("checkEmailTitle")}
</CardTitle>
<CardDescription className="text-slate-400">
We&apos;ve sent a password reset link to{" "}
<span className="text-white">{email}</span>. Please check your
inbox.
{t.rich("checkEmailBody", {
email,
highlight: (chunks) => (
<span className="text-white">{chunks}</span>
),
})}
</CardDescription>
</CardHeader>
<CardContent>
@ -64,7 +69,7 @@ export default function ForgotPasswordPage() {
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
>
Back to sign in
{t("backToSignIn")}
</Button>
</Link>
</CardContent>
@ -80,9 +85,9 @@ export default function ForgotPasswordPage() {
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<MessageSquare className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-white">Reset password</CardTitle>
<CardTitle className="text-xl text-white">{t("title")}</CardTitle>
<CardDescription className="text-slate-400">
Enter your email and we&apos;ll send you a reset link
{t("subtitle")}
</CardDescription>
</CardHeader>
<CardContent>
@ -95,12 +100,12 @@ export default function ForgotPasswordPage() {
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-slate-300">
Email
{t("emailLabel")}
</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
placeholder={t("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@ -113,7 +118,7 @@ export default function ForgotPasswordPage() {
disabled={loading}
className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Sending..." : "Send reset link"}
{loading ? t("submitting") : t("submit")}
</Button>
</form>
@ -122,7 +127,7 @@ export default function ForgotPasswordPage() {
className="mt-6 flex items-center justify-center gap-2 text-sm text-slate-400 hover:text-slate-300"
>
<ArrowLeft className="h-4 w-4" />
Back to sign in
{t("backToSignIn")}
</Link>
</CardContent>
</Card>

View File

@ -1,8 +1,9 @@
"use client";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Link, useRouter } from "@/i18n/navigation";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -30,6 +31,7 @@ export default function LoginPage() {
}
function LoginPageInner() {
const t = useTranslations("auth.login");
const searchParams = useSearchParams();
// Forwarded from `/join/<token>` when the visitor already has an
// account. After a successful sign-in we send them to the join
@ -78,12 +80,10 @@ function LoginPageInner() {
)}
</div>
<CardTitle className="text-xl text-white">
{inviteToken ? "Sign in to accept" : "Welcome back"}
{inviteToken ? t("titleInvite") : t("title")}
</CardTitle>
<CardDescription className="text-slate-400">
{inviteToken
? "Sign in and we'll take you to the invitation."
: "Sign in to your account"}
{inviteToken ? t("subtitleInvite") : t("subtitle")}
</CardDescription>
</CardHeader>
<CardContent>
@ -96,12 +96,12 @@ function LoginPageInner() {
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-slate-300">
Email
{t("emailLabel")}
</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
placeholder={t("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@ -112,19 +112,19 @@ function LoginPageInner() {
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-slate-300">
Password
{t("passwordLabel")}
</Label>
<Link
href="/forgot-password"
className="text-sm text-primary hover:text-primary/80"
>
Forgot password?
{t("forgotPassword")}
</Link>
</div>
<Input
id="password"
type="password"
placeholder="Enter your password"
placeholder={t("passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@ -137,12 +137,12 @@ function LoginPageInner() {
disabled={loading}
className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Signing in..." : "Sign in"}
{loading ? t("submitting") : t("submit")}
</Button>
</form>
<p className="mt-6 text-center text-sm text-slate-400">
Don&apos;t have an account?{" "}
{t("noAccount")}{" "}
<Link
href={
inviteToken
@ -151,7 +151,7 @@ function LoginPageInner() {
}
className="text-primary hover:text-primary/80"
>
Create account
{t("createAccount")}
</Link>
</p>
</CardContent>

View File

@ -1,8 +1,9 @@
"use client";
import { Suspense, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -27,6 +28,7 @@ export default function SignupPage() {
}
function SignupPageInner() {
const t = useTranslations("auth.signup");
const searchParams = useSearchParams();
// When the user lands here from `/join/<token>` we carry the
// invite token in the query so it survives the signup → email
@ -49,12 +51,12 @@ function SignupPageInner() {
setError(null);
if (password !== confirmPassword) {
setError("Passwords do not match");
setError(t("passwordMismatch"));
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
setError(t("passwordTooShort"));
return;
}
@ -98,12 +100,15 @@ function SignupPageInner() {
<CheckCircle className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-white">
Check your email
{t("checkEmailTitle")}
</CardTitle>
<CardDescription className="text-slate-400">
We&apos;ve sent a confirmation link to{" "}
<span className="text-white">{email}</span>. Please check your
inbox and click the link to verify your account.
{t.rich("checkEmailBody", {
email,
highlight: (chunks) => (
<span className="text-white">{chunks}</span>
),
})}
</CardDescription>
</CardHeader>
<CardContent>
@ -118,7 +123,7 @@ function SignupPageInner() {
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
>
Back to sign in
{t("backToSignIn")}
</Button>
</Link>
</CardContent>
@ -139,12 +144,10 @@ function SignupPageInner() {
)}
</div>
<CardTitle className="text-xl text-white">
{inviteToken ? "Create account & join" : "Create account"}
{inviteToken ? t("titleInvite") : t("title")}
</CardTitle>
<CardDescription className="text-slate-400">
{inviteToken
? "Verify your email, then accept the invitation to join your team."
: "Get started with CRM Template for WhatsApp"}
{inviteToken ? t("subtitleInvite") : t("subtitle")}
</CardDescription>
</CardHeader>
<CardContent>
@ -157,12 +160,12 @@ function SignupPageInner() {
<div className="flex flex-col gap-2">
<Label htmlFor="fullName" className="text-slate-300">
Full name
{t("fullNameLabel")}
</Label>
<Input
id="fullName"
type="text"
placeholder="John Doe"
placeholder={t("fullNamePlaceholder")}
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
@ -172,12 +175,12 @@ function SignupPageInner() {
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-slate-300">
Email
{t("emailLabel")}
</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
placeholder={t("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@ -187,12 +190,12 @@ function SignupPageInner() {
<div className="flex flex-col gap-2">
<Label htmlFor="password" className="text-slate-300">
Password
{t("passwordLabel")}
</Label>
<Input
id="password"
type="password"
placeholder="At least 6 characters"
placeholder={t("passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@ -202,12 +205,12 @@ function SignupPageInner() {
<div className="flex flex-col gap-2">
<Label htmlFor="confirmPassword" className="text-slate-300">
Confirm password
{t("confirmPasswordLabel")}
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Repeat your password"
placeholder={t("confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
@ -220,12 +223,12 @@ function SignupPageInner() {
disabled={loading}
className="mt-2 h-10 w-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "Creating account..." : "Create account"}
{loading ? t("submitting") : t("submit")}
</Button>
</form>
<p className="mt-6 text-center text-sm text-slate-400">
Already have an account?{" "}
{t("haveAccount")}{" "}
<Link
href={
inviteToken
@ -234,7 +237,7 @@ function SignupPageInner() {
}
className="text-primary hover:text-primary/80"
>
Sign in
{t("signIn")}
</Link>
</p>
</CardContent>

View File

@ -23,8 +23,9 @@
// ============================================================
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { toast } from 'sonner';
import {
AlertTriangle,
@ -65,32 +66,16 @@ interface PeekFail {
}
type PeekResult = PeekOk | PeekFail;
const ROLE_LABEL: Record<PeekOk['role'], string> = {
admin: 'Admin',
agent: 'Agent',
viewer: 'Viewer',
};
const FAIL_COPY: Record<PeekFail['reason'], { title: string; body: string }> = {
not_found: {
title: 'Invite not found',
body: 'This link doesn’t match a valid invitation. Double-check the URL or ask the person who invited you to send a new one.',
},
used: {
title: 'Invite already used',
body: 'This invitation has already been accepted. If that wasn’t you, ask the account admin to send a fresh link.',
},
expired: {
title: 'Invite expired',
body: 'This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate.',
},
server_error: {
title: 'Something went wrong',
body: 'We couldn’t verify this invitation right now. Try refreshing the page in a moment.',
},
const FAIL_REASON_KEY: Record<PeekFail['reason'], string> = {
not_found: 'notFound',
used: 'used',
expired: 'expired',
server_error: 'serverError',
};
export default function JoinPage() {
const t = useTranslations('join');
const tRoles = useTranslations('roles');
const params = useParams<{ token: string }>();
const token = params?.token;
@ -182,25 +167,24 @@ export default function JoinPage() {
// rather than a 3-second toast.
if (res.status === 409) {
setConflictMessage(
payload.error ||
'You are already in another account. Sign in with a different email to join this one.',
payload.error || t('redeemConflictFallback'),
);
} else {
toast.error(payload.error || 'Failed to accept invitation');
toast.error(payload.error || t('redeemFailedFallback'));
}
setAccepting(false);
return;
}
toast.success('Welcome to the team');
toast.success(t('welcomeToast'));
// Full reload (not router.push) so AuthProvider re-fetches
// the profile with the new account_id and account_role.
window.location.href = '/dashboard';
} catch (err) {
console.error('[join] redeem error:', err);
toast.error('Could not reach the server');
toast.error(t('redeemNetworkError'));
setAccepting(false);
}
}, [token]);
}, [token, t]);
const handleSignOutAndRetry = useCallback(async () => {
setSigningOut(true);
@ -212,10 +196,10 @@ export default function JoinPage() {
window.location.reload();
} catch (err) {
console.error('[join] sign-out error:', err);
toast.error('Could not sign out. Try refreshing the page.');
toast.error(t('signOutError'));
setSigningOut(false);
}
}, []);
}, [t]);
// ----- Loading state (peek pending OR auth not yet resolved) -----
if (peek === null || authedUserId === undefined) {
@ -223,7 +207,7 @@ export default function JoinPage() {
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<CardContent className="flex flex-col items-center gap-3 py-12">
<Loader2 className="size-6 animate-spin text-primary" />
<p className="text-sm text-slate-400">Verifying invitation…</p>
<p className="text-sm text-slate-400">{t('verifying')}</p>
</CardContent>
</Card>
);
@ -231,16 +215,18 @@ export default function JoinPage() {
// ----- Peek failed -----
if (!peek.ok) {
const copy = FAIL_COPY[peek.reason];
const reasonKey = FAIL_REASON_KEY[peek.reason];
return (
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-red-500/10">
<MailX className="h-6 w-6 text-red-400" />
</div>
<CardTitle className="text-xl text-white">{copy.title}</CardTitle>
<CardTitle className="text-xl text-white">
{t(`fail.${reasonKey}.title`)}
</CardTitle>
<CardDescription className="text-slate-400">
{copy.body}
{t(`fail.${reasonKey}.body`)}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
@ -257,14 +243,14 @@ export default function JoinPage() {
onClick={loadPeekAndAuth}
className="w-full bg-primary text-primary-foreground hover:bg-primary/90"
>
Try again
{t('tryAgain')}
</Button>
<Link href="/signup">
<Button
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
>
Create a new account instead
{t('createNewAccountInstead')}
</Button>
</Link>
</>
@ -272,7 +258,7 @@ export default function JoinPage() {
<>
<Link href="/signup">
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
Create a new account instead
{t('createNewAccountInstead')}
</Button>
</Link>
<Link href="/login">
@ -280,7 +266,7 @@ export default function JoinPage() {
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
>
Sign in
{t('signIn')}
</Button>
</Link>
</>
@ -297,22 +283,21 @@ export default function JoinPage() {
<UsersRound className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-white">
You&apos;re invited to{' '}
<span className="text-primary">{peek.account_name}</span>
{t.rich('invitedToTitle', {
accountName: peek.account_name,
highlight: (chunks) => <span className="text-primary">{chunks}</span>,
})}
</CardTitle>
<CardDescription className="text-slate-400">
You&apos;ll join as{' '}
{t.rich('invitedAs', {
role: () => (
<span className="inline-flex items-center gap-1 text-white">
<ShieldCheck className="size-3.5 text-primary" />
{ROLE_LABEL[peek.role]}
{tRoles(peek.role)}
</span>
. Link valid until{' '}
{new Date(peek.expires_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
),
date: new Date(peek.expires_at),
})}
.
</CardDescription>
</CardHeader>
);
@ -332,19 +317,22 @@ export default function JoinPage() {
{accepting ? (
<>
<Loader2 className="size-4 animate-spin" />
Accepting…
{t('accepting')}
</>
) : (
<>
<CheckCircle className="size-4" />
Accept invitation
{t('acceptInvitation')}
</>
)}
</Button>
<p className="text-center text-xs text-slate-500">
Accepting moves your login into{' '}
<span className="text-slate-400">{peek.account_name}</span>. Your
empty personal account from signup will be cleaned up.
{t.rich('acceptFooter', {
accountName: peek.account_name,
highlight: (chunks) => (
<span className="text-slate-400">{chunks}</span>
),
})}
</p>
</CardContent>
</Card>
@ -363,7 +351,7 @@ export default function JoinPage() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<AlertTriangle className="size-4 text-amber-400" />
Can&apos;t join {peek.account_name} with this account
{t('conflictTitle', { accountName: peek.account_name })}
</DialogTitle>
<DialogDescription className="text-slate-400">
{conflictMessage}
@ -371,11 +359,12 @@ export default function JoinPage() {
</DialogHeader>
<div className="space-y-2 py-2 text-xs text-slate-500">
<p>
To join{' '}
<span className="text-slate-300">{peek.account_name}</span>,
sign out and sign up again with a different email address.
The invite link stays valid as long as it hasn&apos;t
expired.
{t.rich('conflictBody', {
accountName: peek.account_name,
highlight: (chunks) => (
<span className="text-slate-300">{chunks}</span>
),
})}
</p>
</div>
<DialogFooter className="bg-slate-900 border-slate-700">
@ -384,7 +373,7 @@ export default function JoinPage() {
onClick={() => setConflictMessage(null)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
Stay signed in
{t('staySignedIn')}
</Button>
<Button
onClick={handleSignOutAndRetry}
@ -394,10 +383,10 @@ export default function JoinPage() {
{signingOut ? (
<>
<Loader2 className="size-4 animate-spin" />
Signing out…
{t('signingOut')}
</>
) : (
'Sign out & use a different email'
t('signOutAndRetry')
)}
</Button>
</DialogFooter>
@ -414,7 +403,7 @@ export default function JoinPage() {
<CardContent className="flex flex-col gap-2">
<Link href={`/signup?invite=${encodeURIComponent(token!)}`}>
<Button className="w-full bg-primary text-primary-foreground hover:bg-primary/90">
Create account &amp; join
{t('createAccountAndJoin')}
</Button>
</Link>
<Link href={`/login?invite=${encodeURIComponent(token!)}`}>
@ -422,7 +411,7 @@ export default function JoinPage() {
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
>
I already have an account
{t('alreadyHaveAccount')}
</Button>
</Link>
</CardContent>

View File

@ -1,9 +1,13 @@
import type { Metadata, Viewport } from "next";
import { hasLocale, NextIntlClientProvider } from "next-intl";
import { setRequestLocale } from "next-intl/server";
import { Inter } from "next/font/google";
import { notFound } from "next/navigation";
import Script from "next/script";
import { Toaster } from "sonner";
import "./globals.css";
import "../globals.css";
import { ThemeProvider } from "@/hooks/use-theme";
import { routing } from "@/i18n/routing";
import { DEFAULT_THEME, STORAGE_KEY, THEME_IDS } from "@/lib/themes";
const inter = Inter({
@ -40,11 +44,6 @@ export const viewport: Viewport = {
// chosen theme is on the <html> element before first paint. Without
// this every page load flashes the default Violet for a frame before
// the React tree mounts and applies the picked theme.
//
// Kept dependency-free (no imports, no JSX) — must be a string the
// browser can run as a single <script>. Knowledge of valid theme IDs
// is sourced from the THEME_IDS constant so adding a theme doesn't
// silently break the boot path.
const THEME_BOOT_SCRIPT = `
(function(){
try {
@ -60,14 +59,28 @@ const THEME_BOOT_SCRIPT = `
})();
`;
export default function RootLayout({
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function RootLayout({
children,
}: Readonly<{
params,
}: {
children: React.ReactNode;
}>) {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
return (
<html
lang="en"
lang={locale}
data-theme={DEFAULT_THEME}
className={`${inter.variable} h-full antialiased`}
>
@ -79,6 +92,7 @@ export default function RootLayout({
/>
</head>
<body className="min-h-full bg-background text-foreground font-sans">
<NextIntlClientProvider>
<ThemeProvider>
{children}
<Toaster
@ -93,6 +107,7 @@ export default function RootLayout({
}}
/>
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);

10
src/app/[locale]/page.tsx Normal file
View File

@ -0,0 +1,10 @@
import { redirect } from "@/i18n/navigation";
export default async function RootPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
redirect({ href: "/dashboard", locale });
}

View File

@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function RootPage() {
redirect('/dashboard')
}

View File

@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
import { useAuth } from "@/hooks/use-auth";
import { LogOut, Menu, Settings as SettingsIcon, User } from "lucide-react";
import {
@ -16,23 +16,27 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LocaleSwitcher } from "@/components/layout/locale-switcher";
const pageTitles: Record<string, string> = {
"/dashboard": "Dashboard",
"/inbox": "Inbox",
"/contacts": "Contacts",
"/pipelines": "Pipelines",
"/broadcasts": "Broadcasts",
"/automations": "Automations",
"/settings": "Settings",
// Maps path → translation key in the `nav` namespace. Order matters
// for prefix matches: longest first, but here keys are simple enough
// that order isn't important.
const PAGE_KEYS: Record<string, string> = {
"/dashboard": "dashboard",
"/inbox": "inbox",
"/contacts": "contacts",
"/pipelines": "pipelines",
"/broadcasts": "broadcasts",
"/automations": "automations",
"/settings": "settings",
};
function getPageTitle(pathname: string): string {
if (pageTitles[pathname]) return pageTitles[pathname];
const match = Object.entries(pageTitles).find(([path]) =>
function getPageKey(pathname: string): string {
if (PAGE_KEYS[pathname]) return PAGE_KEYS[pathname];
const match = Object.entries(PAGE_KEYS).find(([path]) =>
pathname.startsWith(path),
);
return match ? match[1] : "Dashboard";
return match ? match[1] : "dashboard";
}
interface HeaderProps {
@ -42,9 +46,10 @@ interface HeaderProps {
}
export function Header({ onOpenSidebar }: HeaderProps) {
const t = useTranslations("nav");
const pathname = usePathname();
const { profile, signOut } = useAuth();
const title = getPageTitle(pathname);
const title = t(getPageKey(pathname));
const initial =
profile?.full_name?.charAt(0)?.toUpperCase() ??
@ -58,7 +63,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
<button
type="button"
onClick={onOpenSidebar}
aria-label="Open menu"
aria-label={t("openMenu")}
className="flex h-10 w-10 items-center justify-center rounded-md text-slate-300 transition-colors hover:bg-slate-800 hover:text-white lg:hidden"
>
<Menu className="h-5 w-5" />
@ -68,16 +73,19 @@ export function Header({ onOpenSidebar }: HeaderProps) {
</h1>
</div>
<div className="flex items-center gap-2">
<LocaleSwitcher />
<DropdownMenu>
<DropdownMenuTrigger
className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-slate-800/70 focus:bg-slate-800/70 focus:outline-none data-popup-open:bg-slate-800/70 sm:gap-3 sm:pl-1 sm:pr-3"
aria-label="Open account menu"
aria-label={t("openAccountMenu")}
>
<Avatar className="size-8">
{profile?.avatar_url ? (
<AvatarImage
src={profile.avatar_url}
alt={profile.full_name ?? "Avatar"}
alt={profile.full_name ?? t("avatarAlt")}
/>
) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
@ -85,7 +93,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium text-white sm:inline">
{profile?.full_name ?? "User"}
{profile?.full_name ?? t("userFallback")}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent
@ -95,7 +103,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
>
<div className="px-2 py-1.5">
<p className="truncate text-sm font-medium text-white">
{profile?.full_name ?? "User"}
{profile?.full_name ?? t("userFallback")}
</p>
<p className="truncate text-xs text-slate-400">
{profile?.email ?? ""}
@ -111,7 +119,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
}
>
<User className="size-4" />
Profile
{t("profile")}
</DropdownMenuItem>
<DropdownMenuItem
render={
@ -122,7 +130,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
}
>
<SettingsIcon className="size-4" />
Settings
{t("settings")}
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-800" />
<DropdownMenuItem
@ -130,10 +138,11 @@ export function Header({ onOpenSidebar }: HeaderProps) {
className="text-slate-200 focus:bg-slate-800 focus:text-white"
>
<LogOut className="size-4" />
Sign out
{t("signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View File

@ -0,0 +1,73 @@
"use client";
import { useTransition } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Globe } from "lucide-react";
import { usePathname, useRouter } from "@/i18n/navigation";
import { routing, type Locale } from "@/i18n/routing";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export function LocaleSwitcher() {
const t = useTranslations("localeSwitcher");
const tLocales = useTranslations("localeSwitcher.locales");
const locale = useLocale() as Locale;
const router = useRouter();
const pathname = usePathname();
const [pending, startTransition] = useTransition();
function switchTo(next: Locale) {
if (next === locale) return;
startTransition(() => {
router.replace(pathname, { locale: next });
});
}
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={t("ariaLabel")}
disabled={pending}
className={cn(
"flex h-9 items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900 px-2.5 text-xs font-medium text-slate-300 transition-colors",
"hover:border-slate-700 hover:bg-slate-800 hover:text-white",
"focus:outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/40",
"data-popup-open:border-slate-700 data-popup-open:bg-slate-800 data-popup-open:text-white",
pending && "cursor-wait opacity-60",
)}
>
<Globe className="h-3.5 w-3.5" />
<span className="uppercase">{locale}</span>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={6}
className="min-w-40 bg-slate-900 text-slate-100 ring-slate-700"
>
{routing.locales.map((option) => {
const isActive = option === locale;
return (
<DropdownMenuItem
key={option}
onClick={() => switchTo(option)}
className={cn(
"text-slate-200 focus:bg-slate-800 focus:text-white",
isActive && "bg-primary/10 text-primary focus:text-primary",
)}
>
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
{option}
</span>
<span>{tLocales(option)}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth";
import { useTotalUnread } from "@/hooks/use-total-unread";
@ -29,37 +29,32 @@ import type { AccountRole } from "@/lib/auth/roles";
// Members tab roster. Keeping this near both consumers in a single
// place avoids drift between the two surfaces — when a designer
// wants to recolour "agent" rows, this is the one diff.
//
// `label` is resolved at render via `t(`roles.${role}`)` so the chip
// text follows the active locale.
const ROLE_CHIP: Record<
AccountRole,
{ icon: typeof Crown; label: string; className: string }
{ icon: typeof Crown; className: string }
> = {
owner: {
icon: Crown,
label: "Owner",
// Amber: scarce, immutable, "the boss" — gets visual emphasis.
className:
"border-amber-500/40 bg-amber-500/10 text-amber-300",
className: "border-amber-500/40 bg-amber-500/10 text-amber-300",
},
admin: {
icon: Shield,
label: "Admin",
// Primary-tinted: significant but not as scarce as owner.
className:
"border-primary/40 bg-primary/10 text-primary",
className: "border-primary/40 bg-primary/10 text-primary",
},
agent: {
icon: UserCog,
label: "Agent",
// Neutral slate: the operational default.
className:
"border-slate-700 bg-slate-800 text-slate-300",
className: "border-slate-700 bg-slate-800 text-slate-300",
},
viewer: {
icon: User,
label: "Viewer",
// Muted slate: read-only role; visually quieter than agent.
className:
"border-slate-800 bg-slate-900 text-slate-500",
className: "border-slate-800 bg-slate-900 text-slate-500",
},
};
import {
@ -77,7 +72,8 @@ import {
interface NavItem {
href: string;
label: string;
/** Key in the `nav` translation namespace. */
key: string;
icon: typeof LayoutDashboard;
/**
* When true, the nav row renders a small "Beta" chip after the label.
@ -87,17 +83,17 @@ interface NavItem {
}
const navItems: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/inbox", label: "Inbox", icon: MessageSquare },
{ href: "/contacts", label: "Contacts", icon: Users },
{ href: "/pipelines", label: "Pipelines", icon: GitBranch },
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
{ href: "/automations", label: "Automations", icon: Zap },
{ href: "/flows", label: "Flows", icon: Workflow, beta: true },
{ href: "/dashboard", key: "dashboard", icon: LayoutDashboard },
{ href: "/inbox", key: "inbox", icon: MessageSquare },
{ href: "/contacts", key: "contacts", icon: Users },
{ href: "/pipelines", key: "pipelines", icon: GitBranch },
{ href: "/broadcasts", key: "broadcasts", icon: Radio },
{ href: "/automations", key: "automations", icon: Zap },
{ href: "/flows", key: "flows", icon: Workflow, beta: true },
];
const bottomNavItems = [
{ href: "/settings", label: "Settings", icon: Settings },
const bottomNavItems: NavItem[] = [
{ href: "/settings", key: "settings", icon: Settings },
];
interface SidebarProps {
@ -111,6 +107,9 @@ interface SidebarProps {
const ACCOUNT_SHARING_FLAG = "account_sharing";
export function Sidebar({ open = false, onClose }: SidebarProps) {
const t = useTranslations("nav");
const tRoles = useTranslations("roles");
const tApp = useTranslations();
const pathname = usePathname();
const { profile, profileLoading, account, accountRole, signOut } = useAuth();
const totalUnread = useTotalUnread();
@ -155,7 +154,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
part of the main flex row there. */}
<button
type="button"
aria-label="Close menu"
aria-label={t("closeMenu")}
onClick={onClose}
className={cn(
"fixed inset-0 z-30 bg-slate-950/70 backdrop-blur-sm transition-opacity lg:hidden",
@ -174,7 +173,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
// Desktop: static, always visible — reset all the mobile framing.
"lg:static lg:z-0 lg:w-60 lg:translate-x-0 lg:transition-none",
)}
aria-label="Primary"
aria-label={t("primaryAria")}
>
{/* Logo row. On mobile we put a close button here; on desktop the
close button is hidden since the sidebar is always-visible. */}
@ -184,13 +183,13 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
<MessageSquare className="h-4 w-4" />
</div>
<span className="text-sm font-semibold text-white">
CRM Template for WhatsApp
{tApp("appTagline")}
</span>
</Link>
<button
type="button"
onClick={onClose}
aria-label="Close menu"
aria-label={t("closeMenu")}
className="flex h-9 w-9 items-center justify-center rounded-md text-slate-400 hover:bg-slate-800 hover:text-white lg:hidden"
>
<X className="h-5 w-5" />
@ -221,18 +220,18 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)}
>
<item.icon className="h-4 w-4" />
<span className="flex-1">{item.label}</span>
<span className="flex-1">{t(item.key)}</span>
{item.beta && (
<span
aria-label="Beta feature"
aria-label={t("betaFeatureAria")}
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-300"
>
Beta
{t("beta")}
</span>
)}
{showUnreadDot && (
<span
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
aria-label={t("unreadAria", { count: totalUnread })}
className="relative flex h-2 w-2"
>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
@ -262,7 +261,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)}
>
<item.icon className="h-4 w-4" />
{item.label}
{t(item.key)}
</Link>
</li>
);
@ -300,7 +299,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
className={`ml-auto inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${meta.className}`}
>
<Icon className="size-3" />
{meta.label}
{tRoles(accountRole)}
</span>
);
})()
@ -313,7 +312,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
{profile?.avatar_url ? (
<AvatarImage
src={profile.avatar_url}
alt={profile.full_name ?? "Avatar"}
alt={profile.full_name ?? t("avatarAlt")}
/>
) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
@ -324,7 +323,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
{profile?.full_name ?? "User"}
{profile?.full_name ?? t("userFallback")}
</p>
<p className="truncate text-xs text-slate-400">
{profile?.email ?? ""}
@ -347,7 +346,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
}
>
<User className="size-4" />
Profile
{t("profile")}
</DropdownMenuItem>
<DropdownMenuItem
render={
@ -359,7 +358,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
}
>
<Settings className="size-4" />
Settings
{t("settings")}
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-800" />
<DropdownMenuItem
@ -367,7 +366,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
className="text-slate-200 focus:bg-slate-800 focus:text-white"
>
<LogOut className="size-4" />
Sign out
{t("signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

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

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

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

@ -0,0 +1,38 @@
import { hasLocale } from 'next-intl';
import { getRequestConfig } from 'next-intl/server';
import { routing, type Locale } from './routing';
// Static import map. Template-literal dynamic imports (e.g.
// `import(\`../../messages/\${locale}.json\`)`) can confuse Next.js's
// file tracer in some build environments — the JSONs may be missing
// from the production artifact even though they resolve in `next dev`
// and `next start` locally. Naming each path explicitly forces every
// supported locale's message file into the bundle.
const LOADERS: Record<Locale, () => Promise<{ default: Record<string, unknown> }>> = {
en: () => import('../../messages/en.json'),
es: () => import('../../messages/es.json'),
lt: () => import('../../messages/lt.json'),
};
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale: Locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
// Fall back to English if the requested locale's bundle is missing
// at runtime. A missing locale should display English content rather
// than 503 the whole render path.
let messages: Record<string, unknown>;
try {
messages = (await LOADERS[locale]()).default;
} catch (err) {
console.error(`[i18n] failed to load messages for locale "${locale}", falling back to en:`, err);
messages = (await LOADERS[routing.defaultLocale]()).default;
}
return {
locale,
messages,
};
});

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

@ -0,0 +1,9 @@
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'es', 'lt'],
defaultLocale: 'en',
localePrefix: 'always',
});
export type Locale = (typeof routing.locales)[number];

View File

@ -1,7 +1,60 @@
import { createServerClient } from '@supabase/ssr'
import createIntlMiddleware from 'next-intl/middleware'
import { NextResponse, type NextRequest } from 'next/server'
import { routing } from '@/i18n/routing'
const intlMiddleware = createIntlMiddleware(routing)
// Strip the leading /<locale> segment so the auth path checks below
// stay locale-agnostic. Returns ["en", "/dashboard"] for "/en/dashboard".
function splitLocale(pathname: string): [string | null, string] {
for (const locale of routing.locales) {
if (pathname === `/${locale}`) return [locale, '/']
if (pathname.startsWith(`/${locale}/`)) {
return [locale, pathname.slice(locale.length + 1)]
}
}
return [null, pathname]
}
// Copy any cookies the intl middleware set (e.g. NEXT_LOCALE for
// sticky locale preference) onto the response we hand back, so its
// side-effects survive when auth needs to issue its own response.
function carryIntlCookies(intl: NextResponse, target: NextResponse) {
for (const cookie of intl.cookies.getAll()) {
target.cookies.set(cookie)
}
}
export async function middleware(request: NextRequest) {
const isApiRoute = request.nextUrl.pathname.startsWith('/api/')
// 1. Let next-intl handle locale routing (page routes only — API
// routes don't live under [locale] and would be wrongly prefixed).
// If next-intl redirects (missing locale), return immediately;
// the redirected URL re-enters middleware with the prefix in place.
//
// The intlMiddleware call is wrapped because an unhandled throw
// here would cascade to a 503 in front of every page — we'd
// rather degrade to "no locale routing applied for this request"
// than take the whole site down. Errors are logged so they show
// up in Hostinger's process logs without being swallowed silently.
let intlResponse: NextResponse | null = null
if (!isApiRoute) {
try {
intlResponse = intlMiddleware(request)
if (intlResponse.headers.get('location')) {
return intlResponse
}
} catch (err) {
console.error('[middleware] intl middleware threw, falling through to auth-only:', err)
intlResponse = null
}
}
// 2. Run Supabase auth against the locale-stripped pathname so the
// `/login`, `/dashboard`, etc. checks below don't have to know
// about every locale.
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
@ -13,7 +66,7 @@ export async function middleware(request: NextRequest) {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
@ -24,48 +77,63 @@ export async function middleware(request: NextRequest) {
)
const { data: { user } } = await supabase.auth.getUser()
const [locale, unprefixed] = splitLocale(request.nextUrl.pathname)
const activeLocale = locale ?? routing.defaultLocale
// Auth pages - redirect to dashboard if already logged in.
// Exception: when an invite token is in the query string we
// send the already-signed-in user to /join/<token> instead so
// they can accept the invitation in one click. Without this,
// a forwarded invite link to someone who's already signed in
// would silently drop them on /dashboard.
if (user && (
request.nextUrl.pathname === '/login' ||
request.nextUrl.pathname === '/signup' ||
request.nextUrl.pathname === '/forgot-password'
)) {
// Auth pages — bounce signed-in users to the dashboard, or to the
// invite hand-off if they arrived via an invite link.
if (
user &&
(unprefixed === '/login' ||
unprefixed === '/signup' ||
unprefixed === '/forgot-password')
) {
const url = request.nextUrl.clone()
const inviteToken = request.nextUrl.searchParams.get('invite')
if (
inviteToken &&
(request.nextUrl.pathname === '/login' ||
request.nextUrl.pathname === '/signup')
(unprefixed === '/login' || unprefixed === '/signup')
) {
url.pathname = `/join/${encodeURIComponent(inviteToken)}`
url.pathname = `/${activeLocale}/join/${encodeURIComponent(inviteToken)}`
url.search = ''
} else {
url.pathname = '/dashboard'
url.pathname = `/${activeLocale}/dashboard`
url.search = ''
}
return NextResponse.redirect(url)
const redirectResponse = NextResponse.redirect(url)
if (intlResponse) carryIntlCookies(intlResponse, redirectResponse)
return redirectResponse
}
// Protected pages - redirect to login if not authenticated
const protectedPaths = ['/dashboard', '/inbox', '/contacts', '/pipelines', '/broadcasts', '/automations', '/settings']
if (!user && protectedPaths.some(path => request.nextUrl.pathname.startsWith(path))) {
// Protected pages — bounce signed-out users to login.
const protectedPaths = [
'/dashboard',
'/inbox',
'/contacts',
'/pipelines',
'/broadcasts',
'/automations',
'/flows',
'/settings',
]
if (!user && protectedPaths.some(path => unprefixed.startsWith(path))) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
url.pathname = `/${activeLocale}/login`
const redirectResponse = NextResponse.redirect(url)
if (intlResponse) carryIntlCookies(intlResponse, redirectResponse)
return redirectResponse
}
// API routes that need auth (not webhooks)
if (!user && request.nextUrl.pathname.startsWith('/api/whatsapp/') &&
!request.nextUrl.pathname.includes('/webhook')) {
// API routes that need auth (not webhooks).
if (
!user &&
request.nextUrl.pathname.startsWith('/api/whatsapp/') &&
!request.nextUrl.pathname.includes('/webhook')
) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (intlResponse) carryIntlCookies(intlResponse, supabaseResponse)
return supabaseResponse
}