mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(i18n): scaffold next-intl with [locale] routing + extract auth/layout
Introduces an internationalization layer so wacrm can ship in multiple
languages — initially en (canonical), es, lt (untranslated mirrors of
en, ready for translators).
Foundation
- next-intl ^4.13 (Next 16 + React 19 compatible)
- src/i18n/{routing,navigation,request}.ts — locale config, locale-aware
Link/router/redirect, server-side message loader
- next.config.ts wired with `createNextIntlPlugin`
- src/app/* moved under src/app/[locale]/ (excluding /api and globals.css)
- [locale]/layout.tsx is now the root layout: <html lang={locale}>,
hasLocale guard, setRequestLocale, NextIntlClientProvider,
generateStaticParams for build-time prerendering of all locales
- middleware.ts composes next-intl's createMiddleware with the existing
Supabase auth check: intl middleware runs first to handle locale
prefix; auth redirects retain the active locale (e.g. signed-out user
on /es/dashboard → /es/login); intl cookies (NEXT_LOCALE) are carried
onto auth-issued responses so the sticky preference survives
Strings extracted (~80 keys across en/es/lt.json)
- common, nav (sidebar + header), roles (Owner/Admin/Agent/Viewer chips)
- auth.login, auth.signup, auth.forgotPassword (all forms + success states)
- join (all four UI states: loading, fail, accept, sign-up CTA + conflict modal)
- localeSwitcher
UI
- New `<LocaleSwitcher />` in the header — globe icon + locale dropdown
using next-intl's useLocale + locale-aware useRouter
Out of scope for this PR (intentional — keeps diff reviewable)
- Dashboard feature pages (dashboard, inbox, contacts, pipelines,
broadcasts, automations, flows, settings) and their components/
namespaces still have hardcoded English; they continue to render
fine because next/link redirects through the locale middleware
- Shared UI a11y strings (dialog close, etc.)
- Translating es.json / lt.json — they currently mirror en.json so the
app runs in any locale without crashes (English fallback)
- Renaming middleware.ts → proxy.ts per Next 16 deprecation
Verification
- npm run build → 62 static pages prerendered across en/es/lt
- npm run typecheck → clean
- npm run test → 366 passing
- npm run lint → 0 errors (18 pre-existing warnings untouched)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
962e4fb27d
commit
8a8758a487
145
messages/en.json
Normal file
145
messages/en.json
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"submit": "Submit",
|
||||||
|
"retry": "Try again",
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"appName": "wacrm",
|
||||||
|
"appTagline": "CRM Template for WhatsApp",
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"inbox": "Inbox",
|
||||||
|
"contacts": "Contacts",
|
||||||
|
"pipelines": "Pipelines",
|
||||||
|
"broadcasts": "Broadcasts",
|
||||||
|
"automations": "Automations",
|
||||||
|
"flows": "Flows",
|
||||||
|
"settings": "Settings",
|
||||||
|
"beta": "Beta",
|
||||||
|
"betaFeatureAria": "Beta feature",
|
||||||
|
"unreadAria": "{count, plural, =1 {# unread conversation} other {# unread conversations}}",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"closeMenu": "Close menu",
|
||||||
|
"primaryAria": "Primary",
|
||||||
|
"openAccountMenu": "Open account menu",
|
||||||
|
"avatarAlt": "Avatar",
|
||||||
|
"userFallback": "User",
|
||||||
|
"profile": "Profile",
|
||||||
|
"signOut": "Sign out"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"owner": "Owner",
|
||||||
|
"admin": "Admin",
|
||||||
|
"agent": "Agent",
|
||||||
|
"viewer": "Viewer"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": {
|
||||||
|
"title": "Welcome back",
|
||||||
|
"titleInvite": "Sign in to accept",
|
||||||
|
"subtitle": "Sign in to your account",
|
||||||
|
"subtitleInvite": "Sign in and we'll take you to the invitation.",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "Enter your password",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"submit": "Sign in",
|
||||||
|
"submitting": "Signing in...",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"createAccount": "Create account"
|
||||||
|
},
|
||||||
|
"signup": {
|
||||||
|
"title": "Create account",
|
||||||
|
"titleInvite": "Create account & join",
|
||||||
|
"subtitle": "Get started with CRM Template for WhatsApp",
|
||||||
|
"subtitleInvite": "Verify your email, then accept the invitation to join your team.",
|
||||||
|
"fullNameLabel": "Full name",
|
||||||
|
"fullNamePlaceholder": "John Doe",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "At least 6 characters",
|
||||||
|
"confirmPasswordLabel": "Confirm password",
|
||||||
|
"confirmPasswordPlaceholder": "Repeat your password",
|
||||||
|
"submit": "Create account",
|
||||||
|
"submitting": "Creating account...",
|
||||||
|
"haveAccount": "Already have an account?",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"passwordMismatch": "Passwords do not match",
|
||||||
|
"passwordTooShort": "Password must be at least 6 characters",
|
||||||
|
"checkEmailTitle": "Check your email",
|
||||||
|
"checkEmailBody": "We've sent a confirmation link to <highlight>{email}</highlight>. Please check your inbox and click the link to verify your account.",
|
||||||
|
"backToSignIn": "Back to sign in"
|
||||||
|
},
|
||||||
|
"forgotPassword": {
|
||||||
|
"title": "Reset password",
|
||||||
|
"subtitle": "Enter your email and we'll send you a reset link",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"submit": "Send reset link",
|
||||||
|
"submitting": "Sending...",
|
||||||
|
"checkEmailTitle": "Check your email",
|
||||||
|
"checkEmailBody": "We've sent a password reset link to <highlight>{email}</highlight>. Please check your inbox.",
|
||||||
|
"backToSignIn": "Back to sign in"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"join": {
|
||||||
|
"verifying": "Verifying invitation…",
|
||||||
|
"invitedToTitle": "You're invited to <highlight>{accountName}</highlight>",
|
||||||
|
"invitedAs": "You'll join as <role>{role}</role>. Link valid until {date, date, medium}.",
|
||||||
|
"acceptInvitation": "Accept invitation",
|
||||||
|
"accepting": "Accepting…",
|
||||||
|
"acceptFooter": "Accepting moves your login into <highlight>{accountName}</highlight>. Your empty personal account from signup will be cleaned up.",
|
||||||
|
"createAccountAndJoin": "Create account & join",
|
||||||
|
"alreadyHaveAccount": "I already have an account",
|
||||||
|
"conflictTitle": "Can't join {accountName} with this account",
|
||||||
|
"conflictBody": "To join <highlight>{accountName}</highlight>, sign out and sign up again with a different email address. The invite link stays valid as long as it hasn't expired.",
|
||||||
|
"staySignedIn": "Stay signed in",
|
||||||
|
"signOutAndRetry": "Sign out & use a different email",
|
||||||
|
"signingOut": "Signing out…",
|
||||||
|
"createNewAccountInstead": "Create a new account instead",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"tryAgain": "Try again",
|
||||||
|
"redeemConflictFallback": "You are already in another account. Sign in with a different email to join this one.",
|
||||||
|
"redeemFailedFallback": "Failed to accept invitation",
|
||||||
|
"redeemNetworkError": "Could not reach the server",
|
||||||
|
"signOutError": "Could not sign out. Try refreshing the page.",
|
||||||
|
"welcomeToast": "Welcome to the team",
|
||||||
|
"fail": {
|
||||||
|
"notFound": {
|
||||||
|
"title": "Invite not found",
|
||||||
|
"body": "This link doesn't match a valid invitation. Double-check the URL or ask the person who invited you to send a new one."
|
||||||
|
},
|
||||||
|
"used": {
|
||||||
|
"title": "Invite already used",
|
||||||
|
"body": "This invitation has already been accepted. If that wasn't you, ask the account admin to send a fresh link."
|
||||||
|
},
|
||||||
|
"expired": {
|
||||||
|
"title": "Invite expired",
|
||||||
|
"body": "This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate."
|
||||||
|
},
|
||||||
|
"serverError": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"body": "We couldn't verify this invitation right now. Try refreshing the page in a moment."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"localeSwitcher": {
|
||||||
|
"label": "Language",
|
||||||
|
"ariaLabel": "Change language",
|
||||||
|
"locales": {
|
||||||
|
"en": "English",
|
||||||
|
"es": "Español",
|
||||||
|
"lt": "Lietuvių"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
messages/es.json
Normal file
145
messages/es.json
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"submit": "Submit",
|
||||||
|
"retry": "Try again",
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"appName": "wacrm",
|
||||||
|
"appTagline": "CRM Template for WhatsApp",
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"inbox": "Inbox",
|
||||||
|
"contacts": "Contacts",
|
||||||
|
"pipelines": "Pipelines",
|
||||||
|
"broadcasts": "Broadcasts",
|
||||||
|
"automations": "Automations",
|
||||||
|
"flows": "Flows",
|
||||||
|
"settings": "Settings",
|
||||||
|
"beta": "Beta",
|
||||||
|
"betaFeatureAria": "Beta feature",
|
||||||
|
"unreadAria": "{count, plural, =1 {# unread conversation} other {# unread conversations}}",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"closeMenu": "Close menu",
|
||||||
|
"primaryAria": "Primary",
|
||||||
|
"openAccountMenu": "Open account menu",
|
||||||
|
"avatarAlt": "Avatar",
|
||||||
|
"userFallback": "User",
|
||||||
|
"profile": "Profile",
|
||||||
|
"signOut": "Sign out"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"owner": "Owner",
|
||||||
|
"admin": "Admin",
|
||||||
|
"agent": "Agent",
|
||||||
|
"viewer": "Viewer"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": {
|
||||||
|
"title": "Welcome back",
|
||||||
|
"titleInvite": "Sign in to accept",
|
||||||
|
"subtitle": "Sign in to your account",
|
||||||
|
"subtitleInvite": "Sign in and we'll take you to the invitation.",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "Enter your password",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"submit": "Sign in",
|
||||||
|
"submitting": "Signing in...",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"createAccount": "Create account"
|
||||||
|
},
|
||||||
|
"signup": {
|
||||||
|
"title": "Create account",
|
||||||
|
"titleInvite": "Create account & join",
|
||||||
|
"subtitle": "Get started with CRM Template for WhatsApp",
|
||||||
|
"subtitleInvite": "Verify your email, then accept the invitation to join your team.",
|
||||||
|
"fullNameLabel": "Full name",
|
||||||
|
"fullNamePlaceholder": "John Doe",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "At least 6 characters",
|
||||||
|
"confirmPasswordLabel": "Confirm password",
|
||||||
|
"confirmPasswordPlaceholder": "Repeat your password",
|
||||||
|
"submit": "Create account",
|
||||||
|
"submitting": "Creating account...",
|
||||||
|
"haveAccount": "Already have an account?",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"passwordMismatch": "Passwords do not match",
|
||||||
|
"passwordTooShort": "Password must be at least 6 characters",
|
||||||
|
"checkEmailTitle": "Check your email",
|
||||||
|
"checkEmailBody": "We've sent a confirmation link to <highlight>{email}</highlight>. Please check your inbox and click the link to verify your account.",
|
||||||
|
"backToSignIn": "Back to sign in"
|
||||||
|
},
|
||||||
|
"forgotPassword": {
|
||||||
|
"title": "Reset password",
|
||||||
|
"subtitle": "Enter your email and we'll send you a reset link",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"submit": "Send reset link",
|
||||||
|
"submitting": "Sending...",
|
||||||
|
"checkEmailTitle": "Check your email",
|
||||||
|
"checkEmailBody": "We've sent a password reset link to <highlight>{email}</highlight>. Please check your inbox.",
|
||||||
|
"backToSignIn": "Back to sign in"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"join": {
|
||||||
|
"verifying": "Verifying invitation…",
|
||||||
|
"invitedToTitle": "You're invited to <highlight>{accountName}</highlight>",
|
||||||
|
"invitedAs": "You'll join as <role>{role}</role>. Link valid until {date, date, medium}.",
|
||||||
|
"acceptInvitation": "Accept invitation",
|
||||||
|
"accepting": "Accepting…",
|
||||||
|
"acceptFooter": "Accepting moves your login into <highlight>{accountName}</highlight>. Your empty personal account from signup will be cleaned up.",
|
||||||
|
"createAccountAndJoin": "Create account & join",
|
||||||
|
"alreadyHaveAccount": "I already have an account",
|
||||||
|
"conflictTitle": "Can't join {accountName} with this account",
|
||||||
|
"conflictBody": "To join <highlight>{accountName}</highlight>, sign out and sign up again with a different email address. The invite link stays valid as long as it hasn't expired.",
|
||||||
|
"staySignedIn": "Stay signed in",
|
||||||
|
"signOutAndRetry": "Sign out & use a different email",
|
||||||
|
"signingOut": "Signing out…",
|
||||||
|
"createNewAccountInstead": "Create a new account instead",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"tryAgain": "Try again",
|
||||||
|
"redeemConflictFallback": "You are already in another account. Sign in with a different email to join this one.",
|
||||||
|
"redeemFailedFallback": "Failed to accept invitation",
|
||||||
|
"redeemNetworkError": "Could not reach the server",
|
||||||
|
"signOutError": "Could not sign out. Try refreshing the page.",
|
||||||
|
"welcomeToast": "Welcome to the team",
|
||||||
|
"fail": {
|
||||||
|
"notFound": {
|
||||||
|
"title": "Invite not found",
|
||||||
|
"body": "This link doesn't match a valid invitation. Double-check the URL or ask the person who invited you to send a new one."
|
||||||
|
},
|
||||||
|
"used": {
|
||||||
|
"title": "Invite already used",
|
||||||
|
"body": "This invitation has already been accepted. If that wasn't you, ask the account admin to send a fresh link."
|
||||||
|
},
|
||||||
|
"expired": {
|
||||||
|
"title": "Invite expired",
|
||||||
|
"body": "This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate."
|
||||||
|
},
|
||||||
|
"serverError": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"body": "We couldn't verify this invitation right now. Try refreshing the page in a moment."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"localeSwitcher": {
|
||||||
|
"label": "Language",
|
||||||
|
"ariaLabel": "Change language",
|
||||||
|
"locales": {
|
||||||
|
"en": "English",
|
||||||
|
"es": "Español",
|
||||||
|
"lt": "Lietuvių"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
messages/lt.json
Normal file
145
messages/lt.json
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"submit": "Submit",
|
||||||
|
"retry": "Try again",
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"appName": "wacrm",
|
||||||
|
"appTagline": "CRM Template for WhatsApp",
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"inbox": "Inbox",
|
||||||
|
"contacts": "Contacts",
|
||||||
|
"pipelines": "Pipelines",
|
||||||
|
"broadcasts": "Broadcasts",
|
||||||
|
"automations": "Automations",
|
||||||
|
"flows": "Flows",
|
||||||
|
"settings": "Settings",
|
||||||
|
"beta": "Beta",
|
||||||
|
"betaFeatureAria": "Beta feature",
|
||||||
|
"unreadAria": "{count, plural, =1 {# unread conversation} other {# unread conversations}}",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"closeMenu": "Close menu",
|
||||||
|
"primaryAria": "Primary",
|
||||||
|
"openAccountMenu": "Open account menu",
|
||||||
|
"avatarAlt": "Avatar",
|
||||||
|
"userFallback": "User",
|
||||||
|
"profile": "Profile",
|
||||||
|
"signOut": "Sign out"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"owner": "Owner",
|
||||||
|
"admin": "Admin",
|
||||||
|
"agent": "Agent",
|
||||||
|
"viewer": "Viewer"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": {
|
||||||
|
"title": "Welcome back",
|
||||||
|
"titleInvite": "Sign in to accept",
|
||||||
|
"subtitle": "Sign in to your account",
|
||||||
|
"subtitleInvite": "Sign in and we'll take you to the invitation.",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "Enter your password",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"submit": "Sign in",
|
||||||
|
"submitting": "Signing in...",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"createAccount": "Create account"
|
||||||
|
},
|
||||||
|
"signup": {
|
||||||
|
"title": "Create account",
|
||||||
|
"titleInvite": "Create account & join",
|
||||||
|
"subtitle": "Get started with CRM Template for WhatsApp",
|
||||||
|
"subtitleInvite": "Verify your email, then accept the invitation to join your team.",
|
||||||
|
"fullNameLabel": "Full name",
|
||||||
|
"fullNamePlaceholder": "John Doe",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "At least 6 characters",
|
||||||
|
"confirmPasswordLabel": "Confirm password",
|
||||||
|
"confirmPasswordPlaceholder": "Repeat your password",
|
||||||
|
"submit": "Create account",
|
||||||
|
"submitting": "Creating account...",
|
||||||
|
"haveAccount": "Already have an account?",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"passwordMismatch": "Passwords do not match",
|
||||||
|
"passwordTooShort": "Password must be at least 6 characters",
|
||||||
|
"checkEmailTitle": "Check your email",
|
||||||
|
"checkEmailBody": "We've sent a confirmation link to <highlight>{email}</highlight>. Please check your inbox and click the link to verify your account.",
|
||||||
|
"backToSignIn": "Back to sign in"
|
||||||
|
},
|
||||||
|
"forgotPassword": {
|
||||||
|
"title": "Reset password",
|
||||||
|
"subtitle": "Enter your email and we'll send you a reset link",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"submit": "Send reset link",
|
||||||
|
"submitting": "Sending...",
|
||||||
|
"checkEmailTitle": "Check your email",
|
||||||
|
"checkEmailBody": "We've sent a password reset link to <highlight>{email}</highlight>. Please check your inbox.",
|
||||||
|
"backToSignIn": "Back to sign in"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"join": {
|
||||||
|
"verifying": "Verifying invitation…",
|
||||||
|
"invitedToTitle": "You're invited to <highlight>{accountName}</highlight>",
|
||||||
|
"invitedAs": "You'll join as <role>{role}</role>. Link valid until {date, date, medium}.",
|
||||||
|
"acceptInvitation": "Accept invitation",
|
||||||
|
"accepting": "Accepting…",
|
||||||
|
"acceptFooter": "Accepting moves your login into <highlight>{accountName}</highlight>. Your empty personal account from signup will be cleaned up.",
|
||||||
|
"createAccountAndJoin": "Create account & join",
|
||||||
|
"alreadyHaveAccount": "I already have an account",
|
||||||
|
"conflictTitle": "Can't join {accountName} with this account",
|
||||||
|
"conflictBody": "To join <highlight>{accountName}</highlight>, sign out and sign up again with a different email address. The invite link stays valid as long as it hasn't expired.",
|
||||||
|
"staySignedIn": "Stay signed in",
|
||||||
|
"signOutAndRetry": "Sign out & use a different email",
|
||||||
|
"signingOut": "Signing out…",
|
||||||
|
"createNewAccountInstead": "Create a new account instead",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"tryAgain": "Try again",
|
||||||
|
"redeemConflictFallback": "You are already in another account. Sign in with a different email to join this one.",
|
||||||
|
"redeemFailedFallback": "Failed to accept invitation",
|
||||||
|
"redeemNetworkError": "Could not reach the server",
|
||||||
|
"signOutError": "Could not sign out. Try refreshing the page.",
|
||||||
|
"welcomeToast": "Welcome to the team",
|
||||||
|
"fail": {
|
||||||
|
"notFound": {
|
||||||
|
"title": "Invite not found",
|
||||||
|
"body": "This link doesn't match a valid invitation. Double-check the URL or ask the person who invited you to send a new one."
|
||||||
|
},
|
||||||
|
"used": {
|
||||||
|
"title": "Invite already used",
|
||||||
|
"body": "This invitation has already been accepted. If that wasn't you, ask the account admin to send a fresh link."
|
||||||
|
},
|
||||||
|
"expired": {
|
||||||
|
"title": "Invite expired",
|
||||||
|
"body": "This invitation has expired. Ask the account admin to send a new one — they take a few seconds to generate."
|
||||||
|
},
|
||||||
|
"serverError": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"body": "We couldn't verify this invitation right now. Try refreshing the page in a moment."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"localeSwitcher": {
|
||||||
|
"label": "Language",
|
||||||
|
"ariaLabel": "Change language",
|
||||||
|
"locales": {
|
||||||
|
"en": "English",
|
||||||
|
"es": "Español",
|
||||||
|
"lt": "Lietuvių"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
|
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.
|
||||||
*
|
*
|
||||||
@ -127,4 +130,4 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
687
package-lock.json
generated
687
package-lock.json
generated
@ -22,6 +22,7 @@
|
|||||||
"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",
|
||||||
@ -1015,6 +1016,36 @@
|
|||||||
"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",
|
||||||
@ -2025,6 +2056,313 @@
|
|||||||
"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",
|
||||||
@ -2351,6 +2689,12 @@
|
|||||||
"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",
|
||||||
@ -2490,6 +2834,204 @@
|
|||||||
"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.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@ -2499,6 +3041,15 @@
|
|||||||
"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",
|
||||||
@ -5133,7 +5684,6 @@
|
|||||||
"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"
|
||||||
@ -6760,6 +7310,21 @@
|
|||||||
"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",
|
||||||
@ -6835,6 +7400,16 @@
|
|||||||
"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",
|
||||||
@ -8302,6 +8877,89 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-intl": {
|
||||||
|
"version": "4.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.0.tgz",
|
||||||
|
"integrity": "sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/amannn"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/intl-localematcher": "^0.8.1",
|
||||||
|
"@parcel/watcher": "^2.4.1",
|
||||||
|
"@swc/core": "^1.15.2",
|
||||||
|
"icu-minify": "^4.13.0",
|
||||||
|
"negotiator": "^1.0.0",
|
||||||
|
"next-intl-swc-plugin-extractor": "^4.13.0",
|
||||||
|
"po-parser": "^2.1.1",
|
||||||
|
"use-intl": "^4.13.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-intl-swc-plugin-extractor": {
|
||||||
|
"version": "4.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz",
|
||||||
|
"integrity": "sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/next-intl/node_modules/@swc/core": {
|
||||||
|
"version": "1.15.40",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz",
|
||||||
|
"integrity": "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3",
|
||||||
|
"@swc/types": "^0.1.26"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/swc"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/core-darwin-arm64": "1.15.40",
|
||||||
|
"@swc/core-darwin-x64": "1.15.40",
|
||||||
|
"@swc/core-linux-arm-gnueabihf": "1.15.40",
|
||||||
|
"@swc/core-linux-arm64-gnu": "1.15.40",
|
||||||
|
"@swc/core-linux-arm64-musl": "1.15.40",
|
||||||
|
"@swc/core-linux-ppc64-gnu": "1.15.40",
|
||||||
|
"@swc/core-linux-s390x-gnu": "1.15.40",
|
||||||
|
"@swc/core-linux-x64-gnu": "1.15.40",
|
||||||
|
"@swc/core-linux-x64-musl": "1.15.40",
|
||||||
|
"@swc/core-win32-arm64-msvc": "1.15.40",
|
||||||
|
"@swc/core-win32-ia32-msvc": "1.15.40",
|
||||||
|
"@swc/core-win32-x64-msvc": "1.15.40"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@swc/helpers": ">=0.5.17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@swc/helpers": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"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",
|
||||||
@ -8822,6 +9480,12 @@
|
|||||||
"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",
|
||||||
@ -10802,6 +11466,27 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -52,6 +52,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { 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";
|
||||||
@ -16,6 +17,7 @@ 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);
|
||||||
@ -50,12 +52,15 @@ 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">
|
||||||
Check your email
|
{t("checkEmailTitle")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
We've sent a password reset link to{" "}
|
{t.rich("checkEmailBody", {
|
||||||
<span className="text-white">{email}</span>. Please check your
|
email,
|
||||||
inbox.
|
highlight: (chunks) => (
|
||||||
|
<span className="text-white">{chunks}</span>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -64,7 +69,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"
|
||||||
>
|
>
|
||||||
Back to sign in
|
{t("backToSignIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -80,9 +85,9 @@ export default function ForgotPasswordPage() {
|
|||||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
<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">Reset password</CardTitle>
|
<CardTitle className="text-xl text-white">{t("title")}</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
Enter your email and we'll send you a reset link
|
{t("subtitle")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -95,12 +100,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">
|
||||||
Email
|
{t("emailLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@example.com"
|
placeholder={t("emailPlaceholder")}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -113,7 +118,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 ? "Sending..." : "Send reset link"}
|
{loading ? t("submitting") : t("submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -122,7 +127,7 @@ export default function ForgotPasswordPage() {
|
|||||||
className="mt-6 flex items-center justify-center gap-2 text-sm text-slate-400 hover:text-slate-300"
|
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" />
|
||||||
Back to sign in
|
{t("backToSignIn")}
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import { useTranslations } from "next-intl";
|
||||||
|
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";
|
||||||
@ -30,6 +31,7 @@ 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
|
||||||
@ -78,12 +80,10 @@ function LoginPageInner() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl text-white">
|
<CardTitle className="text-xl text-white">
|
||||||
{inviteToken ? "Sign in to accept" : "Welcome back"}
|
{inviteToken ? t("titleInvite") : t("title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
{inviteToken
|
{inviteToken ? t("subtitleInvite") : t("subtitle")}
|
||||||
? "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">
|
||||||
Email
|
{t("emailLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@example.com"
|
placeholder={t("emailPlaceholder")}
|
||||||
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">
|
||||||
Password
|
{t("passwordLabel")}
|
||||||
</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"
|
||||||
>
|
>
|
||||||
Forgot password?
|
{t("forgotPassword")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter your password"
|
placeholder={t("passwordPlaceholder")}
|
||||||
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 ? "Signing in..." : "Sign in"}
|
{loading ? t("submitting") : t("submit")}
|
||||||
</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">
|
||||||
Don't have an account?{" "}
|
{t("noAccount")}{" "}
|
||||||
<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"
|
||||||
>
|
>
|
||||||
Create account
|
{t("createAccount")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -1,8 +1,9 @@
|
|||||||
"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";
|
||||||
@ -27,6 +28,7 @@ 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
|
||||||
@ -49,12 +51,12 @@ function SignupPageInner() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError("Passwords do not match");
|
setError(t("passwordMismatch"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
setError("Password must be at least 6 characters");
|
setError(t("passwordTooShort"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,12 +100,15 @@ 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">
|
||||||
Check your email
|
{t("checkEmailTitle")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
We've sent a confirmation link to{" "}
|
{t.rich("checkEmailBody", {
|
||||||
<span className="text-white">{email}</span>. Please check your
|
email,
|
||||||
inbox and click the link to verify your account.
|
highlight: (chunks) => (
|
||||||
|
<span className="text-white">{chunks}</span>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -118,7 +123,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"
|
||||||
>
|
>
|
||||||
Back to sign in
|
{t("backToSignIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -139,12 +144,10 @@ function SignupPageInner() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl text-white">
|
<CardTitle className="text-xl text-white">
|
||||||
{inviteToken ? "Create account & join" : "Create account"}
|
{inviteToken ? t("titleInvite") : t("title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
{inviteToken
|
{inviteToken ? t("subtitleInvite") : t("subtitle")}
|
||||||
? "Verify your email, then accept the invitation to join your team."
|
|
||||||
: "Get started with CRM Template for WhatsApp"}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -157,12 +160,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">
|
||||||
Full name
|
{t("fullNameLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="fullName"
|
id="fullName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="John Doe"
|
placeholder={t("fullNamePlaceholder")}
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -172,12 +175,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">
|
||||||
Email
|
{t("emailLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@example.com"
|
placeholder={t("emailPlaceholder")}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -187,12 +190,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">
|
||||||
Password
|
{t("passwordLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="At least 6 characters"
|
placeholder={t("passwordPlaceholder")}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -202,12 +205,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">
|
||||||
Confirm password
|
{t("confirmPasswordLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Repeat your password"
|
placeholder={t("confirmPasswordPlaceholder")}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
@ -220,12 +223,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 ? "Creating account..." : "Create account"}
|
{loading ? t("submitting") : t("submit")}
|
||||||
</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">
|
||||||
Already have an account?{" "}
|
{t("haveAccount")}{" "}
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
inviteToken
|
inviteToken
|
||||||
@ -234,7 +237,7 @@ function SignupPageInner() {
|
|||||||
}
|
}
|
||||||
className="text-primary hover:text-primary/80"
|
className="text-primary hover:text-primary/80"
|
||||||
>
|
>
|
||||||
Sign in
|
{t("signIn")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -23,8 +23,9 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
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,
|
||||||
@ -65,32 +66,16 @@ interface PeekFail {
|
|||||||
}
|
}
|
||||||
type PeekResult = PeekOk | PeekFail;
|
type PeekResult = PeekOk | PeekFail;
|
||||||
|
|
||||||
const ROLE_LABEL: Record<PeekOk['role'], string> = {
|
const FAIL_REASON_KEY: Record<PeekFail['reason'], string> = {
|
||||||
admin: 'Admin',
|
not_found: 'notFound',
|
||||||
agent: 'Agent',
|
used: 'used',
|
||||||
viewer: 'Viewer',
|
expired: 'expired',
|
||||||
};
|
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;
|
||||||
|
|
||||||
@ -182,25 +167,24 @@ 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 ||
|
payload.error || t('redeemConflictFallback'),
|
||||||
'You are already in another account. Sign in with a different email to join this one.',
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(payload.error || 'Failed to accept invitation');
|
toast.error(payload.error || t('redeemFailedFallback'));
|
||||||
}
|
}
|
||||||
setAccepting(false);
|
setAccepting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success('Welcome to the team');
|
toast.success(t('welcomeToast'));
|
||||||
// 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('Could not reach the server');
|
toast.error(t('redeemNetworkError'));
|
||||||
setAccepting(false);
|
setAccepting(false);
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token, t]);
|
||||||
|
|
||||||
const handleSignOutAndRetry = useCallback(async () => {
|
const handleSignOutAndRetry = useCallback(async () => {
|
||||||
setSigningOut(true);
|
setSigningOut(true);
|
||||||
@ -212,10 +196,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('Could not sign out. Try refreshing the page.');
|
toast.error(t('signOutError'));
|
||||||
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) {
|
||||||
@ -223,7 +207,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">Verifying invitation…</p>
|
<p className="text-sm text-slate-400">{t('verifying')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@ -231,16 +215,18 @@ export default function JoinPage() {
|
|||||||
|
|
||||||
// ----- Peek failed -----
|
// ----- Peek failed -----
|
||||||
if (!peek.ok) {
|
if (!peek.ok) {
|
||||||
const copy = FAIL_COPY[peek.reason];
|
const reasonKey = FAIL_REASON_KEY[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">{copy.title}</CardTitle>
|
<CardTitle className="text-xl text-white">
|
||||||
|
{t(`fail.${reasonKey}.title`)}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
{copy.body}
|
{t(`fail.${reasonKey}.body`)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-2">
|
<CardContent className="flex flex-col gap-2">
|
||||||
@ -257,14 +243,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"
|
||||||
>
|
>
|
||||||
Try again
|
{t('tryAgain')}
|
||||||
</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"
|
||||||
>
|
>
|
||||||
Create a new account instead
|
{t('createNewAccountInstead')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@ -272,7 +258,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">
|
||||||
Create a new account instead
|
{t('createNewAccountInstead')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/login">
|
<Link href="/login">
|
||||||
@ -280,7 +266,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"
|
||||||
>
|
>
|
||||||
Sign in
|
{t('signIn')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@ -297,22 +283,21 @@ 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">
|
||||||
You're invited to{' '}
|
{t.rich('invitedToTitle', {
|
||||||
<span className="text-primary">{peek.account_name}</span>
|
accountName: peek.account_name,
|
||||||
|
highlight: (chunks) => <span className="text-primary">{chunks}</span>,
|
||||||
|
})}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-slate-400">
|
<CardDescription className="text-slate-400">
|
||||||
You'll join as{' '}
|
{t.rich('invitedAs', {
|
||||||
<span className="inline-flex items-center gap-1 text-white">
|
role: () => (
|
||||||
<ShieldCheck className="size-3.5 text-primary" />
|
<span className="inline-flex items-center gap-1 text-white">
|
||||||
{ROLE_LABEL[peek.role]}
|
<ShieldCheck className="size-3.5 text-primary" />
|
||||||
</span>
|
{tRoles(peek.role)}
|
||||||
. Link valid until{' '}
|
</span>
|
||||||
{new Date(peek.expires_at).toLocaleDateString(undefined, {
|
),
|
||||||
year: 'numeric',
|
date: new Date(peek.expires_at),
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
})}
|
||||||
.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
);
|
);
|
||||||
@ -332,19 +317,22 @@ export default function JoinPage() {
|
|||||||
{accepting ? (
|
{accepting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Accepting…
|
{t('accepting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="size-4" />
|
<CheckCircle className="size-4" />
|
||||||
Accept invitation
|
{t('acceptInvitation')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-center text-xs text-slate-500">
|
<p className="text-center text-xs text-slate-500">
|
||||||
Accepting moves your login into{' '}
|
{t.rich('acceptFooter', {
|
||||||
<span className="text-slate-400">{peek.account_name}</span>. Your
|
accountName: peek.account_name,
|
||||||
empty personal account from signup will be cleaned up.
|
highlight: (chunks) => (
|
||||||
|
<span className="text-slate-400">{chunks}</span>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -363,7 +351,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" />
|
||||||
Can't join {peek.account_name} with this account
|
{t('conflictTitle', { accountName: peek.account_name })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-slate-400">
|
<DialogDescription className="text-slate-400">
|
||||||
{conflictMessage}
|
{conflictMessage}
|
||||||
@ -371,11 +359,12 @@ 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>
|
||||||
To join{' '}
|
{t.rich('conflictBody', {
|
||||||
<span className="text-slate-300">{peek.account_name}</span>,
|
accountName: peek.account_name,
|
||||||
sign out and sign up again with a different email address.
|
highlight: (chunks) => (
|
||||||
The invite link stays valid as long as it hasn't
|
<span className="text-slate-300">{chunks}</span>
|
||||||
expired.
|
),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||||
@ -384,7 +373,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"
|
||||||
>
|
>
|
||||||
Stay signed in
|
{t('staySignedIn')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSignOutAndRetry}
|
onClick={handleSignOutAndRetry}
|
||||||
@ -394,10 +383,10 @@ export default function JoinPage() {
|
|||||||
{signingOut ? (
|
{signingOut ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Signing out…
|
{t('signingOut')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Sign out & use a different email'
|
t('signOutAndRetry')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@ -414,7 +403,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">
|
||||||
Create account & join
|
{t('createAccountAndJoin')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/login?invite=${encodeURIComponent(token!)}`}>
|
<Link href={`/login?invite=${encodeURIComponent(token!)}`}>
|
||||||
@ -422,7 +411,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"
|
||||||
>
|
>
|
||||||
I already have an account
|
{t('alreadyHaveAccount')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -1,9 +1,13 @@
|
|||||||
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({
|
||||||
@ -40,11 +44,6 @@ 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 {
|
||||||
@ -60,14 +59,28 @@ const THEME_BOOT_SCRIPT = `
|
|||||||
})();
|
})();
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function RootLayout({
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
params,
|
||||||
|
}: {
|
||||||
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="en"
|
lang={locale}
|
||||||
data-theme={DEFAULT_THEME}
|
data-theme={DEFAULT_THEME}
|
||||||
className={`${inter.variable} h-full antialiased`}
|
className={`${inter.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
@ -79,20 +92,22 @@ export default 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">
|
||||||
<ThemeProvider>
|
<NextIntlClientProvider>
|
||||||
{children}
|
<ThemeProvider>
|
||||||
<Toaster
|
{children}
|
||||||
theme="dark"
|
<Toaster
|
||||||
position="top-right"
|
theme="dark"
|
||||||
toastOptions={{
|
position="top-right"
|
||||||
style: {
|
toastOptions={{
|
||||||
background: "rgb(30 41 59)",
|
style: {
|
||||||
border: "1px solid rgb(51 65 85)",
|
background: "rgb(30 41 59)",
|
||||||
color: "white",
|
border: "1px solid rgb(51 65 85)",
|
||||||
},
|
color: "white",
|
||||||
}}
|
},
|
||||||
/>
|
}}
|
||||||
</ThemeProvider>
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
10
src/app/[locale]/page.tsx
Normal file
10
src/app/[locale]/page.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
export default async function RootPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
redirect({ href: "/dashboard", locale });
|
||||||
|
}
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation'
|
|
||||||
|
|
||||||
export default function RootPage() {
|
|
||||||
redirect('/dashboard')
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { useTranslations } from "next-intl";
|
||||||
import { usePathname } from "next/navigation";
|
import { Link, usePathname } from "@/i18n/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,23 +16,27 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { LocaleSwitcher } from "@/components/layout/locale-switcher";
|
||||||
|
|
||||||
const pageTitles: Record<string, string> = {
|
// Maps path → translation key in the `nav` namespace. Order matters
|
||||||
"/dashboard": "Dashboard",
|
// for prefix matches: longest first, but here keys are simple enough
|
||||||
"/inbox": "Inbox",
|
// that order isn't important.
|
||||||
"/contacts": "Contacts",
|
const PAGE_KEYS: Record<string, string> = {
|
||||||
"/pipelines": "Pipelines",
|
"/dashboard": "dashboard",
|
||||||
"/broadcasts": "Broadcasts",
|
"/inbox": "inbox",
|
||||||
"/automations": "Automations",
|
"/contacts": "contacts",
|
||||||
"/settings": "Settings",
|
"/pipelines": "pipelines",
|
||||||
|
"/broadcasts": "broadcasts",
|
||||||
|
"/automations": "automations",
|
||||||
|
"/settings": "settings",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getPageTitle(pathname: string): string {
|
function getPageKey(pathname: string): string {
|
||||||
if (pageTitles[pathname]) return pageTitles[pathname];
|
if (PAGE_KEYS[pathname]) return PAGE_KEYS[pathname];
|
||||||
const match = Object.entries(pageTitles).find(([path]) =>
|
const match = Object.entries(PAGE_KEYS).find(([path]) =>
|
||||||
pathname.startsWith(path),
|
pathname.startsWith(path),
|
||||||
);
|
);
|
||||||
return match ? match[1] : "Dashboard";
|
return match ? match[1] : "dashboard";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@ -42,9 +46,10 @@ 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 = getPageTitle(pathname);
|
const title = t(getPageKey(pathname));
|
||||||
|
|
||||||
const initial =
|
const initial =
|
||||||
profile?.full_name?.charAt(0)?.toUpperCase() ??
|
profile?.full_name?.charAt(0)?.toUpperCase() ??
|
||||||
@ -58,7 +63,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenSidebar}
|
onClick={onOpenSidebar}
|
||||||
aria-label="Open menu"
|
aria-label={t("openMenu")}
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-md text-slate-300 transition-colors hover:bg-slate-800 hover:text-white lg:hidden"
|
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" />
|
||||||
@ -68,16 +73,19 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-2">
|
||||||
|
<LocaleSwitcher />
|
||||||
|
|
||||||
|
<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="Open account menu"
|
aria-label={t("openAccountMenu")}
|
||||||
>
|
>
|
||||||
<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 ?? "Avatar"}
|
alt={profile.full_name ?? t("avatarAlt")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
|
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
|
||||||
@ -85,7 +93,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 ?? "User"}
|
{profile?.full_name ?? t("userFallback")}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@ -95,7 +103,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 ?? "User"}
|
{profile?.full_name ?? t("userFallback")}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-slate-400">
|
<p className="truncate text-xs text-slate-400">
|
||||||
{profile?.email ?? ""}
|
{profile?.email ?? ""}
|
||||||
@ -111,7 +119,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<User className="size-4" />
|
<User className="size-4" />
|
||||||
Profile
|
{t("profile")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
render={
|
render={
|
||||||
@ -122,7 +130,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="size-4" />
|
<SettingsIcon className="size-4" />
|
||||||
Settings
|
{t("settings")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className="bg-slate-800" />
|
<DropdownMenuSeparator className="bg-slate-800" />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@ -130,10 +138,11 @@ 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" />
|
||||||
Sign out
|
{t("signOut")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
73
src/components/layout/locale-switcher.tsx
Normal file
73
src/components/layout/locale-switcher.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { Globe } from "lucide-react";
|
||||||
|
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||||
|
import { routing, type Locale } from "@/i18n/routing";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function LocaleSwitcher() {
|
||||||
|
const t = useTranslations("localeSwitcher");
|
||||||
|
const tLocales = useTranslations("localeSwitcher.locales");
|
||||||
|
const locale = useLocale() as Locale;
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function switchTo(next: Locale) {
|
||||||
|
if (next === locale) return;
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace(pathname, { locale: next });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
aria-label={t("ariaLabel")}
|
||||||
|
disabled={pending}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900 px-2.5 text-xs font-medium text-slate-300 transition-colors",
|
||||||
|
"hover:border-slate-700 hover:bg-slate-800 hover:text-white",
|
||||||
|
"focus:outline-none focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||||
|
"data-popup-open:border-slate-700 data-popup-open:bg-slate-800 data-popup-open:text-white",
|
||||||
|
pending && "cursor-wait opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe className="h-3.5 w-3.5" />
|
||||||
|
<span className="uppercase">{locale}</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
sideOffset={6}
|
||||||
|
className="min-w-40 bg-slate-900 text-slate-100 ring-slate-700"
|
||||||
|
>
|
||||||
|
{routing.locales.map((option) => {
|
||||||
|
const isActive = option === locale;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option}
|
||||||
|
onClick={() => switchTo(option)}
|
||||||
|
className={cn(
|
||||||
|
"text-slate-200 focus:bg-slate-800 focus:text-white",
|
||||||
|
isActive && "bg-primary/10 text-primary focus:text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
{option}
|
||||||
|
</span>
|
||||||
|
<span>{tLocales(option)}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,37 +29,32 @@ 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; label: string; className: string }
|
{ icon: typeof Crown; 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:
|
className: "border-amber-500/40 bg-amber-500/10 text-amber-300",
|
||||||
"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:
|
className: "border-primary/40 bg-primary/10 text-primary",
|
||||||
"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:
|
className: "border-slate-700 bg-slate-800 text-slate-300",
|
||||||
"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:
|
className: "border-slate-800 bg-slate-900 text-slate-500",
|
||||||
"border-slate-800 bg-slate-900 text-slate-500",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
import {
|
import {
|
||||||
@ -77,7 +72,8 @@ import {
|
|||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
/** Key in the `nav` translation namespace. */
|
||||||
|
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.
|
||||||
@ -87,17 +83,17 @@ interface NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/dashboard", key: "dashboard", icon: LayoutDashboard },
|
||||||
{ href: "/inbox", label: "Inbox", icon: MessageSquare },
|
{ href: "/inbox", key: "inbox", icon: MessageSquare },
|
||||||
{ href: "/contacts", label: "Contacts", icon: Users },
|
{ href: "/contacts", key: "contacts", icon: Users },
|
||||||
{ href: "/pipelines", label: "Pipelines", icon: GitBranch },
|
{ href: "/pipelines", key: "pipelines", icon: GitBranch },
|
||||||
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
|
{ href: "/broadcasts", key: "broadcasts", icon: Radio },
|
||||||
{ href: "/automations", label: "Automations", icon: Zap },
|
{ href: "/automations", key: "automations", icon: Zap },
|
||||||
{ href: "/flows", label: "Flows", icon: Workflow, beta: true },
|
{ href: "/flows", key: "flows", icon: Workflow, beta: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const bottomNavItems = [
|
const bottomNavItems: NavItem[] = [
|
||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", key: "settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@ -111,6 +107,9 @@ 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();
|
||||||
@ -155,7 +154,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="Close menu"
|
aria-label={t("closeMenu")}
|
||||||
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",
|
||||||
@ -174,7 +173,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="Primary"
|
aria-label={t("primaryAria")}
|
||||||
>
|
>
|
||||||
{/* 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. */}
|
||||||
@ -184,13 +183,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">
|
||||||
CRM Template for WhatsApp
|
{tApp("appTagline")}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close menu"
|
aria-label={t("closeMenu")}
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-md text-slate-400 hover:bg-slate-800 hover:text-white lg:hidden"
|
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" />
|
||||||
@ -221,18 +220,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">{item.label}</span>
|
<span className="flex-1">{t(item.key)}</span>
|
||||||
{item.beta && (
|
{item.beta && (
|
||||||
<span
|
<span
|
||||||
aria-label="Beta feature"
|
aria-label={t("betaFeatureAria")}
|
||||||
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-300"
|
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-300"
|
||||||
>
|
>
|
||||||
Beta
|
{t("beta")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{showUnreadDot && (
|
{showUnreadDot && (
|
||||||
<span
|
<span
|
||||||
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
|
aria-label={t("unreadAria", { count: totalUnread })}
|
||||||
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" />
|
||||||
@ -262,7 +261,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-4 w-4" />
|
||||||
{item.label}
|
{t(item.key)}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -300,7 +299,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
className={`ml-auto inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${meta.className}`}
|
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" />
|
||||||
{meta.label}
|
{tRoles(accountRole)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
@ -313,7 +312,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 ?? "Avatar"}
|
alt={profile.full_name ?? t("avatarAlt")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
|
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
|
||||||
@ -324,7 +323,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 ?? "User"}
|
{profile?.full_name ?? t("userFallback")}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-slate-400">
|
<p className="truncate text-xs text-slate-400">
|
||||||
{profile?.email ?? ""}
|
{profile?.email ?? ""}
|
||||||
@ -347,7 +346,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<User className="size-4" />
|
<User className="size-4" />
|
||||||
Profile
|
{t("profile")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
render={
|
render={
|
||||||
@ -359,7 +358,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
Settings
|
{t("settings")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className="bg-slate-800" />
|
<DropdownMenuSeparator className="bg-slate-800" />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@ -367,7 +366,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" />
|
||||||
Sign out
|
{t("signOut")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
5
src/i18n/navigation.ts
Normal file
5
src/i18n/navigation.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createNavigation } from 'next-intl/navigation';
|
||||||
|
import { routing } from './routing';
|
||||||
|
|
||||||
|
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||||
|
createNavigation(routing);
|
||||||
15
src/i18n/request.ts
Normal file
15
src/i18n/request.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
9
src/i18n/routing.ts
Normal file
9
src/i18n/routing.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: ['en', 'es', 'lt'],
|
||||||
|
defaultLocale: 'en',
|
||||||
|
localePrefix: 'always',
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Locale = (typeof routing.locales)[number];
|
||||||
@ -1,7 +1,49 @@
|
|||||||
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(
|
||||||
@ -13,7 +55,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
return request.cookies.getAll()
|
return request.cookies.getAll()
|
||||||
},
|
},
|
||||||
setAll(cookiesToSet) {
|
setAll(cookiesToSet) {
|
||||||
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
|
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
|
||||||
supabaseResponse = NextResponse.next({ request })
|
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)
|
||||||
@ -24,48 +66,63 @@ 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 - redirect to dashboard if already logged in.
|
// Auth pages — bounce signed-in users to the dashboard, or to the
|
||||||
// Exception: when an invite token is in the query string we
|
// invite hand-off if they arrived via an invite link.
|
||||||
// send the already-signed-in user to /join/<token> instead so
|
if (
|
||||||
// they can accept the invitation in one click. Without this,
|
user &&
|
||||||
// a forwarded invite link to someone who's already signed in
|
(unprefixed === '/login' ||
|
||||||
// would silently drop them on /dashboard.
|
unprefixed === '/signup' ||
|
||||||
if (user && (
|
unprefixed === '/forgot-password')
|
||||||
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 &&
|
||||||
(request.nextUrl.pathname === '/login' ||
|
(unprefixed === '/login' || unprefixed === '/signup')
|
||||||
request.nextUrl.pathname === '/signup')
|
|
||||||
) {
|
) {
|
||||||
url.pathname = `/join/${encodeURIComponent(inviteToken)}`
|
url.pathname = `/${activeLocale}/join/${encodeURIComponent(inviteToken)}`
|
||||||
url.search = ''
|
url.search = ''
|
||||||
} else {
|
} else {
|
||||||
url.pathname = '/dashboard'
|
url.pathname = `/${activeLocale}/dashboard`
|
||||||
url.search = ''
|
url.search = ''
|
||||||
}
|
}
|
||||||
return NextResponse.redirect(url)
|
const redirectResponse = NextResponse.redirect(url)
|
||||||
|
if (intlResponse) carryIntlCookies(intlResponse, redirectResponse)
|
||||||
|
return redirectResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected pages - redirect to login if not authenticated
|
// Protected pages — bounce signed-out users to login.
|
||||||
const protectedPaths = ['/dashboard', '/inbox', '/contacts', '/pipelines', '/broadcasts', '/automations', '/settings']
|
const protectedPaths = [
|
||||||
if (!user && protectedPaths.some(path => request.nextUrl.pathname.startsWith(path))) {
|
'/dashboard',
|
||||||
|
'/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 = '/login'
|
url.pathname = `/${activeLocale}/login`
|
||||||
return NextResponse.redirect(url)
|
const redirectResponse = 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 (!user && request.nextUrl.pathname.startsWith('/api/whatsapp/') &&
|
if (
|
||||||
!request.nextUrl.pathname.includes('/webhook')) {
|
!user &&
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user