first commit

This commit is contained in:
el 2025-04-15 19:39:21 +02:00
parent b1fc9f95e4
commit 135afa6d19
27 changed files with 2798 additions and 134 deletions

View file

@ -0,0 +1,5 @@
import LoginForm from '@/components/auth/LoginForm';
export default function LoginPage() {
return <LoginForm />;
}

View file

@ -0,0 +1,5 @@
import RegisterForm from '@/components/auth/RegisterForm';
export default function RegisterPage() {
return <RegisterForm />;
}

View file

@ -0,0 +1,87 @@
'use client';
import { Button, ButtonProps } from '@mui/material';
import { motion } from 'framer-motion';
interface ElegantButtonProps extends Omit<ButtonProps, 'variant'> {
variant?: 'primary' | 'secondary' | 'tertiary';
}
export default function ElegantButton({
children,
variant = 'primary',
sx,
...props
}: ElegantButtonProps) {
const getVariantStyles = () => {
switch (variant) {
case 'primary':
return {
backgroundColor: '#007AFF',
color: 'white',
'&:hover': {
backgroundColor: '#0062cc',
transform: 'translateY(-2px)',
boxShadow: '0 4px 12px rgba(0, 122, 255, 0.3)',
},
};
case 'secondary':
return {
backgroundColor: 'transparent',
color: '#007AFF',
border: '1px solid #007AFF',
'&:hover': {
backgroundColor: 'rgba(0, 122, 255, 0.05)',
transform: 'translateY(-2px)',
},
};
case 'tertiary':
return {
backgroundColor: 'transparent',
color: '#007AFF',
'&:hover': {
backgroundColor: 'rgba(0, 122, 255, 0.05)',
},
};
default:
return {};
}
};
return (
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Button
{...props}
sx={{
textTransform: 'none',
fontWeight: 500,
borderRadius: '12px',
transition: 'all 0.3s ease',
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
transform: 'translateX(-100%)',
transition: 'transform 0.6s ease',
},
'&:hover::before': {
transform: 'translateX(100%)',
},
...getVariantStyles(),
...sx,
}}
>
{children}
</Button>
</motion.div>
);
}

View file

