Internationalization + Finnish language (#181)

* I18n with next-intl

* package-lock

* Finnish translations

* Development fix

* Use locale for positioning currency symbol

* Translations: Expenses.ActiveUserModal

* Translations: group 404

* Better translation for ExpenseCard

* Apply translations in CategorySelect search

* Fix for Finnish translation

* Translations for ExpenseDocumentsInput

* Translations for CreateFromReceipt

* Fix for Finnish translation

* Translations for schema errors

* Fix for Finnish translation

* Fixes for Finnish translations

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
This commit is contained in:
Tuomas Jaakola
2024-08-02 18:26:23 +03:00
committed by GitHub
parent c392c06b39
commit 4f5e124ff0
41 changed files with 1439 additions and 396 deletions

42
src/lib/locale.ts Normal file
View File

@@ -0,0 +1,42 @@
'use server'
import { Locale, Locales, defaultLocale, locales } from '@/i18n'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import { cookies, headers } from 'next/headers'
const COOKIE_NAME = 'NEXT_LOCALE'
function getAcceptLanguageLocale(requestHeaders: Headers, locales: Locales) {
let locale
const languages = new Negotiator({
headers: {
'accept-language': requestHeaders.get('accept-language') || undefined,
},
}).languages()
try {
locale = match(languages, locales, defaultLocale)
} catch (e) {
// invalid language
}
return locale
}
export async function getUserLocale() {
let locale
// Prio 1: use existing cookie
locale = cookies().get(COOKIE_NAME)?.value
// Prio 2: use `accept-language` header
// Prio 3: use default locale
if (!locale) {
locale = getAcceptLanguageLocale(headers(), locales)
}
return locale
}
export async function setUserLocale(locale: Locale) {
cookies().set(COOKIE_NAME, locale)
}

View File

@@ -3,22 +3,13 @@ import * as z from 'zod'
export const groupFormSchema = z
.object({
name: z
.string()
.min(2, 'Enter at least two characters.')
.max(50, 'Enter at most 50 characters.'),
currency: z
.string()
.min(1, 'Enter at least one character.')
.max(5, 'Enter at most five characters.'),
name: z.string().min(2, 'min2').max(50, 'max50'),
currency: z.string().min(1, 'min1').max(5, 'max5'),
participants: z
.array(
z.object({
id: z.string().optional(),
name: z
.string()
.min(2, 'Enter at least two characters.')
.max(50, 'Enter at most 50 characters.'),
name: z.string().min(2, 'min2').max(50, 'max50'),
}),
)
.min(1),
@@ -29,7 +20,7 @@ export const groupFormSchema = z
if (otherParticipant.name === participant.name) {
ctx.addIssue({
code: 'custom',
message: 'Another participant already has this name.',
message: 'duplicateParticipantName',
path: ['participants', i, 'name'],
})
}
@@ -42,9 +33,7 @@ export type GroupFormValues = z.infer<typeof groupFormSchema>
export const expenseFormSchema = z
.object({
expenseDate: z.coerce.date(),
title: z
.string({ required_error: 'Please enter a title.' })
.min(2, 'Enter at least two characters.'),
title: z.string({ required_error: 'titleRequired' }).min(2, 'min2'),
category: z.coerce.number().default(0),
amount: z
.union(
@@ -55,19 +44,16 @@ export const expenseFormSchema = z
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid number.',
message: 'invalidNumber',
})
return Math.round(valueAsNumber * 100)
}),
],
{ required_error: 'You must enter an amount.' },
{ required_error: 'amountRequired' },
)
.refine((amount) => amount != 1, 'The amount must not be zero.')
.refine(
(amount) => amount <= 10_000_000_00,
'The amount must be lower than 10,000,000.',
),
paidBy: z.string({ required_error: 'You must select a participant.' }),
.refine((amount) => amount != 1, 'amountNotZero')
.refine((amount) => amount <= 10_000_000_00, 'amountTenMillion'),
paidBy: z.string({ required_error: 'paidByRequired' }),
paidFor: z
.array(
z.object({
@@ -80,14 +66,14 @@ export const expenseFormSchema = z
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid number.',
message: 'invalidNumber',
})
return Math.round(valueAsNumber * 100)
}),
]),
}),
)
.min(1, 'The expense must be paid for at least one participant.')
.min(1, 'paidForMin1')
.superRefine((paidFor, ctx) => {
let sum = 0
for (const { shares } of paidFor) {
@@ -95,7 +81,7 @@ export const expenseFormSchema = z
if (shares < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'All shares must be higher than 0.',
message: 'noZeroShares',
})
}
}
@@ -138,7 +124,7 @@ export const expenseFormSchema = z
: `${((sum - expense.amount) / 100).toFixed(2)} surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Sum of amounts must equal the expense amount (${detail}).`,
message: 'amountSum',
path: ['paidFor'],
})
}
@@ -152,7 +138,7 @@ export const expenseFormSchema = z
: `${((sum - 10000) / 100).toFixed(0)}% surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Sum of percentages must equal 100 (${detail})`,
message: 'percentageSum',
path: ['paidFor'],
})
}

View File

@@ -15,9 +15,10 @@ export type DateTimeStyle = NonNullable<
>['dateStyle']
export function formatDate(
date: Date,
locale: string,
options: { dateStyle?: DateTimeStyle; timeStyle?: DateTimeStyle } = {},
) {
return date.toLocaleString('en-GB', {
return date.toLocaleString(locale, {
...options,
timeZone: 'UTC',
})
@@ -27,18 +28,25 @@ export function formatCategoryForAIPrompt(category: Category) {
return `"${category.grouping}/${category.name}" (ID: ${category.id})`
}
export function formatCurrency(currency: string, amount: number) {
const format = new Intl.NumberFormat('en-US', {
export function formatCurrency(
currency: string,
amount: number,
locale: string,
) {
const format = new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
style: 'currency',
// '€' will be placed in correct position
currency: 'EUR',
})
const formattedAmount = format.format(amount / 100)
return `${currency} ${formattedAmount}`
return formattedAmount.replace('€', currency)
}
export function formatFileSize(size: number) {
export function formatFileSize(size: number, locale: string) {
const formatNumber = (num: number) =>
num.toLocaleString('en-US', {
num.toLocaleString(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
})