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

View File

@@ -17,6 +17,7 @@ import {
} from '@/components/ui/popover'
import { useMediaQuery } from '@/lib/hooks'
import { Category } from '@prisma/client'
import { useTranslations } from 'next-intl'
import { forwardRef, useEffect, useState } from 'react'
type Props = {
@@ -100,6 +101,7 @@ function CategoryCommand({
categories: Category[]
onValueChange: (categoryId: Category['id']) => void
}) {
const t = useTranslations('Categories')
const categoriesByGroup = categories.reduce<Record<string, Category[]>>(
(acc, category) => ({
...acc,
@@ -110,16 +112,18 @@ function CategoryCommand({
return (
<Command>
<CommandInput placeholder="Search category..." className="text-base" />
<CommandEmpty>No category found.</CommandEmpty>
<CommandInput placeholder={t('search')} className="text-base" />
<CommandEmpty>{t('noCategory')}</CommandEmpty>
<div className="w-full max-h-[300px] overflow-y-auto">
{Object.entries(categoriesByGroup).map(
([group, groupCategories], index) => (
<CommandGroup key={index} heading={group}>
<CommandGroup key={index} heading={t(`${group}.heading`)}>
{groupCategories.map((category) => (
<CommandItem
key={category.id}
value={`${category.id} ${category.grouping} ${category.name}`}
value={`${category.id} ${t(
`${category.grouping}.heading`,
)} ${t(`${category.grouping}.${category.name}`)}`}
onSelect={(currentValue) => {
const id = Number(currentValue.split(' ')[0])
onValueChange(id)
@@ -169,10 +173,11 @@ const CategoryButton = forwardRef<HTMLButtonElement, CategoryButtonProps>(
CategoryButton.displayName = 'CategoryButton'
function CategoryLabel({ category }: { category: Category }) {
const t = useTranslations('Categories')
return (
<div className="flex items-center gap-3">
<CategoryIcon category={category} className="w-4 h-4" />
{category.name}
{t(`${category.grouping}.${category.name}`)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import { Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { AsyncButton } from './async-button'
import { Button } from './ui/button'
import {
@@ -14,20 +15,18 @@ import {
} from './ui/dialog'
export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
const t = useTranslations('ExpenseForm.DeletePopup')
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
{t('label')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Delete this expense?</DialogTitle>
<DialogDescription>
Do you really want to delete this expense? This action is
irreversible.
</DialogDescription>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
<DialogFooter className="flex flex-col gap-2">
<AsyncButton
type="button"
@@ -35,10 +34,10 @@ export function DeletePopup({ onDelete }: { onDelete: () => Promise<void> }) {
loadingContent="Deleting…"
action={onDelete}
>
Yes
{t('yes')}
</AsyncButton>
<DialogClose asChild>
<Button variant={'secondary'}>Cancel</Button>
<Button variant={'secondary'}>{t('cancel')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>

View File

@@ -19,6 +19,7 @@ import { randomId } from '@/lib/api'
import { ExpenseFormValues } from '@/lib/schemas'
import { formatFileSize } from '@/lib/utils'
import { Loader2, Plus, Trash, X } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { getImageData, usePresignedUpload } from 'next-s3-upload'
import Image from 'next/image'
import { useEffect, useState } from 'react'
@@ -31,6 +32,8 @@ type Props = {
const MAX_FILE_SIZE = 5 * 1024 ** 2
export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
const locale = useLocale()
const t = useTranslations('ExpenseDocumentsInput')
const [pending, setPending] = useState(false)
const { FileInput, openFileDialog, uploadToS3 } = usePresignedUpload() // use presigned uploads to addtionally support providers other than AWS
const { toast } = useToast()
@@ -38,10 +41,11 @@ export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
const handleFileChange = async (file: File) => {
if (file.size > MAX_FILE_SIZE) {
toast({
title: 'The file is too big',
description: `The maximum file size you can upload is ${formatFileSize(
MAX_FILE_SIZE,
)}. Yours is ${formatFileSize(file.size)}.`,
title: t('TooBigToast.title'),
description: t('TooBigToast.description', {
maxSize: formatFileSize(MAX_FILE_SIZE, locale),
size: formatFileSize(file.size, locale),
}),
variant: 'destructive',
})
return
@@ -57,13 +61,15 @@ export function ExpenseDocumentsInput({ documents, updateDocuments }: Props) {
} catch (err) {
console.error(err)
toast({
title: 'Error while uploading document',
description:
'Something wrong happened when uploading the document. Please retry later or select a different file.',
title: t('ErrorToast.title'),
description: t('ErrorToast.description'),
variant: 'destructive',
action: (
<ToastAction altText="Retry" onClick={() => upload()}>
Retry
<ToastAction
altText={t('ErrorToast.retry')}
onClick={() => upload()}
>
{t('ErrorToast.retry')}
</ToastAction>
),
})

View File

@@ -44,6 +44,7 @@ import {
import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
@@ -71,9 +72,6 @@ const enforceCurrencyPattern = (value: string) =>
.replace(/#/, '.') // change back # to dot
.replace(/[^-\d.]/g, '') // remove all non-numeric characters
const capitalize = (value: string) =>
value.charAt(0).toUpperCase() + value.slice(1)
const getDefaultSplittingOptions = (group: Props['group']) => {
const defaultValue = {
splitMode: 'EVENLY' as const,
@@ -154,6 +152,7 @@ export function ExpenseForm({
onDelete,
runtimeFeatureFlags,
}: Props) {
const t = useTranslations('ExpenseForm')
const isCreate = expense === undefined
const searchParams = useSearchParams()
const getSelectedPayer = (field?: { value: string }) => {
@@ -249,7 +248,7 @@ export function ExpenseForm({
Set<string>
>(new Set())
const sExpense = isIncome ? 'income' : 'expense'
const sExpense = isIncome ? 'Income' : 'Expense'
const sPaid = isIncome ? 'received' : 'paid'
useEffect(() => {
@@ -322,7 +321,9 @@ export function ExpenseForm({
<form onSubmit={form.handleSubmit(submit)}>
<Card>
<CardHeader>
<CardTitle>{(isCreate ? 'Create ' : 'Edit ') + sExpense}</CardTitle>
<CardTitle>
{t(`${sExpense}.${isCreate ? 'create' : 'edit'}`)}
</CardTitle>
</CardHeader>
<CardContent className="grid sm:grid-cols-2 gap-6">
<FormField
@@ -330,10 +331,10 @@ export function ExpenseForm({
name="title"
render={({ field }) => (
<FormItem className="">
<FormLabel>{capitalize(sExpense)} title</FormLabel>
<FormLabel>{t(`${sExpense}.TitleField.label`)}</FormLabel>
<FormControl>
<Input
placeholder="Monday evening restaurant"
placeholder={t(`${sExpense}.TitleField.placeholder`)}
className="text-base"
{...field}
onBlur={async () => {
@@ -350,7 +351,7 @@ export function ExpenseForm({
/>
</FormControl>
<FormDescription>
Enter a description for the {sExpense}.
{t(`${sExpense}.TitleField.description`)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -362,7 +363,7 @@ export function ExpenseForm({
name="expenseDate"
render={({ field }) => (
<FormItem className="sm:order-1">
<FormLabel>{capitalize(sExpense)} date</FormLabel>
<FormLabel>{t(`${sExpense}.DateField.label`)}</FormLabel>
<FormControl>
<Input
className="date-base"
@@ -374,7 +375,7 @@ export function ExpenseForm({
/>
</FormControl>
<FormDescription>
Enter the date the {sExpense} was {sPaid}.
{t(`${sExpense}.DateField.description`)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -386,7 +387,7 @@ export function ExpenseForm({
name="amount"
render={({ field: { onChange, ...field } }) => (
<FormItem className="sm:order-3">
<FormLabel>Amount</FormLabel>
<FormLabel>{t('amountField.label')}</FormLabel>
<div className="flex items-baseline gap-2">
<span>{group.currency}</span>
<FormControl>
@@ -426,7 +427,9 @@ export function ExpenseForm({
/>
</FormControl>
<div>
<FormLabel>This is a reimbursement</FormLabel>
<FormLabel>
{t('isReimbursementField.label')}
</FormLabel>
</div>
</FormItem>
)}
@@ -441,7 +444,7 @@ export function ExpenseForm({
name="category"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>Category</FormLabel>
<FormLabel>{t('categoryField.label')}</FormLabel>
<CategorySelector
categories={categories}
defaultValue={
@@ -451,7 +454,7 @@ export function ExpenseForm({
isLoading={isCategoryLoading}
/>
<FormDescription>
Select the {sExpense} category.
{t(`${sExpense}.categoryFieldDescription`)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -463,7 +466,7 @@ export function ExpenseForm({
name="paidBy"
render={({ field }) => (
<FormItem className="sm:order-5">
<FormLabel>{capitalize(sPaid)} by</FormLabel>
<FormLabel>{t(`${sExpense}.paidByField.label`)}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={getSelectedPayer(field)}
@@ -480,7 +483,7 @@ export function ExpenseForm({
</SelectContent>
</Select>
<FormDescription>
Select the participant who {sPaid} the {sExpense}.
{t(`${sExpense}.paidByField.description`)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -491,7 +494,7 @@ export function ExpenseForm({
name="notes"
render={({ field }) => (
<FormItem className="sm:order-6">
<FormLabel>Notes</FormLabel>
<FormLabel>{t('notesField.label')}</FormLabel>
<FormControl>
<Textarea className="text-base" {...field} />
</FormControl>
@@ -504,7 +507,7 @@ export function ExpenseForm({
<Card className="mt-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>{capitalize(sPaid)} for</span>
<span>{t(`${sExpense}.paidFor.title`)}</span>
<Button
variant="link"
type="button"
@@ -530,14 +533,14 @@ export function ExpenseForm({
>
{form.getValues().paidFor.length ===
group.participants.length ? (
<>Select none</>
<>{t('selectNone')}</>
) : (
<>Select all</>
<>{t('selectAll')}</>
)}
</Button>
</CardTitle>
<CardDescription>
Select who the {sExpense} was {sPaid} for.
{t(`${sExpense}.paidFor.description`)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -602,7 +605,9 @@ export function ExpenseForm({
})}
>
{match(form.getValues().splitMode)
.with('BY_SHARES', () => <>share(s)</>)
.with('BY_SHARES', () => (
<>{t('shares')}</>
))
.with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => (
<>{group.currency}</>
@@ -700,7 +705,7 @@ export function ExpenseForm({
>
<CollapsibleTrigger asChild>
<Button variant="link" className="-mx-4">
Advanced splitting options
{t('advancedOptions')}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -710,7 +715,7 @@ export function ExpenseForm({
name="splitMode"
render={({ field }) => (
<FormItem>
<FormLabel>Split mode</FormLabel>
<FormLabel>{t('SplitModeField.label')}</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
@@ -726,21 +731,23 @@ export function ExpenseForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVENLY">Evenly</SelectItem>
<SelectItem value="EVENLY">
{t('SplitModeField.evenly')}
</SelectItem>
<SelectItem value="BY_SHARES">
Unevenly By shares
{t('SplitModeField.byShares')}
</SelectItem>
<SelectItem value="BY_PERCENTAGE">
Unevenly By percentage
{t('SplitModeField.byPercentage')}
</SelectItem>
<SelectItem value="BY_AMOUNT">
Unevenly By amount
{t('SplitModeField.byAmount')}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Select how to split the {sExpense}.
{t(`${sExpense}.splitModeDescription`)}
</FormDescription>
</FormItem>
)}
@@ -758,7 +765,7 @@ export function ExpenseForm({
</FormControl>
<div>
<FormLabel>
Save as default splitting options
{t('SplitModeField.saveAsDefault')}
</FormLabel>
</div>
</FormItem>
@@ -774,10 +781,10 @@ export function ExpenseForm({
<Card className="mt-4">
<CardHeader>
<CardTitle className="flex justify-between">
<span>Attach documents</span>
<span>{t('attachDocuments')}</span>
</CardTitle>
<CardDescription>
See and attach receipts to the {sExpense}.
{t(`${sExpense}.attachDescription`)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -796,11 +803,9 @@ export function ExpenseForm({
)}
<div className="flex mt-4 gap-2">
<SubmitButton
loadingContent={isCreate ? <>Creating</> : <>Saving</>}
>
<SubmitButton loadingContent={t(isCreate ? 'creating' : 'saving')}>
<Save className="w-4 h-4 mr-2" />
{isCreate ? <>Create</> : <>Save</>}
{t(isCreate ? 'create' : 'save')}
</SubmitButton>
{!isCreate && onDelete && (
<DeletePopup
@@ -808,7 +813,7 @@ export function ExpenseForm({
></DeletePopup>
)}
<Button variant="ghost" asChild>
<Link href={`/groups/${group.id}`}>Cancel</Link>
<Link href={`/groups/${group.id}`}>{t('cancel')}</Link>
</Button>
</div>
</form>

View File

@@ -35,6 +35,7 @@ import { getGroup } from '@/lib/api'
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save, Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
@@ -53,6 +54,7 @@ export function GroupForm({
onSubmit,
protectedParticipantIds = [],
}: Props) {
const t = useTranslations('GroupForm')
const form = useForm<GroupFormValues>({
resolver: zodResolver(groupFormSchema),
defaultValues: group
@@ -64,7 +66,11 @@ export function GroupForm({
: {
name: '',
currency: '',
participants: [{ name: 'John' }, { name: 'Jane' }, { name: 'Jack' }],
participants: [
{ name: t('Participants.John') },
{ name: t('Participants.Jane') },
{ name: t('Participants.Jack') },
],
},
})
const { fields, append, remove } = useFieldArray({
@@ -79,10 +85,10 @@ export function GroupForm({
const currentActiveUser =
fields.find(
(f) => f.id === localStorage.getItem(`${group?.id}-activeUser`),
)?.name || 'None'
)?.name || t('Settings.ActiveUserField.none')
setActiveUser(currentActiveUser)
}
}, [activeUser, fields, group?.id])
}, [t, activeUser, fields, group?.id])
const updateActiveUser = () => {
if (!activeUser) return
@@ -111,7 +117,7 @@ export function GroupForm({
>
<Card className="mb-4">
<CardHeader>
<CardTitle>Group information</CardTitle>
<CardTitle>{t('title')}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
@@ -119,16 +125,16 @@ export function GroupForm({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Group name</FormLabel>
<FormLabel>{t('NameField.label')}</FormLabel>
<FormControl>
<Input
className="text-base"
placeholder="Summer vacations"
placeholder={t('NameField.placeholder')}
{...field}
/>
</FormControl>
<FormDescription>
Enter a name for your group.
{t('NameField.description')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -140,17 +146,17 @@ export function GroupForm({
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Currency symbol</FormLabel>
<FormLabel>{t('CurrencyField.label')}</FormLabel>
<FormControl>
<Input
className="text-base"
placeholder="$, €, £…"
placeholder={t('CurrencyField.placeholder')}
max={5}
{...field}
/>
</FormControl>
<FormDescription>
Well use it to display amounts.
{t('CurrencyField.description')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -161,10 +167,8 @@ export function GroupForm({
<Card className="mb-4">
<CardHeader>
<CardTitle>Participants</CardTitle>
<CardDescription>
Enter the name for each participant
</CardDescription>
<CardTitle>{t('Participants.title')}</CardTitle>
<CardDescription>{t('Participants.description')}</CardDescription>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-2">
@@ -183,7 +187,7 @@ export function GroupForm({
<Input
className="text-base"
{...field}
placeholder="New"
placeholder={t('Participants.new')}
/>
{item.id &&
protectedParticipantIds.includes(item.id) ? (
@@ -203,8 +207,7 @@ export function GroupForm({
align="end"
className="text-sm"
>
This participant is part of expenses, and can
not be removed.
{t('Participants.protectedParticipant')}
</HoverCardContent>
</HoverCard>
) : (
@@ -236,24 +239,21 @@ export function GroupForm({
}}
type="button"
>
Add participant
{t('Participants.add')}
</Button>
</CardFooter>
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle>Local settings</CardTitle>
<CardDescription>
These settings are set per-device, and are used to customize your
experience.
</CardDescription>
<CardTitle>{t('Settings.title')}</CardTitle>
<CardDescription>{t('Settings.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 gap-4">
{activeUser !== null && (
<FormItem>
<FormLabel>Active user</FormLabel>
<FormLabel>{t('Settings.ActiveUserField.label')}</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
@@ -262,10 +262,17 @@ export function GroupForm({
defaultValue={activeUser}
>
<SelectTrigger>
<SelectValue placeholder="Select a participant" />
<SelectValue
placeholder={t(
'Settings.ActiveUserField.placeholder',
)}
/>
</SelectTrigger>
<SelectContent>
{[{ name: 'None' }, ...form.watch('participants')]
{[
{ name: t('Settings.ActiveUserField.none') },
...form.watch('participants'),
]
.filter((item) => item.name.length > 0)
.map(({ name }) => (
<SelectItem key={name} value={name}>
@@ -276,7 +283,7 @@ export function GroupForm({
</Select>
</FormControl>
<FormDescription>
User used as default for paying expenses.
{t('Settings.ActiveUserField.description')}
</FormDescription>
</FormItem>
)}
@@ -286,14 +293,15 @@ export function GroupForm({
<div className="flex mt-4 gap-2">
<SubmitButton
loadingContent={group ? 'Saving' : 'Creating'}
loadingContent={t(group ? 'Settings.saving' : 'Settings.creating')}
onClick={updateActiveUser}
>
<Save className="w-4 h-4 mr-2" /> {group ? <>Save</> : <> Create</>}
<Save className="w-4 h-4 mr-2" />{' '}
{t(group ? 'Settings.save' : 'Settings.create')}
</SubmitButton>
{!group && (
<Button variant="ghost" asChild>
<Link href="/groups">Cancel</Link>
<Link href="/groups">{t('Settings.cancel')}</Link>
</Button>
)}
</div>

View File

@@ -0,0 +1,33 @@
'use client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { locales } from '@/i18n'
import { setUserLocale } from '@/lib/locale'
import { useLocale, useTranslations } from 'next-intl'
export function LocaleSwitcher() {
const t = useTranslations('Locale')
const locale = useLocale()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" asChild className="-my-3 text-primary">
<span>{t(locale)}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{locales.map((locale) => (
<DropdownMenuItem key={locale} onClick={() => setUserLocale(locale)}>
{t(locale)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,5 +1,6 @@
'use client'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale } from 'next-intl'
type Props = {
currency: string
@@ -14,6 +15,7 @@ export function Money({
bold = false,
colored = false,
}: Props) {
const locale = useLocale()
return (
<span
className={cn(
@@ -25,7 +27,7 @@ export function Money({
bold && 'font-bold',
)}
>
{formatCurrency(currency, amount)}
{formatCurrency(currency, amount, locale)}
</span>
)
}

View File

@@ -12,6 +12,7 @@ import {
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { useMessages } from "next-intl"
const Form = FormProvider
@@ -144,8 +145,18 @@ const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const messages = useMessages()
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
let body
if (error) {
body = String(error?.message)
const translation = (messages.SchemaErrors as any)[body]
if (translation) {
body = translation
}
} else {
body = children
}
if (!body) {
return null

View File

@@ -2,6 +2,7 @@ import * as React from 'react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useTranslations } from 'next-intl'
import { Search, XCircle } from 'lucide-react'
export interface InputProps
@@ -11,6 +12,7 @@ export interface InputProps
const SearchBar = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, onValueChange, ...props }, ref) => {
const t = useTranslations('Expenses')
const [value, _setValue] = React.useState('')
const setValue = (v: string) => {
@@ -28,7 +30,7 @@ const SearchBar = React.forwardRef<HTMLInputElement, InputProps>(
className,
)}
ref={ref}
placeholder="Search for an expense…"
placeholder={t("searchPlaceholder")}
value={value}
onChange={(e) => setValue(e.target.value)}
{...props}