@ -0,0 +1,204 @@
'use client';
import { AppBar, Toolbar, Button, Typography, Box, IconButton, Menu, MenuItem, useMediaQuery, useTheme } from '@mui/material';
import Link from 'next/link';
import MenuIcon from '@mui/icons-material/Menu';
import WorkOutlineIcon from '@mui/icons-material/WorkOutline';
import BusinessCenterIcon from '@mui/icons-material/BusinessCenter';
import SchoolIcon from '@mui/icons-material/School';
import { useState } from 'react';
export default function Header() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileMenuAnchor, setMobileMenuAnchor] = useState<null | HTMLElement>(null);
const handleMobileMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setMobileMenuAnchor(event.currentTarget);
};
const handleMobileMenuClose = () => {
setMobileMenuAnchor(null);
};
const navItems = [
{ label: 'Offres', icon: <WorkOutlineIcon />, href: '/offres' },
{ label: 'Entreprises', icon: <BusinessCenterIcon />, href: '/entreprises' },
{ label: 'Formations', icon: <SchoolIcon />, href: '/formations' },
];
return (
<AppBar
position="static"
elevation={0}
sx={{
backgroundColor: 'white',
color: 'black',
borderBottom: '1px solid #F5F5F7'
}}
>
<Toolbar sx={{
justifyContent: 'space-between',
maxWidth: '1200px',
width: '100%',
mx: 'auto',
px: { xs: 2, md: 4 }
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Typography
variant="h6"
component={Link}
href="/"
sx={{
textDecoration: 'none',
color: 'black',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
JobMatch
</Typography>
{!isMobile && (
<Box sx={{ display: 'flex', gap: 2 }}>
{navItems.map((item) => (
<Button
key={item.label}
component={Link}
href={item.href}
startIcon={item.icon}
sx={{
color: '#1D1D1F',
textTransform: 'none',
fontWeight: 500,
'&:hover': {
color: '#007AFF',
backgroundColor: 'transparent'
}
}}
>
{item.label}
</Button>
))}
</Box>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{isMobile ? (
<>
<IconButton
edge="end"
color="inherit"
aria-label="menu"
onClick={handleMobileMenuOpen}
sx={{ color: '#1D1D1F' }}
>
<MenuIcon />
</IconButton>
<Menu
anchorEl={mobileMenuAnchor}
open={Boolean(mobileMenuAnchor)}
onClose={handleMobileMenuClose}
sx={{
'& .MuiPaper-root': {
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}
}}
>
{navItems.map((item) => (
<MenuItem
key={item.label}
component={Link}
href={item.href}
onClick={handleMobileMenuClose}
sx={{
py: 1,
px: 2,
'&:hover': {
backgroundColor: 'rgba(0, 122, 255, 0.05)',
}
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{item.icon}
{item.label}
</Box>
</MenuItem>
))}
<MenuItem
component={Link}
href="/signup"
onClick={handleMobileMenuClose}
sx={{
py: 1,
px: 2,
color: '#007AFF',
'&:hover': {
backgroundColor: 'rgba(0, 122, 255, 0.05)',
}
}}
>
Inscription
</MenuItem>
<MenuItem
component={Link}
href="/login"
onClick={handleMobileMenuClose}
sx={{
py: 1,
px: 2,
color: '#007AFF',
'&:hover': {
backgroundColor: 'rgba(0, 122, 255, 0.05)',
}
}}
>
Connexion
</MenuItem>
</Menu>
</>
) : (
<>
<Button
component={Link}
href="/signup"
sx={{
color: '#007AFF',
textTransform: 'none',
fontWeight: 500,
'&:hover': {
backgroundColor: 'rgba(0, 122, 255, 0.05)',
}
}}
>
Inscription
</Button>
<Button
component={Link}
href="/login"
variant="contained"
sx={{
backgroundColor: '#007AFF',
textTransform: 'none',
fontWeight: 500,
borderRadius: '8px',
'&:hover': {
backgroundColor: '#0062cc',
transform: 'translateY(-1px)',
boxShadow: '0 4px 12px rgba(0, 122, 255, 0.2)',
}
}}
>
Connexion
</Button>
</>
)}
</Box>
</Toolbar>
</AppBar>
);
}

View file

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from './providers';
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,21 +14,19 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "JobMatch - Plateforme d'emploi intelligente",
description: "Trouvez votre prochain emploi tech avec une approche intelligente basée sur vos compétences et aspirations",
};
export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode;
}>) {
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="fr">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Providers>{children}</Providers>
</body>
</html>
);

137
src/app/login/page.tsx Normal file
View file

@ -0,0 +1,137 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Box, Container, Typography, TextField, Button, Link as MuiLink } from '@mui/material';
import Link from 'next/link';
import { supabase } from '@/lib/supabase';
import { useAuthStore } from '@/store/auth';
export default function Login() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { setUser } = useAuthStore();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
setUser(data.user);
router.push('/profile');
} catch (error) {
setError('Email ou mot de passe incorrect');
} finally {
setIsLoading(false);
}
};
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: 'calc(100vh - 64px)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
py: 8,
}}
>
<Typography
variant="h4"
component="h1"
sx={{
mb: 4,
fontWeight: 700,
textAlign: 'center',
}}
>
Connexion
</Typography>
<Box
component="form"
onSubmit={handleLogin}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 3,
p: 4,
borderRadius: '16px',
backgroundColor: 'white',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
}}
>
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
fullWidth
error={!!error}
helperText={error}
/>
<TextField
label="Mot de passe"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
fullWidth
/>
<Button
type="submit"
variant="contained"
size="large"
disabled={isLoading}
sx={{
backgroundColor: '#007AFF',
'&:hover': {
backgroundColor: '#0062cc',
},
}}
>
{isLoading ? 'Connexion...' : 'Se connecter'}
</Button>
<Typography
variant="body2"
sx={{
textAlign: 'center',
color: '#86868B',
}}
>
Pas encore de compte ?{' '}
<MuiLink
component={Link}
href="/signup"
sx={{
color: '#007AFF',
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
},
}}
>
Créer un compte
</MuiLink>
</Typography>
</Box>
</Box>
</Container>
);
}

