feat: add i18n for multi languages

This commit is contained in:
2025-09-30 15:53:17 +08:00
parent 99a97139df
commit cf266880bb
13 changed files with 343 additions and 49 deletions

View File

@@ -5,12 +5,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import type { Visit, VisitCreateInput } from '../types';
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
import { useVisitDrawerStore } from '../../../state/useVisitDrawerStore';
import { useTranslation } from 'react-i18next';
const visitFormSchema = z
.object({
country: z.string().min(1, '請填寫國家'),
country: z.string().min(1, 'validation.countryRequired'),
city: z.string().optional(),
start: z.string().min(1, '請選擇開始日期'),
start: z.string().min(1, 'validation.startDateRequired'),
end: z.string().optional(),
notes: z.string().optional(),
lat: z.number(),
@@ -21,7 +22,7 @@ const visitFormSchema = z
if (!data.end) return true;
return new Date(data.end) >= new Date(data.start);
},
{ message: '結束日期需晚於開始日期', path: ['end'] }
{ message: 'validation.endAfterStart', path: ['end'] }
);
type VisitFormSchema = z.infer<typeof visitFormSchema>;
@@ -34,6 +35,7 @@ interface VisitFormProps {
export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
const closeDrawer = useVisitDrawerStore((state) => state.close);
const initialLocation = useVisitDrawerStore((state) => state.initialLocation);
const { t } = useTranslation();
const createMutation = useCreateVisitMutation();
const updateMutation = useUpdateVisitMutation();
@@ -104,63 +106,65 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="country">
{t('form.country')}
</label>
<input
id="country"
className="rounded border border-slate-300 px-3 py-2"
placeholder="例如:日本"
placeholder={t('form.countryPlaceholder')}
{...register('country')}
/>
{errors.country ? <p className="text-sm text-rose-500">{errors.country.message}</p> : null}
{errors.country ? (
<p className="text-sm text-rose-500">{t(errors.country.message ?? '')}</p>
) : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="city">
{t('form.city')}
</label>
<input
id="city"
className="rounded border border-slate-300 px-3 py-2"
placeholder="例如:東京"
placeholder={t('form.cityPlaceholder')}
{...register('city')}
/>
{errors.city ? <p className="text-sm text-rose-500">{errors.city.message}</p> : null}
{errors.city ? <p className="text-sm text-rose-500">{t(errors.city.message ?? '')}</p> : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="start">
{t('form.startDate')}
</label>
<input id="start" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('start')} />
{errors.start ? <p className="text-sm text-rose-500">{errors.start.message}</p> : null}
{errors.start ? <p className="text-sm text-rose-500">{t(errors.start.message ?? '')}</p> : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="end">
{t('form.endDate')}
</label>
<input id="end" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('end')} />
{errors.end ? <p className="text-sm text-rose-500">{errors.end.message}</p> : null}
{errors.end ? <p className="text-sm text-rose-500">{t(errors.end.message ?? '')}</p> : null}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium" htmlFor="notes">
{t('form.notes')}
</label>
<textarea
id="notes"
className="min-h-[96px] rounded border border-slate-300 px-3 py-2"
placeholder="留下旅程的感想或提醒"
placeholder={t('form.notesPlaceholder')}
{...register('notes')}
/>
{errors.notes ? <p className="text-sm text-rose-500">{errors.notes.message}</p> : null}
{errors.notes ? <p className="text-sm text-rose-500">{t(errors.notes.message ?? '')}</p> : null}
</div>
<div className="grid gap-2 md:grid-cols-2">
<div>
<label className="text-sm font-medium" htmlFor="lat">
{t('form.latitude')}
</label>
<input
id="lat"
@@ -169,11 +173,11 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
className="w-full rounded border border-slate-300 px-3 py-2"
{...register('lat', { valueAsNumber: true })}
/>
{errors.lat ? <p className="text-sm text-rose-500">{errors.lat.message}</p> : null}
{errors.lat ? <p className="text-sm text-rose-500">{t(errors.lat.message ?? '')}</p> : null}
</div>
<div>
<label className="text-sm font-medium" htmlFor="lng">
{t('form.longitude')}
</label>
<input
id="lng"
@@ -182,7 +186,7 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
className="w-full rounded border border-slate-300 px-3 py-2"
{...register('lng', { valueAsNumber: true })}
/>
{errors.lng ? <p className="text-sm text-rose-500">{errors.lng.message}</p> : null}
{errors.lng ? <p className="text-sm text-rose-500">{t(errors.lng.message ?? '')}</p> : null}
</div>
</div>
@@ -192,14 +196,14 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
className="rounded border border-slate-300 px-4 py-2"
onClick={() => closeDrawer()}
>
{t('form.cancel')}
</button>
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-primary px-4 py-2 font-semibold text-white hover:bg-primary/90 disabled:opacity-60"
>
{editingVisit ? '儲存變更' : '新增足跡'}
{editingVisit ? t('form.update') : t('form.create')}
</button>
</div>
</form>

View File

@@ -1,7 +1,5 @@
import {
useDeleteVisitMutation,
useVisitsQuery
} from '../hooks/useVisitQueries';
import { useTranslation } from 'react-i18next';
import { useDeleteVisitMutation, useVisitsQuery } from '../hooks/useVisitQueries';
import type { Visit } from '../types';
interface VisitListProps {
@@ -11,13 +9,14 @@ interface VisitListProps {
export function VisitList({ onEdit }: VisitListProps) {
const { data: visits = [], isLoading } = useVisitsQuery();
const deleteMutation = useDeleteVisitMutation();
const { t } = useTranslation();
if (isLoading) {
return <p className="text-sm text-slate-500">...</p>;
return <p className="text-sm text-slate-500">{t('list.loading')}</p>;
}
if (!visits.length) {
return <p className="text-sm text-slate-500"></p>;
return <p className="text-sm text-slate-500">{t('list.empty')}</p>;
}
return (
@@ -42,14 +41,14 @@ export function VisitList({ onEdit }: VisitListProps) {
className="rounded bg-slate-100 px-2 py-1 hover:bg-slate-200"
onClick={() => onEdit(visit)}
>
{t('list.edit')}
</button>
<button
type="button"
className="rounded bg-rose-100 px-2 py-1 text-rose-600 hover:bg-rose-200"
onClick={() => deleteMutation.mutate(visit.id)}
>
{t('list.delete')}
</button>
</div>
</div>

View File

@@ -1,8 +1,10 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useVisitsQuery } from '../hooks/useVisitQueries';
export function VisitSummary() {
const { data: visits = [] } = useVisitsQuery();
const { t } = useTranslation();
const stats = useMemo(() => {
const countries = new Set<string>();
@@ -15,14 +17,14 @@ export function VisitSummary() {
return (
<div className="rounded-lg bg-white p-4 shadow-sm">
<h3 className="text-sm font-semibold text-slate-500"></h3>
<h3 className="text-sm font-semibold text-slate-500">{t('summary.title')}</h3>
<dl className="mt-3 grid grid-cols-2 gap-3 text-center">
<div className="rounded bg-slate-100 p-3">
<dt className="text-xs uppercase tracking-wide text-slate-500"></dt>
<dt className="text-xs uppercase tracking-wide text-slate-500">{t('summary.totalVisits')}</dt>
<dd className="text-xl font-bold">{stats.totalVisits}</dd>
</div>
<div className="rounded bg-slate-100 p-3">
<dt className="text-xs uppercase tracking-wide text-slate-500"></dt>
<dt className="text-xs uppercase tracking-wide text-slate-500">{t('summary.countries')}</dt>
<dd className="text-xl font-bold">{stats.uniqueCountries}</dd>
</div>
</dl>