feat: add i18n for multi languages
This commit is contained in:
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user