Merge pull request #183 from ArnasDon/revert/i18n-pr-182

revert(i18n): roll back PR #182 to restore production
This commit is contained in:
Arnas Donauskas 2026-05-31 19:44:20 +02:00 committed by GitHub
commit 872f751c07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 243 additions and 1552 deletions

View File

@ -1,145 +0,0 @@
{
"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,145 +0,0 @@
{
"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,145 +0,0 @@
{
"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,8 +1,5 @@
import createNextIntlPlugin from "next-intl/plugin";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
/** /**
* Baseline security headers applied to every response. * Baseline security headers applied to every response.
* *
@ -130,4 +127,4 @@ const nextConfig: NextConfig = {
}, },
}; };
export default withNextIntl(nextConfig); export default nextConfig;

693
package-lock.json generated
View File

@ -22,7 +22,6 @@
"date-fns": "^4.3.0", "date-fns": "^4.3.0",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"next": "16.2.6", "next": "16.2.6",
"next-intl": "^4.13.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"recharts": "^3.8.1", "recharts": "^3.8.1",
@ -1016,36 +1015,6 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT" "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": { "node_modules/@hono/node-server": {
"version": "1.19.14", "version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@ -2056,313 +2025,6 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@reduxjs/toolkit": {
"version": "2.12.0", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
@ -2689,12 +2351,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@sec-ant/readable-stream": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
@ -2834,222 +2490,15 @@
"node": ">=20.0.0" "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": { "node_modules/@swc/helpers": {
"version": "0.5.23", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "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": { "node_modules/@tailwindcss/node": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
@ -5684,6 +5133,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -7310,21 +6760,6 @@
"url": "https://opencollective.com/express" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -7400,16 +6835,6 @@
"node": ">=12" "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": { "node_modules/ip-address": {
"version": "10.2.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
@ -8877,89 +8302,6 @@
} }
} }
}, },
"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": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@ -9480,12 +8822,6 @@
"node": ">=16.20.0" "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": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -11466,27 +10802,6 @@
"punycode": "^2.1.0" "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": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",

View File

@ -52,7 +52,6 @@
"date-fns": "^4.3.0", "date-fns": "^4.3.0",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"next": "16.2.6", "next": "16.2.6",
"next-intl": "^4.13.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"recharts": "^3.8.1", "recharts": "^3.8.1",
@ -78,7 +77,6 @@
"postcss": "^8.5.10", "postcss": "^8.5.10",
"ip-address": "^10.1.1", "ip-address": "^10.1.1",
"fast-uri": "^3.1.2", "fast-uri": "^3.1.2",
"hono": "^4.12.18", "hono": "^4.12.18"
"@swc/helpers": "^0.5.17"
} }
} }

View File

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

View File

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

View File

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

View File

@ -1,10 +0,0 @@
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

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

View File

@ -1,13 +1,9 @@
import type { Metadata, Viewport } from "next"; 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 { Inter } from "next/font/google";
import { notFound } from "next/navigation";
import Script from "next/script"; import Script from "next/script";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import "../globals.css"; import "./globals.css";
import { ThemeProvider } from "@/hooks/use-theme"; import { ThemeProvider } from "@/hooks/use-theme";
import { routing } from "@/i18n/routing";
import { DEFAULT_THEME, STORAGE_KEY, THEME_IDS } from "@/lib/themes"; import { DEFAULT_THEME, STORAGE_KEY, THEME_IDS } from "@/lib/themes";
const inter = Inter({ const inter = Inter({
@ -44,6 +40,11 @@ export const viewport: Viewport = {
// chosen theme is on the <html> element before first paint. Without // chosen theme is on the <html> element before first paint. Without
// this every page load flashes the default Violet for a frame before // this every page load flashes the default Violet for a frame before
// the React tree mounts and applies the picked theme. // 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 = ` const THEME_BOOT_SCRIPT = `
(function(){ (function(){
try { try {
@ -59,28 +60,14 @@ const THEME_BOOT_SCRIPT = `
})(); })();
`; `;
export function generateStaticParams() { export default function RootLayout({
return routing.locales.map((locale) => ({ locale }));
}
export default async function RootLayout({
children, children,
params, }: Readonly<{
}: {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ locale: string }>; }>) {
}) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
return ( return (
<html <html
lang={locale} lang="en"
data-theme={DEFAULT_THEME} data-theme={DEFAULT_THEME}
className={`${inter.variable} h-full antialiased`} className={`${inter.variable} h-full antialiased`}
> >
@ -92,7 +79,6 @@ export default async function RootLayout({
/> />
</head> </head>
<body className="min-h-full bg-background text-foreground font-sans"> <body className="min-h-full bg-background text-foreground font-sans">
<NextIntlClientProvider>
<ThemeProvider> <ThemeProvider>
{children} {children}
<Toaster <Toaster
@ -107,7 +93,6 @@ export default async function RootLayout({
}} }}
/> />
</ThemeProvider> </ThemeProvider>
</NextIntlClientProvider>
</body> </body>
</html> </html>
); );

5
src/app/page.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +0,0 @@
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,49 +1,7 @@
import { createServerClient } from '@supabase/ssr' import { createServerClient } from '@supabase/ssr'
import createIntlMiddleware from 'next-intl/middleware'
import { NextResponse, type NextRequest } from 'next/server' 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) { 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.
let intlResponse: NextResponse | null = null
if (!isApiRoute) {
intlResponse = intlMiddleware(request)
if (intlResponse.headers.get('location')) {
return intlResponse
}
}
// 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 }) let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient( const supabase = createServerClient(
@ -55,7 +13,7 @@ export async function middleware(request: NextRequest) {
return request.cookies.getAll() return request.cookies.getAll()
}, },
setAll(cookiesToSet) { setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({ request }) supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) => cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options) supabaseResponse.cookies.set(name, value, options)
@ -66,63 +24,48 @@ export async function middleware(request: NextRequest) {
) )
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
const [locale, unprefixed] = splitLocale(request.nextUrl.pathname)
const activeLocale = locale ?? routing.defaultLocale
// Auth pages — bounce signed-in users to the dashboard, or to the // Auth pages - redirect to dashboard if already logged in.
// invite hand-off if they arrived via an invite link. // Exception: when an invite token is in the query string we
if ( // send the already-signed-in user to /join/<token> instead so
user && // they can accept the invitation in one click. Without this,
(unprefixed === '/login' || // a forwarded invite link to someone who's already signed in
unprefixed === '/signup' || // would silently drop them on /dashboard.
unprefixed === '/forgot-password') if (user && (
) { request.nextUrl.pathname === '/login' ||
request.nextUrl.pathname === '/signup' ||
request.nextUrl.pathname === '/forgot-password'
)) {
const url = request.nextUrl.clone() const url = request.nextUrl.clone()
const inviteToken = request.nextUrl.searchParams.get('invite') const inviteToken = request.nextUrl.searchParams.get('invite')
if ( if (
inviteToken && inviteToken &&
(unprefixed === '/login' || unprefixed === '/signup') (request.nextUrl.pathname === '/login' ||
request.nextUrl.pathname === '/signup')
) { ) {
url.pathname = `/${activeLocale}/join/${encodeURIComponent(inviteToken)}` url.pathname = `/join/${encodeURIComponent(inviteToken)}`
url.search = '' url.search = ''
} else { } else {
url.pathname = `/${activeLocale}/dashboard` url.pathname = '/dashboard'
url.search = '' url.search = ''
} }
const redirectResponse = NextResponse.redirect(url) return NextResponse.redirect(url)
if (intlResponse) carryIntlCookies(intlResponse, redirectResponse)
return redirectResponse
} }
// Protected pages — bounce signed-out users to login. // Protected pages - redirect to login if not authenticated
const protectedPaths = [ const protectedPaths = ['/dashboard', '/inbox', '/contacts', '/pipelines', '/broadcasts', '/automations', '/settings']
'/dashboard', if (!user && protectedPaths.some(path => request.nextUrl.pathname.startsWith(path))) {
'/inbox',
'/contacts',
'/pipelines',
'/broadcasts',
'/automations',
'/flows',
'/settings',
]
if (!user && protectedPaths.some(path => unprefixed.startsWith(path))) {
const url = request.nextUrl.clone() const url = request.nextUrl.clone()
url.pathname = `/${activeLocale}/login` url.pathname = '/login'
const redirectResponse = NextResponse.redirect(url) return NextResponse.redirect(url)
if (intlResponse) carryIntlCookies(intlResponse, redirectResponse)
return redirectResponse
} }
// API routes that need auth (not webhooks). // API routes that need auth (not webhooks)
if ( if (!user && request.nextUrl.pathname.startsWith('/api/whatsapp/') &&
!user && !request.nextUrl.pathname.includes('/webhook')) {
request.nextUrl.pathname.startsWith('/api/whatsapp/') &&
!request.nextUrl.pathname.includes('/webhook')
) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} }
if (intlResponse) carryIntlCookies(intlResponse, supabaseResponse)
return supabaseResponse return supabaseResponse
} }