updates and clean of commit history due to issues...

This commit is contained in:
headpatsyou 2026-01-08 19:06:37 +00:00
parent d4b8796b9f
commit 6ec65d22b6
21 changed files with 493 additions and 169 deletions

8
.gitignore vendored
View file

@ -5,5 +5,9 @@ node_modules
out out
dist dist
.DS_Store .DS_Store
config.json src/config/config.json
*.csv src/data/*.csv
public/branding/*.png
public/branding/*.jpg
public/branding/*.jpeg
public/branding/*.svg

14
package-lock.json generated
View file

@ -1,13 +1,14 @@
{ {
"name": "heights", "name": "altarra",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "heights", "name": "altarra",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"lucide-react": "^0.562.0",
"next": "14.1.0", "next": "14.1.0",
"papaparse": "5.4.1", "papaparse": "5.4.1",
"react": "18.2.0", "react": "18.2.0",
@ -3834,6 +3835,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/lucide-react": {
"version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View file

@ -4,22 +4,24 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"prebuild": "node scripts/setup-config.js",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^0.562.0",
"next": "14.1.0", "next": "14.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"papaparse": "5.4.1", "papaparse": "5.4.1",
"react-checkmark": "2.1.1" "react": "18.2.0",
"react-checkmark": "2.1.1",
"react-dom": "18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.10.6", "@types/node": "20.10.6",
"@types/papaparse": "5.3.14",
"@types/react": "18.2.37", "@types/react": "18.2.37",
"@types/react-dom": "18.2.15", "@types/react-dom": "18.2.15",
"@types/papaparse": "5.3.14",
"autoprefixer": "10.4.16", "autoprefixer": "10.4.16",
"eslint": "8.55.0", "eslint": "8.55.0",
"eslint-config-next": "14.1.0", "eslint-config-next": "14.1.0",

37
scripts/setup-config.js Normal file
View file

@ -0,0 +1,37 @@
#!/usr/bin/env node
/**
* Setup script: creates config.json from example if it doesn't exist.
* Runs during build phase.
*/
const fs = require('fs')
const path = require('path')
const projectRoot = path.join(__dirname, '..')
const configPath = path.join(projectRoot, 'src', 'config', 'config.json')
const examplePath = path.join(projectRoot, 'src', 'config', 'config.json.example')
// Check if config.json exists
if (fs.existsSync(configPath)) {
console.log('✓ config.json found')
process.exit(0)
}
// Check if example exists
if (!fs.existsSync(examplePath)) {
console.error('✗ config.json.example not found at', examplePath)
process.exit(1)
}
// Copy example to config.json
try {
const exampleContent = fs.readFileSync(examplePath, 'utf8')
fs.writeFileSync(configPath, exampleContent)
console.log('✓ config.json created from example')
process.exit(0)
} catch (err) {
console.error('✗ Failed to create config.json:', err.message)
process.exit(1)
}

View file

@ -4,7 +4,6 @@ import path from 'node:path'
import config from '@/config/config.json' import config from '@/config/config.json'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const config_edge = { maxDuration: 30 }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const password = req.headers.get('x-admin-password') || '' const password = req.headers.get('x-admin-password') || ''

View file

@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export const runtime = 'nodejs'
export async function GET() {
try {
const configPath = path.join(process.cwd(), 'src', 'config', 'config.json')
const content = await readFile(configPath, 'utf8')
const data = JSON.parse(content)
return NextResponse.json({ competitionName: data.competitionName })
} catch {
return NextResponse.json({ competitionName: 'Altarra' })
}
}

View file

@ -2,6 +2,7 @@ import './globals.css'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Inter } from 'next/font/google' import { Inter } from 'next/font/google'
import Footer from '@/components/Footer' import Footer from '@/components/Footer'
import { LanguageProvider } from '@/context/LanguageContext'
const inter = Inter({ subsets: ['latin'] }) const inter = Inter({ subsets: ['latin'] })
@ -14,8 +15,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return ( return (
<html lang="pt-PT"> <html lang="pt-PT">
<body className={`${inter.className} flex flex-col min-h-screen bg-white text-gray-900`}> <body className={`${inter.className} flex flex-col min-h-screen bg-white text-gray-900`}>
<main className="flex-1">{children}</main> <LanguageProvider>
<Footer /> <div className="flex flex-col min-h-screen">
<main className="flex-1">{children}</main>
<Footer />
</div>
</LanguageProvider>
</body> </body>
</html> </html>
) )

View file

@ -1,108 +1,5 @@
"use client" import HomePageContent from '@/components/HomePage'
import { useEffect, useMemo, useRef, useState } from 'react'
import Image from 'next/image'
import LandingPage from '@/components/LandingPage'
import IdentityForm from '@/components/IdentityForm'
import RackHeightsForm from '@/components/RackHeightsForm'
import Spinner from '@/components/Spinner'
export default function HomePage() { export default function HomePage() {
const [authorized, setAuthorized] = useState<boolean | null>(null) return <HomePageContent />
const [memberNumber, setMemberNumber] = useState<string | null>(null)
const [birthDate, setBirthDate] = useState<string | null>(null)
const [lifterName, setLifterName] = useState<string | null>(null)
const [competitionName, setCompetitionName] = useState('Altarra')
const search = useMemo(() => new URLSearchParams(typeof window !== 'undefined' ? window.location.search : ''), [])
const auth = search.get('auth') || ''
useEffect(() => {
let mounted = true
async function check() {
try {
const res = await fetch(`/api/auth?auth=${encodeURIComponent(auth)}`)
if (mounted) setAuthorized(res.ok)
} catch {
if (mounted) setAuthorized(false)
}
// Load competition name from config
try {
const configRes = await fetch('/api/admin/config', {
headers: { 'x-admin-password': '' }
}).catch(() => null)
if (configRes && configRes.ok) {
const data = await configRes.json()
if (mounted && data.competitionName) {
setCompetitionName(data.competitionName)
}
}
} catch {
// Fallback to default
}
}
check()
return () => { mounted = false }
}, [auth])
if (authorized === null) {
return (
<main className="grid min-h-screen place-items-center">
<div className="flex flex-col items-center gap-2 text-gray-600">
<Spinner />
<p>A carregar</p>
</div>
</main>
)
}
if (!authorized) {
return <LandingPage />
}
return (
<main className="mx-auto max-w-xl px-4 py-8">
<header className="mb-6 flex flex-col items-start gap-3">
<div className="flex items-center gap-3">
<Image
src="/branding/logo.png"
alt="Logo"
width={360}
height={120}
className="h-auto w-72 object-contain"
priority
/>
</div>
<div>
<h1 className="text-2xl font-semibold text-black">{competitionName}</h1>
<p className="text-gray-600">Submissão de Alturas</p>
</div>
</header>
<section className="rounded-lg border p-4 shadow-sm">
{!memberNumber || !birthDate ? (
<IdentityForm
onVerified={({ name, birthDate, memberNumber }) => {
setMemberNumber(memberNumber)
setBirthDate(birthDate)
setLifterName(name)
}}
/>
) : (
<div>
<div className="mb-4 text-sm text-gray-700">Identificad@ como: <span className="font-medium">{lifterName}</span></div>
<RackHeightsForm
memberNumber={memberNumber}
birthDate={birthDate}
onSuccess={() => {
// Return to step 1
setMemberNumber(null)
setBirthDate(null)
setLifterName(null)
}}
/>
</div>
)}
</section>
</main>
)
} }

View file

