diff --git a/.env.example b/.env.example index 5a28083..c590540 100644 --- a/.env.example +++ b/.env.example @@ -22,14 +22,13 @@ AUTH_SECRET="dev-secret-do-not-use-in-production-please-change-me" # must consciously opt in by editing their .env. See README "Auth" section. AUTH_DEV_AUTOLOGIN="false" -# Base URL Auth.js uses to build callback/redirect URLs. It MUST match the host -# of the app being served. This shared .env can hold only ONE value, set here to -# the operator-pwa (:3000). The admin-web (:3001) therefore needs its OWN -# AUTH_URL whenever autologin is OFF (real-login dev, or production) — otherwise -# Auth.js redirects admin users to :3000 and the admin login breaks. -# - Local with autologin ON: this value is harmless (middleware never redirects). -# - E2E real-login: e2e/playwright.auth.config.ts passes AUTH_URL=:3001 to admin. -# - Production: give EACH app its own AUTH_URL (per-app env), not this shared file. +# Base URL Auth.js uses to build callback/redirect URLs. Must match the host of +# the app being served. This shared .env holds ONE value — the operator-pwa +# (:3000). The admin-web (:3001) gets its own from apps/admin-web/.env.admin, +# which its `dev` script loads with precedence over this file — so admin login +# works with autologin OFF without any extra step. +# - Production: each app still gets its own AUTH_URL from the deploy env +# (a value already in the process wins over .env.admin). NEXT_PUBLIC_APP_URL="http://localhost:3000" AUTH_URL="http://localhost:3000" diff --git a/README.md b/README.md index 08d768b..8fa4779 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,28 @@ you want end-to-end confidence before a demo: --- +## Languages (i18n) + +Both apps support **Portuguese (PT, default) and English (EN)**. The language is +stored in a `NEXT_LOCALE` cookie and selected via the **PT | EN** switcher in the +app header. + +### Changing language + +Click the **PT | EN** pill in the header of either app. The preference is saved +in a cookie (1-year expiry) — no account change required. + +### Adding a new language + +1. Create `apps//messages/.json` (copy `en.json` as a starting point). +2. Add the locale to `LOCALES` in `apps//i18n/locales.ts`. +3. That's it — the switcher picks it up automatically. + +See **[docs/i18n.md](docs/i18n.md)** for the full guide, including the +key-parity and ICU validation scripts to run before shipping a new language. + +--- + ## Common commands | Command | What it does | @@ -299,12 +321,12 @@ by changing the endpoint env var. **`Tenant not found`** — the seed was wiped. Run `pnpm db:seed`. -**Admin login redirects to `:3000` / the operator picker** — the shared `.env` -sets `AUTH_URL=http://localhost:3000`, which is correct for the operator-pwa but -wrong for the admin-web (:3001). With `AUTH_DEV_AUTOLOGIN=true` it never bites -(the middleware doesn't redirect). With autologin OFF, give the admin-web its own -`AUTH_URL=http://localhost:3001` (the E2E auth config does this). In production, -each app must have its own `AUTH_URL`. +**Admin login redirects to `:3000` / the operator picker** — the admin-web needs +its own `AUTH_URL=http://localhost:3001` (the shared `.env` points at the operator +on :3000). This is handled automatically: the admin `dev` script loads +`apps/admin-web/.env.admin` with precedence. If you deleted or edited that file +and hit this, restore `AUTH_URL="http://localhost:3001"` there. In production, +each app gets its own `AUTH_URL` from the deploy environment. **`DATABASE_URL not found`** — `.env` is missing or Docker Postgres is not running. Run `docker compose up -d` then retry. diff --git a/apps/admin-web/.env.admin b/apps/admin-web/.env.admin new file mode 100644 index 0000000..5fdcf7a --- /dev/null +++ b/apps/admin-web/.env.admin @@ -0,0 +1,9 @@ +# Per-app AUTH_URL for admin-web (port 3001). +# +# The shared root .env sets AUTH_URL=http://localhost:3000 (correct for the +# operator-pwa). Auth.js needs each app to know its OWN base URL, otherwise the +# admin redirects unauthenticated users to :3000 and login breaks when autologin +# is off. This file gives admin-web its own value; the dev/start scripts load it +# BEFORE the root .env, and dotenv never overrides an already-set variable, so +# this wins. Not a secret — safe to commit. +AUTH_URL="http://localhost:3001" diff --git a/apps/admin-web/app/language-switcher.tsx b/apps/admin-web/app/language-switcher.tsx new file mode 100644 index 0000000..0b5a38d --- /dev/null +++ b/apps/admin-web/app/language-switcher.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useLocale } from 'next-intl'; +import { LOCALES, LOCALE_LABELS, type Locale } from '@/i18n/locales'; + +export function LanguageSwitcher() { + const router = useRouter(); + const current = useLocale() as Locale; + + function switchTo(locale: Locale) { + document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000; SameSite=Lax`; + router.refresh(); + } + + return ( +
+ {LOCALES.map((l) => ( + + ))} +
+ ); +} diff --git a/apps/admin-web/app/layout.tsx b/apps/admin-web/app/layout.tsx index 70e04c3..4c2a529 100644 --- a/apps/admin-web/app/layout.tsx +++ b/apps/admin-web/app/layout.tsx @@ -1,17 +1,27 @@ +import { NextIntlClientProvider } from 'next-intl'; +import { getLocale, getMessages, getTranslations } from 'next-intl/server'; import type { Metadata } from 'next'; import { Providers } from './providers'; import './globals.css'; -export const metadata: Metadata = { - title: 'FieldOps — Manutenção', - description: 'Backoffice de manutenção industrial.', -}; +export async function generateMetadata(): Promise { + const t = await getTranslations('metadata'); + return { + title: t('title'), + description: t('description'), + }; +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const locale = await getLocale(); + const messages = await getMessages(); -export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + - {children} + + {children} + ); diff --git a/apps/admin-web/app/login/login-form.tsx b/apps/admin-web/app/login/login-form.tsx index c1ff07a..cb90fec 100644 --- a/apps/admin-web/app/login/login-form.tsx +++ b/apps/admin-web/app/login/login-form.tsx @@ -2,10 +2,13 @@ import { useState, type FormEvent } from 'react'; import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { signIn } from 'next-auth/react'; export function LoginForm() { const router = useRouter(); + const t = useTranslations('auth'); + const tc = useTranslations('common'); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -20,13 +23,13 @@ export function LoginForm() { try { const result = await signIn('credentials', { email, password, redirect: false }); if (result?.error) { - setError('Email ou password incorretos. Tente novamente.'); + setError(t('invalidCredentials')); } else { router.push('/maintenance'); router.refresh(); } } catch { - setError('Erro inesperado. Tente novamente.'); + setError(t('unexpectedError')); } finally { setBusy(false); } @@ -36,7 +39,7 @@ export function LoginForm() {
- {busy ? 'A entrar…' : 'Entrar'} + {busy ? tc('entering') : tc('enter')} ); diff --git a/apps/admin-web/app/login/page.tsx b/apps/admin-web/app/login/page.tsx index 96293ac..55ee930 100644 --- a/apps/admin-web/app/login/page.tsx +++ b/apps/admin-web/app/login/page.tsx @@ -1,13 +1,13 @@ +import { getTranslations } from 'next-intl/server'; import { LoginForm } from './login-form'; -export default function LoginPage() { +export default async function LoginPage() { + const t = await getTranslations('auth'); return (
-

FieldOps

-

- Acesso à consola de manutenção -

+

{t('title')}

+

{t('subtitle')}

diff --git a/apps/admin-web/app/maintenance/maintenance-queue.tsx b/apps/admin-web/app/maintenance/maintenance-queue.tsx index be07d5b..bdd4ea3 100644 --- a/apps/admin-web/app/maintenance/maintenance-queue.tsx +++ b/apps/admin-web/app/maintenance/maintenance-queue.tsx @@ -3,22 +3,26 @@ import { useState, useEffect, useRef } from 'react'; import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import { trpc } from '@/lib/trpc/client'; import type { RouterOutputs } from '@/lib/trpc/server'; +import { LanguageSwitcher } from '../language-switcher'; type Status = 'OPEN' | 'CLAIMED' | 'RESOLVED'; type QueueItem = RouterOutputs['maintenanceRequest']['queue']['items'][number]; // ── Helpers ──────────────────────────────────────────────────────────────── -function timeAgo(date: Date | string): string { +type TFn = (key: string, values?: Record) => string; + +function timeAgo(date: Date | string, t: TFn): string { const diffMs = Date.now() - new Date(date).getTime(); const mins = Math.floor(diffMs / 60_000); - if (mins < 1) return 'agora'; - if (mins < 60) return `há ${mins}m`; + if (mins < 1) return t('timeAgo.now'); + if (mins < 60) return t('timeAgo.minutesAgo', { mins }); const hours = Math.floor(mins / 60); - if (hours < 24) return `há ${hours}h`; - return `há ${Math.floor(hours / 24)}d`; + if (hours < 24) return t('timeAgo.hoursAgo', { hours }); + return t('timeAgo.daysAgo', { days: Math.floor(hours / 24) }); } function playBeep() { @@ -39,12 +43,6 @@ function playBeep() { } } -const STATUS_LABEL: Record = { - OPEN: 'Aberto', - CLAIMED: 'Em curso', - RESOLVED: 'Resolvido', -}; - const STATUS_CLASS: Record = { OPEN: 'bg-orange-100 text-orange-700', CLAIMED: 'bg-blue-100 text-blue-700', @@ -53,7 +51,7 @@ const STATUS_CLASS: Record = { // ── Thumbnail ─────────────────────────────────────────────────────────────── -function Thumbnail({ photoKey }: { photoKey: string | null }) { +function Thumbnail({ photoKey, alt }: { photoKey: string | null; alt: string }) { const { data } = trpc.storage.signPhotoDownload.useQuery( { photoKey: photoKey! }, { enabled: !!photoKey, staleTime: 50_000 }, @@ -66,7 +64,7 @@ function Thumbnail({ photoKey }: { photoKey: string | null }) { } return ( // eslint-disable-next-line @next/next/no-img-element - Foto + {alt} ); } @@ -77,17 +75,21 @@ function RequestCard({ onClaim, onResolve, claiming, + t, + tc, }: { item: QueueItem; onClaim: () => void; onResolve: () => void; claiming: boolean; + t: ReturnType>; + tc: ReturnType>; }) { return (
{/* Top row: thumbnail + main info */}
- +

{item.workstation.code} — {item.workstation.name}{' '} @@ -95,17 +97,15 @@ function RequestCard({

{item.description}

- Reportado por {item.reportedBy.email} · {timeAgo(item.createdAt)} + {t('reportedBy', { email: item.reportedBy.email, time: timeAgo(item.createdAt, tc) })}

{/* Footer: badge + actions */}
- - {STATUS_LABEL[item.status as Status]} + + {tc(`status.${item.status.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)} {item.status === 'OPEN' && ( @@ -115,28 +115,28 @@ function RequestCard({ className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50" > {claiming ? : } - Aceitar + {t('accept')} )} {item.status === 'CLAIMED' && (

- Aceite por {item.claimedBy?.email ?? '?'} · {timeAgo(item.claimedAt!)} + {t('claimedBy', { email: item.claimedBy?.email ?? '?', time: timeAgo(item.claimedAt!, tc) })}

)} {item.status === 'RESOLVED' && (

- Resolvido por {item.resolvedBy?.email ?? '?'} · {timeAgo(item.resolvedAt!)} + {t('resolvedBy', { email: item.resolvedBy?.email ?? '?', time: timeAgo(item.resolvedAt!, tc) })}

)}
@@ -152,33 +152,34 @@ function ResolveDialog({ note, onNoteChange, resolving, + t, + tc, }: { onConfirm: () => void; onCancel: () => void; note: string; onNoteChange: (v: string) => void; resolving: boolean; + t: ReturnType>; + tc: ReturnType>; }) { return (
-

Marcar como resolvido

+

{t('resolveDialogTitle')}