feat: i18n — EN/MS/ZH translations with cookie-based locale switching, report form translated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
@@ -1,26 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { FileUpload } from '@/components/incidents/file-upload'
|
||||
import type { IncidentType, MedicalStatus } from '@/lib/incidents/validate'
|
||||
|
||||
const INCIDENT_TYPE_LABELS: Record<IncidentType, string> = {
|
||||
injury: 'Injury / Medical',
|
||||
near_miss: 'Near Miss',
|
||||
hazard: 'Hazard / Unsafe Condition',
|
||||
asset_damage: 'Asset / Equipment Damage',
|
||||
environmental: 'Environmental Incident',
|
||||
security: 'Security Incident',
|
||||
fire: 'Fire / Emergency',
|
||||
}
|
||||
|
||||
const MEDICAL_STATUS_LABELS: Record<MedicalStatus, string> = {
|
||||
none: 'No treatment needed',
|
||||
first_aid: 'First aid only',
|
||||
medical_treatment: 'Medical treatment (non-LTI)',
|
||||
lti: 'Lost Time Injury (LTI)',
|
||||
}
|
||||
import { useTranslations } from '@/lib/i18n/context'
|
||||
|
||||
interface Props {
|
||||
zoneToken: string | null
|
||||
@@ -30,9 +14,32 @@ interface Props {
|
||||
|
||||
export function ReportForm({ zoneToken }: Props) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations('ReportForm')
|
||||
const itLabels = useTranslations('IncidentType')
|
||||
const msLabels = useTranslations('MedicalStatus')
|
||||
|
||||
const INCIDENT_TYPE_OPTIONS: [IncidentType, string][] = [
|
||||
['injury', itLabels.injury],
|
||||
['near_miss', itLabels.near_miss],
|
||||
['hazard', itLabels.hazard],
|
||||
['asset_damage', itLabels.asset_damage],
|
||||
['environmental', itLabels.environmental],
|
||||
['security', itLabels.security],
|
||||
['fire', itLabels.fire],
|
||||
]
|
||||
|
||||
const MEDICAL_STATUS_OPTIONS: [MedicalStatus, string][] = [
|
||||
['none', msLabels.none],
|
||||
['first_aid', msLabels.first_aid],
|
||||
['medical_treatment', msLabels.medical_treatment],
|
||||
['lti', msLabels.lti],
|
||||
]
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isOnline, setIsOnline] = useState(true)
|
||||
const [savedOffline, setSavedOffline] = useState(false)
|
||||
const [qualityCheck, setQualityCheck] = useState<{
|
||||
score: number
|
||||
passes: boolean
|
||||
@@ -49,11 +56,46 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
asset_involved: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsOnline(navigator.onLine)
|
||||
const onOnline = () => setIsOnline(true)
|
||||
const onOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
|
||||
// Offline path — save to IndexedDB
|
||||
if (!isOnline) {
|
||||
try {
|
||||
const { addPendingReport } = await import('@/lib/offline/db')
|
||||
await addPendingReport({
|
||||
zone_token: zoneToken ?? '',
|
||||
incident_type: form.incident_type as IncidentType,
|
||||
description: form.description,
|
||||
injury_involved: form.injury_involved,
|
||||
asset_involved: form.asset_involved,
|
||||
medical_status: form.medical_status || undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
setSavedOffline(true)
|
||||
} catch (err) {
|
||||
console.error('Offline save error:', err)
|
||||
setError(t.errorGeneric)
|
||||
}
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!overrideQuality) {
|
||||
try {
|
||||
const qcRes = await fetch('/api/incidents/ai/quality-check', {
|
||||
@@ -78,7 +120,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Quality check failure is non-blocking — proceed with submission
|
||||
// Quality check failure is non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,14 +146,28 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
|
||||
router.push(`/report/success?ref=${data.reference_no}`)
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
setError(t.errorGeneric)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (savedOffline) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-5 text-sm text-green-700">
|
||||
{t.savedOffline}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 bg-white rounded-xl shadow-sm p-5">
|
||||
{!isOnline && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 text-sm text-yellow-700">
|
||||
{t.offlineBanner}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
|
||||
{error}
|
||||
@@ -120,7 +176,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Incident type <span className="text-red-500">*</span>
|
||||
{t.incidentTypeLabel} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
@@ -128,8 +184,8 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
value={form.incident_type}
|
||||
onChange={e => setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))}
|
||||
>
|
||||
<option value="">Select type…</option>
|
||||
{Object.entries(INCIDENT_TYPE_LABELS).map(([v, l]) => (
|
||||
<option value="">{t.incidentTypePlaceholder}</option>
|
||||
{INCIDENT_TYPE_OPTIONS.map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -137,13 +193,13 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
What happened? <span className="text-red-500">*</span>
|
||||
{t.descriptionLabel} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
rows={4}
|
||||
placeholder="Describe what happened, where, and any immediate actions taken…"
|
||||
placeholder={t.descriptionPlaceholder}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
@@ -153,7 +209,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
{qualityCheck && !qualityCheck.passes && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide">
|
||||
Report quality — {qualityCheck.score}/10
|
||||
{t.qualityScoreLabel.replace('{score}', String(qualityCheck.score))}
|
||||
</p>
|
||||
<p className="text-sm text-amber-800">{qualityCheck.feedback}</p>
|
||||
{qualityCheck.suggestions.length > 0 && (
|
||||
@@ -170,7 +226,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
onChange={e => setOverrideQuality(e.target.checked)}
|
||||
className="rounded border-amber-300 text-amber-600"
|
||||
/>
|
||||
Submit anyway
|
||||
{t.submitAnyway}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
@@ -183,13 +239,13 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
checked={form.injury_involved}
|
||||
onChange={e => setForm(f => ({ ...f, injury_involved: e.target.checked, medical_status: '' }))}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Person was injured</span>
|
||||
<span className="text-sm font-medium text-gray-700">{t.injuryInvolved}</span>
|
||||
</label>
|
||||
|
||||
{form.injury_involved && (
|
||||
<div className="ml-7">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Treatment level <span className="text-red-500">*</span>
|
||||
{t.treatmentLevel} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
required={form.injury_involved}
|
||||
@@ -197,8 +253,8 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
value={form.medical_status}
|
||||
onChange={e => setForm(f => ({ ...f, medical_status: e.target.value as MedicalStatus }))}
|
||||
>
|
||||
<option value="">Select treatment…</option>
|
||||
{Object.entries(MEDICAL_STATUS_LABELS).map(([v, l]) => (
|
||||
<option value="">{t.treatmentPlaceholder}</option>
|
||||
{MEDICAL_STATUS_OPTIONS.map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -212,13 +268,13 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
checked={form.asset_involved}
|
||||
onChange={e => setForm(f => ({ ...f, asset_involved: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Equipment / asset was damaged</span>
|
||||
<span className="text-sm font-medium text-gray-700">{t.assetInvolved}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Photos / Videos / Documents
|
||||
{t.filesLabel}
|
||||
</label>
|
||||
<FileUpload onFilesChange={setFiles} disabled={submitting} />
|
||||
</div>
|
||||
@@ -229,7 +285,7 @@ export function ReportForm({ zoneToken }: Props) {
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium text-sm
|
||||
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Submit Incident Report'}
|
||||
{submitting ? t.submitting : t.submitButton}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const LOCALES: Record<string, string> = {
|
||||
en: 'English',
|
||||
ms: 'Bahasa Malaysia',
|
||||
zh: '中文',
|
||||
}
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const router = useRouter()
|
||||
const [current, setCurrent] = useState('en')
|
||||
|
||||
useEffect(() => {
|
||||
const match = document.cookie
|
||||
.split('; ')
|
||||
.find(c => c.startsWith('locale='))
|
||||
?.split('=')[1]
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
if (match && match in LOCALES) setCurrent(match)
|
||||
}, [])
|
||||
|
||||
function handleChange(locale: string) {
|
||||
document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`
|
||||
setCurrent(locale)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
value={current}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
aria-label="Select language"
|
||||
className="text-xs border border-gray-300 rounded px-2 py-1 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
{Object.entries(LOCALES).map(([code, label]) => (
|
||||
<option key={code} value={code}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user