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:
2026-07-11 19:01:44 +08:00
co-authored by Claude Sonnet 4.6
parent c67c489ab3
commit 87264a5497
12 changed files with 372 additions and 52 deletions
+34 -17
View File
@@ -1,33 +1,50 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import './globals.css'
import { I18nProvider } from '@/lib/i18n/context'
import { getLocale, loadMessages } from '@/lib/i18n/server'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
variable: '--font-geist-sans',
subsets: ['latin'],
})
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
variable: '--font-geist-mono',
subsets: ['latin'],
})
export const metadata: Metadata = {
title: "IMS — HSE Incident Management",
description: "Setia Corporation HSE Incident Management System",
};
title: 'IMS — HSE Incident Management',
description: 'Setia Corporation HSE Incident Management System',
}
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode
}>) {
const locale = await getLocale()
const messages = await loadMessages(locale)
return (
<html
lang="en"
lang={locale}
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<head>
<link rel="manifest" href="/ims/manifest.json" />
</head>
<body className="min-h-full flex flex-col">
<I18nProvider messages={messages}>
{children}
</I18nProvider>
<script
dangerouslySetInnerHTML={{
__html: `if('serviceWorker'in navigator){navigator.serviceWorker.register('/ims/sw.js',{scope:'/ims/'}).catch(console.error)}`,
}}
/>
</body>
</html>
);
)
}
+5 -1
View File
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { ReportForm } from '@/components/incidents/report-form'
import { LanguageSwitcher } from '@/components/language-switcher'
interface Props {
searchParams: Promise<{ zone?: string }>
@@ -33,7 +34,10 @@ export default async function ReportPage({ searchParams }: Props) {
return (
<main className="min-h-screen bg-gray-50 py-6 px-4 max-w-lg mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Report an Incident</h1>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">{/* will be translated via ReportForm */}Report an Incident</h1>
<LanguageSwitcher />
</div>
{zoneName ? (
<p className="text-sm text-gray-600 mt-1">
{siteName ?? 'Unknown Site'} {zoneName}
+90 -34
View File
@@ -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>
)
+43
View File
@@ -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>
)
}
+24
View File
@@ -0,0 +1,24 @@
'use client'
import { createContext, useContext } from 'react'
import type en from '../../messages/en.json'
export type Messages = typeof en
const I18nContext = createContext<Messages | null>(null)
export function I18nProvider({
messages,
children,
}: {
messages: Messages
children: React.ReactNode
}) {
return <I18nContext.Provider value={messages}>{children}</I18nContext.Provider>
}
export function useTranslations<K extends keyof Messages>(namespace: K): Messages[K] {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useTranslations must be used inside I18nProvider')
return ctx[namespace]
}
+2
View File
@@ -0,0 +1,2 @@
export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const
export type Locale = typeof SUPPORTED_LOCALES[number]
+20
View File
@@ -0,0 +1,20 @@
import { cookies } from 'next/headers'
import { SUPPORTED_LOCALES, type Locale } from './locales'
import type { Messages } from './context'
export async function getLocale(): Promise<Locale> {
const cookieStore = await cookies()
const lang = cookieStore.get('locale')?.value
if (lang && (SUPPORTED_LOCALES as readonly string[]).includes(lang)) {
return lang as Locale
}
return 'en'
}
export async function loadMessages(locale: Locale): Promise<Messages> {
switch (locale) {
case 'ms': return (await import('../../messages/ms.json')).default as Messages
case 'zh': return (await import('../../messages/zh.json')).default as Messages
default: return (await import('../../messages/en.json')).default as Messages
}
}
+16
View File
@@ -0,0 +1,16 @@
// Stub — full implementation in Task 5 (IndexedDB offline queue)
// This file exists so TypeScript can resolve the dynamic import in report-form.tsx.
export interface PendingReport {
zone_token: string
incident_type: string
description: string
injury_involved: boolean
asset_involved: boolean
medical_status?: string
created_at: string
}
export async function addPendingReport(_report: PendingReport): Promise<void> {
throw new Error('IndexedDB offline store not yet implemented — Task 5 will replace this stub')
}
+36
View File
@@ -0,0 +1,36 @@
{
"ReportForm": {
"title": "Report an Incident",
"incidentTypeLabel": "Incident type",
"incidentTypePlaceholder": "Select type…",
"descriptionLabel": "What happened?",
"descriptionPlaceholder": "Describe what happened, where, and any immediate actions taken…",
"injuryInvolved": "Person was injured",
"treatmentLevel": "Treatment level",
"treatmentPlaceholder": "Select treatment…",
"assetInvolved": "Equipment / asset was damaged",
"filesLabel": "Photos / Videos / Documents",
"submitButton": "Submit Incident Report",
"submitting": "Submitting…",
"submitAnyway": "Submit anyway",
"qualityScoreLabel": "Report quality — {score}/10",
"errorGeneric": "Something went wrong. Please try again.",
"savedOffline": "Report saved. It will be submitted automatically when you're back online.",
"offlineBanner": "You're offline. Your report will be saved and submitted when you reconnect."
},
"IncidentType": {
"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"
},
"MedicalStatus": {
"none": "No treatment needed",
"first_aid": "First aid only",
"medical_treatment": "Medical treatment (non-LTI)",
"lti": "Lost Time Injury (LTI)"
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"ReportForm": {
"title": "Laporkan Insiden",
"incidentTypeLabel": "Jenis insiden",
"incidentTypePlaceholder": "Pilih jenis…",
"descriptionLabel": "Apa yang berlaku?",
"descriptionPlaceholder": "Terangkan apa yang berlaku, di mana, dan tindakan segera yang diambil…",
"injuryInvolved": "Seseorang telah cedera",
"treatmentLevel": "Tahap rawatan",
"treatmentPlaceholder": "Pilih rawatan…",
"assetInvolved": "Peralatan / aset rosak",
"filesLabel": "Foto / Video / Dokumen",
"submitButton": "Hantar Laporan Insiden",
"submitting": "Menghantar…",
"submitAnyway": "Hantar juga",
"qualityScoreLabel": "Kualiti laporan — {score}/10",
"errorGeneric": "Berlaku ralat. Sila cuba lagi.",
"savedOffline": "Laporan disimpan. Ia akan dihantar secara automatik apabila anda dalam talian semula.",
"offlineBanner": "Anda tiada sambungan. Laporan anda akan disimpan dan dihantar apabila disambungkan semula."
},
"IncidentType": {
"injury": "Kecederaan / Perubatan",
"near_miss": "Hampir Berlaku",
"hazard": "Bahaya / Keadaan Tidak Selamat",
"asset_damage": "Kerosakan Aset / Peralatan",
"environmental": "Insiden Alam Sekitar",
"security": "Insiden Keselamatan",
"fire": "Kebakaran / Kecemasan"
},
"MedicalStatus": {
"none": "Tiada rawatan diperlukan",
"first_aid": "Pertolongan cemas sahaja",
"medical_treatment": "Rawatan perubatan (bukan LTI)",
"lti": "Kecederaan Masa Hilang (LTI)"
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"ReportForm": {
"title": "事故报告",
"incidentTypeLabel": "事故类型",
"incidentTypePlaceholder": "选择类型…",
"descriptionLabel": "发生了什么?",
"descriptionPlaceholder": "描述发生了什么、在哪里,以及采取的即时行动…",
"injuryInvolved": "有人受伤",
"treatmentLevel": "治疗级别",
"treatmentPlaceholder": "选择治疗方式…",
"assetInvolved": "设备/资产受损",
"filesLabel": "照片/视频/文件",
"submitButton": "提交事故报告",
"submitting": "提交中…",
"submitAnyway": "仍然提交",
"qualityScoreLabel": "报告质量 — {score}/10",
"errorGeneric": "出现错误,请重试。",
"savedOffline": "报告已保存。当您重新联网时将自动提交。",
"offlineBanner": "您处于离线状态。您的报告将在重新联网时自动提交。"
},
"IncidentType": {
"injury": "受伤/医疗",
"near_miss": "未遂事故",
"hazard": "危险/不安全状况",
"asset_damage": "资产/设备损坏",
"environmental": "环境事故",
"security": "安全事故",
"fire": "火灾/紧急情况"
},
"MedicalStatus": {
"none": "无需治疗",
"first_aid": "仅急救",
"medical_treatment": "医疗治疗(非 LTI",
"lti": "工伤失时(LTI"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import en from '@/messages/en.json'
import ms from '@/messages/ms.json'
import zh from '@/messages/zh.json'
const NAMESPACES = ['ReportForm', 'IncidentType', 'MedicalStatus'] as const
NAMESPACES.forEach(ns => {
describe(`${ns} namespace`, () => {
const enKeys = Object.keys(en[ns])
it(`ms.${ns} has all keys present in en.${ns}`, () => {
const msKeys = Object.keys(ms[ns])
enKeys.forEach(key => expect(msKeys, `missing key: ${key}`).toContain(key))
})
it(`zh.${ns} has all keys present in en.${ns}`, () => {
const zhKeys = Object.keys(zh[ns])
enKeys.forEach(key => expect(zhKeys, `missing key: ${key}`).toContain(key))
})
it(`en.${ns} values are non-empty strings`, () => {
enKeys.forEach(key => {
const val = (en[ns] as Record<string, string>)[key]
expect(typeof val).toBe('string')
expect(val.length).toBeGreaterThan(0)
})
})
})
})