mirror of
https://github.com/spliit-app/spliit.git
synced 2026-02-26 17:36:12 +01:00
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:
42
src/lib/locale.ts
Normal file
42
src/lib/locale.ts
Normal 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)
|
||||
}
|
||||
@@ -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'],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user