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:
+34
-17
@@ -1,33 +1,50 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next'
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from 'next/font/google'
|
||||||
import "./globals.css";
|
import './globals.css'
|
||||||
|
import { I18nProvider } from '@/lib/i18n/context'
|
||||||
|
import { getLocale, loadMessages } from '@/lib/i18n/server'
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: '--font-geist-sans',
|
||||||
subsets: ["latin"],
|
subsets: ['latin'],
|
||||||
});
|
})
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const geistMono = Geist_Mono({
|
||||||
variable: "--font-geist-mono",
|
variable: '--font-geist-mono',
|
||||||
subsets: ["latin"],
|
subsets: ['latin'],
|
||||||
});
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "IMS — HSE Incident Management",
|
title: 'IMS — HSE Incident Management',
|
||||||
description: "Setia Corporation HSE Incident Management System",
|
description: 'Setia Corporation HSE Incident Management System',
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
|
const locale = await getLocale()
|
||||||
|
const messages = await loadMessages(locale)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang={locale}
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
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>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
|
|||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { ReportForm } from '@/components/incidents/report-form'
|
import { ReportForm } from '@/components/incidents/report-form'
|
||||||
|
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchParams: Promise<{ zone?: string }>
|
searchParams: Promise<{ zone?: string }>
|
||||||
@@ -33,7 +34,10 @@ export default async function ReportPage({ searchParams }: Props) {
|
|||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-50 py-6 px-4 max-w-lg mx-auto">
|
<main className="min-h-screen bg-gray-50 py-6 px-4 max-w-lg mx-auto">
|
||||||
<div className="mb-6">
|
<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 ? (
|
{zoneName ? (
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
{siteName ?? 'Unknown Site'} — {zoneName}
|
{siteName ?? 'Unknown Site'} — {zoneName}
|
||||||
|
|||||||
@@ -1,26 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { FileUpload } from '@/components/incidents/file-upload'
|
import { FileUpload } from '@/components/incidents/file-upload'
|
||||||
import type { IncidentType, MedicalStatus } from '@/lib/incidents/validate'
|
import type { IncidentType, MedicalStatus } from '@/lib/incidents/validate'
|
||||||
|
import { useTranslations } from '@/lib/i18n/context'
|
||||||
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)',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
zoneToken: string | null
|
zoneToken: string | null
|
||||||
@@ -30,9 +14,32 @@ interface Props {
|
|||||||
|
|
||||||
export function ReportForm({ zoneToken }: Props) {
|
export function ReportForm({ zoneToken }: Props) {
|
||||||
const router = useRouter()
|
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 [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [files, setFiles] = useState<File[]>([])
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
const [isOnline, setIsOnline] = useState(true)
|
||||||
|
const [savedOffline, setSavedOffline] = useState(false)
|
||||||
const [qualityCheck, setQualityCheck] = useState<{
|
const [qualityCheck, setQualityCheck] = useState<{
|
||||||
score: number
|
score: number
|
||||||
passes: boolean
|
passes: boolean
|
||||||
@@ -49,11 +56,46 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
asset_involved: false,
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
setSubmitting(true)
|
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) {
|
if (!overrideQuality) {
|
||||||
try {
|
try {
|
||||||
const qcRes = await fetch('/api/incidents/ai/quality-check', {
|
const qcRes = await fetch('/api/incidents/ai/quality-check', {
|
||||||
@@ -78,7 +120,7 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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}`)
|
router.push(`/report/success?ref=${data.reference_no}`)
|
||||||
} catch {
|
} catch {
|
||||||
setError('Something went wrong. Please try again.')
|
setError(t.errorGeneric)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
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 (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-5 bg-white rounded-xl shadow-sm p-5">
|
<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 && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
|
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm text-red-700">
|
||||||
{error}
|
{error}
|
||||||
@@ -120,7 +176,7 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
@@ -128,8 +184,8 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
value={form.incident_type}
|
value={form.incident_type}
|
||||||
onChange={e => setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))}
|
onChange={e => setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))}
|
||||||
>
|
>
|
||||||
<option value="">Select type…</option>
|
<option value="">{t.incidentTypePlaceholder}</option>
|
||||||
{Object.entries(INCIDENT_TYPE_LABELS).map(([v, l]) => (
|
{INCIDENT_TYPE_OPTIONS.map(([v, l]) => (
|
||||||
<option key={v} value={v}>{l}</option>
|
<option key={v} value={v}>{l}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -137,13 +193,13 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<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>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
minLength={10}
|
minLength={10}
|
||||||
rows={4}
|
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"
|
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}
|
value={form.description}
|
||||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||||
@@ -153,7 +209,7 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
{qualityCheck && !qualityCheck.passes && (
|
{qualityCheck && !qualityCheck.passes && (
|
||||||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3 space-y-2">
|
<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">
|
<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>
|
||||||
<p className="text-sm text-amber-800">{qualityCheck.feedback}</p>
|
<p className="text-sm text-amber-800">{qualityCheck.feedback}</p>
|
||||||
{qualityCheck.suggestions.length > 0 && (
|
{qualityCheck.suggestions.length > 0 && (
|
||||||
@@ -170,7 +226,7 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
onChange={e => setOverrideQuality(e.target.checked)}
|
onChange={e => setOverrideQuality(e.target.checked)}
|
||||||
className="rounded border-amber-300 text-amber-600"
|
className="rounded border-amber-300 text-amber-600"
|
||||||
/>
|
/>
|
||||||
Submit anyway
|
{t.submitAnyway}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,13 +239,13 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
checked={form.injury_involved}
|
checked={form.injury_involved}
|
||||||
onChange={e => setForm(f => ({ ...f, injury_involved: e.target.checked, medical_status: '' }))}
|
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>
|
</label>
|
||||||
|
|
||||||
{form.injury_involved && (
|
{form.injury_involved && (
|
||||||
<div className="ml-7">
|
<div className="ml-7">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
required={form.injury_involved}
|
required={form.injury_involved}
|
||||||
@@ -197,8 +253,8 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
value={form.medical_status}
|
value={form.medical_status}
|
||||||
onChange={e => setForm(f => ({ ...f, medical_status: e.target.value as MedicalStatus }))}
|
onChange={e => setForm(f => ({ ...f, medical_status: e.target.value as MedicalStatus }))}
|
||||||
>
|
>
|
||||||
<option value="">Select treatment…</option>
|
<option value="">{t.treatmentPlaceholder}</option>
|
||||||
{Object.entries(MEDICAL_STATUS_LABELS).map(([v, l]) => (
|
{MEDICAL_STATUS_OPTIONS.map(([v, l]) => (
|
||||||
<option key={v} value={v}>{l}</option>
|
<option key={v} value={v}>{l}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -212,13 +268,13 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
checked={form.asset_involved}
|
checked={form.asset_involved}
|
||||||
onChange={e => setForm(f => ({ ...f, asset_involved: e.target.checked }))}
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Photos / Videos / Documents
|
{t.filesLabel}
|
||||||
</label>
|
</label>
|
||||||
<FileUpload onFilesChange={setFiles} disabled={submitting} />
|
<FileUpload onFilesChange={setFiles} disabled={submitting} />
|
||||||
</div>
|
</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
|
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"
|
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{submitting ? 'Submitting…' : 'Submit Incident Report'}
|
{submitting ? t.submitting : t.submitButton}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const SUPPORTED_LOCALES = ['en', 'ms', 'zh'] as const
|
||||||
|
export type Locale = typeof SUPPORTED_LOCALES[number]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user