View file

@ -1,103 +1,200 @@
import Image from "next/image";
'use client';
import { Typography, Box, Container, TextField, InputAdornment, Chip } from '@mui/material';
import Header from './components/Header';
import ElegantButton from './components/ElegantButton';
import { motion } from 'framer-motion';
import SearchIcon from '@mui/icons-material/Search';
import LocationOnIcon from '@mui/icons-material/LocationOn';
const contractTypes = [
'CDI',
'CDD',
'Stage',
'Alternance',
'Freelance',
];
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<main>
<Header />
<Box
sx={{
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'radial-gradient(circle at 50% 50%, rgba(0,122,255,0.05) 0%, rgba(255,255,255,0) 70%)',
zIndex: -1,
},
}}
>
<Container maxWidth="lg">
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
py: { xs: 6, md: 12 },
px: 4,
minHeight: 'calc(100vh - 64px)',
justifyContent: 'center',
}}
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
>
<Typography
variant="h1"
sx={{
fontSize: { xs: '2.5rem', md: '4rem' },
fontWeight: 700,
mb: 3,
maxWidth: '1000px',
background: 'linear-gradient(45deg, #000000, #007AFF)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
lineHeight: 1.2,
}}
>
Trouvez votre prochain emploi tech avec une approche intelligente
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
>
<Typography
variant="body1"
sx={{
fontSize: { xs: '1.1rem', md: '1.5rem' },
color: '#86868B',
mb: 6,
maxWidth: '800px',
lineHeight: 1.6,
}}
>
Découvrez des opportunités qui correspondent vraiment à vos compétences et aspirations
</Typography>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 2,
width: '100%',
maxWidth: '800px',
mb: 4,
}}
>
<TextField
fullWidth
placeholder="Métier, compétence, mot-clé..."
variant="outlined"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ color: '#007AFF' }} />
</InputAdornment>
),
sx: {
borderRadius: '12px',
backgroundColor: 'white',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
},
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
}
}}
/>
<TextField
fullWidth
placeholder="Où ? (Ville, département...)"
variant="outlined"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LocationOnIcon sx={{ color: '#007AFF' }} />
</InputAdornment>
),
sx: {
borderRadius: '12px',
backgroundColor: 'white',
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
},
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
}
}}
/>
</Box>
<Box
sx={{
display: 'flex',
gap: 1,
flexWrap: 'wrap',
justifyContent: 'center',
mb: 4,
}}
>
{contractTypes.map((type, index) => (
<motion.div
key={type}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.6 + index * 0.1 }}
>
<Chip
label={type}
clickable
sx={{
borderRadius: '8px',
backgroundColor: 'white',
border: '1px solid rgba(0, 122, 255, 0.2)',
'&:hover': {
backgroundColor: 'rgba(0, 122, 255, 0.05)',
borderColor: '#007AFF',
},
px: 1,
}}
/>
</motion.div>
))}
</Box>
<ElegantButton
variant="primary"
size="large"
href="/search"
sx={{
fontSize: '1.2rem',
px: 6,
py: 2,
}}
>
Rechercher des offres
</ElegantButton>
</motion.div>
</Box>
</Container>
</Box>
</main>
);
}

17
src/app/providers.tsx Normal file
View file

@ -0,0 +1,17 @@
'use client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from './theme';
import { AuthProvider } from '@/contexts/AuthContext';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
);
}

153
src/app/signup/page.tsx Normal file
View file

