ui update

This commit is contained in:
headpatsyou 2026-01-21 18:47:43 +00:00
parent d121596c8f
commit 737998bd6d
15 changed files with 1287 additions and 266 deletions

92
MUI_MIGRATION.md Normal file
View file

@ -0,0 +1,92 @@
# Material-UI Migration Summary
This document outlines the migration of the Altarra app from Tailwind CSS to Material-UI (MUI).
## Dependencies Added
- `@mui/material@^5.14.20`
- `@mui/icons-material@^5.14.20`
- `@emotion/react@^11.11.1`
- `@emotion/styled@^11.11.0`
## Changes Made
### 1. Theme Setup
- Created `src/context/ThemeProvider.tsx` - Custom MUI theme with:
- Primary color: `#006600` (green)
- Secondary color: `#FF0000` (red)
- Custom typography and component overrides
### 2. Layout
- Updated `src/app/layout.tsx` to:
- Use `ThemeProvider` wrapper
- Remove Tailwind CSS import
- Use MUI `Box` for flex layouts
### 3. Component Migration
#### FormField.tsx
- Replaced custom input with `TextField` component
- Automatic error handling with `helperText` prop
- Integrated label and validation styling
#### SubmitButton.tsx
- Replaced custom button with `Button` component
- Used `startIcon` for loading spinner (`CircularProgress`)
- Automatic disabled state styling
#### StatusMessage.tsx
- Replaced custom alerts with `Alert` component
- Automatic severity-based styling (success, error, info)
#### IdentityForm.tsx
- Used `Box` component for form wrapper
- Used `Typography` for headings
- FormField components integrated
#### RackHeightsForm.tsx
- Replaced form inputs with FormField components
- Used `Alert` for success message
- Used `Button` for secondary actions
- Replaced custom spinner with MUI `CircularProgress`
#### HomePage.tsx
- Used `Box`, `Paper`, `Typography`, and `Button` components
- Language toggle buttons use variant-based styling
- Logo and layout uses MUI's sx prop system
#### LandingPage.tsx
- Used `Paper` component for card styling
- Used `Button` with link functionality
- Replaced Lucide icon with `OpenInNewIcon` from `@mui/icons-material`
#### Footer.tsx
- Used `Box`, `Typography`, and `Container` components
- Maintained styling with MUI theming
#### Spinner.tsx
- Replaced custom spinner with `CircularProgress` component
## Styling Approach
All components now use MUI's `sx` prop system, which provides:
- Type-safe styling
- Direct access to theme values
- Responsive design support
- Better performance
## Migration Benefits
1. **Consistency**: Unified design system across the app
2. **Accessibility**: Better built-in a11y features
3. **Theming**: Centralized theme management
4. **Components**: Rich set of pre-built components
5. **Developer Experience**: Better TypeScript support and IDE integration
## Notes
- Tailwind CSS configuration can be safely removed if no longer needed
- The app maintains the same visual appearance and functionality
- All forms and interactive elements work as before
- Language switching is preserved
- Responsive design is maintained through MUI's breakpoint system

742
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,10 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.14.20",
"@mui/icons-material": "^5.14.20",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "14.1.0", "next": "14.1.0",
"papaparse": "5.4.1", "papaparse": "5.4.1",

View file

@ -1,10 +1,8 @@
import './globals.css'
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Footer from '@/components/Footer' import Footer from '@/components/Footer'
import { LanguageProvider } from '@/context/LanguageContext' import { LanguageProvider } from '@/context/LanguageContext'
import { ThemeProvider } from '@/context/ThemeProvider'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Altarra - Submissão de Alturas', title: 'Altarra - Submissão de Alturas',
@ -14,13 +12,15 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { 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>
<LanguageProvider> <ThemeProvider>
<div className="flex flex-col min-h-screen"> <LanguageProvider>
<main className="flex-1">{children}</main> <div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Footer /> <main style={{ flex: 1 }}>{children}</main>
</div> <Footer />
</LanguageProvider> </div>
</LanguageProvider>
</ThemeProvider>
</body> </body>
</html> </html>
) )

View file

@ -1,25 +1,30 @@
"use client" "use client"
import Link from 'next/link' import Link from 'next/link'
import { Box, Typography, Container } from '@mui/material'
export default function Footer() { export default function Footer() {
return ( return (
<footer className="mt-12 border-t border-gray-200 bg-white py-8"> <Box component="footer" sx={{ mt: 6, borderTop: '1px solid', borderTopColor: 'divider', py: 4 }}>
<div className="mx-auto max-w-2xl px-4"> <Container maxWidth="sm">
<p className="text-center text-sm font-medium text-gray-900"> <Typography align="center" variant="body2" sx={{ fontWeight: 500 }}>
© 2026 APP Associação Portuguesa de Powerlifting © 2026 APP Associação Portuguesa de Powerlifting
</p> </Typography>
<p className="mt-3 text-center text-xs text-gray-500"> <Typography align="center" variant="caption" sx={{ mt: 2, display: 'block', color: 'text.secondary' }}>
feito por{' '} feito por{' '}
<Link href="https://comfy.solutions" className="hover:underline"> <Link href="https://comfy.solutions" style={{ textDecoration: 'none', color: 'inherit' }}>
comfy.solutions <Typography component="span" variant="caption" sx={{ '&:hover': { textDecoration: 'underline' } }}>
comfy.solutions
</Typography>
</Link> </Link>
{' '}{' '} {' '}{' '}
<Link href="https://comfy.fillout.com/altarra-errors" className="hover:underline"> <Link href="https://comfy.fillout.com/altarra-errors" style={{ textDecoration: 'none', color: 'inherit' }}>
Erros? Informe-nos! <Typography component="span" variant="caption" sx={{ '&:hover': { textDecoration: 'underline' } }}>
Erros? Informe-nos!
</Typography>
</Link> </Link>
</p> </Typography>
</div> </Container>
</footer> </Box>
) )
} }

View file

@ -1,7 +1,6 @@
"use client" "use client"
import { useId } from 'react' import { TextField, FormHelperText, Box } from '@mui/material'
import { AlertCircle } from 'lucide-react'
type Props = { type Props = {
label: string label: string
@ -30,37 +29,27 @@ export default function FormField({
disabled, disabled,
error error
}: Props) { }: Props) {
const id = useId()
return ( return (
<div className="mb-4"> <Box sx={{ mb: 2 }}>
<label htmlFor={id} className="mb-2 block text-sm font-semibold text-gray-900"> <TextField
{label} {required ? <span className="text-[#FF0000]" aria-hidden>*</span> : null} fullWidth
</label> label={label}
<input
id={id}
className={`w-full rounded-lg border-2 px-4 py-3 text-base outline-none transition-colors ${
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}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={type === 'date' ? undefined : placeholder}
required={required} required={required}
min={min}
max={max}
step={step}
disabled={disabled} disabled={disabled}
aria-invalid={!!error} error={!!error}
aria-describedby={error ? `${id}-error` : undefined} variant="outlined"
inputProps={{
min,
max,
step,
}}
InputLabelProps={type === 'date' ? { shrink: true } : undefined}
helperText={error}
/> />
{error ? ( </Box>
<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}
</div>
) : null}
</div>
) )
} }

View file

@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { Box, Container, Paper, Typography, Button, CircularProgress } from '@mui/material'
import LandingPage from '@/components/LandingPage' import LandingPage from '@/components/LandingPage'
import IdentityForm from '@/components/IdentityForm' import IdentityForm from '@/components/IdentityForm'
import RackHeightsForm from '@/components/RackHeightsForm' import RackHeightsForm from '@/components/RackHeightsForm'
@ -63,12 +64,19 @@ export default function HomePageContent() {
if (authorized === null) { if (authorized === null) {
return ( return (
<main className="grid min-h-screen place-items-center"> <Box
<div className="flex flex-col items-center gap-2 text-gray-600"> component="main"
sx={{
display: 'grid',
placeItems: 'center',
minHeight: '100vh',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Spinner /> <Spinner />
<p>A carregar</p> <Typography color="textSecondary">A carregar</Typography>
</div> </Box>
</main> </Box>
) )
} }
@ -77,48 +85,46 @@ export default function HomePageContent() {
} }
return ( return (
<main className="mx-auto max-w-xl px-4 py-8"> <Box component="main" sx={{ mx: 'auto', maxWidth: 'md', px: 2, py: 4 }}>
<header className="mb-6 flex flex-col items-start gap-3"> <Box sx={{ mb: 3, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<div className="flex items-center justify-between w-full"> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', width: '100%', flexWrap: 'wrap', gap: 1.5 }}>
<div className="flex items-center gap-3"> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Image <Image
src="/branding/logo.png" src="/branding/logo.png"
alt="Logo" alt="Logo"
width={360} width={360}
height={120} height={120}
className="h-auto w-72 object-contain" style={{ height: 'auto', width: 288, objectFit: 'contain' }}
priority priority
/> />
</div> </Box>
<div className="flex gap-2"> <Box sx={{ display: 'flex', gap: 1, alignSelf: 'center' }}>
<button <Button
onClick={() => setLanguage('pt')} onClick={() => setLanguage('pt')}
className={`px-3 py-2 rounded-lg font-medium transition-colors ${ variant={language === 'pt' ? 'contained' : 'outlined'}
language === 'pt' size="small"
? 'bg-[#006600] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
> >
PT PT
</button> </Button>
<button <Button
onClick={() => setLanguage('en')} onClick={() => setLanguage('en')}
className={`px-3 py-2 rounded-lg font-medium transition-colors ${ variant={language === 'en' ? 'contained' : 'outlined'}
language === 'en' size="small"
? 'bg-[#006600] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
> >
EN EN
</button> </Button>
</div> </Box>
</div> </Box>
<div> <Box>
<h1 className="text-2xl font-semibold text-black">{competitionName}</h1> <Typography variant="h4" sx={{ fontWeight: 600 }}>
<p className="text-gray-600">{language === 'pt' ? 'Submissão de Alturas' : 'Heights Submission'}</p> {competitionName}
</div> </Typography>
</header> <Typography color="textSecondary">
<section className="rounded-lg border p-4 shadow-sm"> {language === 'pt' ? 'Submissão de Alturas' : 'Heights Submission'}
</Typography>
</Box>
</Box>
<Paper sx={{ p: 2 }}>
{!memberNumber || !birthDate ? ( {!memberNumber || !birthDate ? (
<IdentityForm <IdentityForm
onVerified={({ name, birthDate, memberNumber }) => { onVerified={({ name, birthDate, memberNumber }) => {
@ -128,8 +134,10 @@ export default function HomePageContent() {
}} }}
/> />
) : ( ) : (
<div> <Box>
<div className="mb-4 text-sm text-gray-700">{t('identified_as', language)} <span className="font-medium">{lifterName}</span></div> <Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
{t('identified_as', language)} <Typography component="span" sx={{ fontWeight: 600 }}>{lifterName}</Typography>
</Typography>
<RackHeightsForm <RackHeightsForm
memberNumber={memberNumber} memberNumber={memberNumber}
birthDate={birthDate} birthDate={birthDate}
@ -140,9 +148,9 @@ export default function HomePageContent() {
setLifterName(null) setLifterName(null)
}} }}
/> />
</div> </Box>
)} )}
</section> </Paper>
</main> </Box>
) )
} }

View file

@ -1,9 +1,11 @@
"use client" "use client"
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { Box, Typography } from '@mui/material'
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 LoadingOverlay from '@/components/LoadingOverlay'
import { isValidDateDDMMYYYY } from '@/utils/validation' import { isValidDateDDMMYYYY } from '@/utils/validation'
import { useLanguage } from '@/context/LanguageContext' import { useLanguage } from '@/context/LanguageContext'
import { t, translations } from '@/utils/translations' import { t, translations } from '@/utils/translations'
@ -36,7 +38,14 @@ export default function IdentityForm({ onVerified }: Props) {
setStatus({ type: 'error', msg: errorMessages.validation_error }) setStatus({ type: 'error', msg: errorMessages.validation_error })
return return
} }
if (!isValidDateDDMMYYYY(birthDate)) {
// Convert ISO date (YYYY-MM-DD) to DD/MM/YYYY format
const isISODate = birthDate.match(/^\d{4}-\d{2}-\d{2}$/)
const formattedDate = isISODate
? birthDate.split('-').reverse().join('/')
: birthDate
if (!isValidDateDDMMYYYY(formattedDate)) {
setStatus({ type: 'error', msg: errorMessages.date_format_error }) setStatus({ type: 'error', msg: errorMessages.date_format_error })
return return
} }
@ -46,7 +55,7 @@ export default function IdentityForm({ onVerified }: Props) {
const res = await fetch('/api/find-lifter', { const res = await fetch('/api/find-lifter', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, birthDate }) body: JSON.stringify({ name, birthDate: formattedDate })
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
@ -65,24 +74,29 @@ export default function IdentityForm({ onVerified }: Props) {
} }
return ( return (
<form onSubmit={onSubmit} aria-labelledby="identificacao"> <>
<h2 id="identificacao" className="mb-4 text-lg font-semibold">{t('step_1_identification', language)}</h2> <LoadingOverlay visible={loading} />
<FormField <Box component="form" onSubmit={onSubmit} aria-labelledby="identificacao">
label={t('full_name', language)} <Typography id="identificacao" variant="h6" sx={{ mb: 2 }}>
value={name} {t('step_1_identification', language)}
onChange={setName} </Typography>
placeholder={t('full_name_placeholder', language)} <FormField
required label={t('full_name', language)}
/> value={name}
<FormField onChange={setName}
label={t('birth_date', language)} placeholder={t('full_name_placeholder', language)}
value={birthDate} required
onChange={setBirthDate} />
placeholder={t('birth_date_placeholder', language)} <FormField
required label={t('birth_date', language)}
/> type="date"
<SubmitButton loading={loading}>{t('search', language)}</SubmitButton> value={birthDate}
<StatusMessage status={status.type === 'success' ? 'success' : status.type === 'error' ? 'error' : 'info'} message={status.msg} /> onChange={setBirthDate}
</form> required
/>
<SubmitButton disabled={loading}>{t('search', language)}</SubmitButton>
<StatusMessage status={status.type === 'success' ? 'success' : status.type === 'error' ? 'error' : 'info'} message={status.msg} />
</Box>
</>
) )
} }

View file

@ -2,37 +2,103 @@
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 { Box, Paper, Typography, Button, Container } from '@mui/material'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import { useLanguage } from '@/context/LanguageContext' import { useLanguage } from '@/context/LanguageContext'
import { t } from '@/utils/translations' import { t } from '@/utils/translations'
export default function LandingPage() { export default function LandingPage() {
const { language } = useLanguage() const { language, setLanguage } = useLanguage()
return ( return (
<main className="mx-auto max-w-xl px-4 py-10"> <Box
<header className="mb-8 flex flex-col items-center gap-3 text-center"> component="main"
<Image sx={{
src="/branding/logo.png" minHeight: '100vh',
alt="Logo" display: 'flex',
width={360} flexDirection: 'column',
height={120} py: 4,
className="h-auto w-72 object-contain" }}
priority >
/> <Container maxWidth="sm" sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
</header> <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 4, gap: 1 }}>
<section className="rounded-lg border-l-4 border-[#006600] bg-[#f0fdf4] p-8 shadow-md"> <Button
<h2 className="mb-3 text-2xl font-bold text-[#004d00]">{t('welcome_title', language)}</h2> onClick={() => setLanguage('pt')}
<p className="mb-8 text-gray-700 leading-relaxed"> variant={language === 'pt' ? 'contained' : 'outlined'}
{t('welcome_text', language)} size="small"
</p> >
<Link PT
href="https://powerlifting.pt" </Button>
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]" <Button
onClick={() => setLanguage('en')}
variant={language === 'en' ? 'contained' : 'outlined'}
size="small"
>
EN
</Button>
</Box>
<Box sx={{ textAlign: 'center', mb: 5 }}>
<Image
src="/branding/logo.png"
alt="Logo"
width={360}
height={120}
style={{ height: 'auto', width: 320, objectFit: 'contain' }}
priority
/>
</Box>
<Paper
sx={{
p: { xs: 3, sm: 5 },
borderLeft: '6px solid',
borderLeftColor: 'primary.main',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
backgroundColor: 'rgba(0, 102, 0, 0.02)',
}}
> >
{t('visit_website', language)} <Typography
<ExternalLink className="h-4 w-4" /> variant="h4"
</Link> sx={{
</section> mb: 3,
</main> fontWeight: 700,
color: 'primary.dark',
fontSize: { xs: '1.75rem', sm: '2.125rem' },
}}
>
{t('welcome_title', language)}
</Typography>
<Typography
variant="body1"
sx={{
mb: 4,
color: 'text.secondary',
lineHeight: 1.8,
fontSize: { xs: '0.95rem', sm: '1rem' },
}}
>
{t('welcome_text', language)}
</Typography>
<Button
component="a"
href="https://powerlifting.pt"
target="_blank"
rel="noopener noreferrer"
variant="contained"
color="primary"
size="large"
fullWidth
endIcon={<OpenInNewIcon />}
sx={{
py: 1.5,
fontSize: '1rem',
fontWeight: 600,
}}
>
{t('visit_website', language)}
</Button>
</Paper>
</Container>
</Box>
) )
} }

View file

@ -0,0 +1,31 @@
"use client"
import { Box, CircularProgress } from '@mui/material'
type Props = {
visible: boolean
}
export default function LoadingOverlay({ visible }: Props) {
if (!visible) return null
return (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(0, 0, 0, 0.5)',
zIndex: 9999,
}}
aria-busy="true"
>
<CircularProgress size={60} />
</Box>
)
}

View file

@ -1,10 +1,12 @@
"use client" "use client"
import { useEffect, useRef, useState, lazy, Suspense } from 'react' import { useEffect, useRef, useState } from 'react'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { Box, Typography, Alert, Button, TextField, MenuItem } from '@mui/material'
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 LoadingOverlay from '@/components/LoadingOverlay'
import type { RackHeightsPayload } from '@/types/rackHeights' import type { RackHeightsPayload } from '@/types/rackHeights'
import { required } from '@/utils/validation' import { required } from '@/utils/validation'
import { useLanguage } from '@/context/LanguageContext' import { useLanguage } from '@/context/LanguageContext'
@ -91,77 +93,89 @@ export default function RackHeightsForm({ memberNumber, birthDate, onSuccess }:
} }
return ( return (
<div aria-live="polite"> <>
{submitted ? ( <LoadingOverlay visible={loading} />
<div className="space-y-4"> <Box aria-live="polite">
<div className="rounded-lg border-l-4 border-[#006600] bg-[#f0fdf4] px-6 py-6 shadow-sm"> {submitted ? (
<div className="mb-4 flex justify-center"> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Checkmark size="xxLarge" color="#006600" /> <Box
</div> sx={{
<h2 className="text-center text-xl font-bold text-[#004d00]">{t('heights_submitted_title', language)}</h2> py: 3,
<p className="mt-3 text-center text-[#1f5e2e]">{t('heights_submitted_text', language)}</p> px: 2,
</div> borderRadius: 1,
<button textAlign: 'center',
type="button" bgcolor: '#f0fdf4',
onClick={handleNewSubmission} borderLeft: '4px solid',
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]" borderLeftColor: 'primary.main',
> }}
{t('submit_another', language)} >
</button> <Box sx={{ mb: 2, display: 'flex', justifyContent: 'center' }}>
</div> <Checkmark size="xxLarge" color="#006600" />
) : ( </Box>
<form onSubmit={onSubmit} aria-labelledby="alturas"> <Typography variant="h6" sx={{ fontWeight: 600, mb: 1, color: 'primary.dark' }}>
<h2 id="alturas" className="mb-4 text-lg font-semibold text-black">{t('step_2_heights', language)}</h2> {t('heights_submitted_title', language)}
<div className="grid grid-cols-1 gap-3"> </Typography>
<div> <Typography variant="body2" sx={{ color: '#1f5e2e' }}>
<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> {t('heights_submitted_text', language)}
<input </Typography>
ref={firstFieldRef} </Box>
<Button
variant="contained"
color="primary"
fullWidth
onClick={handleNewSubmission}
sx={{ py: 1.5 }}
>
{t('submit_another', language)}
</Button>
</Box>
) : (
<Box component="form" onSubmit={onSubmit} aria-labelledby="alturas">
<Typography id="alturas" variant="h6" sx={{ mb: 2 }}>
{t('step_2_heights', language)}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormField
label={t('squat_rack_height', language)}
type="number" type="number"
inputMode="numeric"
min={1}
max={20}
value={squatRackHeight} value={squatRackHeight}
onChange={(e) => setSquatRackHeight(e.target.value)} onChange={(e) => setSquatRackHeight(e)}
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
/>
</div>
<div>
<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
type="number"
inputMode="numeric"
min={1} min={1}
max={20} max={20}
value={benchRackHeight}
onChange={(e) => setBenchRackHeight(e.target.value)}
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> <FormField
<div> label={t('bench_rack_height', language)}
<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> type="number"
<select value={benchRackHeight}
value={benchRackFootBlocks} onChange={(e) => setBenchRackHeight(e)}
onChange={(e) => setBenchRackFootBlocks(e.target.value as any)} min={1}
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" max={20}
required required
/>
<TextField
fullWidth
select
label={t('bench_foot_blocks', language)}
value={benchRackFootBlocks}
onChange={(e) => setBenchRackFootBlocks(e.target.value as 'NONE' | '5cm' | '10cm' | '20cm' | '30cm')}
required
variant="outlined"
> >
<option value="NONE">{t('foot_blocks_none', language)}</option> <MenuItem value="NONE">{t('foot_blocks_none', language)}</MenuItem>
<option value="5cm">5cm</option> <MenuItem value="5cm">5cm</MenuItem>
<option value="10cm">10cm</option> <MenuItem value="10cm">10cm</MenuItem>
<option value="20cm">20cm</option> <MenuItem value="20cm">20cm</MenuItem>
<option value="30cm">30cm</option> <MenuItem value="30cm">30cm</MenuItem>
</select> </TextField>
</div> </Box>
</div> <Box sx={{ mt: 2 }}>
<div className="mt-4"> <SubmitButton disabled={loading}>{t('submit_heights', language)}</SubmitButton>
<SubmitButton loading={loading}>{t('submit_heights', language)}</SubmitButton> </Box>
</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} /> </Box>
</form> )}
)} </Box>
</div> </>
) )
} }

View file

@ -1,7 +1,7 @@
"use client" "use client"
import { CircularProgress } from '@mui/material'
export default function Spinner() { export default function Spinner() {
return ( return <CircularProgress aria-hidden size={20} />
<div className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-brand-600 border-t-transparent" aria-hidden />
)
} }

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { CheckCircle2, AlertCircle, Info } from 'lucide-react' import { Alert, AlertTitle } from '@mui/material'
type Props = { type Props = {
status: 'idle' | 'success' | 'error' | 'info' status: 'idle' | 'success' | 'error' | 'info'
@ -9,36 +9,22 @@ 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 config = { const severityMap = {
success: { success: 'success' as const,
color: 'bg-[#f0fdf4] border-[#006600] text-[#004d00]', error: 'error' as const,
icon: <CheckCircle2 className="h-5 w-5 flex-shrink-0" style={{ color: '#006600' }} /> info: 'info' as const,
}, idle: 'info' as const,
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 <Alert
severity={severityMap[status]}
sx={{ mt: 2 }}
role="status" role="status"
aria-live="polite" aria-live="polite"
className={`mt-4 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 text-sm font-medium ${current.color}`}
> >
{current.icon} {message}
<span>{message}</span> </Alert>
</div>
) )
} }

View file

@ -1,6 +1,7 @@
"use client" "use client"
import { CheckCircle2 } from 'lucide-react' import { Button, CircularProgress, Box } from '@mui/material'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
type Props = { type Props = {
children: React.ReactNode children: React.ReactNode
@ -10,17 +11,21 @@ type Props = {
export default function SubmitButton({ children, loading, disabled }: Props) { 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-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" fullWidth
variant="contained"
color="primary"
disabled={disabled || loading} disabled={disabled || loading}
sx={{
py: 1.5,
display: 'flex',
gap: 1,
alignItems: 'center',
}}
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <CheckCircleIcon />}
> >
{loading ? ( {children}
<span className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-white border-r-transparent" aria-hidden /> </Button>
) : (
<CheckCircle2 className="h-5 w-5" />
)}
<span>{children}</span>
</button>
) )
} }

View file

@ -0,0 +1,117 @@
'use client'
import { createTheme, ThemeProvider as MuiThemeProvider } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline'
import { ReactNode } from 'react'
const theme = createTheme({
palette: {
primary: {
main: '#006600',
dark: '#004d00',
light: '#00cc00',
},
secondary: {
main: '#FF0000',
},
error: {
main: '#FF0000',
},
warning: {
main: '#FFA500',
},
success: {
main: '#006600',
},
background: {
default: '#ffffff',
paper: '#ffffff',
},
text: {
primary: '#1f2937',
secondary: '#6b7280',
},
},
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
].join(','),
h1: {
fontSize: '2.5rem',
fontWeight: 600,
},
h2: {
fontSize: '1.875rem',
fontWeight: 600,
},
body1: {
fontSize: '1rem',
lineHeight: 1.6,
},
body2: {
fontSize: '0.875rem',
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 600,
},
contained: {
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
'&:hover': {
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
},
},
},
},
MuiTextField: {
defaultProps: {
variant: 'outlined',
},
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
fontSize: '1rem',
},
},
},
},
MuiSelect: {
defaultProps: {
variant: 'outlined',
},
},
MuiCard: {
styleOverrides: {
root: {
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
},
},
},
MuiAlert: {
styleOverrides: {
root: {
borderRadius: '0.5rem',
},
},
},
},
})
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
)
}