@ -1,6 +1,7 @@
"use client" "use client"
import { useId } from 'react' import { useId } from 'react'
import { AlertCircle } from 'lucide-react'
type Props = { type Props = {
label: string label: string
@ -32,13 +33,15 @@ export default function FormField({
const id = useId() const id = useId()
return ( return (
<div className="mb-4"> <div className="mb-4">
<label htmlFor={id} className="mb-1 block text-sm font-medium"> <label htmlFor={id} className="mb-2 block text-sm font-semibold text-gray-900">
{label} {required ? <span aria-hidden>*</span> : null} {label} {required ? <span className="text-[#FF0000]" aria-hidden>*</span> : null}
</label> </label>
<input <input
id={id} id={id}
className={`w-full rounded-md border px-3 py-2 text-base outline-none focus-visible:ring-2 focus-visible:ring-brand-600 ${ className={`w-full rounded-lg border-2 px-4 py-3 text-base outline-none transition-colors ${
error ? 'border-red-500' : 'border-gray-300' error
? 'border-[#FF0000] bg-[#fff5f5] focus-visible:ring-2 focus-visible:ring-[#FF0000]/30'
: 'border-gray-300 focus-visible:ring-2 focus-visible:ring-[#006600]/30'
}`} }`}
type={type} type={type}
value={value} value={value}
@ -53,9 +56,10 @@ export default function FormField({
aria-describedby={error ? `${id}-error` : undefined} aria-describedby={error ? `${id}-error` : undefined}
/> />
{error ? ( {error ? (
<p id={`${id}-error`} className="mt-1 text-sm text-red-600"> <div id={`${id}-error`} className="mt-2 flex items-center gap-2 text-sm text-[#991b1b] font-medium">
<AlertCircle className="h-4 w-4 flex-shrink-0" style={{ color: '#FF0000' }} />
{error} {error}
</p> </div>
) : null} ) : null}
</div> </div>
) )

148
src/components/HomePage.tsx Normal file
View file

@ -0,0 +1,148 @@
"use client"
import { useEffect, useMemo, useRef, useState } from 'react'
import Image from 'next/image'
import LandingPage from '@/components/LandingPage'
import IdentityForm from '@/components/IdentityForm'
import RackHeightsForm from '@/components/RackHeightsForm'
import Spinner from '@/components/Spinner'
import { useLanguage } from '@/context/LanguageContext'
import { t } from '@/utils/translations'
export default function HomePageContent() {
const { language, setLanguage } = useLanguage()
const [authorized, setAuthorized] = useState<boolean | null>(null)
const [memberNumber, setMemberNumber] = useState<string | null>(null)
const [birthDate, setBirthDate] = useState<string | null>(null)
const [lifterName, setLifterName] = useState<string | null>(null)
const [competitionName, setCompetitionName] = useState('Altarra')
useEffect(() => {
let mounted = true
let timeoutId: NodeJS.Timeout
async function check() {
const search = new URLSearchParams(window.location.search)
const auth = search.get('auth') || ''
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const res = await fetch(`/api/auth?auth=${encodeURIComponent(auth)}`, {
signal: controller.signal
})
clearTimeout(timeoutId)
console.log('Auth response:', res.ok, res.status)
if (mounted) setAuthorized(res.ok)
} catch (err) {
console.error('Auth error:', err)
if (mounted) setAuthorized(false)
}
// Load competition name from config
try {
const configRes = await fetch('/api/competition-name')
if (configRes.ok) {
const data = await configRes.json()
if (mounted && data.competitionName) {
setCompetitionName(data.competitionName)
}
}
} catch {
// Fallback to default
}
}
check()
return () => {
mounted = false
clearTimeout(timeoutId)
}
}, [])
if (authorized === null) {
return (
<main className="grid min-h-screen place-items-center">
<div className="flex flex-col items-center gap-2 text-gray-600">
<Spinner />
<p>A carregar</p>
</div>
</main>
)
}
if (!authorized) {
return <LandingPage />
}
return (
<main className="mx-auto max-w-xl px-4 py-8">
<header className="mb-6 flex flex-col items-start gap-3">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<Image
src="/branding/logo.png"
alt="Logo"
width={360}
height={120}
className="h-auto w-72 object-contain"
priority
/>
</div>
<div className="flex gap-2">
<button
onClick={() => setLanguage('pt')}
className={`px-3 py-2 rounded-lg font-medium transition-colors ${
language === 'pt'
? 'bg-[#006600] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
PT
</button>
<button
onClick={() => setLanguage('en')}
className={`px-3 py-2 rounded-lg font-medium transition-colors ${
language === 'en'
? 'bg-[#006600] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
EN
</button>
</div>
</div>
<div>
<h1 className="text-2xl font-semibold text-black">{competitionName}</h1>
<p className="text-gray-600">{language === 'pt' ? 'Submissão de Alturas' : 'Heights Submission'}</p>
</div>
</header>
<section className="rounded-lg border p-4 shadow-sm">
{!memberNumber || !birthDate ? (
<IdentityForm
onVerified={({ name, birthDate, memberNumber }) => {
setMemberNumber(memberNumber)
setBirthDate(birthDate)
setLifterName(name)
}}
/>
) : (
<div>
<div className="mb-4 text-sm text-gray-700">{t('identified_as', language)} <span className="font-medium">{lifterName}</span></div>
<RackHeightsForm
memberNumber={memberNumber}
birthDate={birthDate}
onSuccess={() => {
// Return to step 1
setMemberNumber(null)
setBirthDate(null)
setLifterName(null)
}}
/>
</div>
)}
</section>
</main>
)
}

View file

@ -4,19 +4,31 @@ import { useRef, useState } from 'react'
import FormField from '@/components/FormField' import FormField from '@/components/FormField'
import SubmitButton from '@/components/SubmitButton' import SubmitButton from '@/components/SubmitButton'
import StatusMessage from '@/components/StatusMessage' import StatusMessage from '@/components/StatusMessage'
import { errorMessages, isValidDateDDMMYYYY } from '@/utils/validation' import { isValidDateDDMMYYYY } from '@/utils/validation'
import { useLanguage } from '@/context/LanguageContext'
import { t, translations } from '@/utils/translations'
type Props = { type Props = {
onVerified: (data: { name: string; birthDate: string; memberNumber: string }) => void onVerified: (data: { name: string; birthDate: string; memberNumber: string }) => void
} }
export default function IdentityForm({ onVerified }: Props) { export default function IdentityForm({ onVerified }: Props) {
const { language } = useLanguage()
const [name, setName] = useState('') const [name, setName] = useState('')
const [birthDate, setBirthDate] = useState('') const [birthDate, setBirthDate] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [status, setStatus] = useState<{ type: 'idle' | 'success' | 'error'; msg: string | null }>({ type: 'idle', msg: null }) const [status, setStatus] = useState<{ type: 'idle' | 'success' | 'error'; msg: string | null }>({ type: 'idle', msg: null })
const nameRef = useRef<HTMLInputElement | null>(null) const nameRef = useRef<HTMLInputElement | null>(null)
const errorMessages = {
no_match: t('error_no_match', language),
multiple_matches: t('error_multiple_matches', language),
network_error: t('error_network', language),
api_error: t('error_api', language),
validation_error: t('error_validation', language),
date_format_error: t('error_date_format', language)
}
async function onSubmit(e: React.FormEvent) { async function onSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setStatus({ type: 'idle', msg: null }) setStatus({ type: 'idle', msg: null })
@ -43,7 +55,7 @@ export default function IdentityForm({ onVerified }: Props) {
return return
} }
const data = (await res.json()) as { lifter: { name: string; birthDate: string; memberNumber: string } } const data = (await res.json()) as { lifter: { name: string; birthDate: string; memberNumber: string } }
setStatus({ type: 'success', msg: '✓ Identificação confirmada.' }) setStatus({ type: 'success', msg: t('identification_success', language) })
onVerified(data.lifter) onVerified(data.lifter)
} catch { } catch {
setStatus({ type: 'error', msg: errorMessages.network_error }) setStatus({ type: 'error', msg: errorMessages.network_error })
@ -54,22 +66,22 @@ export default function IdentityForm({ onVerified }: Props) {
return ( return (
<form onSubmit={onSubmit} aria-labelledby="identificacao"> <form onSubmit={onSubmit} aria-labelledby="identificacao">
<h2 id="identificacao" className="mb-4 text-lg font-semibold">Passo 1: Identificação</h2> <h2 id="identificacao" className="mb-4 text-lg font-semibold">{t('step_1_identification', language)}</h2>
<FormField <FormField
label="Nome Completo" label={t('full_name', language)}
value={name} value={name}
onChange={setName} onChange={setName}
placeholder="Ex: João Silva" placeholder={t('full_name_placeholder', language)}
required required
/> />
<FormField <FormField
label="Data de Nascimento" label={t('birth_date', language)}
value={birthDate} value={birthDate}
onChange={setBirthDate} onChange={setBirthDate}
placeholder="DD/MM/AAAA" placeholder={t('birth_date_placeholder', language)}
required required
/> />
<SubmitButton loading={loading}>Procurar</SubmitButton> <SubmitButton loading={loading}>{t('search', language)}</SubmitButton>
<StatusMessage status={status.type === 'success' ? 'success' : status.type === 'error' ? 'error' : 'info'} message={status.msg} /> <StatusMessage status={status.type === 'success' ? 'success' : status.type === 'error' ? 'error' : 'info'} message={status.msg} />
</form> </form>
) )

View file

@ -2,8 +2,12 @@
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { ExternalLink } from 'lucide-react'
import { useLanguage } from '@/context/LanguageContext'
import { t } from '@/utils/translations'
export default function LandingPage() { export default function LandingPage() {
const { language } = useLanguage()
return ( return (
<main className="mx-auto max-w-xl px-4 py-10"> <main className="mx-auto max-w-xl px-4 py-10">
<header className="mb-8 flex flex-col items-center gap-3 text-center"> <header className="mb-8 flex flex-col items-center gap-3 text-center">
@ -16,16 +20,17 @@ export default function LandingPage() {
priority priority
/> />
</header> </header>
<section className="rounded-lg border p-6 shadow-sm"> <section className="rounded-lg border-l-4 border-[#006600] bg-[#f0fdf4] p-8 shadow-md">
<h2 className="mb-3 text-xl font-semibold">Boas-vindas ao Altarra</h2> <h2 className="mb-3 text-2xl font-bold text-[#004d00]">{t('welcome_title', language)}</h2>
<p className="mb-6 text-gray-700"> <p className="mb-8 text-gray-700 leading-relaxed">
Leia o QR Code na área de treino para inserir as suas alturas. {t('welcome_text', language)}
</p> </p>
<Link <Link
href="https://powerlifting.pt" href="https://powerlifting.pt"
className="inline-flex items-center justify-center rounded-md bg-brand-600 px-4 py-2 text-white hover:bg-brand-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600" className="inline-flex items-center justify-center gap-2 rounded-lg bg-[#006600] px-6 py-3 font-semibold text-white shadow-sm hover:bg-[#004d00] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#006600]"
> >
Ir para powerlifting.pt {t('visit_website', language)}
<ExternalLink className="h-4 w-4" />
</Link> </Link>
</section> </section>
</main> </main>

View file

@ -1,12 +1,16 @@
"use client" "use client"
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, lazy, Suspense } from 'react'
import { Checkmark } from 'react-checkmark' import dynamic from 'next/dynamic'
import FormField from '@/components/FormField' import FormField from '@/components/FormField'
import SubmitButton from '@/components/SubmitButton' import SubmitButton from '@/components/SubmitButton'
import StatusMessage from '@/components/StatusMessage' import StatusMessage from '@/components/StatusMessage'
import type { RackHeightsPayload } from '@/types/rackHeights' import type { RackHeightsPayload } from '@/types/rackHeights'
import { errorMessages, required } from '@/utils/validation' import { required } from '@/utils/validation'
import { useLanguage } from '@/context/LanguageContext'
import { t } from '@/utils/translations'
const Checkmark = dynamic(() => import('react-checkmark').then(mod => ({ default: mod.Checkmark })), { ssr: false })
type Props = { type Props = {
memberNumber: string memberNumber: string
@ -15,6 +19,7 @@ type Props = {
} }
export default function RackHeightsForm({ memberNumber, birthDate, onSuccess }: Props) { export default function RackHeightsForm({ memberNumber, birthDate, onSuccess }: Props) {
const { language } = useLanguage()
const [squatRackHeight, setSquatRackHeight] = useState('') const [squatRackHeight, setSquatRackHeight] = useState('')
const [squatRackInOut, setSquatRackInOut] = useState('') const [squatRackInOut, setSquatRackInOut] = useState('')
const [benchRackHeight, setBenchRackHeight] = useState('') const [benchRackHeight, setBenchRackHeight] = useState('')
@ -25,6 +30,12 @@ export default function RackHeightsForm({ memberNumber, birthDate, onSuccess }:
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const firstFieldRef = useRef<HTMLInputElement | null>(null) const firstFieldRef = useRef<HTMLInputElement | null>(null)
const errorMessages = {
validation_error: t('error_validation', language),
network_error: t('error_network', language),
api_error: t('error_api', language)
}
useEffect(() => { useEffect(() => {
firstFieldRef.current?.focus() firstFieldRef.current?.focus()
}, []) }, [])
@ -59,7 +70,7 @@ export default function RackHeightsForm({ memberNumber, birthDate, onSuccess }:
setStatus({ type: 'error', msg }) setStatus({ type: 'error', msg })
return return
} }
setStatus({ type: 'success', msg: '✓ Alturas submetidas com sucesso!' }) setStatus({ type: 'success', msg: t('submission_success', language) })
setSubmitted(true) setSubmitted(true)
} catch { } catch {
setStatus({ type: 'error', msg: errorMessages.network_error }) setStatus({ type: 'error', msg: errorMessages.network_error })
@ -83,27 +94,27 @@ export default function RackHeightsForm({ memberNumber, birthDate, onSuccess }:
<div aria-live="polite"> <div aria-live="polite">
{submitted ? ( {submitted ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-lg border border-brand-accent bg-white px-4 py-5 shadow-sm"> <div className="rounded-lg border-l-4 border-[#006600] bg-[#f0fdf4] px-6 py-6 shadow-sm">
<div className="mb-3 flex justify-center"> <div className="mb-4 flex justify-center">
<Checkmark size="xxLarge" color="#ff0000" /> <Checkmark size="xxLarge" color="#006600" />
</div> </div>
<h2 className="text-center text-lg font-semibold text-black">As suas alturas foram submetidas.</h2> <h2 className="text-center text-xl font-bold text-[#004d00]">{t('heights_submitted_title', language)}</h2>
<p className="mt-3 text-center text-gray-700">As alturas foram enviadas e foram alteradas. Se precisar de alterar as alturas outra vez, pode voltar a submeter este formulário.</p> <p className="mt-3 text-center text-[#1f5e2e]">{t('heights_submitted_text', language)}</p>
</div> </div>
<button <button
type="button" type="button"
onClick={handleNewSubmission} onClick={handleNewSubmission}
className="inline-flex w-full items-center justify-center rounded-md border border-brand-700 bg-brand-600 px-4 py-2 text-white hover:bg-brand-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600" className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-[#006600] px-5 py-3 font-semibold text-white shadow-sm hover:bg-[#004d00] transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#006600]"
> >
Submeter outro atleta ou Alterar alturas {t('submit_another', language)}
</button> </button>
</div> </div>
) : ( ) : (
<form onSubmit={onSubmit} aria-labelledby="alturas"> <form onSubmit={onSubmit} aria-labelledby="alturas">
<h2 id="alturas" className="mb-4 text-lg font-semibold text-black">Passo 2: Alturas de Rack</h2> <h2 id="alturas" className="mb-4 text-lg font-semibold text-black">{t('step_2_heights', language)}</h2>
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
<div> <div>
<label className="mb-1 block text-sm font-medium">Altura do Rack de Agachamento <span aria-hidden>*</span></label> <label className="mb-2 block text-sm font-semibold text-gray-900">{t('squat_rack_height', language)} <span className="text-[#FF0000]" aria-hidden>*</span></label>
<input <input
ref={firstFieldRef} ref={firstFieldRef}
type="number" type="number"
@ -112,12 +123,12 @@ export default function RackHeightsForm({ memberNumber, birthDate, onSuccess }:
max={20} max={20}
value={squatRackHeight} value={squatRackHeight}
onChange={(e) => setSquatRackHeight(e.target.value)} onChange={(e) => setSquatRackHeight(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus-visible:ring-2 focus-visible:ring-brand-600" className="w-full rounded-lg border-2 border-gray-300 px-4 py-3 text-base outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#006600]/30"
required required
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium">Altura do Rack de Supino <span aria-hidden>*</span></label> <label className="mb-2 block text-sm font-semibold text-gray-900">{t('bench_rack_height', language)} <span className="text-[#FF0000]" aria-hidden>*</span></label>
<input <input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
@ -125,19 +136,19 @@ export default function RackHeightsForm({ memberNumber, birthDate, onSuccess }:
max={20} max={20}
value={benchRackHeight} value={benchRackHeight}
onChange={(e) => setBenchRackHeight(e.target.value)} onChange={(e) => setBenchRackHeight(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus-visible:ring-2 focus-visible:ring-brand-600" className="w-full rounded-lg border-2 border-gray-300 px-4 py-3 text-base outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#006600]/30"
required required
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium">Blocos para os Pés no Supino <span aria-hidden>*</span></label> <label className="mb-2 block text-sm font-semibold text-gray-900">{t('bench_foot_blocks', language)} <span className="text-[#FF0000]" aria-hidden>*</span></label>
<select <select
value={benchRackFootBlocks} value={benchRackFootBlocks}
onChange={(e) => setBenchRackFootBlocks(e.target.value as any)} onChange={(e) => setBenchRackFootBlocks(e.target.value as any)}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus-visible:ring-2 focus-visible:ring-brand-600" className="w-full rounded-lg border-2 border-gray-300 px-4 py-3 text-base outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#006600]/30"
required required
> >
<option value="NONE">Nenhum</option> <option value="NONE">{t('foot_blocks_none', language)}</option>
<option value="5cm">5cm</option> <option value="5cm">5cm</option>
<option value="10cm">10cm</option> <option value="10cm">10cm</option>
<option value="20cm">20cm</option> <option value="20cm">20cm</option>
@ -146,7 +157,7 @@ export default function RackHeightsForm({ memberNumber, birthDate, onSuccess }:
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<SubmitButton loading={loading}>Submeter Alturas</SubmitButton> <SubmitButton loading={loading}>{t('submit_heights', language)}</SubmitButton>
</div> </div>
<StatusMessage status={status.type === 'success' ? 'success' : status.type === 'error' ? 'error' : 'info'} message={status.msg} /> <StatusMessage status={status.type === 'success' ? 'success' : status.type === 'error' ? 'error' : 'info'} message={status.msg} />
</form> </form>

View file

@ -1,5 +1,7 @@
"use client" "use client"
import { CheckCircle2, AlertCircle, Info } from 'lucide-react'
type Props = { type Props = {
status: 'idle' | 'success' | 'error' | 'info' status: 'idle' | 'success' | 'error' | 'info'
message: string | null message: string | null
@ -7,17 +9,36 @@ type Props = {
export default function StatusMessage({ status, message }: Props) { export default function StatusMessage({ status, message }: Props) {
if (!message) return null if (!message) return null
const color =
status === 'success' ? 'text-green-700 bg-green-50 border-green-200' : const config = {
status === 'error' ? 'text-red-700 bg-red-50 border-red-200' : success: {
'text-gray-700 bg-gray-50 border-gray-200' color: 'bg-[#f0fdf4] border-[#006600] text-[#004d00]',
icon: <CheckCircle2 className="h-5 w-5 flex-shrink-0" style={{ color: '#006600' }} />
},
error: {
color: 'bg-[#fef2f2] border-[#FF0000] text-[#991b1b]',
icon: <AlertCircle className="h-5 w-5 flex-shrink-0" style={{ color: '#FF0000' }} />
},
info: {
color: 'bg-[#f3f4f6] border-[#6b7280] text-[#374151]',
icon: <Info className="h-5 w-5 flex-shrink-0" style={{ color: '#6b7280' }} />
},
idle: {
color: 'bg-[#f3f4f6] border-[#6b7280] text-[#374151]',
icon: <Info className="h-5 w-5 flex-shrink-0" style={{ color: '#6b7280' }} />
}
}
const current = config[status]
return ( return (
<div <div
role="status" role="status"
aria-live="polite" aria-live="polite"
className={`mt-3 rounded-md border px-3 py-2 text-sm ${color}`} className={`mt-4 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 text-sm font-medium ${current.color}`}
> >
{message} {current.icon}
<span>{message}</span>
</div> </div>
) )
} }

View file

@ -1,5 +1,7 @@
"use client" "use client"
import { CheckCircle2 } from 'lucide-react'
type Props = { type Props = {
children: React.ReactNode children: React.ReactNode
loading?: boolean loading?: boolean
@ -10,12 +12,14 @@ export default function SubmitButton({ children, loading, disabled }: Props) {
return ( return (
<button <button
type="submit" type="submit"
className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-white hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-[#006600] px-5 py-3 font-semibold text-white shadow-sm hover:bg-[#004d00] transition-colors disabled:cursor-not-allowed disabled:opacity-60"
disabled={disabled || loading} disabled={disabled || loading}
> >
{loading ? ( {loading ? (
<span className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-white border-r-transparent" aria-hidden /> <span className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-white border-r-transparent" aria-hidden />
) : null} ) : (
<CheckCircle2 className="h-5 w-5" />
)}
<span>{children}</span> <span>{children}</span>
</button> </button>
) )

View file

@ -0,0 +1,49 @@
'use client'
import { createContext, useContext, useState, useEffect } from 'react'
import type { Language } from '@/utils/translations'
type LanguageContextType = {
language: Language
setLanguage: (lang: Language) => void
}
const LanguageContext = createContext<LanguageContextType>({
language: 'pt',
setLanguage: () => {}
})
export function LanguageProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState<Language>('pt')
useEffect(() => {
// Load language from localStorage
const saved = localStorage.getItem('language') as Language | null
if (saved && ['pt', 'en'].includes(saved)) {
setLanguageState(saved)
} else {
// Detect browser language
const browserLang = navigator.language.toLowerCase()
if (browserLang.startsWith('en')) {
setLanguageState('en')
} else {
setLanguageState('pt')
}
}
}, [])
const setLanguage = (lang: Language) => {
setLanguageState(lang)
localStorage.setItem('language', lang)
}
return (
<LanguageContext.Provider value={{ language, setLanguage }}>
{children}
</LanguageContext.Provider>
)
}
export function useLanguage() {
return useContext(LanguageContext)
}

9
src/react-checkmark.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
declare module 'react-checkmark' {
import { ReactNode } from 'react'
interface CheckmarkProps {
[key: string]: any
}
export const Checkmark: React.FC<CheckmarkProps>
}

84
src/utils/translations.ts Normal file
View file

@ -0,0 +1,84 @@
export type Language = 'pt' | 'en'
export const translations = {
pt: {
// Navigation
logout: 'Sair',
// Landing page
welcome_title: 'Boas-vindas ao Altarra',
welcome_text: 'Leia o QR Code na área de treino para inserir as suas alturas.',
visit_website: 'Ir para powerlifting.pt',
// Forms
step_1_identification: 'Passo 1: Identificação',
step_2_heights: 'Passo 2: Alturas de Rack',
full_name: 'Nome Completo',
full_name_placeholder: 'Ex: João Silva',
birth_date: 'Data de Nascimento',
birth_date_placeholder: 'DD/MM/AAAA',
search: 'Procurar',
squat_rack_height: 'Altura do Rack de Agachamento',
bench_rack_height: 'Altura do Rack de Supino',
bench_foot_blocks: 'Blocos para os Pés no Supino',
foot_blocks_none: 'Nenhum',
submit_heights: 'Submeter Alturas',
submit_another: 'Submeter outro atleta ou Alterar alturas',
identified_as: 'Identificad@ como:',
competition: 'Competição',
heights_submitted_title: 'As suas alturas foram submetidas.',
heights_submitted_text: 'As alturas foram enviadas e já foram alteradas. Se precisar de alterar as alturas outra vez, pode voltar a submeter este formulário.',
heights_submission_loading: 'A enviar...',
// Errors
error_no_match: 'Não foi possível encontrar o seu nome. O nome está igual ao LiftingCast?',
error_multiple_matches: 'Erro inesperado - fale com a equipa da APP.',
error_network: 'Occoreu um erro de ligação. Tem rede/wi-fi?',
error_api: 'Erro ao submeter. Por favor, tente novamente ou contacte a equipa.',
error_validation: 'Por favor, preenche todos os campos obrigatórios.',
error_date_format: 'Formato de data inválido. Use DD/MM/AAAA.',
// Other
loading: 'A carregar…',
submission_success: '✓ Alturas submetidas com sucesso!',
identification_success: '✓ Identificação confirmada.'
},
en: {
// Navigation
logout: 'Logout',
// Landing page
welcome_title: 'Welcome to Altarra',
welcome_text: 'Scan the QR Code in the training area to enter your heights.',
visit_website: 'Go to powerlifting.pt',
// Forms
step_1_identification: 'Step 1: Identification',
step_2_heights: 'Step 2: Rack Heights',
full_name: 'Full Name',
full_name_placeholder: 'Ex: John Smith',
birth_date: 'Birth Date',
birth_date_placeholder: 'DD/MM/YYYY',
search: 'Search',
squat_rack_height: 'Squat Rack Height',
bench_rack_height: 'Bench Rack Height',
bench_foot_blocks: 'Foot Blocks for Bench',
foot_blocks_none: 'None',
submit_heights: 'Submit Heights',
submit_another: 'Submit another athlete or Change heights',
identified_as: 'Identified as:',
competition: 'Competition',
heights_submitted_title: 'Your heights have been submitted.',
heights_submitted_text: 'The heights have been sent and have already been changed. If you need to change the heights again, you can resubmit this form.',
heights_submission_loading: 'Sending...',
// Errors
error_no_match: 'Could not find your name. Is the name the same as in LiftingCast?',
error_multiple_matches: 'Unexpected error - contact the app team.',
error_network: 'A connection error occurred. Do you have internet/wi-fi?',
error_api: 'Error submitting. Please try again or contact the team.',
error_validation: 'Please fill in all required fields.',
error_date_format: 'Invalid date format. Use DD/MM/YYYY.',
// Other
loading: 'Loading…',
submission_success: '✓ Heights submitted successfully!',
identification_success: '✓ Identification confirmed.'
}
}
export function t(key: keyof typeof translations.pt, lang: Language): string {
return translations[lang][key]
}

View file

@ -1,10 +1,10 @@
export const errorMessages = { export const errorMessages = {
no_match: 'Não foi possível encontrar o seu nome. Por favor, verifique se está igual ao LiftingCast e tente novamente.', no_match: 'Não foi possível encontrar o seu nome. O nome está igual ao LiftingCast?',
multiple_matches: 'Por favor fala com a equipa da APP.', multiple_matches: 'Erro inesperado - fale com a equipa da APP.',
network_error: 'Occoreu um erro de ligação. Tente outravez.', network_error: 'Occoreu um erro de ligação. Tem rede/wi-fi?',
api_error: 'Erro ao submeter. Por favor, tente novamente ou contacte a equipa.', api_error: 'Erro ao submeter. Por favor, tente novamente ou contacte a equipa.',
validation_error: 'Por favor, preencha todos os campos obrigatórios.', validation_error: 'Por favor, preenche todos os campos obrigatórios.',
date_format_error: 'Formato de data inválido. Use DD/MM/YYYY.' date_format_error: 'Formato de data inválido. Use DD/MM/AAAA.'
} }
export function isValidDateDDMMYYYY(value: string): boolean { export function isValidDateDDMMYYYY(value: string): boolean {

View file

@ -15,6 +15,10 @@ const config: Config = {
600: '#006600', 600: '#006600',
700: '#004d00', 700: '#004d00',
accent: '#ff0000' accent: '#ff0000'
},
pt: {
red: '#FF0000',
green: '#006600'
} }
} }
} }

View file

@ -21,6 +21,9 @@
"@/components/*": [ "@/components/*": [
"src/components/*" "src/components/*"
], ],
"@/context/*": [
"src/context/*"
],
"@/services/*": [ "@/services/*": [
"src/services/*" "src/services/*"
], ],