@ -0,0 +1,153 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Box, Container, Typography, TextField, Button, Link as MuiLink } from '@mui/material';
import Link from 'next/link';
import { supabase } from '@/lib/supabase';
import { useAuthStore } from '@/store/auth';
export default function Signup() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { setUser } = useAuthStore();
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError('Les mots de passe ne correspondent pas');
return;
}
setIsLoading(true);
try {
const { data, error } = await supabase.auth.signUp({
email,
password,
});
if (error) throw error;
setUser(data.user);
router.push('/profile');
} catch (error) {
setError('Une erreur est survenue lors de l\'inscription');
} finally {
setIsLoading(false);
}
};
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: 'calc(100vh - 64px)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
py: 8,
}}
>
<Typography
variant="h4"
component="h1"
sx={{
mb: 4,
fontWeight: 700,
textAlign: 'center',
}}
>
Créer un compte
</Typography>
<Box
component="form"
onSubmit={handleSignup}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 3,
p: 4,
borderRadius: '16px',
backgroundColor: 'white',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
}}
>
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
fullWidth
error={!!error}
helperText={error}
/>
<TextField
label="Mot de passe"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
fullWidth
/>
<TextField
label="Confirmer le mot de passe"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
fullWidth
/>
<Button
type="submit"
variant="contained"
size="large"
disabled={isLoading}
sx={{
backgroundColor: '#007AFF',
'&:hover': {
backgroundColor: '#0062cc',
},
}}
>
{isLoading ? 'Inscription...' : 'S\'inscrire'}
</Button>
<Typography
variant="body2"
sx={{
textAlign: 'center',
color: '#86868B',
}}
>
Déjà un compte ?{' '}
<MuiLink
component={Link}
href="/login"
sx={{
color: '#007AFF',
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
},
}}
>
Se connecter
</MuiLink>
</Typography>
</Box>
</Box>
</Container>
);
}

64
src/app/theme.ts Normal file
View file

@ -0,0 +1,64 @@
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
primary: {
main: '#007AFF',
light: '#4da3ff',
dark: '#0062cc',
},
secondary: {
main: '#86868B',
light: '#a3a3a7',
dark: '#6a6a6e',
},
background: {
default: '#FFFFFF',
paper: '#F5F5F7',
},
text: {
primary: '#000000',
secondary: '#86868B',
},
},
typography: {
fontFamily: '"SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
h1: {
fontSize: '3rem',
fontWeight: 600,
},
h2: {
fontSize: '2.25rem',
fontWeight: 600,
},
h3: {
fontSize: '1.5rem',
fontWeight: 600,
},
body1: {
fontSize: '1rem',
lineHeight: 1.5,
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
borderRadius: '8px',
fontWeight: 500,
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
backgroundColor: '#FFFFFF',
color: '#000000',
},
},
},
},
});
export default theme;

View file

@ -0,0 +1,58 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/auth';
import { supabase } from '@/lib/supabase';
import { Box, CircularProgress } from '@mui/material';
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { user, setUser, isLoading, setIsLoading } = useAuthStore();
useEffect(() => {
const checkUser = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
setUser(session?.user ?? null);
} catch (error) {
console.error('Error checking session:', error);
setUser(null);
} finally {
setIsLoading(false);
}
};
checkUser();
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
});
return () => {
subscription.unsubscribe();
};
}, [setUser, setIsLoading]);
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}}
>
<CircularProgress />
</Box>
);
}
if (!user) {
router.push('/login');
return null;
}
return <>{children}</>;
}

View file

