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

@@ -4,6 +4,7 @@ import { getGroupExpenses } from '@/lib/api'
import { DateTimeStyle, cn, formatDate } from '@/lib/utils'
import { Activity, ActivityType, Participant } from '@prisma/client'
import { ChevronRight } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
@@ -15,36 +16,27 @@ type Props = {
dateStyle: DateTimeStyle
}
function getSummary(activity: Activity, participantName?: string) {
const participant = participantName ?? 'Someone'
function useSummary(activity: Activity, participantName?: string) {
const t = useTranslations('Activity')
const participant = participantName ?? t('someone')
const expense = activity.data ?? ''
const tr = (key: string) =>
t.rich(key, {
expense,
participant,
em: (chunks) => <em>&ldquo;{chunks}&rdquo;</em>,
strong: (chunks) => <strong>{chunks}</strong>,
})
if (activity.activityType == ActivityType.UPDATE_GROUP) {
return (
<>
Group settings were modified by <strong>{participant}</strong>
</>
)
return <>{tr('settingsModified')}</>
} else if (activity.activityType == ActivityType.CREATE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> created by{' '}
<strong>{participant}</strong>.
</>
)
return <>{tr('expenseCreated')}</>
} else if (activity.activityType == ActivityType.UPDATE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> updated by{' '}
<strong>{participant}</strong>.
</>
)
return <>{tr('expenseUpdated')}</>
} else if (activity.activityType == ActivityType.DELETE_EXPENSE) {
return (
<>
Expense <em>&ldquo;{expense}&rdquo;</em> deleted by{' '}
<strong>{participant}</strong>.
</>
)
return <>{tr('expenseDeleted')}</>
}
}
@@ -56,9 +48,10 @@ export function ActivityItem({
dateStyle,
}: Props) {
const router = useRouter()
const locale = useLocale()
const expenseExists = expense !== undefined
const summary = getSummary(activity, participant?.name)
const summary = useSummary(activity, participant?.name)
return (
<div
@@ -75,11 +68,11 @@ export function ActivityItem({
<div className="flex flex-col justify-between items-start">
{dateStyle !== undefined && (
<div className="mt-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { dateStyle })}
{formatDate(activity.time, locale, { dateStyle })}
</div>
)}
<div className="my-1 text-xs/5 text-muted-foreground">
{formatDate(activity.time, { timeStyle: 'short' })}
{formatDate(activity.time, locale, { timeStyle: 'short' })}
</div>
</div>
<div className="flex-1">

View File

@@ -2,6 +2,7 @@ import { ActivityItem } from '@/app/groups/[groupId]/activity/activity-item'
import { getGroupExpenses } from '@/lib/api'
import { Activity, Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
type Props = {
groupId: string
@@ -11,15 +12,15 @@ type Props = {
}
const DATE_GROUPS = {
TODAY: 'Today',
YESTERDAY: 'Yesterday',
EARLIER_THIS_WEEK: 'Earlier this week',
LAST_WEEK: 'Last week',
EARLIER_THIS_MONTH: 'Earlier this month',
LAST_MONTH: 'Last month',
EARLIER_THIS_YEAR: 'Earlier this year',
LAST_YEAR: 'Last year',
OLDER: 'Older',
TODAY: 'today',
YESTERDAY: 'yesterday',
EARLIER_THIS_WEEK: 'earlierThisWeek',
LAST_WEEK: 'lastWeek',
EARLIER_THIS_MONTH: 'earlierThisMonth',
LAST_MONTH: 'lastMonth',
EARLIER_THIS_YEAR: 'earlierThisYear',
LAST_YEAR: 'lastYear',
OLDER: 'older',
}
function getDateGroup(date: Dayjs, today: Dayjs) {
@@ -63,6 +64,7 @@ export function ActivityList({
expenses,
activities,
}: Props) {
const t = useTranslations('Activity')
const groupedActivitiesByDate = getGroupedActivitiesByDate(activities)
return activities.length > 0 ? (
@@ -82,7 +84,7 @@ export function ActivityList({
'text-muted-foreground text-xs py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
}
>
{dateGroup}
{t(`Groups.${dateGroup}`)}
</div>
{groupActivities.map((activity: Activity) => {
const participant =
@@ -105,8 +107,6 @@ export function ActivityList({
})}
</>
) : (
<p className="px-6 text-sm py-6">
There is not yet any activity in your group.
</p>
<p className="px-6 text-sm py-6">{t('noActivity')}</p>
)
}

View File

@@ -9,6 +9,7 @@ import {
} from '@/components/ui/card'
import { getActivities, getGroupExpenses } from '@/lib/api'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
@@ -20,6 +21,7 @@ export default async function ActivityPage({
}: {
params: { groupId: string }
}) {
const t = await getTranslations('Activity')
const group = await cached.getGroup(groupId)
if (!group) notFound()
@@ -30,10 +32,8 @@ export default async function ActivityPage({
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>
Overview of all activity in this group.
</CardDescription>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<ActivityList

View File

@@ -1,6 +1,7 @@
import { Balances } from '@/lib/balances'
import { cn, formatCurrency } from '@/lib/utils'
import { Participant } from '@prisma/client'
import { useLocale } from 'next-intl'
type Props = {
balances: Balances
@@ -9,6 +10,7 @@ type Props = {
}
export function BalancesList({ balances, participants, currency }: Props) {
const locale = useLocale()
const maxBalance = Math.max(
...Object.values(balances).map((b) => Math.abs(b.total)),
)
@@ -28,7 +30,7 @@ export function BalancesList({ balances, participants, currency }: Props) {
</div>
<div className={cn('w-1/2 relative', isLeft || 'text-right')}>
<div className="absolute inset-0 p-2 z-20">
{formatCurrency(currency, balance)}
{formatCurrency(currency, balance, locale)}
</div>
{balance !== 0 && (
<div

View File

@@ -15,6 +15,7 @@ import {
getSuggestedReimbursements,
} from '@/lib/balances'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
@@ -26,6 +27,7 @@ export default async function GroupPage({
}: {
params: { groupId: string }
}) {
const t = await getTranslations('Balances')
const group = await cached.getGroup(groupId)
if (!group) notFound()
@@ -38,10 +40,8 @@ export default async function GroupPage({
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>Balances</CardTitle>
<CardDescription>
This is the amount that each participant paid or was paid for.
</CardDescription>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardContent>
<BalancesList
@@ -53,11 +53,8 @@ export default async function GroupPage({
</Card>
<Card className="mb-4">
<CardHeader>
<CardTitle>Suggested reimbursements</CardTitle>
<CardDescription>
Here are suggestions for optimized reimbursements between
participants.
</CardDescription>
<CardTitle>{t('Reimbursements.title')}</CardTitle>
<CardDescription>{t('Reimbursements.description')}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ReimbursementList

View File

@@ -2,6 +2,7 @@
import { Money } from '@/components/money'
import { getBalances } from '@/lib/balances'
import { useActiveUser } from '@/lib/hooks'
import { useTranslations } from 'next-intl'
type Props = {
groupId: string
@@ -10,6 +11,7 @@ type Props = {
}
export function ActiveUserBalance({ groupId, currency, expense }: Props) {
const t = useTranslations('ExpenseCard')
const activeUserId = useActiveUser(groupId)
if (activeUserId === null || activeUserId === '' || activeUserId === 'None') {
return null
@@ -33,7 +35,7 @@ export function ActiveUserBalance({ groupId, currency, expense }: Props) {
}
fmtBalance = (
<>
Your balance:{' '}
{t('yourBalance')}{' '}
<Money {...{ currency, amount: balance.total }} bold colored />
{balanceDetail}
</>

View File

@@ -12,7 +12,6 @@ import {
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
@@ -22,6 +21,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { getGroup } from '@/lib/api'
import { useMediaQuery } from '@/lib/hooks'
import { cn } from '@/lib/utils'
import { useTranslations } from 'next-intl'
import { ComponentProps, useEffect, useState } from 'react'
type Props = {
@@ -29,6 +29,7 @@ type Props = {
}
export function ActiveUserModal({ group }: Props) {
const t = useTranslations('Expenses.ActiveUserModal')
const [open, setOpen] = useState(false)
const isDesktop = useMediaQuery('(min-width: 768px)')
@@ -52,16 +53,13 @@ export function ActiveUserModal({ group }: Props) {
<Dialog open={open} onOpenChange={updateOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Who are you?</DialogTitle>
<DialogDescription>
Tell us which participant you are to let us customize how the
information is displayed.
</DialogDescription>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<ActiveUserForm group={group} close={() => setOpen(false)} />
<DialogFooter className="sm:justify-center">
<p className="text-sm text-center text-muted-foreground">
This setting can be changed later in the group settings.
{t('footer')}
</p>
</DialogFooter>
</DialogContent>
@@ -73,11 +71,8 @@ export function ActiveUserModal({ group }: Props) {
<Drawer open={open} onOpenChange={updateOpen}>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Who are you?</DrawerTitle>
<DrawerDescription>
Tell us which participant you are to let us customize how the
information is displayed.
</DrawerDescription>
<DrawerTitle>{t('title')}</DrawerTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DrawerHeader>
<ActiveUserForm
className="px-4"
@@ -86,7 +81,7 @@ export function ActiveUserModal({ group }: Props) {
/>
<DrawerFooter className="pt-2">
<p className="text-sm text-center text-muted-foreground">
This setting can be changed later in the group settings.
{t('footer')}
</p>
</DrawerFooter>
</DrawerContent>
@@ -99,6 +94,7 @@ function ActiveUserForm({
close,
className,
}: ComponentProps<'form'> & { group: Props['group']; close: () => void }) {
const t = useTranslations('Expenses.ActiveUserModal')
const [selected, setSelected] = useState('None')
return (
@@ -115,7 +111,7 @@ function ActiveUserForm({
<div className="flex items-center space-x-2">
<RadioGroupItem value="none" id="none" />
<Label htmlFor="none" className="italic font-normal flex-1">
I dont want to select anyone
{t('nobody')}
</Label>
</div>
{group.participants.map((participant) => (
@@ -128,7 +124,7 @@ function ActiveUserForm({
))}
</div>
</RadioGroup>
<Button type="submit">Save changes</Button>
<Button type="submit">{t('save')}</Button>
</form>
)
}

View File

@@ -29,6 +29,7 @@ import { useMediaQuery } from '@/lib/hooks'
import { formatCurrency, formatDate, formatFileSize } from '@/lib/utils'
import { Category } from '@prisma/client'
import { ChevronRight, FileQuestion, Loader2, Receipt } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { getImageData, usePresignedUpload } from 'next-s3-upload'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
@@ -47,6 +48,8 @@ export function CreateFromReceiptButton({
groupCurrency,
categories,
}: Props) {
const locale = useLocale()
const t = useTranslations('CreateFromReceipt')
const [pending, setPending] = useState(false)
const { uploadToS3, FileInput, openFileDialog } = usePresignedUpload()
const { toast } = useToast()
@@ -60,10 +63,11 @@ export function CreateFromReceiptButton({
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
@@ -82,13 +86,15 @@ export function CreateFromReceiptButton({
} 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>
),
})
@@ -114,26 +120,23 @@ export function CreateFromReceiptButton({
<Button
size="icon"
variant="secondary"
title="Create expense from receipt"
title={t('Dialog.triggerTitle')}
>
<Receipt className="w-4 h-4" />
</Button>
}
title={
<>
<span>Create from receipt</span>
<span>{t('Dialog.title')}</span>
<Badge className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600">
Beta
</Badge>
</>
}
description={<>Extract the expense information from a receipt photo.</>}
description={<>{t('Dialog.description')}</>}
>
<div className="prose prose-sm dark:prose-invert">
<p>
Upload the photo of a receipt, and well scan it to extract the
expense information if we can.
</p>
<p>{t('Dialog.body')}</p>
<div>
<FileInput
onChange={handleFileChange}
@@ -161,16 +164,16 @@ export function CreateFromReceiptButton({
</div>
) : (
<span className="text-xs sm:text-sm text-muted-foreground">
Select image
{t('Dialog.selectImage')}
</span>
)}
</Button>
<div className="col-span-2">
<strong>Title:</strong>
<strong>{t('Dialog.titleLabel')}</strong>
<div>{receiptInfo ? receiptInfo.title ?? <Unknown /> : '…'}</div>
</div>
<div className="col-span-2">
<strong>Category:</strong>
<strong>{t('Dialog.categoryLabel')}</strong>
<div>
{receiptInfo ? (
receiptInfoCategory ? (
@@ -194,11 +197,17 @@ export function CreateFromReceiptButton({
</div>
</div>
<div>
<strong>Amount:</strong>
<strong>{t('Dialog.amountLabel')}</strong>
<div>
{receiptInfo ? (
receiptInfo.amount ? (
<>{formatCurrency(groupCurrency, receiptInfo.amount)}</>
<>
{formatCurrency(
groupCurrency,
receiptInfo.amount,
locale,
)}
</>
) : (
<Unknown />
)
@@ -208,13 +217,15 @@ export function CreateFromReceiptButton({
</div>
</div>
<div>
<strong>Date:</strong>
<strong>{t('Dialog.dateLabel')}</strong>
<div>
{receiptInfo ? (
receiptInfo.date ? (
formatDate(new Date(`${receiptInfo?.date}T12:00:00.000Z`), {
dateStyle: 'medium',
})
formatDate(
new Date(`${receiptInfo?.date}T12:00:00.000Z`),
locale,
{ dateStyle: 'medium' },
)
) : (
<Unknown />
)
@@ -225,7 +236,7 @@ export function CreateFromReceiptButton({
</div>
</div>
</div>
<p>Youll be able to edit the expense information next.</p>
<p>{t('Dialog.editNext')}</p>
<div className="text-center">
<Button
disabled={pending || !receiptInfo}
@@ -244,7 +255,7 @@ export function CreateFromReceiptButton({
)
}}
>
Continue
{t('Dialog.continue')}
</Button>
</div>
</div>
@@ -253,10 +264,11 @@ export function CreateFromReceiptButton({
}
function Unknown() {
const t = useTranslations('CreateFromReceipt')
return (
<div className="flex gap-1 items-center text-muted-foreground">
<FileQuestion className="w-4 h-4" />
<em>Unknown</em>
<em>{t('unknown')}</em>
</div>
)
}

View File

@@ -5,18 +5,40 @@ import { Button } from '@/components/ui/button'
import { getGroupExpenses } from '@/lib/api'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { ChevronRight } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Fragment } from 'react'
type Expense = Awaited<ReturnType<typeof getGroupExpenses>>[number]
function Participants({ expense }: { expense: Expense }) {
const t = useTranslations('ExpenseCard')
const key = expense.amount > 0 ? 'paidBy' : 'receivedBy'
const paidFor = expense.paidFor.map((paidFor, index) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>{paidFor.participant.name}</strong>
</Fragment>
))
const participants = t.rich(key, {
strong: (chunks) => <strong>{chunks}</strong>,
paidBy: expense.paidBy.name,
paidFor: () => paidFor,
forCount: expense.paidFor.length,
})
return <>{participants}</>
}
type Props = {
expense: Awaited<ReturnType<typeof getGroupExpenses>>[number]
expense: Expense
currency: string
groupId: string
}
export function ExpenseCard({ expense, currency, groupId }: Props) {
const router = useRouter()
const locale = useLocale()
return (
<div
@@ -38,14 +60,7 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
{expense.title}
</div>
<div className="text-xs text-muted-foreground">
{expense.amount > 0 ? 'Paid by ' : 'Received by '}
<strong>{expense.paidBy.name}</strong> for{' '}
{expense.paidFor.map((paidFor, index) => (
<Fragment key={index}>
{index !== 0 && <>, </>}
<strong>{paidFor.participant.name}</strong>
</Fragment>
))}
<Participants expense={expense} />
</div>
<div className="text-xs text-muted-foreground">
<ActiveUserBalance {...{ groupId, currency, expense }} />
@@ -58,10 +73,10 @@ export function ExpenseCard({ expense, currency, groupId }: Props) {
expense.isReimbursement ? 'italic' : 'font-bold',
)}
>
{formatCurrency(currency, expense.amount)}
{formatCurrency(currency, expense.amount, locale)}
</div>
<div className="text-xs text-muted-foreground">
{formatDate(expense.expenseDate, { dateStyle: 'medium' })}
{formatDate(expense.expenseDate, locale, { dateStyle: 'medium' })}
</div>
</div>
<Button

View File

@@ -7,6 +7,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { normalizeString } from '@/lib/utils'
import { Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { useInView } from 'react-intersection-observer'
@@ -24,13 +25,13 @@ type Props = {
}
const EXPENSE_GROUPS = {
UPCOMING: 'Upcoming',
THIS_WEEK: 'This week',
EARLIER_THIS_MONTH: 'Earlier this month',
LAST_MONTH: 'Last month',
EARLIER_THIS_YEAR: 'Earlier this year',
LAST_YEAR: 'Last year',
OLDER: 'Older',
UPCOMING: 'upcoming',
THIS_WEEK: 'thisWeek',
EARLIER_THIS_MONTH: 'earlierThisMonth',
LAST_MONTH: 'lastMonth',
EARLIER_THIS_YEAR: 'earlierThisYear',
LAST_YEAR: 'lastYear',
OLDER: 'older',
}
function getExpenseGroup(date: Dayjs, today: Dayjs) {
@@ -76,6 +77,7 @@ export function ExpenseList({
const [isFetching, setIsFetching] = useState(false)
const [expenses, setExpenses] = useState(expensesFirstPage)
const { ref, inView } = useInView()
const t = useTranslations('Expenses')
useEffect(() => {
const activeUser = localStorage.getItem('newGroup-activeUser')
@@ -155,7 +157,7 @@ export function ExpenseList({
'text-muted-foreground text-xs pl-4 sm:pl-6 py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
}
>
{expenseGroup}
{t(`Groups.${expenseGroup}`)}
</div>
{groupExpenses.map((expense) => (
<ExpenseCard
@@ -187,10 +189,10 @@ export function ExpenseList({
</>
) : (
<p className="px-6 text-sm py-6">
Your group doesnt contain any expense yet.{' '}
{t('noExpenses')}{' '}
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/${groupId}/expenses/create`}>
Create the first one
{t('createFirst')}
</Link>
</Button>
</p>

View File

@@ -19,6 +19,7 @@ import {
import { env } from '@/lib/env'
import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
@@ -34,6 +35,7 @@ export default async function GroupExpensesPage({
}: {
params: { groupId: string }
}) {
const t = await getTranslations('Expenses')
const group = await cached.getGroup(groupId)
if (!group) notFound()
@@ -44,10 +46,8 @@ export default async function GroupExpensesPage({
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
<div className="flex flex-1">
<CardHeader className="flex-1 p-4 sm:p-6">
<CardTitle>Expenses</CardTitle>
<CardDescription>
Here are the expenses that you created for your group.
</CardDescription>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
<Button variant="secondary" size="icon" asChild>
@@ -55,7 +55,7 @@ export default async function GroupExpensesPage({
prefetch={false}
href={`/groups/${groupId}/expenses/export/json`}
target="_blank"
title="Export to JSON"
title={t('exportJson')}
>
<Download className="w-4 h-4" />
</Link>
@@ -70,7 +70,7 @@ export default async function GroupExpensesPage({
<Button asChild size="icon">
<Link
href={`/groups/${groupId}/expenses/create`}
title="Create expense"
title={t('create')}
>
<Plus className="w-4 h-4" />
</Link>

View File

@@ -1,5 +1,6 @@
'use client'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTranslations } from 'next-intl'
import { usePathname, useRouter } from 'next/navigation'
type Props = {
@@ -7,6 +8,7 @@ type Props = {
}
export function GroupTabs({ groupId }: Props) {
const t = useTranslations()
const pathname = usePathname()
const value =
pathname.replace(/\/groups\/[^\/]+\/([^/]+).*/, '$1') || 'expenses'
@@ -21,11 +23,11 @@ export function GroupTabs({ groupId }: Props) {
}}
>
<TabsList>
<TabsTrigger value="expenses">Expenses</TabsTrigger>
<TabsTrigger value="balances">Balances</TabsTrigger>
<TabsTrigger value="stats">Stats</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="edit">Settings</TabsTrigger>
<TabsTrigger value="expenses">{t('Expenses.title')}</TabsTrigger>
<TabsTrigger value="balances">{t('Balances.title')}</TabsTrigger>
<TabsTrigger value="stats">{t('Stats.title')}</TabsTrigger>
<TabsTrigger value="activity">{t('Activity.title')}</TabsTrigger>
<TabsTrigger value="edit">{t('Settings.title')}</TabsTrigger>
</TabsList>
</Tabs>
)

View File

@@ -2,6 +2,7 @@ import { Button } from '@/components/ui/button'
import { Reimbursement } from '@/lib/balances'
import { formatCurrency } from '@/lib/utils'
import { Participant } from '@prisma/client'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
type Props = {
@@ -17,12 +18,10 @@ export function ReimbursementList({
currency,
groupId,
}: Props) {
const locale = useLocale()
const t = useTranslations('Balances.Reimbursements')
if (reimbursements.length === 0) {
return (
<p className="px-6 text-sm pb-6">
It looks like your group doesnt need any reimbursement 😁
</p>
)
return <p className="px-6 text-sm pb-6">{t('noImbursements')}</p>
}
const getParticipant = (id: string) => participants.find((p) => p.id === id)
@@ -32,18 +31,21 @@ export function ReimbursementList({
<div className="border-t px-6 py-4 flex justify-between" key={index}>
<div className="flex flex-col gap-1 items-start sm:flex-row sm:items-baseline sm:gap-4">
<div>
<strong>{getParticipant(reimbursement.from)?.name}</strong> owes{' '}
<strong>{getParticipant(reimbursement.to)?.name}</strong>
{t.rich('owes', {
from: getParticipant(reimbursement.from)?.name,
to: getParticipant(reimbursement.to)?.name,
strong: (chunks) => <strong>{chunks}</strong>,
})}
</div>
<Button variant="link" asChild className="-mx-4 -my-3">
<Link
href={`/groups/${groupId}/expenses/create?reimbursement=yes&from=${reimbursement.from}&to=${reimbursement.to}&amount=${reimbursement.amount}`}
>
Mark as paid
{t('markAsPaid')}
</Link>
</Button>
</div>
<div>{formatCurrency(currency, reimbursement.amount)}</div>
<div>{formatCurrency(currency, reimbursement.amount, locale)}</div>
</div>
))}
</div>

View File

@@ -11,27 +11,26 @@ import {
import { useBaseUrl } from '@/lib/hooks'
import { Group } from '@prisma/client'
import { Share } from 'lucide-react'
import { useTranslations } from 'next-intl'
type Props = {
group: Group
}
export function ShareButton({ group }: Props) {
const t = useTranslations('Share')
const baseUrl = useBaseUrl()
const url = baseUrl && `${baseUrl}/groups/${group.id}/expenses?ref=share`
return (
<Popover>
<PopoverTrigger asChild>
<Button title="Share" size="icon" className="flex-shrink-0">
<Button title={t('title')} size="icon" className="flex-shrink-0">
<Share className="w-4 h-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="[&_p]:text-sm flex flex-col gap-3">
<p>
For other participants to see the group and add expenses, share its
URL with them.
</p>
<p>{t('description')}</p>
{url && (
<div className="flex gap-2">
<Input className="flex-1" defaultValue={url} readOnly />
@@ -43,8 +42,7 @@ export function ShareButton({ group }: Props) {
</div>
)}
<p>
<strong>Warning!</strong> Every person with the group URL will be able
to see and edit expenses. Share with caution!
<strong>{t('warning')}</strong> {t('warningHelp')}
</p>
</PopoverContent>
</Popover>

View File

@@ -10,6 +10,7 @@ import {
import { getGroupExpenses } from '@/lib/api'
import { getTotalGroupSpending } from '@/lib/totals'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { notFound } from 'next/navigation'
export const metadata: Metadata = {
@@ -21,6 +22,7 @@ export default async function TotalsPage({
}: {
params: { groupId: string }
}) {
const t = await getTranslations('Stats')
const group = await cached.getGroup(groupId)
if (!group) notFound()
@@ -31,10 +33,8 @@ export default async function TotalsPage({
<>
<Card className="mb-4">
<CardHeader>
<CardTitle>Totals</CardTitle>
<CardDescription>
Spending summary of the entire group.
</CardDescription>
<CardTitle>{t('Totals.title')}</CardTitle>
<CardDescription>{t('Totals.description')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col space-y-4">
<Totals

View File

@@ -1,4 +1,5 @@
import { formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
type Props = {
totalGroupSpendings: number
@@ -6,12 +7,14 @@ type Props = {
}
export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) {
const balance = totalGroupSpendings < 0 ? 'earnings' : 'spendings'
const locale = useLocale()
const t = useTranslations('Stats.Totals')
const balance = totalGroupSpendings < 0 ? 'groupEarnings' : 'groupSpendings'
return (
<div>
<div className="text-muted-foreground">Total group {balance}</div>
<div className="text-muted-foreground">{t(balance)}</div>
<div className="text-lg">
{formatCurrency(currency, Math.abs(totalGroupSpendings))}
{formatCurrency(currency, Math.abs(totalGroupSpendings), locale)}
</div>
</div>
)

View File

@@ -2,6 +2,7 @@
import { getGroup, getGroupExpenses } from '@/lib/api'
import { getTotalActiveUserShare } from '@/lib/totals'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
type Props = {
@@ -10,6 +11,8 @@ type Props = {
}
export function TotalsYourShare({ group, expenses }: Props) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')
const [activeUser, setActiveUser] = useState('')
useEffect(() => {
@@ -25,14 +28,14 @@ export function TotalsYourShare({ group, expenses }: Props) {
return (
<div>
<div className="text-muted-foreground">Your total share</div>
<div className="text-muted-foreground">{t('yourShare')}</div>
<div
className={cn(
'text-lg',
totalActiveUserShare < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalActiveUserShare))}
{formatCurrency(currency, Math.abs(totalActiveUserShare), locale)}
</div>
</div>
)

View File

@@ -3,6 +3,7 @@ import { getGroup, getGroupExpenses } from '@/lib/api'
import { useActiveUser } from '@/lib/hooks'
import { getTotalActiveUserPaidFor } from '@/lib/totals'
import { cn, formatCurrency } from '@/lib/utils'
import { useLocale, useTranslations } from 'next-intl'
type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
@@ -10,6 +11,8 @@ type Props = {
}
export function TotalsYourSpendings({ group, expenses }: Props) {
const locale = useLocale()
const t = useTranslations('Stats.Totals')
const activeUser = useActiveUser(group.id)
const totalYourSpendings =
@@ -17,11 +20,11 @@ export function TotalsYourSpendings({ group, expenses }: Props) {
? 0
: getTotalActiveUserPaidFor(activeUser, expenses)
const currency = group.currency
const balance = totalYourSpendings < 0 ? 'earnings' : 'spendings'
const balance = totalYourSpendings < 0 ? 'yourEarnings' : 'yourSpendings'
return (
<div>
<div className="text-muted-foreground">Your total {balance}</div>
<div className="text-muted-foreground">{t(balance)}</div>
<div
className={cn(
@@ -29,7 +32,7 @@ export function TotalsYourSpendings({ group, expenses }: Props) {
totalYourSpendings < 0 ? 'text-green-600' : 'text-red-600',
)}
>
{formatCurrency(currency, Math.abs(totalYourSpendings))}
{formatCurrency(currency, Math.abs(totalYourSpendings), locale)}
</div>
</div>
)

View File

@@ -9,6 +9,7 @@ import {
} from '@/components/ui/popover'
import { useMediaQuery } from '@/lib/hooks'
import { Loader2, Plus } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
type Props = {
@@ -16,6 +17,7 @@ type Props = {
}
export function AddGroupByUrlButton({ reload }: Props) {
const t = useTranslations('Groups.AddByURL')
const isDesktop = useMediaQuery('(min-width: 640px)')
const [url, setUrl] = useState('')
const [error, setError] = useState(false)
@@ -27,18 +29,15 @@ export function AddGroupByUrlButton({ reload }: Props) {
<PopoverTrigger asChild>
<Button variant="secondary">
{/* <Plus className="w-4 h-4 mr-2" /> */}
<>Add by URL</>
{t('button')}
</Button>
</PopoverTrigger>
<PopoverContent
align={isDesktop ? 'end' : 'start'}
className="[&_p]:text-sm flex flex-col gap-3"
>
<h3 className="font-bold">Add a group by URL</h3>
<p>
If a group was shared with you, you can paste its URL here to add it
to your list.
</p>
<h3 className="font-bold">{t('title')}</h3>
<p>{t('description')}</p>
<form
className="flex gap-2"
onSubmit={async (event) => {
@@ -80,11 +79,7 @@ export function AddGroupByUrlButton({ reload }: Props) {
)}
</Button>
</form>
{error && (
<p className="text-destructive">
Oops, we are not able to find the group from the URL you provided
</p>
)}
{error && <p className="text-destructive">{t('error')}</p>}
</PopoverContent>
</Popover>
)

View File

@@ -1,13 +1,15 @@
import { Button } from '@/components/ui/button'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
export default function NotFound() {
const t = useTranslations('Groups.NotFound')
return (
<div className="flex flex-col gap-2">
<p>This group does not exist.</p>
<p>{t('text')}</p>
<p>
<Button asChild variant="secondary">
<Link href="/groups">Go to recently visited groups</Link>
<Link href="/groups">{t('link')}</Link>
</Button>
</p>
</div>

View File

@@ -23,6 +23,7 @@ import { ToastAction } from '@/components/ui/toast'
import { useToast } from '@/components/ui/use-toast'
import { StarFilledIcon } from '@radix-ui/react-icons'
import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { SetStateAction } from 'react'
@@ -37,7 +38,9 @@ export function RecentGroupListCard({
setState: (state: SetStateAction<RecentGroupsState>) => void
}) {
const router = useRouter()
const locale = useLocale()
const toast = useToast()
const t = useTranslations('Groups')
const details =
state.status === 'complete'
@@ -118,12 +121,11 @@ export function RecentGroupListCard({
groups: state.groups.filter((g) => g.id !== group.id),
})
toast.toast({
title: 'Group has been removed',
description:
'The group was removed from your recent groups list.',
title: t('RecentRemovedToast.title'),
description: t('RecentRemovedToast.description'),
action: (
<ToastAction
altText="Undo group removal"
altText={t('RecentRemovedToast.undoAlt')}
onClick={() => {
saveRecentGroup(group)
setState({
@@ -132,13 +134,13 @@ export function RecentGroupListCard({
})
}}
>
Undo
{t('RecentRemovedToast.undo')}
</ToastAction>
),
})
}}
>
Remove from recent groups
{t('removeRecent')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(event) => {
@@ -152,7 +154,7 @@ export function RecentGroupListCard({
refreshGroupsFromStorage()
}}
>
{isArchived ? <>Unarchive group</> : <>Archive group</>}
{t(isArchived ? 'unarchive' : 'archive')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -168,7 +170,7 @@ export function RecentGroupListCard({
<div className="flex items-center">
<Calendar className="w-3 h-3 inline mx-1" />
<span>
{new Date(details.createdAt).toLocaleDateString('en-US', {
{new Date(details.createdAt).toLocaleDateString(locale, {
dateStyle: 'medium',
})}
</span>

View File

@@ -10,6 +10,7 @@ import {
import { Button } from '@/components/ui/button'
import { getGroups } from '@/lib/api'
import { Loader2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { PropsWithChildren, SetStateAction, useEffect, useState } from 'react'
import { RecentGroupListCard } from './recent-group-list-card'
@@ -53,6 +54,7 @@ function sortGroups(
}
export function RecentGroupList() {
const t = useTranslations('Groups')
const [state, setState] = useState<RecentGroupsState>({ status: 'pending' })
function loadGroups() {
@@ -84,8 +86,8 @@ export function RecentGroupList() {
return (
<GroupsPage reload={loadGroups}>
<p>
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" /> Loading
recent groups
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" />{' '}
{t('loadingRecent')}
</p>
</GroupsPage>
)
@@ -95,12 +97,12 @@ export function RecentGroupList() {
return (
<GroupsPage reload={loadGroups}>
<div className="text-sm space-y-2">
<p>You have not visited any group recently.</p>
<p>{t('NoRecent.description')}</p>
<p>
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/create`}>Create one</Link>
<Link href={`/groups/create`}>{t('NoRecent.create')}</Link>
</Button>{' '}
or ask a friend to send you the link to an existing one.
{t('NoRecent.orAsk')}
</p>
</div>
</GroupsPage>
@@ -113,7 +115,7 @@ export function RecentGroupList() {
<GroupsPage reload={loadGroups}>
{starredGroupInfo.length > 0 && (
<>
<h2 className="mb-2">Starred groups</h2>
<h2 className="mb-2">{t('starred')}</h2>
<GroupList
groups={starredGroupInfo}
state={state}
@@ -124,14 +126,14 @@ export function RecentGroupList() {
{groupInfo.length > 0 && (
<>
<h2 className="mt-6 mb-2">Recent groups</h2>
<h2 className="mt-6 mb-2">{t('recent')}</h2>
<GroupList groups={groupInfo} state={state} setState={setState} />
</>
)}
{archivedGroupInfo.length > 0 && (
<>
<h2 className="mt-6 mb-2 opacity-50">Archived groups</h2>
<h2 className="mt-6 mb-2 opacity-50">{t('archived')}</h2>
<div className="opacity-50">
<GroupList
groups={archivedGroupInfo}
@@ -172,18 +174,19 @@ function GroupsPage({
children,
reload,
}: PropsWithChildren<{ reload: () => void }>) {
const t = useTranslations('Groups')
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="font-bold text-2xl flex-1">
<Link href="/groups">My groups</Link>
<Link href="/groups">{t('myGroups')}</Link>
</h1>
<div className="flex gap-2">
<AddGroupByUrlButton reload={reload} />
<Button asChild>
<Link href="/groups/create">
{/* <Plus className="w-4 h-4 mr-2" /> */}
<>Create</>
{t('create')}
</Link>
</Button>
</div>