Skip to content

Commit a4d3520

Browse files
committed
fix: wire hardcoded strings to i18n and correct locale key mismatches
1 parent b476ce1 commit a4d3520

20 files changed

Lines changed: 949 additions & 698 deletions

File tree

docs/missing-locale-strings.md

Lines changed: 198 additions & 0 deletions
Large diffs are not rendered by default.

src/SEBT.Portal.Web/content/scripts/generate-locales.js

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ const CONFIG = {
128128
// This prevents key collisions (e.g., both OTP Enter Email and OTP Confirm have "title")
129129
pageKeyPrefix: {
130130
'otp confirm': 'verify',
131+
'otp email message': 'email',
132+
'co-loaded off-boarding': 'coLoaded',
131133
},
132134
};
133135

@@ -309,18 +311,23 @@ function buildStateLocaleData(rows, state) {
309311

310312
const { namespace, key } = parsed;
311313

312-
// English
314+
// English — don't overwrite a non-empty value with an empty one
315+
// (handles key collisions where multiple CSV rows map to the same namespace+key)
313316
if (!data.en[namespace]) {
314317
data.en[namespace] = {};
315318
}
316-
data.en[namespace][key] = englishValue;
319+
if (englishValue || !data.en[namespace][key]) {
320+
data.en[namespace][key] = englishValue;
321+
}
317322

318-
// Spanish
319-
if (spanishIdx !== -1 && spanishValue) {
323+
// Spanish — same collision protection
324+
if (spanishIdx !== -1) {
320325
if (!data.es[namespace]) {
321326
data.es[namespace] = {};
322327
}
323-
data.es[namespace][key] = spanishValue;
328+
if (spanishValue || !data.es[namespace][key]) {
329+
data.es[namespace][key] = spanishValue;
330+
}
324331
}
325332
}
326333

@@ -346,11 +353,14 @@ function discoverStateCsvFiles() {
346353
}
347354

348355
/**
349-
* Calculate combined SHA-256 hash of all state CSV files
356+
* Calculate combined SHA-256 hash of all state CSV files and this script
350357
*/
351358
function calculateCombinedHash(stateFiles) {
352359
const hash = createHash('sha256');
353360

361+
// Include the script itself so logic changes invalidate the cache
362+
hash.update(readFileSync(fileURLToPath(import.meta.url), 'utf8'));
363+
354364
for (const { state, csvPath } of stateFiles) {
355365
if (existsSync(csvPath)) {
356366
const content = readFileSync(csvPath, 'utf8');

src/SEBT.Portal.Web/content/states/co.csv

Lines changed: 269 additions & 253 deletions
Large diffs are not rendered by default.

src/SEBT.Portal.Web/content/states/dc.csv

Lines changed: 349 additions & 316 deletions
Large diffs are not rendered by default.

src/SEBT.Portal.Web/src/app/(public)/login/COLoginPage.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { StateCode } from '@/lib/state'
88
export function COLoginPage({ state }: { state: StateCode }) {
99
const links = getStateLinks(state)
1010
const t = getTranslations('login')
11+
const tCommon = getTranslations('common')
1112

1213
return (
1314
<div className="usa-section">
@@ -17,8 +18,7 @@ export function COLoginPage({ state }: { state: StateCode }) {
1718
id="login-title"
1819
className="font-sans-xl text-bold line-height-sans-1 margin-bottom-3 text-primary-dark"
1920
>
20-
{/* TODO: Use t('title') once login.title is fixed in co.csv (clobbered by S8 OTP row) */}
21-
Log in to your Summer EBT account
21+
{t('title')}
2222
</h1>
2323

2424
<p className="margin-top-4 font-sans-sm">{t('logInDisclaimerBody1')}</p>
@@ -29,8 +29,7 @@ export function COLoginPage({ state }: { state: StateCode }) {
2929
href="#"
3030
className="usa-button bg-primary-dark text-white border-primary-dark"
3131
>
32-
{/* TODO: Use t('logIn', { ns: 'common' }) once common.logIn is fixed in co.csv (column-shifted to "Suggested address") */}
33-
Log in
32+
{tCommon('logIn')}
3433
</Link>
3534
</div>
3635

@@ -41,8 +40,7 @@ export function COLoginPage({ state }: { state: StateCode }) {
4140
className="usa-button usa-button--outline border-primary text-primary"
4241
lang="es"
4342
>
44-
{/* TODO: Use t('logInEsp', { ns: 'common' }) once common.logInEsp is fixed in co.csv (column-shifted to "Address you entered") */}
45-
Iniciar sesión
43+
{tCommon('logInEsp')}
4644
</Link>
4745
</div>
4846

src/SEBT.Portal.Web/src/app/(public)/login/id-proofing/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export default function IdProofingPage() {
5151
const state = getState()
5252
const links = getStateLinks(state)
5353
const t = getTranslations('idProofing')
54+
const tCommon = getTranslations('common')
5455

5556
return (
5657
<div className="usa-section">
@@ -65,8 +66,7 @@ export default function IdProofingPage() {
6566

6667
<p className="margin-top-0 font-sans-sm">{t('body')}</p>
6768

68-
{/* TODO: Use t('requiredDisclaimer') once key is available in dc.csv */}
69-
<p className="margin-top-2 font-sans-sm">Asterisks (*) indicate a required field.</p>
69+
<p className="margin-top-2 font-sans-sm">{tCommon('requiredFields')}</p>
7070

7171
<IdProofingForm
7272
idOptions={DC_ID_OPTIONS}

src/SEBT.Portal.Web/src/app/(public)/login/page.test.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@ vi.mock('@/lib/translations', () => ({
1717
getTranslations: vi.fn().mockImplementation((namespace: string) => {
1818
const namespaces: Record<string, Record<string, string>> = {
1919
login: {
20-
title: 'Log in to your account',
20+
title: 'Access your Summer EBT account',
2121
body: 'Enter your email to receive a one-time code.',
22-
logInDisclaimerBody1: 'You can use the same login you use to access PEAK.',
22+
logInDisclaimerBody1:
23+
'After tapping "Log in" you\'ll be redirected to log in using your myColorado™ account.',
2324
logInDisclaimerBody2: 'Contact us if you need assistance logging into your account.'
25+
},
26+
common: {
27+
logIn: 'Log in with myColorado™',
28+
logInEsp: 'Iniciar sesión con myColorado™'
2429
}
2530
}
2631
const translations = namespaces[namespace] ?? {}
@@ -49,36 +54,36 @@ describe('LoginPage', () => {
4954
render(<LoginPage />)
5055
expect(
5156
screen.getByRole('heading', {
52-
name: /Log in to your Summer EBT account/i
57+
name: /Access your Summer EBT account/i
5358
})
5459
).toBeInTheDocument()
5560
})
5661

5762
it('applies text-primary-dark class to the title', () => {
5863
render(<LoginPage />)
5964
const heading = screen.getByRole('heading', {
60-
name: /Log in to your Summer EBT account/i
65+
name: /Access your Summer EBT account/i
6166
})
6267
expect(heading).toHaveClass('text-primary-dark')
6368
})
6469

65-
it('renders the PEAK body text', () => {
70+
it('renders the disclaimer body text', () => {
6671
render(<LoginPage />)
6772
expect(
68-
screen.getByText(/You can use the same login you use to access PEAK/i)
73+
screen.getByText(/you'll be redirected to log in using your myColorado/i)
6974
).toBeInTheDocument()
7075
})
7176

7277
it('renders the Log in button with primary-dark styling', () => {
7378
render(<LoginPage />)
74-
const logInButton = screen.getByRole('link', { name: 'Log in' })
79+
const logInButton = screen.getByRole('link', { name: /Log in with myColorado/i })
7580
expect(logInButton).toHaveClass('usa-button')
7681
expect(logInButton).toHaveClass('bg-primary-dark')
7782
})
7883

7984
it('renders the Iniciar sesión outline button', () => {
8085
render(<LoginPage />)
81-
const espButton = screen.getByRole('link', { name: 'Iniciar sesión' })
86+
const espButton = screen.getByRole('link', { name: /Iniciar sesión con myColorado/i })
8287
expect(espButton).toHaveAttribute('lang', 'es')
8388
expect(espButton).toHaveClass('usa-button--outline')
8489
expect(espButton).toHaveClass('border-primary')

src/SEBT.Portal.Web/src/components/layout/Footer.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,14 @@ export function Footer({ state = 'dc' }: FooterProps) {
5151
rel="noopener noreferrer"
5252
className="usa-link text-ink font-ui-md text-semibold"
5353
>
54-
{t('publicNotifications')}
54+
{t('linkPublicNotices')}
5555
</Link>
5656
</div>
5757
</div>
5858

5959
<div className="usa-footer__secondary-section padding-y-2">
6060
<div className="grid-container">
61-
<nav aria-label="Footer navigation">
61+
<nav aria-label={t('footerNavLabel', 'Footer navigation')}>
6262
<ul className="usa-list usa-list--unstyled display-flex flex-column flex-align-center add-list-reset">
6363
{footerLinks.map((link) => (
6464
<li
@@ -82,14 +82,15 @@ export function Footer({ state = 'dc' }: FooterProps) {
8282

8383
<div className="usa-footer__secondary-section text-center">
8484
<div className="grid-container">
85-
<p className="margin-0 text-ink footer-copyright">{t('copyright')}</p>
85+
<p className="margin-0 text-ink footer-copyright">{t('copyrite')}</p>
8686
</div>
8787
</div>
8888
</footer>
8989
)
9090
}
9191

9292
function COFooter({ state = 'co' }: FooterProps) {
93+
const { t } = useTranslation('common')
9394
const config = getStateConfig(state)
9495
const links = getStateLinks(state)
9596

@@ -101,16 +102,15 @@ function COFooter({ state = 'co' }: FooterProps) {
101102
<div className="usa-footer__primary-section padding-y-2">
102103
<div className="grid-container text-center">
103104
<p className="margin-0 text-white font-sans-xs">
104-
{/* TODO: Use t('copyright') once the key is added to co.csv */}© 2026 State of Colorado
105+
{t('copyrite', '© 2026 State of Colorado')}
105106
{' | '}
106107
<Link
107108
href={links.footer.transparencyOnline ?? '#'}
108109
target="_blank"
109110
rel="noopener noreferrer"
110111
className="usa-link text-white text-underline"
111112
>
112-
{/* TODO: Use t('transparencyOnline') once the key is added to co.csv */}
113-
Transparency Online
113+
{t('transparencyOnline', 'Transparency Online')}
114114
</Link>
115115
{' | '}
116116
<Link
@@ -119,8 +119,7 @@ function COFooter({ state = 'co' }: FooterProps) {
119119
rel="noopener noreferrer"
120120
className="usa-link text-white text-underline"
121121
>
122-
{/* TODO: Use t('generalNotices') once the key is added to co.csv */}
123-
General Notices
122+
{t('generalNotices', 'General Notices')}
124123
</Link>
125124
</p>
126125
</div>

src/SEBT.Portal.Web/src/features/auth/components/id-proofing/IdProofingForm.tsx

Lines changed: 22 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -30,27 +30,23 @@ interface IdProofingFormProps {
3030
contactLink: string
3131
}
3232

33-
// Month options for the DOB select field
34-
// TODO: Localize month names via i18n or Intl.DateTimeFormat once translation keys are available
35-
const MONTHS = [
36-
{ value: '01', label: 'January' },
37-
{ value: '02', label: 'February' },
38-
{ value: '03', label: 'March' },
39-
{ value: '04', label: 'April' },
40-
{ value: '05', label: 'May' },
41-
{ value: '06', label: 'June' },
42-
{ value: '07', label: 'July' },
43-
{ value: '08', label: 'August' },
44-
{ value: '09', label: 'September' },
45-
{ value: '10', label: 'October' },
46-
{ value: '11', label: 'November' },
47-
{ value: '12', label: 'December' }
48-
] as const
33+
// Generate localized month names using Intl.DateTimeFormat
34+
function getLocalizedMonths(locale: string) {
35+
const formatter = new Intl.DateTimeFormat(locale, { month: 'long' })
36+
return Array.from({ length: 12 }, (_, i) => ({
37+
value: String(i + 1).padStart(2, '0'),
38+
label: formatter.format(new Date(2024, i, 1))
39+
}))
40+
}
4941

5042
export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps) {
5143
const router = useRouter()
52-
const { t } = useTranslation('idProofing')
44+
const { t, i18n } = useTranslation('idProofing')
45+
const { t: tCommon } = useTranslation('common')
46+
const { t: tPersonalInfo } = useTranslation('personalInfo')
47+
const { t: tValidation } = useTranslation('validation')
5348
const formId = useId()
49+
const months = getLocalizedMonths(i18n.language)
5450

5551
const [dobMonth, setDobMonth] = useState('')
5652
const [dobDay, setDobDay] = useState('')
@@ -69,8 +65,7 @@ export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps)
6965
const selectedOption = idOptions.find((opt) => opt.value === selectedIdType)
7066
const showIdValueInput = selectedIdType !== null && selectedIdType !== NONE_VALUE
7167

72-
// TODO: Use t('validation.required') once shared validation namespace is set up
73-
const REQUIRED_FIELD_ERROR = "We're sorry. Some required questions aren't answered."
68+
const REQUIRED_FIELD_ERROR = tValidation('required')
7469

7570
function validateFields(): boolean {
7671
const newDobErrors: { month?: string; day?: string; year?: string } = {}
@@ -114,8 +109,7 @@ export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps)
114109
if (err instanceof ApiError) {
115110
setSubmitError(err.message)
116111
} else {
117-
// TODO: Use t('errorUnexpected') once key is available in dc.csv
118-
setSubmitError('Something went wrong. Please try again.')
112+
setSubmitError(tValidation('globalInternalError'))
119113
}
120114
}
121115
}
@@ -150,12 +144,11 @@ export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps)
150144
dobErrors.month ? 'usa-form-group usa-form-group--error' : 'usa-form-group'
151145
}
152146
>
153-
{/* TODO: Use t('labelDobMonth') once key is available in dc.csv */}
154147
<label
155148
className="usa-label"
156149
htmlFor={`${formId}-dob-month`}
157150
>
158-
Month
151+
{tPersonalInfo('labelMonth')}
159152
</label>
160153
{dobErrors.month && (
161154
<span
@@ -175,7 +168,7 @@ export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps)
175168
aria-invalid={!!dobErrors.month}
176169
>
177170
<option value=""></option>
178-
{MONTHS.map((m) => (
171+
{months.map((m) => (
179172
<option
180173
key={m.value}
181174
value={m.value}
@@ -190,10 +183,7 @@ export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps)
190183
{/* Day */}
191184
<div className="mobile-lg:grid-col-4">
192185
<InputField
193-
label={
194-
// TODO: Use t('labelDobDay') once key is available in dc.csv
195-
'Day'
196-
}
186+
label={tPersonalInfo('labelDay')}
197187
type="text"
198188
inputMode="numeric"
199189
name="dobDay"
@@ -209,10 +199,7 @@ export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps)
209199
{/* Year */}
210200
<div className="mobile-lg:grid-col-4">
211201
<InputField
212-
label={
213-
// TODO: Use t('labelDobYear') once key is available in dc.csv
214-
'Year'
215-
}
202+
label={tPersonalInfo('labelYear')}
216203
type="text"
217204
inputMode="numeric"
218205
name="dobYear"
@@ -291,15 +278,14 @@ export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps)
291278
</div>
292279
)}
293280

294-
{/* TODO: Use t('actionContinue') once key is available in dc.csv */}
295281
<Button
296282
type="submit"
297283
isLoading={isSubmitting}
298-
loadingText="Continue..."
284+
loadingText={`${tCommon('continue')}...`}
299285
className="margin-top-3 display-block"
300286
disabled={isSubmitting}
301287
>
302-
Continue
288+
{tCommon('continue')}
303289
</Button>
304290

305291
<p className="margin-top-4 font-sans-sm">
@@ -309,8 +295,7 @@ export function IdProofingForm({ idOptions, contactLink }: IdProofingFormProps)
309295
rel="noopener noreferrer"
310296
className="usa-link"
311297
>
312-
{/* TODO: Use a help/contact translation key here */}
313-
Need help? Contact us.
298+
{tCommon('linkContactUs')}
314299
</a>
315300
</p>
316301
</form>

src/SEBT.Portal.Web/src/features/auth/components/login/LoginForm.test.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,9 @@ describe('LoginForm', () => {
9898
await user.type(emailInput, TEST_EMAILS.success)
9999
await user.click(submitButton)
100100

101-
// Button should show loading state (Continue to my account...)
102-
// i18n key: common.continue → "Continue to my account"
103-
expect(
104-
screen.getByRole('button', { name: /continue to my account\.\.\./i })
105-
).toBeInTheDocument()
101+
// Button should show loading state (Continue...)
102+
// i18n key: common.continue → "Continue"
103+
expect(screen.getByRole('button', { name: /continue\.\.\./i })).toBeInTheDocument()
106104
})
107105

108106
it('should disable input during submission', async () => {

0 commit comments

Comments
 (0)