@ -0,0 +1,98 @@
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import {
Box,
Button,
TextField,
Typography,
Alert,
CircularProgress,
} from '@mui/material';
import Link from 'next/link';
export default function LoginForm() {
const { signIn } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await signIn(email, password);
} catch (err) {
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
} finally {
setLoading(false);
}
};
return (
<Box
component="form"
onSubmit={handleSubmit}
sx={{
maxWidth: 400,
mx: 'auto',
mt: 4,
p: 3,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Typography variant="h4" component="h1" gutterBottom align="center">
Connexion
</Typography>
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
fullWidth
/>
<TextField
label="Mot de passe"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
fullWidth
/>
<Button
type="submit"
variant="contained"
disabled={loading}
fullWidth
sx={{ mt: 2 }}
>
{loading ? <CircularProgress size={24} /> : 'Se connecter'}
</Button>
<Typography variant="body2" align="center" sx={{ mt: 2 }}>
Pas encore de compte ?{' '}
<Link href="/auth/register" passHref>
<Button variant="text" color="primary">
S'inscrire
</Button>
</Link>
</Typography>
</Box>
);
}

View file

@ -0,0 +1,29 @@
'use client';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@mui/material';
import { useRouter } from 'next/navigation';
export default function LogoutButton() {
const { signOut } = useAuth();
const router = useRouter();
const handleLogout = async () => {
try {
await signOut();
router.push('/auth/login');
} catch (error) {
console.error('Erreur lors de la déconnexion:', error);
}
};
return (
<Button
variant="outlined"
color="inherit"
onClick={handleLogout}
>
Déconnexion
</Button>
);
}

View file

@ -0,0 +1,139 @@
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import {
Box,
Button,
TextField,
Typography,
Alert,
CircularProgress,
} from '@mui/material';
import Link from 'next/link';
export default function RegisterForm() {
const { signUp } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (password !== confirmPassword) {
setError('Les mots de passe ne correspondent pas');
return;
}
setLoading(true);
try {
await signUp(email, password);
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Une erreur est survenue');
} finally {
setLoading(false);
}
};
if (success) {
return (
<Box
sx={{
maxWidth: 400,
mx: 'auto',
mt: 4,
p: 3,
textAlign: 'center',
}}
>
<Alert severity="success" sx={{ mb: 2 }}>
Un email de confirmation a é envoyé à {email}
</Alert>
<Link href="/auth/login" passHref>
<Button variant="contained" color="primary">
Retour à la connexion
</Button>
</Link>
</Box>
);
}
return (
<Box
component="form"
onSubmit={handleSubmit}
sx={{
maxWidth: 400,
mx: 'auto',
mt: 4,
p: 3,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Typography variant="h4" component="h1" gutterBottom align="center">
Inscription
</Typography>
{error && (
<Alert severity="error" onClose={() => setError(null)}>
{error}
</Alert>
)}
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
fullWidth
/>
<TextField
label="Mot de passe"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
fullWidth
/>
<TextField
label="Confirmer le mot de passe"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
fullWidth
/>
<Button
type="submit"
variant="contained"
disabled={loading}
fullWidth
sx={{ mt: 2 }}
>
{loading ? <CircularProgress size={24} /> : "S'inscrire"}
</Button>
<Typography variant="body2" align="center" sx={{ mt: 2 }}>
Déjà un compte ?{' '}
<Link href="/auth/login" passHref>
<Button variant="text" color="primary">
Se connecter
</Button>
</Link>
</Typography>
</Box>
);
}

View file

@ -0,0 +1,74 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { User } from '@supabase/supabase-js';
import { supabase } from '@/lib/supabase';
type AuthContextType = {
user: User | null;
loading: boolean;
signIn: (email: string, password: string) => Promise<void>;
signUp: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
signIn: async () => {},
signUp: async () => {},
signOut: async () => {},
});
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Vérifier la session active
const checkSession = async () => {
const { data: { session } } = await supabase.auth.getSession();
setUser(session?.user ?? null);
setLoading(false);
};
checkSession();
// Écouter les changements d'authentification
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
setLoading(false);
});
return () => {
subscription.unsubscribe();
};
}, []);
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) throw error;
};
const signUp = async (email: string, password: string) => {
const { error } = await supabase.auth.signUp({ email, password });
if (error) throw error;
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) throw error;
};
return (
<AuthContext.Provider value={{ user, loading, signIn, signUp, signOut }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

6
src/lib/supabase.ts Normal file
View file

@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'http://localhost:8000';
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'your-anon-key';
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

16
src/store/auth.ts Normal file
View file

@ -0,0 +1,16 @@
import { create } from 'zustand';
import { User } from '@supabase/supabase-js';
interface AuthState {
user: User | null;
setUser: (user: User | null) => void;
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
setUser: (user) => set({ user }),
isLoading: true,
setIsLoading: (isLoading) => set({ isLoading }),
}));