From e4488a900b365be03c664adc025a03a2417fe5f7 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 21 May 2026 21:33:22 +0200 Subject: [PATCH] feat: add user locale preference to user_preferences table (#555) * feat: add user locale preference to user_preferences table - Introduced a new column 'locale' in the user_preferences table to store per-user UI language preferences. - Added a CHECK constraint to ensure only supported locales ('sv', 'en') are allowed. - Triggered a schema reload notification for the changes. chore: declare CSS module support in TypeScript - Added a declaration for CSS modules in globals.d.ts to enable TypeScript support for importing CSS files. * feat: add Swish as an invoice payment method in company settings --- CLAUDE.md | 48 + app/(auth)/login/page.tsx | 128 +- app/(auth)/mfa/verify/page.tsx | 39 +- app/(auth)/register/page.tsx | 118 +- app/(auth)/reset-password/page.tsx | 38 +- app/(dashboard)/assets/page.tsx | 57 +- app/(dashboard)/bookkeeping/[id]/page.tsx | 133 +- app/(dashboard)/bookkeeping/page.tsx | 28 +- app/(dashboard)/customers/[id]/page.tsx | 82 +- app/(dashboard)/customers/page.tsx | 45 +- app/(dashboard)/deadlines/page.tsx | 50 +- .../extensions/[sector]/[extension]/page.tsx | 48 +- app/(dashboard)/extensions/[sector]/page.tsx | 19 +- app/(dashboard)/extensions/page.tsx | 19 +- app/(dashboard)/help/page.tsx | 41 +- app/(dashboard)/import/page.tsx | 44 +- app/(dashboard)/invoices/[id]/credit/page.tsx | 96 +- app/(dashboard)/invoices/[id]/page.tsx | 253 +- app/(dashboard)/invoices/new/page.tsx | 222 +- app/(dashboard)/invoices/page.tsx | 133 +- .../invoices/recurring/new/page.tsx | 116 +- app/(dashboard)/invoices/recurring/page.tsx | 54 +- app/(dashboard)/kpi/page.tsx | 10 +- app/(dashboard)/pending/page.tsx | 105 +- app/(dashboard)/reports/page.tsx | 38 +- app/(dashboard)/salary/employees/page.tsx | 44 +- app/(dashboard)/salary/page.tsx | 48 +- app/(dashboard)/settings/account/page.tsx | 85 +- app/(dashboard)/settings/backup/page.tsx | 12 +- app/(dashboard)/settings/banking/page.tsx | 38 +- app/(dashboard)/settings/bookkeeping/page.tsx | 26 +- app/(dashboard)/settings/invoicing/page.tsx | 4 +- app/(dashboard)/settings/salary/page.tsx | 46 +- .../settings/skatteverket/page.tsx | 10 +- .../supplier-invoices/[id]/page.tsx | 215 +- .../supplier-invoices/new/page.tsx | 237 +- app/(dashboard)/supplier-invoices/page.tsx | 68 +- app/(dashboard)/suppliers/[id]/page.tsx | 113 +- app/(dashboard)/suppliers/page.tsx | 66 +- app/(dashboard)/transactions/page.tsx | 166 +- app/api/user/locale/route.ts | 44 + app/companies/new/page.tsx | 51 +- app/invite/[token]/page.tsx | 71 +- app/layout.tsx | 31 +- .../bookkeeping/ChartOfAccountsManager.tsx | 114 +- components/bookkeeping/CorrectionChain.tsx | 29 +- components/bookkeeping/DocumentUploadZone.tsx | 50 +- .../bookkeeping/JournalEntryAttachments.tsx | 12 +- components/bookkeeping/JournalEntryForm.tsx | 114 +- components/bookkeeping/JournalEntryList.tsx | 74 +- .../bookkeeping/JournalEntryStatusBadge.tsx | 97 +- components/common/FiscalYearSelector.tsx | 23 +- components/customers/CustomerForm.tsx | 127 +- components/dashboard/CompanySwitcher.tsx | 12 +- components/dashboard/DashboardContent.tsx | 71 +- components/dashboard/DashboardNav.tsx | 259 +- components/dashboard/WelcomeOnboarding.tsx | 67 +- components/deadlines/TaxTodoWidget.tsx | 30 +- .../deadlines/UpcomingDeadlinesWidget.tsx | 28 +- components/extensions/CategoryBadge.tsx | 14 +- components/extensions/ExtensionCard.tsx | 14 +- components/extensions/SectorCard.tsx | 18 +- .../extensions/general/TicWorkspace.tsx | 84 +- .../invoices/BankDetailsSetupDialog.tsx | 155 +- components/invoices/InvoiceReviewContent.tsx | 46 +- components/invoices/PaymentBookingDialog.tsx | 66 +- components/invoices/SendInvoiceDialog.tsx | 47 +- components/kpi/KPIExpenseMixChart.tsx | 18 +- components/kpi/KPIHeroCards.tsx | 94 +- components/kpi/KPISettingsDialog.tsx | 36 +- components/kpi/KPITopSuppliersChart.tsx | 8 +- components/kpi/KPITrendChart.tsx | 25 +- components/onboarding/BankIdCompanyPicker.tsx | 60 +- components/onboarding/NewUserChecklist.tsx | 43 +- components/onboarding/Step1EntityType.tsx | 52 +- components/onboarding/Step2CompanyDetails.tsx | 50 +- .../onboarding/Step3TaxRegistration.tsx | 78 +- components/onboarding/Step4VatAccounting.tsx | 71 +- components/reports/ReportsNav.tsx | 58 +- components/settings/AccountDangerZone.tsx | 41 +- components/settings/ApiKeysPanel.tsx | 190 +- components/settings/BackupDownloadForm.tsx | 69 +- components/settings/BankDetailsForm.tsx | 26 +- components/settings/BankIdSettings.tsx | 30 +- components/settings/BookingTemplatesPanel.tsx | 154 +- components/settings/CalendarFeedSettings.tsx | 79 +- components/settings/CompanyDangerZone.tsx | 30 +- components/settings/CompanyInfoForm.tsx | 24 +- components/settings/CompanyMembersSection.tsx | 64 +- .../settings/CounterpartyTemplatesPanel.tsx | 135 +- components/settings/FiscalPeriodEditor.tsx | 77 +- components/settings/InvoiceSettingsForm.tsx | 18 +- components/settings/LogoUpload.tsx | 26 +- components/settings/OAuthClientsPanel.tsx | 66 +- components/settings/PdfPrintSettings.tsx | 58 +- components/settings/PeriodLockingSettings.tsx | 20 +- components/settings/SecuritySettings.tsx | 73 +- components/settings/SettingsFormWrapper.tsx | 20 +- components/settings/SettingsSidebar.tsx | 28 +- .../settings/SkatteverketConnectPanel.tsx | 81 +- components/settings/TaxSettingsForm.tsx | 88 +- components/settings/TeamPanel.tsx | 23 +- components/settings/VoucherSeriesManager.tsx | 16 +- .../skattekonto/SkattekontoMatchDialog.tsx | 45 +- components/suppliers/SupplierForm.tsx | 132 +- .../SupplierInvoiceReviewContent.tsx | 76 +- .../transactions/BatchCategorySelector.tsx | 25 +- components/transactions/InboxZeroState.tsx | 18 +- .../transactions/InvoiceMatchDialog.tsx | 66 +- components/transactions/InvoicePicker.tsx | 21 +- components/transactions/QuickReviewDialog.tsx | 66 +- .../transactions/SkattekontoInboxCard.tsx | 20 +- .../transactions/SwipeCategorizationView.tsx | 89 +- components/transactions/TemplatePicker.tsx | 79 +- .../transactions/TransactionBookingDialog.tsx | 20 +- components/transactions/TransactionForm.tsx | 48 +- .../transactions/TransactionHistoryList.tsx | 192 +- .../transactions/TransactionInboxCard.tsx | 64 +- .../transactions/TransactionStatusBar.tsx | 24 +- .../transactions/VatTreatmentSelect.tsx | 8 +- components/transactions/transaction-types.ts | 57 +- components/ui/empty-state.tsx | 59 +- components/ui/retention-notice.tsx | 27 +- components/ui/support-link.tsx | 26 +- .../__tests__/voucher-tools.test.ts | 126 +- extensions/general/mcp-server/server.ts | 112 +- i18n/config.ts | 9 + i18n/request.ts | 13 + .../__tests__/booking-templates.test.ts | 133 +- lib/bookkeeping/booking-templates.ts | 280 +- lib/errors/get-error-message.ts | 123 +- lib/extensions/i18n.ts | 95 + lib/hooks/use-format.ts | 20 + lib/reports/kpi-definitions.ts | 41 +- lib/supabase/middleware.ts | 31 +- lib/utils.ts | 11 +- messages/en.json | 3176 +++++++++++++++++ messages/sv.json | 3176 +++++++++++++++++ next.config.ts | 5 +- package-lock.json | 708 +++- package.json | 1 + .../20260521120000_add_user_locale.sql | 15 + ...20260521120050_company_settings_swish.sql} | 0 types/globals.d.ts | 1 + 144 files changed, 12304 insertions(+), 3853 deletions(-) create mode 100644 app/api/user/locale/route.ts create mode 100644 i18n/config.ts create mode 100644 i18n/request.ts create mode 100644 lib/extensions/i18n.ts create mode 100644 lib/hooks/use-format.ts create mode 100644 messages/en.json create mode 100644 messages/sv.json create mode 100644 supabase/migrations/20260521120000_add_user_locale.sql rename supabase/migrations/{20260521120000_company_settings_swish.sql => 20260521120050_company_settings_swish.sql} (100%) create mode 100644 types/globals.d.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1b1963bc..cdff32d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -352,6 +352,54 @@ Never create a NUL/nul file: \gnubok\NUL --- +## i18n + +The app is Swedish-first and bilingual (Swedish + English) for UI chrome. Locale is per-user on `user_preferences.locale` (`'sv' | 'en'`, default `'sv'`), resolved server-side via next-intl. The user picker lives at `/settings/account`. + +**Pattern for new UI:** + +```tsx +// Server component +import { getTranslations } from 'next-intl/server' +const t = await getTranslations('namespace') + +// Client component +'use client' +import { useTranslations } from 'next-intl' +const t = useTranslations('namespace') + +// In JSX + +``` + +Add new strings to both `messages/sv.json` and `messages/en.json` under the matching namespace (`common`, `nav`, `auth`, `settings`, `empty`, etc.). Never ship an English key without a Swedish counterpart — Swedish is the default and the fallback. + +**Locale-aware formatters:** +- `formatCurrency(amount)` — stays SEK with sv-SE conventions in BOTH locales (Swedish accounting standard, not a UI string). +- `formatDate(date)` — ISO `yyyy-MM-dd`, locale-independent. +- `formatDateLong(date, locale)` — accepts locale. In client components use `useFormat()` (`lib/hooks/use-format.ts`) which pulls the active locale. + +**Error messages:** `getErrorMessage(err, { locale, context })` from `lib/errors/get-error-message.ts` is bilingual on the primary maps (Postgres codes, HTTP statuses, context fallbacks, generic fallback). The structured error envelope (`{ error: { code, message, message_en } }`) already carries both; the function picks the right one from `locale`. Pass `useLocale()` / `getLocale()` as the locale arg. + +**Stays Swedish — do NOT translate:** + +| Surface | Reason | +|---|---| +| Invoice PDFs (`lib/invoices/pdf-template.tsx`) | Sent to the user's customers, who are typically Swedish | +| Customer email templates (`lib/email/invoice-templates.ts`, `reminder-templates.ts`) | Same — recipient is the customer, not the app user | +| Year-end wizard (`app/(dashboard)/bookkeeping/year-end/page.tsx`) | Statutory bokslut terminology; English would be misleading | +| Journal entry editor (`app/(dashboard)/bookkeeping/[id]/page.tsx`) | Deeply regulatory (verifikat, voucher numbers, BAS) | +| INK2 / NE-bilaga / SRU (`lib/reports/ink2/**`, `lib/reports/ne-bilaga/**`, `lib/reports/sru-*`) | Skatteverket forms — field codes and labels are statutory | +| SIE export (`lib/reports/sie-export.ts`) | SIE format is Swedish-only by spec (#KONTO, #VER, etc.) | +| BAS chart names (`lib/bookkeeping/bas-data/**`) | Standardized Swedish account names per BAS 2026 | +| VAT declaration ruta labels (`lib/reports/vat-declaration*.ts`) | Momsdeklaration field labels are Skatteverket form labels | +| Salary AGI / KU (`lib/salary/agi*`, `lib/salary/ku*`) | Skatteverket-bound forms | +| Bookkeeping engine domain errors ("Verifikationen balanserar inte", "Bokföringen är låst") | Regulatory concepts; English equivalents would be ambiguous | + +Anything in the table above stays Swedish in BOTH locales. If you find yourself reaching for `t()` inside one of these files, stop and reconsider. + +--- + ## Design Context ### Users diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index d6bc1f9f..37864143 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useState, useEffect } from 'react' import { useRouter, useSearchParams } from 'next/navigation' +import { useLocale, useTranslations } from 'next-intl' import Link from 'next/link' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' @@ -10,7 +11,7 @@ import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { Loader2, Mail, ArrowLeft, KeyRound } from 'lucide-react' import Image from 'next/image' -import { getErrorMessage } from '@/lib/errors/get-error-message' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { isBankIdEnabled } from '@/lib/auth/bankid' import { BankIdAuth } from '@/components/auth/BankIdAuth' import { getBranding } from '@/lib/branding/service' @@ -43,6 +44,9 @@ function LoginPageContent() { const callbackError = searchParams.get('error') const supabase = createClient() const bankIdEnabled = isBankIdEnabled() + const tAuth = useTranslations('auth') + const tCommon = useTranslations('common') + const errorLocale = useLocale() as ErrorLocale // Reset cooldown timer useEffect(() => { @@ -72,8 +76,8 @@ function LoginPageContent() { if (result.error) { toast({ - title: 'Inloggning misslyckades', - description: 'Kunde inte slutföra BankID-inloggningen.', + title: tAuth('login_failed_title'), + description: tAuth('login_failed_bankid'), variant: 'destructive', }) return @@ -89,8 +93,8 @@ function LoginPageContent() { if (error) { console.error('[login] BankID verifyOtp failed', error) toast({ - title: 'Inloggning misslyckades', - description: 'Kunde inte slutfora BankID-inloggningen.', + title: tAuth('login_failed_title'), + description: tAuth('login_failed_bankid'), variant: 'destructive', }) return @@ -126,8 +130,8 @@ function LoginPageContent() { } catch (error) { console.error('[login] BankID complete error', error) toast({ - title: 'Inloggning misslyckades', - description: getErrorMessage(error, { context: 'auth' }), + title: tAuth('login_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) } @@ -150,10 +154,10 @@ function LoginPageContent() { if (error) { toast({ - title: 'Inloggning misslyckades', + title: tAuth('login_failed_title'), description: error.message === 'Invalid login credentials' - ? 'Fel e-post eller lösenord.' - : getErrorMessage(error, { context: 'auth' }), + ? tAuth('login_invalid_credentials') + : getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) return @@ -195,8 +199,8 @@ function LoginPageContent() { router.refresh() } catch (error) { toast({ - title: 'Inloggning misslyckades', - description: getErrorMessage(error, { context: 'auth' }), + title: tAuth('login_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) } finally { @@ -218,8 +222,8 @@ function LoginPageContent() { if (error) { toast({ - title: 'Kunde inte skicka återställningslänk', - description: getErrorMessage(error, { context: 'auth' }), + title: tAuth('reset_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) return @@ -229,13 +233,13 @@ function LoginPageContent() { setResetCooldownUntil(Date.now() + 60_000) setIsEmailSent(true) toast({ - title: 'Återställningslänk skickad!', - description: 'Kolla din inkorg för att återställa lösenordet.', + title: tAuth('reset_sent_title'), + description: tAuth('reset_sent_body'), }) } catch (error) { toast({ - title: 'Kunde inte skicka återställningslänk', - description: getErrorMessage(error, { context: 'auth' }), + title: tAuth('reset_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) } finally { @@ -255,17 +259,23 @@ function LoginPageContent() {
-

Kolla din e-post

+

{tAuth('email_sent_title')}

- Vi har skickat en {showResetPassword ? 'återställningslänk' : 'inloggningslänk'} till{' '} - {email} + {showResetPassword + ? tAuth.rich('email_sent_body_reset', { + email, + strong: (chunks) => {chunks}, + }) + : tAuth.rich('email_sent_body_login', { + email, + strong: (chunks) => {chunks}, + })}

- Klicka på länken i e-posten för att {showResetPassword ? 'återställa ditt lösenord' : 'logga in'}. - Länken är giltig i 1 timme. + {showResetPassword ? tAuth('email_sent_hint_reset') : tAuth('email_sent_hint_login')}

@@ -278,7 +288,7 @@ function LoginPageContent() { }} > - Tillbaka + {tCommon('back')} @@ -296,22 +306,22 @@ function LoginPageContent() { -

Återställ lösenord

+

{tAuth('reset_title')}

- Ange din e-postadress så skickar vi en återställningslänk + {tAuth('reset_subtitle')}

- + setEmail(e.target.value)} required @@ -323,12 +333,12 @@ function LoginPageContent() { {isLoading ? ( <> - Skickar... + {tAuth('reset_sending')} ) : resetCooldownUntil ? ( - `Vänta ${resetCooldownRemaining}s` + tAuth('reset_cooldown', { seconds: resetCooldownRemaining }) ) : ( - 'Skicka återställningslänk' + tAuth('reset_button') )} @@ -340,7 +350,7 @@ function LoginPageContent() { onClick={() => setShowResetPassword(false)} > - Tillbaka till inloggning + {tAuth('back_to_login')}
@@ -360,7 +370,7 @@ function LoginPageContent() { priority />

- Logga in för att hantera din ekonomi + {tAuth('login_subtitle')}

@@ -368,16 +378,16 @@ function LoginPageContent() { {callbackError === 'auth_error' && (

- Återställningslänken fungerade inte + {tAuth('callback_error_title')}

- Länken har gått ut eller använts redan.{' '} + {tAuth('callback_error_body')}{' '} .

@@ -388,10 +398,10 @@ function LoginPageContent() { {bankIdNoAccount ? (

- Hej {bankIdNoAccount.givenName}! + {tAuth('bankid_no_account_greeting', { name: bankIdNoAccount.givenName ?? '' })}

- Vi hittade inget konto kopplat till ditt BankID. Logga in med e-post nedan och koppla sedan BankID i installningar. + {tAuth('bankid_no_account_body')}

@@ -413,7 +423,7 @@ function LoginPageContent() {
- eller logga in med e-post + {tAuth('or_email_divider')}
@@ -421,30 +431,22 @@ function LoginPageContent() { {bankIdUnavailable && (

- Har du inget lösenord? + {tAuth('bankid_unavailable_title')}

- Om du skapade ditt konto med BankID kan du använda{' '} - {' '} - för att få en inloggningslänk via e-post. + {tAuth('bankid_unavailable_body')}

)}
- + setEmail(e.target.value)} required @@ -454,13 +456,13 @@ function LoginPageContent() {
- +
setPassword(e.target.value)} required @@ -480,10 +482,10 @@ function LoginPageContent() { {isLoading ? ( <> - Loggar in... + {tAuth('logging_in')} ) : ( - 'Logga in' + tAuth('login_button') )} @@ -493,7 +495,7 @@ function LoginPageContent() {
- eller + {tAuth('or_divider')}
@@ -503,19 +505,19 @@ function LoginPageContent() { asChild > - Skapa konto + {tAuth('no_account')}

- Genom att logga in godkänner du våra{' '} + {tAuth('terms_prefix')}{' '} - villkor + {tAuth('terms_link')} {' '} - och{' '} + {tAuth('terms_and')}{' '} - integritetspolicy + {tAuth('privacy_link')} .

diff --git a/app/(auth)/mfa/verify/page.tsx b/app/(auth)/mfa/verify/page.tsx index 876ef442..6fa1ea07 100644 --- a/app/(auth)/mfa/verify/page.tsx +++ b/app/(auth)/mfa/verify/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -11,6 +12,8 @@ import { Loader2, ShieldCheck, LogOut } from 'lucide-react' import { SupportLink } from '@/components/ui/support-link' export default function MfaVerifyPage() { + const t = useTranslations('mfa') + const tCommon = useTranslations('common') const [code, setCode] = useState('') const [isLoading, setIsLoading] = useState(false) const [factorId, setFactorId] = useState(null) @@ -29,7 +32,6 @@ export default function MfaVerifyPage() { if (verifiedFactor) { setFactorId(verifiedFactor.id) } else { - // No MFA factor enrolled — shouldn't be here router.push('/') } } @@ -38,7 +40,6 @@ export default function MfaVerifyPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // Lockout countdown timer useEffect(() => { if (!lockoutUntil) return const tick = () => { @@ -64,8 +65,8 @@ export default function MfaVerifyPage() { if (challengeError) { toast({ - title: 'Verifiering misslyckades', - description: 'Kunde inte starta verifiering. Försök igen.', + title: t('verify_failed_title'), + description: t('verify_challenge_failed_description'), variant: 'destructive', }) setIsLoading(false) @@ -82,7 +83,6 @@ export default function MfaVerifyPage() { const attempts = failedAttempts + 1 setFailedAttempts(attempts) - // Exponential backoff after 3 failed attempts: 5s, 15s, 30s if (attempts >= 3) { const delays = [5_000, 15_000, 30_000] const delay = delays[Math.min(attempts - 3, delays.length - 1)] @@ -90,8 +90,8 @@ export default function MfaVerifyPage() { } toast({ - title: 'Fel kod', - description: 'Kontrollera koden och försök igen.', + title: t('wrong_code_title'), + description: t('wrong_code_description'), variant: 'destructive', }) setCode('') @@ -100,7 +100,6 @@ export default function MfaVerifyPage() { return } - // Check for pending invite token const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) const inviteToken = cookieMatch?.[1] @@ -127,8 +126,8 @@ export default function MfaVerifyPage() { router.refresh() } catch { toast({ - title: 'Verifiering misslyckades', - description: 'Ett oväntat fel uppstod. Försök igen.', + title: t('verify_failed_title'), + description: t('unexpected_error'), variant: 'destructive', }) } finally { @@ -150,16 +149,16 @@ export default function MfaVerifyPage() { -

Tvåfaktorsverifiering

+

{t('verify_title')}

- Ange den 6-siffriga koden från din autentiseringsapp + {t('verify_subtitle_full')}

- + - Verifierar... + {t('verifying')} ) : lockoutUntil ? ( - `Vänta ${lockoutRemaining}s` + t('wait_seconds', { seconds: lockoutRemaining }) ) : ( - 'Verifiera' + t('verify_button') )} @@ -201,13 +200,13 @@ export default function MfaVerifyPage() { onClick={handleLogout} > - Logga ut + {tCommon('logout')}

- Förlorat din autentiseringsapp?{' '} - - Kontakta support + {t('lost_authenticator')}{' '} + + {t('contact_support')}

diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 01cad102..1c595ff8 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, Suspense } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import Link from 'next/link' +import { useLocale, useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -10,7 +11,7 @@ import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { Loader2, Mail, ArrowLeft } from 'lucide-react' import Image from 'next/image' -import { getErrorMessage } from '@/lib/errors/get-error-message' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { isBankIdEnabled } from '@/lib/auth/bankid' import { BankIdAuth } from '@/components/auth/BankIdAuth' import type { BankIdResult } from '@/components/auth/BankIdAuth' @@ -46,6 +47,8 @@ function RegisterPageContent() { const router = useRouter() const supabase = createClient() const bankIdEnabled = isBankIdEnabled() + const t = useTranslations('register') + const errorLocale = useLocale() as ErrorLocale // When arriving from an invite link, fetch the invite info to pre-fill // and lock the email field so the user registers with the correct address. @@ -74,8 +77,8 @@ function RegisterPageContent() { if (result.error) { toast({ - title: 'BankID misslyckades', - description: 'Kunde inte verifiera din identitet.', + title: t('bankid_failed_title'), + description: t('bankid_failed_description'), variant: 'destructive', }) return @@ -108,21 +111,21 @@ function RegisterPageContent() { if (!res.ok) { if (json.error === 'already_linked') { toast({ - title: 'BankID redan kopplat', - description: 'Detta BankID ar redan kopplat till ett konto. Forsok logga in istallet.', + title: t('bankid_already_linked_title'), + description: t('bankid_already_linked_description'), variant: 'destructive', }) } else if (json.error === 'account_exists') { toast({ - title: 'Kontot finns redan', - description: 'Ett konto med den har e-postadressen finns redan. Logga in och koppla BankID i installningarna.', + title: t('account_exists_title'), + description: t('account_exists_description'), variant: 'destructive', }) router.push('/login') } else { toast({ - title: 'Registrering misslyckades', - description: json.message || json.error || 'Ett ovantat fel uppstod.', + title: t('register_failed_title'), + description: json.message || json.error || t('register_failed_default'), variant: 'destructive', }) } @@ -138,8 +141,8 @@ function RegisterPageContent() { if (error) { console.error('[register] BankID verifyOtp failed', error) toast({ - title: 'Kunde inte slutfora registreringen', - description: getErrorMessage(error, { context: 'auth' }), + title: t('register_failed_complete'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) return @@ -150,8 +153,8 @@ function RegisterPageContent() { } catch (error) { console.error('[register] BankID signup error', error) toast({ - title: 'Registrering misslyckades', - description: getErrorMessage(error, { context: 'auth' }), + title: t('register_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) } finally { @@ -178,8 +181,8 @@ function RegisterPageContent() { if (!isStrongPassword(passwordValue)) { toast({ - title: 'Lösenordet är för svagt', - description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.', + title: t('weak_password_title'), + description: t('weak_password_description'), variant: 'destructive', }) setIsLoading(false) @@ -188,8 +191,8 @@ function RegisterPageContent() { if (passwordValue !== confirmValue) { toast({ - title: 'Lösenorden matchar inte', - description: 'Kontrollera att du skrev samma lösenord i båda fälten.', + title: t('password_mismatch_title'), + description: t('password_mismatch_description'), variant: 'destructive', }) setIsLoading(false) @@ -224,8 +227,8 @@ function RegisterPageContent() { fullError: JSON.stringify(error, Object.getOwnPropertyNames(error)), }) toast({ - title: 'Registrering misslyckades', - description: getErrorMessage(error, { context: 'auth' }), + title: t('register_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) return @@ -298,8 +301,8 @@ function RegisterPageContent() { constructor: error?.constructor?.name, }) toast({ - title: 'Registrering misslyckades', - description: getErrorMessage(error, { context: 'auth' }), + title: t('register_failed_title'), + description: getErrorMessage(error, { context: 'auth', locale: errorLocale }), variant: 'destructive', }) } finally { @@ -318,23 +321,23 @@ function RegisterPageContent() {
-

Kontot finns redan

+

{t('duplicate_title')}

- Det finns redan ett konto kopplat till{' '} + {t('duplicate_body_prefix')}{' '} {duplicateEmail}.

- Logga in med din e-post och lösenord. Om du har glömt lösenordet kan du återställa det via "Glömt lösenord?" på inloggningssidan. + {t('duplicate_hint')}

@@ -362,24 +365,25 @@ function RegisterPageContent() {
-

Bekräfta din e-post

+

{t('confirm_email_title')}

- Vi har skickat en bekräftelselänk till{' '} - {email} + {t.rich('confirm_email_body', { + email, + strong: (chunks) => {chunks}, + })}

- Klicka på länken i e-posten för att aktivera ditt konto. - Länken är giltig i 24 timmar. + {t('confirm_email_hint')}

@@ -400,7 +404,7 @@ function RegisterPageContent() { priority />

- Skapa ett konto för att komma igång + {t('subtitle')}

@@ -415,7 +419,7 @@ function RegisterPageContent() {
- eller skapa konto med e-post + {t('or_email_divider')}
@@ -424,7 +428,7 @@ function RegisterPageContent() { {bankIdUnavailable && !bankIdUser && (

- Skapa konto med e-post och lösenord nedan istället. Du kan koppla BankID i inställningar senare. + {t('bankid_unavailable_body')}

)} @@ -436,17 +440,17 @@ function RegisterPageContent() { {bankIdUser.givenName} {bankIdUser.surname}

- Verifierad med BankID + {t('bankid_verified')}

- + setBankIdEmail(e.target.value)} required @@ -454,17 +458,17 @@ function RegisterPageContent() { className="h-11" />

- Anvands for inloggning och notifieringar. + {t('bankid_email_hint')}

) : (
- + setEmail(e.target.value)} required @@ -499,18 +503,18 @@ function RegisterPageContent() { /> {inviteEmail && (

- Inbjudan skickades till denna adress. + {t('invite_email_hint')}

)}
- + setPassword(e.target.value)} required @@ -520,13 +524,13 @@ function RegisterPageContent() { />
- + setConfirmPassword(e.target.value)} required @@ -539,10 +543,10 @@ function RegisterPageContent() { {isLoading ? ( <> - Skapar konto... + {t('creating')} ) : ( - 'Skapa konto' + t('create_account') )} @@ -550,23 +554,23 @@ function RegisterPageContent() {

- Har du redan ett konto?{' '} + {t('already_have_account')}{' '} - Logga in + {t('sign_in')}

- Genom att skapa konto godkänner du våra{' '} + {t('terms_prefix')}{' '} - villkor + {t('terms_link')} {' '} - och{' '} + {t('terms_and')}{' '} - integritetspolicy + {t('privacy_link')} .

diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index 597a5c87..aa589f05 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -9,6 +10,7 @@ import { useToast } from '@/components/ui/use-toast' import { Loader2, KeyRound } from 'lucide-react' export default function ResetPasswordPage() { + const t = useTranslations('reset_password') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [isLoading, setIsLoading] = useState(false) @@ -27,8 +29,8 @@ export default function ResetPasswordPage() { if (!strong) { toast({ - title: 'Lösenordet är för svagt', - description: 'Lösenordet måste vara minst 8 tecken och innehålla versaler, gemener, siffror och specialtecken.', + title: t('weak_title'), + description: t('weak_description'), variant: 'destructive', }) setIsLoading(false) @@ -37,8 +39,8 @@ export default function ResetPasswordPage() { if (password !== confirmPassword) { toast({ - title: 'Lösenorden matchar inte', - description: 'Kontrollera att du skrev samma lösenord i båda fälten.', + title: t('mismatch_title'), + description: t('weak_description'), variant: 'destructive', }) setIsLoading(false) @@ -60,24 +62,24 @@ export default function ResetPasswordPage() { if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string } toast({ - title: 'Kunde inte uppdatera lösenord', - description: body.error || 'Försök igen senare.', + title: t('save_failed_title'), + description: body.error || t('save_failed_description'), variant: 'destructive', }) return } toast({ - title: 'Lösenord uppdaterat', - description: 'Ditt lösenord har ändrats.', + title: t('saved_title'), + description: t('saved_description'), }) router.push('/') router.refresh() } catch { toast({ - title: 'Något gick fel', - description: 'Försök igen senare.', + title: t('save_failed_title'), + description: t('save_failed_description'), variant: 'destructive', }) } finally { @@ -94,21 +96,21 @@ export default function ResetPasswordPage() { -

Nytt lösenord

+

{t('title')}

- Ange ditt nya lösenord nedan + {t('subtitle')}

- + setPassword(e.target.value)} required @@ -118,12 +120,12 @@ export default function ResetPasswordPage() { />
- + setConfirmPassword(e.target.value)} required @@ -136,10 +138,10 @@ export default function ResetPasswordPage() { {isLoading ? ( <> - Sparar... + {t('submitting')} ) : ( - 'Spara nytt lösenord' + t('submit') )} diff --git a/app/(dashboard)/assets/page.tsx b/app/(dashboard)/assets/page.tsx index 26768dad..11b6d568 100644 --- a/app/(dashboard)/assets/page.tsx +++ b/app/(dashboard)/assets/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -21,23 +22,23 @@ import { formatCurrency, formatDate } from '@/lib/utils' import type { Asset, AssetCategory } from '@/types' import { CreateAssetDialog } from '@/components/bookkeeping/assets/CreateAssetDialog' -const CATEGORY_LABELS: Record = { - immaterial: 'Immateriell', - building: 'Byggnad', - land_improvement: 'Markanläggning', - machinery: 'Maskin', - equipment: 'Inventarier', - vehicle: 'Fordon', - computer: 'Dator', - other_tangible: 'Övriga materiella', +const CATEGORY_LABEL_KEYS: Record = { + immaterial: 'category_immaterial', + building: 'category_building', + land_improvement: 'category_land_improvement', + machinery: 'category_machinery', + equipment: 'category_equipment', + vehicle: 'category_vehicle', + computer: 'category_computer', + other_tangible: 'category_other_tangible', } export default function AssetsPage() { + const t = useTranslations('assets') const [assets, setAssets] = useState(null) const [error, setError] = useState(null) const [dialogOpen, setDialogOpen] = useState(false) - // Bumped on create to re-trigger the effect. const [reloadKey, setReloadKey] = useState(0) useEffect(() => { @@ -46,7 +47,7 @@ export default function AssetsPage() { .then(async (res) => { if (cancelled) return if (!res.ok) { - setError('Kunde inte ladda tillgångar') + setError(t('load_failed')) return } const { data } = (await res.json()) as { data: Asset[] } @@ -55,12 +56,12 @@ export default function AssetsPage() { setAssets(data) }) .catch(() => { - if (!cancelled) setError('Kunde inte ladda tillgångar') + if (!cancelled) setError(t('load_failed')) }) return () => { cancelled = true } - }, [reloadKey]) + }, [reloadKey, t]) const handleCreated = useCallback(() => { setDialogOpen(false) @@ -70,10 +71,10 @@ export default function AssetsPage() { return (
setDialogOpen(true)}> - Ny tillgång + {t('new_asset')} } /> @@ -96,9 +97,9 @@ export default function AssetsPage() { {assets !== null && assets.length === 0 && ( setDialogOpen(true)} /> )} @@ -109,12 +110,12 @@ export default function AssetsPage() { - Namn - Kategori - Anskaffat - Anskaffningsvärde - Avskrivningstid - Status + {t('th_name')} + {t('th_category')} + {t('th_acquired')} + {t('th_acquisition_cost')} + {t('th_useful_life')} + {t('th_status')} @@ -123,7 +124,7 @@ export default function AssetsPage() { return ( {asset.name} - {CATEGORY_LABELS[asset.category]} + {t(CATEGORY_LABEL_KEYS[asset.category])} {formatDate(asset.acquisition_date)} @@ -131,13 +132,13 @@ export default function AssetsPage() { {formatCurrency(Number(asset.acquisition_cost))} - {years} år ({asset.useful_life_months} mån) + {t('useful_life_format', { years, months: asset.useful_life_months })} {asset.disposed_at ? ( - Avyttrad + {t('status_disposed')} ) : ( - Aktiv + {t('status_active')} )} diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 9e9389e0..81843510 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, use } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { AccountNumber } from '@/components/ui/account-number' @@ -11,7 +12,7 @@ import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Penc import { useCanWrite } from '@/lib/hooks/use-can-write' import { formatDate } from '@/lib/utils' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' -import JournalEntryStatusBadge, { sourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge' +import JournalEntryStatusBadge, { useSourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' import CorrectionChain from '@/components/bookkeeping/CorrectionChain' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' @@ -24,6 +25,8 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const router = useRouter() const { canWrite } = useCanWrite() const { toast } = useToast() + const t = useTranslations('journal_detail') + const sourceTypeLabels = useSourceTypeLabels() const [entry, setEntry] = useState(null) const [chain, setChain] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -45,7 +48,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const res = await fetch(`/api/bookkeeping/journal-entries/${id}/chain`) if (!res.ok) { const { error: msg } = await res.json() - setError(msg || 'Kunde inte hämta verifikation') + setError(msg || t('error_load_failed')) return } const { data } = await res.json() @@ -53,11 +56,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i setChain(data.chain) setIsLastInSeries(data.is_last_in_series ?? false) } catch { - setError('Kunde inte hämta verifikation') + setError(t('error_load_failed')) } finally { setIsLoading(false) } - }, [id]) + }, [id, t]) const saveNotes = useCallback(async (value: string) => { setSavingNotes(true) @@ -71,14 +74,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i setEntry(prev => prev ? { ...prev, notes: value || null } : prev) setEditingNotes(false) } else { - toast({ title: 'Kunde inte spara anteckning', variant: 'destructive' }) + toast({ title: t('toast_save_note_failed'), variant: 'destructive' }) } } catch { - toast({ title: 'Kunde inte spara anteckning', variant: 'destructive' }) + toast({ title: t('toast_save_note_failed'), variant: 'destructive' }) } finally { setSavingNotes(false) } - }, [id, toast]) + }, [id, toast, t]) const handleCommit = useCallback(async () => { setIsCommitting(true) @@ -88,19 +91,19 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i if (res.ok) { const posted = result.data toast({ - title: 'Verifikat bokfört', - description: `Verifikat ${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''} har bokförts.`, + title: t('toast_posted_title'), + description: t('toast_posted_description', { voucher: `${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''}` }), }) await fetchData() } else { - toast({ title: 'Kunde inte bokföra', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) + toast({ title: t('toast_post_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) } } catch { - toast({ title: 'Kunde inte bokföra verifikat', variant: 'destructive' }) + toast({ title: t('toast_post_failed_generic'), variant: 'destructive' }) } finally { setIsCommitting(false) } - }, [id, toast, fetchData]) + }, [id, toast, fetchData, t]) const handleDelete = useCallback(async () => { setIsDeleting(true) @@ -110,23 +113,23 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i if (res.ok) { const wasDraft = result.data?.was_draft === true toast({ - title: wasDraft ? 'Utkast raderat' : 'Verifikat raderat', + title: wasDraft ? t('toast_delete_draft_title') : t('toast_delete_entry_title'), description: wasDraft - ? 'Utkastet har tagits bort.' - : `Verifikat ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har raderats.`, + ? t('toast_delete_draft_description') + : t('toast_delete_entry_description', { voucher: `${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''}` }), }) router.push('/bookkeeping') } else { - toast({ title: 'Kunde inte radera', description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) + toast({ title: t('toast_delete_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' }) setShowDeleteConfirm(false) } } catch { - toast({ title: 'Kunde inte radera verifikat', variant: 'destructive' }) + toast({ title: t('toast_delete_failed_generic'), variant: 'destructive' }) setShowDeleteConfirm(false) } finally { setIsDeleting(false) } - }, [id, router, toast]) + }, [id, router, toast, t]) useEffect(() => { fetchData() @@ -136,7 +139,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i return (
-

Laddar verifikation...

+

{t('loading')}

) } @@ -149,11 +152,11 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors" > - Tillbaka till bokföring + {t('back')} -

{error || 'Verifikation hittades inte'}

+

{error || t('error_not_found')}

@@ -190,7 +193,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors" > - Tillbaka till bokföring + {t('back')} {/* Header */} @@ -213,10 +216,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="w-full sm:w-auto" onClick={handleCommit} disabled={!canWrite || isCommitting} - title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined} + title={!canWrite ? t('read_only_tooltip') : undefined} > {!canWrite ? : isCommitting && } - Bokför + {t('post')} )} {(entry.status === 'draft' || isLastInSeries) && ( @@ -226,10 +229,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="w-full sm:w-auto" onClick={() => setShowDeleteConfirm(true)} disabled={!canWrite} - title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined} + title={!canWrite ? t('read_only_tooltip') : undefined} > {!canWrite && } - {entry.status === 'draft' ? 'Radera utkast' : 'Radera verifikat'} + {entry.status === 'draft' ? t('delete_draft') : t('delete_entry')} )} {canCorrect && ( @@ -239,10 +242,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="w-full sm:w-auto" onClick={() => setShowCorrection(true)} disabled={!canWrite} - title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined} + title={!canWrite ? t('read_only_tooltip') : undefined} > {!canWrite && } - Skapa ändringsverifikation + {t('create_correction')} )} {entry.status === 'posted' && ( @@ -252,10 +255,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i className="w-full sm:w-auto" onClick={() => router.push(`/bookkeeping?copy_from=${entry.id}`)} disabled={!canWrite} - title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined} + title={!canWrite ? t('read_only_tooltip') : undefined} > {!canWrite ? : } - Kopiera verifikat + {t('copy_entry')} )} @@ -266,26 +269,26 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
- Verifikationsdetaljer + {t('details_title')}
- Datum + {t('field_date')} {formatDate(entry.entry_date)}
{entry.committed_at && (
- Bokförd + {t('field_posted_at')} {new Date(entry.committed_at).toLocaleDateString('sv-SE')}
)}
- Typ + {t('field_type')} {sourceTypeLabels[entry.source_type] || entry.source_type}
{entry.source_voucher_series && entry.source_voucher_number != null && (
- Ursprungligt verifikat + {t('field_source_voucher')} {entry.source_voucher_series}{entry.source_voucher_number} @@ -296,14 +299,14 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
- Anteckning + {t('field_note')} {!editingNotes && canWrite && ( @@ -314,7 +317,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i