updates and clean of commit history due to issues...
This commit is contained in:
parent
d4b8796b9f
commit
6ec65d22b6
21 changed files with 493 additions and 169 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -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
14
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -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
37
scripts/setup-config.js
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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') || ''
|
||||||
|
|
|
||||||
16
src/app/api/competition-name/route.ts
Normal file
16
src/app/api/competition-name/route.ts
Normal 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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
107
src/app/page.tsx
107
src/app/page.tsx
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
148
src/components/HomePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 já 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
49
src/context/LanguageContext.tsx
Normal file
49
src/context/LanguageContext.tsx
Normal 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
9
src/react-checkmark.d.ts
vendored
Normal 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
84
src/utils/translations.ts
Normal 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]
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ const config: Config = {
|
||||||
600: '#006600',
|
600: '#006600',
|
||||||
700: '#004d00',
|
700: '#004d00',
|
||||||
accent: '#ff0000'
|
accent: '#ff0000'
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
red: '#FF0000',
|
||||||
|
green: '#006600'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@
|
||||||
"@/components/*": [
|
"@/components/*": [
|
||||||
"src/components/*"
|
"src/components/*"
|
||||||
],
|
],
|
||||||
|
"@/context/*": [
|
||||||
|
"src/context/*"
|
||||||
|
],
|
||||||
"@/services/*": [
|
"@/services/*": [
|
||||||
"src/services/*"
|
"src/services/*"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue