ui update
This commit is contained in:
parent
d121596c8f
commit
737998bd6d
15 changed files with 1287 additions and 266 deletions
92
MUI_MIGRATION.md
Normal file
92
MUI_MIGRATION.md
Normal 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
742
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,10 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"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",
|
||||
"next": "14.1.0",
|
||||
"papaparse": "5.4.1",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import './globals.css'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import Footer from '@/components/Footer'
|
||||
import { LanguageProvider } from '@/context/LanguageContext'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
import { ThemeProvider } from '@/context/ThemeProvider'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Altarra - Submissão de Alturas',
|
||||
|
|
@ -14,13 +12,15 @@ export const metadata: Metadata = {
|
|||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="pt-PT">
|
||||
<body className={`${inter.className} flex flex-col min-h-screen bg-white text-gray-900`}>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<main className="flex-1">{children}</main>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<main style={{ flex: 1 }}>{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,30 @@
|
|||
"use client"
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Box, Typography, Container } from '@mui/material'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-12 border-t border-gray-200 bg-white py-8">
|
||||
<div className="mx-auto max-w-2xl px-4">
|
||||
<p className="text-center text-sm font-medium text-gray-900">
|
||||
<Box component="footer" sx={{ mt: 6, borderTop: '1px solid', borderTopColor: 'divider', py: 4 }}>
|
||||
<Container maxWidth="sm">
|
||||
<Typography align="center" variant="body2" sx={{ fontWeight: 500 }}>
|
||||
© 2026 APP — Associação Portuguesa de Powerlifting
|
||||
</p>
|
||||
<p className="mt-3 text-center text-xs text-gray-500">
|
||||
</Typography>
|
||||
<Typography align="center" variant="caption" sx={{ mt: 2, display: 'block', color: 'text.secondary' }}>
|
||||
feito por{' '}
|
||||
<Link href="https://comfy.solutions" className="hover:underline">
|
||||
<Link href="https://comfy.solutions" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<Typography component="span" variant="caption" sx={{ '&:hover': { textDecoration: 'underline' } }}>
|
||||
comfy.solutions
|
||||
</Typography>
|
||||
</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' }}>
|
||||
<Typography component="span" variant="caption" sx={{ '&:hover': { textDecoration: 'underline' } }}>
|
||||
Erros? Informe-nos!
|
||||
</Typography>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</Typography>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useId } from 'react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { TextField, FormHelperText, Box } from '@mui/material'
|
||||
|
||||
type Props = {
|
||||
label: string
|
||||
|
|
@ -30,37 +29,27 @@ export default function FormField({
|
|||
disabled,
|
||||
error
|
||||
}: Props) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<label htmlFor={id} className="mb-2 block text-sm font-semibold text-gray-900">
|
||||
{label} {required ? <span className="text-[#FF0000]" aria-hidden>*</span> : null}
|
||||
</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'
|
||||
}`}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
placeholder={type === 'date' ? undefined : placeholder}
|
||||
required={required}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
error={!!error}
|
||||
variant="outlined"
|
||||
inputProps={{
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}}
|
||||
InputLabelProps={type === 'date' ? { shrink: true } : undefined}
|
||||
helperText={error}
|
||||
/>
|
||||
{error ? (
|
||||
<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>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Box, Container, Paper, Typography, Button, CircularProgress } from '@mui/material'
|
||||
import LandingPage from '@/components/LandingPage'
|
||||
import IdentityForm from '@/components/IdentityForm'
|
||||
import RackHeightsForm from '@/components/RackHeightsForm'
|
||||
|
|
@ -63,12 +64,19 @@ export default function HomePageContent() {
|
|||
|
||||
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">
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<Spinner />
|
||||
<p>A carregar…</p>
|
||||
</div>
|
||||
</main>
|
||||
<Typography color="textSecondary">A carregar…</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -77,48 +85,46 @@ export default function HomePageContent() {
|
|||
}
|
||||
|
||||
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">
|
||||
<Box component="main" sx={{ mx: 'auto', maxWidth: 'md', px: 2, py: 4 }}>
|
||||
<Box sx={{ mb: 3, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', width: '100%', flexWrap: 'wrap', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Image
|
||||
src="/branding/logo.png"
|
||||
alt="Logo"
|
||||
width={360}
|
||||
height={120}
|
||||
className="h-auto w-72 object-contain"
|
||||
style={{ height: 'auto', width: 288, objectFit: 'contain' }}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignSelf: 'center' }}>
|
||||
<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'
|
||||
}`}
|
||||
variant={language === 'pt' ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
>
|
||||
PT
|
||||
</button>
|
||||
<button
|
||||
</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'
|
||||
}`}
|
||||
variant={language === 'en' ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
>
|
||||
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">
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600 }}>
|
||||
{competitionName}
|
||||
</Typography>
|
||||
<Typography color="textSecondary">
|
||||
{language === 'pt' ? 'Submissão de Alturas' : 'Heights Submission'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
{!memberNumber || !birthDate ? (
|
||||
<IdentityForm
|
||||
onVerified={({ name, birthDate, memberNumber }) => {
|
||||
|
|
@ -128,8 +134,10 @@ export default function HomePageContent() {
|
|||
}}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4 text-sm text-gray-700">{t('identified_as', language)} <span className="font-medium">{lifterName}</span></div>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
|
||||
{t('identified_as', language)} <Typography component="span" sx={{ fontWeight: 600 }}>{lifterName}</Typography>
|
||||
</Typography>
|
||||
<RackHeightsForm
|
||||
memberNumber={memberNumber}
|
||||
birthDate={birthDate}
|
||||
|
|
@ -140,9 +148,9 @@ export default function HomePageContent() {
|
|||
setLifterName(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</Paper>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"use client"
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import FormField from '@/components/FormField'
|
||||
import SubmitButton from '@/components/SubmitButton'
|
||||
import StatusMessage from '@/components/StatusMessage'
|
||||
import LoadingOverlay from '@/components/LoadingOverlay'
|
||||
import { isValidDateDDMMYYYY } from '@/utils/validation'
|
||||
import { useLanguage } from '@/context/LanguageContext'
|
||||
import { t, translations } from '@/utils/translations'
|
||||
|
|
@ -36,7 +38,14 @@ export default function IdentityForm({ onVerified }: Props) {
|
|||
setStatus({ type: 'error', msg: errorMessages.validation_error })
|
||||
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 })
|
||||
return
|
||||
}
|
||||
|
|
@ -46,7 +55,7 @@ export default function IdentityForm({ onVerified }: Props) {
|
|||
const res = await fetch('/api/find-lifter', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, birthDate })
|
||||
body: JSON.stringify({ name, birthDate: formattedDate })
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
|
@ -65,8 +74,12 @@ export default function IdentityForm({ onVerified }: Props) {
|
|||
}
|
||||
|
||||
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} />
|
||||
<Box component="form" onSubmit={onSubmit} aria-labelledby="identificacao">
|
||||
<Typography id="identificacao" variant="h6" sx={{ mb: 2 }}>
|
||||
{t('step_1_identification', language)}
|
||||
</Typography>
|
||||
<FormField
|
||||
label={t('full_name', language)}
|
||||
value={name}
|
||||
|
|
@ -76,13 +89,14 @@ export default function IdentityForm({ onVerified }: Props) {
|
|||
/>
|
||||
<FormField
|
||||
label={t('birth_date', language)}
|
||||
type="date"
|
||||
value={birthDate}
|
||||
onChange={setBirthDate}
|
||||
placeholder={t('birth_date_placeholder', language)}
|
||||
required
|
||||
/>
|
||||
<SubmitButton loading={loading}>{t('search', language)}</SubmitButton>
|
||||
<SubmitButton disabled={loading}>{t('search', language)}</SubmitButton>
|
||||
<StatusMessage status={status.type === 'success' ? 'success' : status.type === 'error' ? 'error' : 'info'} message={status.msg} />
|
||||
</form>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,37 +2,103 @@
|
|||
|
||||
import Link from 'next/link'
|
||||
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 { t } from '@/utils/translations'
|
||||
|
||||
export default function LandingPage() {
|
||||
const { language } = useLanguage()
|
||||
const { language, setLanguage } = useLanguage()
|
||||
return (
|
||||
<main className="mx-auto max-w-xl px-4 py-10">
|
||||
<header className="mb-8 flex flex-col items-center gap-3 text-center">
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
py: 4,
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm" sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 4, gap: 1 }}>
|
||||
<Button
|
||||
onClick={() => setLanguage('pt')}
|
||||
variant={language === 'pt' ? 'contained' : 'outlined'}
|
||||
size="small"
|
||||
>
|
||||
PT
|
||||
</Button>
|
||||
<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}
|
||||
className="h-auto w-72 object-contain"
|
||||
style={{ height: 'auto', width: 320, objectFit: 'contain' }}
|
||||
priority
|
||||
/>
|
||||
</header>
|
||||
<section className="rounded-lg border-l-4 border-[#006600] bg-[#f0fdf4] p-8 shadow-md">
|
||||
<h2 className="mb-3 text-2xl font-bold text-[#004d00]">{t('welcome_title', language)}</h2>
|
||||
<p className="mb-8 text-gray-700 leading-relaxed">
|
||||
</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)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
mb: 3,
|
||||
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)}
|
||||
</p>
|
||||
<Link
|
||||
</Typography>
|
||||
<Button
|
||||
component="a"
|
||||
href="https://powerlifting.pt"
|
||||
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]"
|
||||
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)}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</section>
|
||||
</main>
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
31
src/components/LoadingOverlay.tsx
Normal file
31
src/components/LoadingOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, lazy, Suspense } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Box, Typography, Alert, Button, TextField, MenuItem } from '@mui/material'
|
||||
import FormField from '@/components/FormField'
|
||||
import SubmitButton from '@/components/SubmitButton'
|
||||
import StatusMessage from '@/components/StatusMessage'
|
||||
import LoadingOverlay from '@/components/LoadingOverlay'
|
||||
import type { RackHeightsPayload } from '@/types/rackHeights'
|
||||
import { required } from '@/utils/validation'
|
||||
import { useLanguage } from '@/context/LanguageContext'
|
||||
|
|
@ -91,77 +93,89 @@ export default function RackHeightsForm({ memberNumber, birthDate, onSuccess }:
|
|||
}
|
||||
|
||||
return (
|
||||
<div aria-live="polite">
|
||||
<>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Box aria-live="polite">
|
||||
{submitted ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border-l-4 border-[#006600] bg-[#f0fdf4] px-6 py-6 shadow-sm">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
py: 3,
|
||||
px: 2,
|
||||
borderRadius: 1,
|
||||
textAlign: 'center',
|
||||
bgcolor: '#f0fdf4',
|
||||
borderLeft: '4px solid',
|
||||
borderLeftColor: 'primary.main',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'center' }}>
|
||||
<Checkmark size="xxLarge" color="#006600" />
|
||||
</div>
|
||||
<h2 className="text-center text-xl font-bold text-[#004d00]">{t('heights_submitted_title', language)}</h2>
|
||||
<p className="mt-3 text-center text-[#1f5e2e]">{t('heights_submitted_text', language)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1, color: 'primary.dark' }}>
|
||||
{t('heights_submitted_title', language)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#1f5e2e' }}>
|
||||
{t('heights_submitted_text', language)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
onClick={handleNewSubmission}
|
||||
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]"
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{t('submit_another', language)}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} aria-labelledby="alturas">
|
||||
<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>
|
||||
<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
|
||||
ref={firstFieldRef}
|
||||
<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"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={20}
|
||||
value={squatRackHeight}
|
||||
onChange={(e) => setSquatRackHeight(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
|
||||
/>
|
||||
</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"
|
||||
onChange={(e) => setSquatRackHeight(e)}
|
||||
min={1}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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
|
||||
value={benchRackFootBlocks}
|
||||
onChange={(e) => setBenchRackFootBlocks(e.target.value as any)}
|
||||
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"
|
||||
<FormField
|
||||
label={t('bench_rack_height', language)}
|
||||
type="number"
|
||||
value={benchRackHeight}
|
||||
onChange={(e) => setBenchRackHeight(e)}
|
||||
min={1}
|
||||
max={20}
|
||||
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>
|
||||
<option value="5cm">5cm</option>
|
||||
<option value="10cm">10cm</option>
|
||||
<option value="20cm">20cm</option>
|
||||
<option value="30cm">30cm</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<SubmitButton loading={loading}>{t('submit_heights', language)}</SubmitButton>
|
||||
</div>
|
||||
<MenuItem value="NONE">{t('foot_blocks_none', language)}</MenuItem>
|
||||
<MenuItem value="5cm">5cm</MenuItem>
|
||||
<MenuItem value="10cm">10cm</MenuItem>
|
||||
<MenuItem value="20cm">20cm</MenuItem>
|
||||
<MenuItem value="30cm">30cm</MenuItem>
|
||||
</TextField>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<SubmitButton disabled={loading}>{t('submit_heights', language)}</SubmitButton>
|
||||
</Box>
|
||||
<StatusMessage status={status.type === 'success' ? 'success' : status.type === 'error' ? 'error' : 'info'} message={status.msg} />
|
||||
</form>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { CircularProgress } from '@mui/material'
|
||||
|
||||
export default function Spinner() {
|
||||
return (
|
||||
<div className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-brand-600 border-t-transparent" aria-hidden />
|
||||
)
|
||||
return <CircularProgress aria-hidden size={20} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { CheckCircle2, AlertCircle, Info } from 'lucide-react'
|
||||
import { Alert, AlertTitle } from '@mui/material'
|
||||
|
||||
type Props = {
|
||||
status: 'idle' | 'success' | 'error' | 'info'
|
||||
|
|
@ -10,35 +10,21 @@ type Props = {
|
|||
export default function StatusMessage({ status, message }: Props) {
|
||||
if (!message) return null
|
||||
|
||||
const config = {
|
||||
success: {
|
||||
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 severityMap = {
|
||||
success: 'success' as const,
|
||||
error: 'error' as const,
|
||||
info: 'info' as const,
|
||||
idle: 'info' as const,
|
||||
}
|
||||
}
|
||||
|
||||
const current = config[status]
|
||||
|
||||
return (
|
||||
<div
|
||||
<Alert
|
||||
severity={severityMap[status]}
|
||||
sx={{ mt: 2 }}
|
||||
role="status"
|
||||
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}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{message}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { Button, CircularProgress, Box } from '@mui/material'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
|
|
@ -10,17 +11,21 @@ type Props = {
|
|||
|
||||
export default function SubmitButton({ children, loading, disabled }: Props) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
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}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <CheckCircleIcon />}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-white border-r-transparent" aria-hidden />
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
117
src/context/ThemeProvider.tsx
Normal file
117
src/context/ThemeProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue