upgrade UI de fou

This commit is contained in:
Arthur Barre 2025-03-10 22:57:18 +01:00
parent 9c773e8e64
commit d5be543421
18 changed files with 1349 additions and 501 deletions

View File

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",

View File

@ -11,6 +11,9 @@ importers:
'@radix-ui/react-avatar': '@radix-ui/react-avatar':
specifier: ^1.1.3 specifier: ^1.1.3
version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-checkbox':
specifier: ^1.1.4
version: 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.6 specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -475,6 +478,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-checkbox@1.1.4':
resolution: {integrity: sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.2': '@radix-ui/react-collection@1.1.2':
resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==} resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==}
peerDependencies: peerDependencies:
@ -2201,6 +2217,22 @@ snapshots:
'@types/react': 19.0.10 '@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10) '@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-checkbox@1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-size': 1.1.0(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-collection@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': '@radix-ui/react-collection@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0) '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -0,0 +1,36 @@
"use client"
import { motion } from "framer-motion"
import type { LucideIcon } from "lucide-react"
interface HowItWorksStepProps {
icon: LucideIcon
title: string
description: string
step: number
delay?: number
}
export function HowItWorksStep({ icon: Icon, title, description, step, delay = 0 }: HowItWorksStepProps) {
return (
<motion.div
className="flex gap-4 items-start"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay }}
viewport={{ once: true }}
>
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-gradient-to-br from-orange-500 to-amber-500 flex items-center justify-center text-white font-bold text-lg">
{step}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-orange-500" />
<h3 className="text-xl font-bold">{title}</h3>
</div>
<p className="text-muted-foreground">{description}</p>
</div>
</motion.div>
)
}

View File

@ -0,0 +1,85 @@
import { Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Link } from "react-router-dom"
interface PricingFeature {
text: string
included: boolean
}
interface PricingCardProps {
name: string
description: string
price: string | null
duration?: string
features: PricingFeature[]
popular?: boolean
buttonText: string
buttonVariant?: "default" | "outline"
className?: string
}
export function PricingCard({
name,
description,
price,
duration = "mois",
features,
popular = false,
buttonText,
buttonVariant = "default",
className,
}: PricingCardProps) {
return (
<Card
className={`flex flex-col ${popular ? "border-orange-500 dark:border-orange-500 shadow-lg relative" : ""} ${className}`}
>
{popular && (
<Badge className="absolute -top-2 right-4 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600">
Populaire
</Badge>
)}
<CardHeader>
<CardTitle className="text-xl">{name}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 flex-1">
<div className="flex items-baseline gap-1">
{price ? (
<>
<span className="text-3xl font-bold">{price}</span>
<span className="text-sm text-muted-foreground">/{duration}</span>
</>
) : (
<span className="text-3xl font-bold">Gratuit</span>
)}
</div>
<ul className="grid gap-2 text-sm">
{features.map((feature, index) => (
<li key={index} className="flex items-center gap-2">
<Check className={`h-4 w-4 ${feature.included ? "text-green-500" : "text-muted-foreground"}`} />
<span className={feature.included ? "" : "text-muted-foreground line-through"}>{feature.text}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Link to="/auth/register" className="w-full">
<Button
variant={buttonVariant}
className={`w-full ${popular && buttonVariant === "default" ? "bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600" : ""}`}
>
{buttonText}
</Button>
</Link>
</CardFooter>
</Card>
)
}

View File

@ -0,0 +1,44 @@
import { Card, CardContent } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Star } from "lucide-react"
interface TestimonialCardProps {
quote: string
author: string
role: string
avatarSrc?: string
rating: number
}
export function TestimonialCard({ quote, author, role, avatarSrc, rating }: TestimonialCardProps) {
return (
<Card className="h-full">
<CardContent className="p-6 flex flex-col h-full">
<div className="flex mb-4">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className={`h-4 w-4 ${i < rating ? "fill-amber-400 text-amber-400" : "text-gray-300"}`} />
))}
</div>
<blockquote className="text-lg mb-6 flex-1">"{quote}"</blockquote>
<div className="flex items-center gap-3 mt-auto">
<Avatar>
<AvatarImage src={avatarSrc} alt={author} />
<AvatarFallback>
{author
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{author}</p>
<p className="text-sm text-muted-foreground">{role}</p>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@ -1,63 +1,73 @@
import { useState, useEffect } from "react"; "use client"
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { useState, useEffect } from "react"
import { GalleryVerticalEnd, Menu, X } from "lucide-react"; import { Link, useLocation } from "react-router-dom"
import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"
import { Menu, X, LogOut, User, Heart, Home, BookOpen } from "lucide-react"
import { cn } from "@/lib/utils"
import { motion, AnimatePresence } from "framer-motion"
import { Logo } from "@/components/illustrations/Logo"
export function Header() { export function Header() {
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const location = useLocation(); const location = useLocation()
useEffect(() => { useEffect(() => {
// Vérifier si l'utilisateur est authentifié // Vérifier si l'utilisateur est authentifié
const token = localStorage.getItem("token"); const token = localStorage.getItem("token")
setIsAuthenticated(!!token); setIsAuthenticated(!!token)
}, [location]); // Re-vérifier à chaque changement de route
// Fermer le menu mobile lors d'un changement de route
setIsMobileMenuOpen(false)
}, [location])
const toggleMobileMenu = () => { const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen); setIsMobileMenuOpen(!isMobileMenuOpen)
}; }
const handleLogout = () => {
localStorage.removeItem("token")
setIsAuthenticated(false)
setIsMobileMenuOpen(false)
window.location.href = "/auth/login"
}
const navItems = [ const navItems = [
{ name: "Accueil", path: "/", public: true }, { name: "Accueil", path: "/", icon: Home, public: true },
{ name: "Recettes", path: "/recipes", public: true }, { name: "Recettes", path: "/recipes", icon: BookOpen, public: true },
{ name: "Mes recettes", path: "/my-recipes", public: false }, // { name: "Mes recettes", path: "/my-recipes", icon: BookOpen, public: false },
{ name: "Favoris", path: "/favorites", public: false }, { name: "Favoris", path: "/favorites", icon: Heart, public: false },
{ name: "Profil", path: "/profile", public: false }, { name: "Profil", path: "/profile", icon: User, public: false },
]; ]
const filteredNavItems = navItems.filter((item) => item.public || isAuthenticated)
return ( return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b bg-white/95 dark:bg-slate-900/95 backdrop-blur supports-[backdrop-filter]:bg-white/60 dark:supports-[backdrop-filter]:bg-slate-900/60">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between"> <div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link to="/" className="flex items-center gap-2 font-medium"> <Link to="/" className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground"> <Logo size="md" showText={true} />
<GalleryVerticalEnd className="size-4" />
</div>
<span className="hidden sm:inline-block">Freedge</span>
</Link> </Link>
</div> </div>
{/* Navigation desktop */} {/* Navigation desktop */}
<nav className="hidden md:flex items-center gap-6"> <nav className="hidden md:flex items-center gap-6">
{navItems {filteredNavItems.map((item) => (
.filter(item => item.public || isAuthenticated) <Link
.map(item => ( key={item.path}
<Link to={item.path}
key={item.path} className={cn(
to={item.path} "text-sm font-medium transition-colors hover:text-orange-500 flex items-center gap-1.5",
className={cn( location.pathname === item.path ? "text-orange-500" : "text-muted-foreground",
"text-sm font-medium transition-colors hover:text-primary", )}
location.pathname === item.path >
? "text-primary" <item.icon className="h-4 w-4" />
: "text-muted-foreground" {item.name}
)} </Link>
> ))}
{item.name}
</Link>
))}
</nav> </nav>
{/* Boutons d'authentification */} {/* Boutons d'authentification */}
@ -65,91 +75,104 @@ export function Header() {
{isAuthenticated ? ( {isAuthenticated ? (
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={handleLogout}
localStorage.removeItem("token"); className="border-orange-200 hover:bg-orange-50 hover:text-orange-600 dark:border-orange-800 dark:hover:bg-orange-900/20 dark:hover:text-orange-400"
setIsAuthenticated(false);
window.location.href = "/auth/login";
}}
> >
<LogOut className="mr-2 h-4 w-4" />
Déconnexion Déconnexion
</Button> </Button>
) : ( ) : (
<> <>
<Link to="/auth/login"> <Link to="/auth/login">
<Button variant="ghost">Connexion</Button> <Button variant="ghost" className="hover:text-orange-500">
Connexion
</Button>
</Link> </Link>
<Link to="/auth/register"> <Link to="/auth/register">
<Button>S'inscrire</Button> <Button className="bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white">
S'inscrire
</Button>
</Link> </Link>
</> </>
)} )}
</div> </div>
{/* Bouton menu mobile */} {/* Bouton menu mobile */}
<Button <Button variant="ghost" size="icon" className="md:hidden rounded-full" onClick={toggleMobileMenu}>
variant="ghost" {isMobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
size="icon"
className="md:hidden"
onClick={toggleMobileMenu}
>
{isMobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</Button> </Button>
</div> </div>
</div> </div>
{/* Menu mobile */} {/* Menu mobile */}
{isMobileMenuOpen && ( <AnimatePresence>
<div className="md:hidden border-b"> {isMobileMenuOpen && (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4 pb-6"> <motion.div
<nav className="flex flex-col space-y-4"> className="md:hidden border-b"
{navItems initial={{ opacity: 0, height: 0 }}
.filter(item => item.public || isAuthenticated) animate={{ opacity: 1, height: "auto" }}
.map(item => ( exit={{ opacity: 0, height: 0 }}
<Link transition={{ duration: 0.2 }}
>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4 pb-6">
<nav className="flex flex-col space-y-4">
{filteredNavItems.map((item, index) => (
<motion.div
key={item.path} key={item.path}
to={item.path} initial={{ opacity: 0, x: -10 }}
className={cn( animate={{ opacity: 1, x: 0 }}
"text-sm font-medium transition-colors hover:text-primary", transition={{ delay: index * 0.05 }}
location.pathname === item.path
? "text-primary"
: "text-muted-foreground"
)}
onClick={() => setIsMobileMenuOpen(false)}
> >
{item.name} <Link
</Link> to={item.path}
className={cn(
"flex items-center gap-3 py-2 text-sm font-medium transition-colors hover:text-orange-500",
location.pathname === item.path ? "text-orange-500" : "text-muted-foreground",
)}
onClick={() => setIsMobileMenuOpen(false)}
>
<item.icon className="h-5 w-5" />
{item.name}
</Link>
</motion.div>
))} ))}
{isAuthenticated ? ( <motion.div
<Button initial={{ opacity: 0, x: -10 }}
variant="outline" animate={{ opacity: 1, x: 0 }}
onClick={() => { transition={{ delay: filteredNavItems.length * 0.05 }}
localStorage.removeItem("token"); className="pt-2"
setIsAuthenticated(false);
setIsMobileMenuOpen(false);
window.location.href = "/auth/login";
}}
> >
Déconnexion {isAuthenticated ? (
</Button> <Button
) : ( variant="outline"
<div className="flex flex-col space-y-2"> onClick={handleLogout}
<Link to="/auth/login" onClick={() => setIsMobileMenuOpen(false)}> className="w-full border-orange-200 hover:bg-orange-50 hover:text-orange-600 dark:border-orange-800 dark:hover:bg-orange-900/20 dark:hover:text-orange-400"
<Button variant="ghost" className="w-full">Connexion</Button> >
</Link> <LogOut className="mr-2 h-4 w-4" />
<Link to="/auth/register" onClick={() => setIsMobileMenuOpen(false)}> Déconnexion
<Button className="w-full">S'inscrire</Button> </Button>
</Link> ) : (
</div> <div className="flex flex-col space-y-2">
)} <Link to="/auth/login" onClick={() => setIsMobileMenuOpen(false)}>
</nav> <Button variant="ghost" className="w-full hover:text-orange-500">
</div> Connexion
</div> </Button>
)} </Link>
<Link to="/auth/register" onClick={() => setIsMobileMenuOpen(false)}>
<Button className="w-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white">
S'inscrire
</Button>
</Link>
</div>
)}
</motion.div>
</nav>
</div>
</motion.div>
)}
</AnimatePresence>
</header> </header>
); )
} }

View File

@ -1,8 +1,23 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
export function KitchenIllustration() { interface KitchenIllustrationProps {
height?: number;
}
export function KitchenIllustration({ height = 200 }: KitchenIllustrationProps) {
// Calculate width based on original ratio (280:200)
const ratio = 280 / 200;
const width = height * ratio;
return ( return (
<svg width="280" height="200" viewBox="0 0 280 200" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
width={width}
height={height}
viewBox="0 0 280 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ maxWidth: '100%' }}
>
{/* Pot */} {/* Pot */}
<rect x="100" y="120" width="80" height="60" rx="5" fill="#6B7280" /> <rect x="100" y="120" width="80" height="60" rx="5" fill="#6B7280" />
<rect x="90" y="110" width="100" height="15" rx="5" fill="#9CA3AF" /> <rect x="90" y="110" width="100" height="15" rx="5" fill="#9CA3AF" />

View File

@ -0,0 +1,61 @@
import { motion } from "framer-motion";
interface LogoProps {
size?: "sm" | "md" | "lg";
showText?: boolean;
}
export function Logo({ size = "md", showText = true }: LogoProps) {
const sizes = {
sm: { container: 24, icon: 18 },
md: { container: 32, icon: 24 },
lg: { container: 40, icon: 30 }
};
const containerSize = sizes[size].container;
const iconSize = sizes[size].icon;
return (
<div className="flex items-center gap-2">
<div
className="relative flex items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-amber-500"
style={{ width: containerSize, height: containerSize }}
>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
{/* Fridge SVG Icon */}
<svg
width={iconSize}
height={iconSize}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-white"
>
<rect x="4" y="2" width="16" height="20" rx="2" stroke="currentColor" strokeWidth="2" />
<line x1="4" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="2" />
<circle cx="7" cy="7" r="1" fill="currentColor" />
<circle cx="7" cy="14" r="1" fill="currentColor" />
<line x1="10" y1="6" x2="16" y2="6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="10" y1="14" x2="16" y2="14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="10" y1="17" x2="14" y2="17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</motion.div>
</div>
{showText && (
<motion.span
className="text-lg font-bold bg-gradient-to-r from-orange-600 to-amber-600 bg-clip-text text-transparent dark:from-orange-400 dark:to-amber-400"
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
Freedge
</motion.span>
)}
</div>
);
}

View File

@ -0,0 +1,72 @@
import { motion } from "framer-motion";
export function RecipeDetailLoader() {
return (
<div className="flex flex-col items-center justify-center py-12">
<div className="relative w-24 h-24">
{/* Pot */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-16 bg-gray-600 rounded-b-xl"></div>
<div className="absolute top-0 w-24 h-4 bg-gray-500 rounded-t-xl"></div>
</div>
{/* Bubbling animation */}
<motion.div
className="absolute top-6 left-8 w-3 h-3 bg-blue-300 rounded-full"
animate={{
y: [0, -15, -5],
opacity: [0.7, 1, 0]
}}
transition={{
duration: 1.5,
repeat: Infinity,
repeatType: "loop"
}}
/>
<motion.div
className="absolute top-6 left-14 w-2 h-2 bg-blue-300 rounded-full"
animate={{
y: [0, -10, -2],
opacity: [0.7, 1, 0]
}}
transition={{
duration: 1.2,
repeat: Infinity,
repeatType: "loop",
delay: 0.3
}}
/>
<motion.div
className="absolute top-6 left-11 w-4 h-4 bg-blue-300 rounded-full"
animate={{
y: [0, -20, -5],
opacity: [0.7, 1, 0]
}}
transition={{
duration: 1.8,
repeat: Infinity,
repeatType: "loop",
delay: 0.5
}}
/>
{/* Spoon stirring animation */}
<motion.div
className="absolute top-2 left-10 w-2 h-14 bg-gray-300 origin-bottom"
animate={{ rotate: [-20, 20, -20] }}
transition={{
duration: 1.5,
repeat: Infinity,
repeatType: "reverse",
ease: "easeInOut"
}}
>
<div className="absolute -top-3 -left-2 w-6 h-3 bg-gray-300 rounded-full"></div>
</motion.div>
</div>
<p className="mt-4 text-center font-medium">Préparation de votre recette...</p>
</div>
);
}

View File

@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { login } from "@/api/auth" import { login } from "@/api/auth"
export function LoginForm({ export function LoginForm({
className, className,
...props ...props
@ -40,9 +39,9 @@ export function LoginForm({
return ( return (
<form className={cn("flex flex-col gap-6", className)} {...props} onSubmit={handleSubmit}> <form className={cn("flex flex-col gap-6", className)} {...props} onSubmit={handleSubmit}>
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Login to your account</h1> <h1 className="text-2xl font-bold">Connexion à votre compte</h1>
<p className="text-muted-foreground text-sm text-balance"> <p className="text-muted-foreground text-sm text-balance">
Enter your email below to login to your account Entrez votre email ci-dessous pour vous connecter à votre compte
</p> </p>
</div> </div>
{error && ( {error && (
@ -56,7 +55,7 @@ export function LoginForm({
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="m@example.com" placeholder="m@exemple.com"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
@ -64,12 +63,12 @@ export function LoginForm({
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
<div className="flex items-center"> <div className="flex items-center">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Mot de passe</Label>
<a <a
href="#" href="#"
className="ml-auto text-sm underline-offset-4 hover:underline" className="ml-auto text-sm underline-offset-4 hover:underline"
> >
Forgot your password? Mot de passe oublié ?
</a> </a>
</div> </div>
<Input <Input
@ -81,11 +80,11 @@ export function LoginForm({
/> />
</div> </div>
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>
{loading ? "Connexion en cours..." : "Login"} {loading ? "Connexion en cours..." : "Se connecter"}
</Button> </Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"> <div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2"> <span className="text-muted-foreground relative z-10 px-2">
Or continue with Ou continuer avec
</span> </span>
</div> </div>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full">
@ -95,13 +94,13 @@ export function LoginForm({
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
Login with Google Se connecter avec Google
</Button> </Button>
</div> </div>
<div className="text-center text-sm"> <div className="text-center text-sm">
Don&apos;t have an account?{" "} Vous n'avez pas de compte ?{" "}
<a href="/register" className="underline underline-offset-4"> <a href="/register" className="underline underline-offset-4">
Sign up S'inscrire
</a> </a>
</div> </div>
</form> </form>

View File

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
{ {
variants: { variants: {
variant: { variant: {
@ -14,7 +14,7 @@ const buttonVariants = cva(
destructive: destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline: outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",

View File

@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -1,10 +1,11 @@
import { GalleryVerticalEnd } from "lucide-react" import { GalleryVerticalEnd } from "lucide-react"
import { LoginForm } from "@/components/login-form" import { LoginForm } from "@/components/login-form"
import PastaVongole from "@/assets/pasta-alla-vongole-home.jpeg"
export default function Login() { export default function Login() {
return ( return (
<div className="grid min-h-svh lg:grid-cols-2"> <div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10"> <div className="flex flex-col gap-4 p-6 md:p-10 bg-gradient-to-b from-amber-50 to-orange-50 dark:from-slate-950 dark:to-slate-900">
<div className="flex justify-center gap-2 md:justify-start"> <div className="flex justify-center gap-2 md:justify-start">
<a href="#" className="flex items-center gap-2 font-medium"> <a href="#" className="flex items-center gap-2 font-medium">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground"> <div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
@ -21,7 +22,7 @@ export default function Login() {
</div> </div>
<div className="relative hidden bg-muted lg:block"> <div className="relative hidden bg-muted lg:block">
<img <img
src="/placeholder.svg" src={PastaVongole}
alt="Image" alt="Image"
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale" className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
/> />

View File

@ -1,90 +1,444 @@
import { Link } from "react-router-dom"; "use client"
import { Button } from "@/components/ui/button";
import { ArrowRight, ChefHat, Heart, Search } from "lucide-react"; import { Link } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { ArrowRight, Heart, Sparkles, Refrigerator, Camera, Mic, Clock, Utensils, Zap } from "lucide-react"
import { motion } from "framer-motion"
import { PricingCard } from "@/components/PricingCard"
import { TestimonialCard } from "@/components/TestimonialCard"
import { HowItWorksStep } from "@/components/HowItWorksStep"
import { KitchenIllustration } from "@/components/illustrations/KitchenIllustration"
import PastaVongole from "@/assets/pasta-alla-vongole-home.jpeg"
export default function Home() { export default function Home() {
return ( return (
<div className="space-y-16"> <div className="md:space-y-24 space-y-4 pb-16">
{/* Hero section */} <section className="py-12 md:py-20 flex justify-center">
<section className="py-12 md:py-20">
<div className="container px-4 md:px-6"> <div className="container px-4 md:px-6">
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12"> <div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
<div className="space-y-4"> <motion.div
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl"> className="space-y-4"
Découvrez et partagez des recettes délicieuses initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="inline-block rounded-lg bg-orange-100 dark:bg-orange-900/30 px-3 py-1 text-sm text-orange-700 dark:text-orange-300 mb-2">
<span className="flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5" />
Propulsé par l'IA
</span>
</div>
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl bg-gradient-to-r from-orange-600 to-amber-600 bg-clip-text text-transparent dark:from-orange-400 dark:to-amber-400">
Des recettes délicieuses avec ce que vous avez déjà
</h1> </h1>
<p className="max-w-[600px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400"> <p className="max-w-[600px] text-lg text-muted-foreground md:text-xl">
Freedge vous aide à trouver des recettes adaptées à vos ingrédients disponibles et à partager vos créations culinaires avec la communauté. Freedge analyse les ingrédients de votre frigo et vous propose des recettes personnalisées en quelques
secondes. Fini le gaspillage alimentaire !
</p> </p>
<div className="flex flex-col gap-2 min-[400px]:flex-row"> <div className="flex flex-col gap-3 min-[400px]:flex-row">
<Link to="/recipes"> <Link to="/recipes">
<Button className="flex gap-1"> <Button className="bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white">
Explorer les recettes Commencer maintenant
<ArrowRight className="h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Button> </Button>
</Link> </Link>
<Link to="/auth/register"> <Link to="/auth/register">
<Button variant="outline">Créer un compte</Button> <Button
variant="outline"
className="border-orange-200 hover:bg-orange-50 hover:text-orange-600 dark:border-orange-800 dark:hover:bg-orange-900/20 dark:hover:text-orange-400"
>
Créer un compte
</Button>
</Link> </Link>
</div> </div>
</div> </motion.div>
<div className="flex items-center justify-center"> <motion.div
<img className="flex items-center justify-center relative"
alt="Hero Image" initial={{ opacity: 0, scale: 0.9 }}
className="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center sm:w-full" animate={{ opacity: 1, scale: 1 }}
height="310" transition={{ duration: 0.5, delay: 0.2 }}
src="/images/hero-image.jpg" >
width="550" <div className="absolute -z-10 h-full w-full bg-gradient-to-br from-orange-100 to-amber-100 dark:from-orange-900/20 dark:to-amber-900/20 rounded-3xl blur-2xl" />
/> <div className="bg-white dark:bg-slate-800 rounded-3xl shadow-xl overflow-hidden border border-orange-100 dark:border-orange-900/30">
</div> <img
alt="Recettes personnalisées"
className="w-full aspect-[4/3] object-cover"
src={PastaVongole}
/>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg">Pasta Primavera</h3>
<div className="flex items-center gap-1 text-sm">
<Clock className="h-4 w-4 text-orange-500" />
<span>25 min</span>
</div>
</div>
<p className="text-sm text-muted-foreground mb-4">
Créé avec les ingrédients de votre frigo : pâtes, tomates, courgettes, basilic
</p>
<div className="flex justify-between items-center">
<div className="flex gap-1">
<span className="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/30 px-2.5 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">
Facile
</span>
<span className="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/30 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">
Végétarien
</span>
</div>
<Button variant="ghost" size="sm" className="rounded-full">
<Heart className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</motion.div>
</div> </div>
</div> </div>
</section> </section>
{/* Features section */} {/* AI Features section */}
<section className="py-12 md:py-20"> <section className="py-12 md:py-20 flex justify-center bg-gradient-to-b from-orange-50 to-amber-50 dark:from-slate-900 dark:to-slate-900/80">
<div className="container px-4 md:px-6"> <div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center"> <motion.div
className="flex flex-col items-center justify-center space-y-4 text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl"> <h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Fonctionnalités principales L'IA au service de votre cuisine
</h2> </h2>
<p className="max-w-[900px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400"> <p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed">
Découvrez tout ce que Freedge peut faire pour vous Notre intelligence artificielle transforme votre façon de cuisiner
</p> </p>
</div> </div>
</div> </motion.div>
<div className="mx-auto grid max-w-5xl grid-cols-1 gap-6 py-12 md:grid-cols-3 lg:gap-12">
<div className="flex flex-col items-center space-y-4 text-center"> <div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10"> <motion.div
<Search className="h-8 w-8 text-primary" /> className="order-2 md:order-1"
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="space-y-8">
<div className="space-y-2">
<h3 className="text-2xl font-bold">Dites adieu au gaspillage alimentaire</h3>
<p className="text-muted-foreground">
Notre IA analyse les ingrédients que vous avez déjà et vous propose des recettes adaptées, vous
permettant d'utiliser ce que vous avez sous la main.
</p>
</div>
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="bg-orange-100 dark:bg-orange-900/30 p-2 rounded-lg">
<Mic className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h4 className="font-medium">Dictez vos ingrédients</h4>
<p className="text-sm text-muted-foreground">
Énumérez simplement ce que vous avez dans votre frigo
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="bg-orange-100 dark:bg-orange-900/30 p-2 rounded-lg">
<Camera className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h4 className="font-medium">Scannez votre frigo</h4>
<p className="text-sm text-muted-foreground">
Prenez une photo et notre IA identifie vos ingrédients
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="bg-orange-100 dark:bg-orange-900/30 p-2 rounded-lg">
<Sparkles className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h4 className="font-medium">Recettes personnalisées</h4>
<p className="text-sm text-muted-foreground">
Obtenez des suggestions adaptées à vos préférences et restrictions
</p>
</div>
</div>
</div>
<Link to="/recipes/new">
<Button className="bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white">
Essayer maintenant
<Zap className="ml-2 h-4 w-4" />
</Button>
</Link>
</div> </div>
<h3 className="text-xl font-bold">Recherche intelligente</h3> </motion.div>
<p className="text-gray-500 dark:text-gray-400">
Trouvez des recettes en fonction des ingrédients que vous avez déjà chez vous. <motion.div
</p> className="order-1 md:order-2 flex justify-center"
</div> initial={{ opacity: 0, x: 20 }}
<div className="flex flex-col items-center space-y-4 text-center"> whileInView={{ opacity: 1, x: 0 }}
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10"> transition={{ duration: 0.5 }}
<ChefHat className="h-8 w-8 text-primary" /> viewport={{ once: true }}
>
<div className="relative w-full max-w-md">
<div className="absolute -z-10 h-full w-full bg-gradient-to-br from-orange-100 to-amber-100 dark:from-orange-900/20 dark:to-amber-900/20 rounded-3xl blur-2xl" />
<KitchenIllustration />
</div> </div>
<h3 className="text-xl font-bold">Créez et partagez</h3> </motion.div>
<p className="text-gray-500 dark:text-gray-400">
Ajoutez vos propres recettes et partagez-les avec la communauté.
</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Heart className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Favoris personnalisés</h3>
<p className="text-gray-500 dark:text-gray-400">
Enregistrez vos recettes préférées pour y accéder rapidement.
</p>
</div>
</div> </div>
</div> </div>
</section> </section>
{/* How it works section */}
<section className="py-12 md:py-20 flex justify-center">
<div className="container px-4 md:px-6">
<motion.div
className="flex flex-col items-center justify-center space-y-4 text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">Comment ça marche</h2>
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed">
En trois étapes simples, transformez les ingrédients de votre frigo en délicieux repas
</p>
</div>
</motion.div>
<div className="max-w-3xl mx-auto space-y-12">
<HowItWorksStep
icon={Refrigerator}
title="Listez vos ingrédients"
description="Dictez ou scannez les ingrédients disponibles dans votre réfrigérateur. Notre IA les identifie automatiquement."
step={1}
delay={0.1}
/>
<HowItWorksStep
icon={Sparkles}
title="L'IA génère des recettes"
description="Notre intelligence artificielle analyse vos ingrédients et crée des recettes personnalisées adaptées à ce que vous avez sous la main."
step={2}
delay={0.2}
/>
<HowItWorksStep
icon={Utensils}
title="Cuisinez et savourez"
description="Suivez les instructions étape par étape et préparez un délicieux repas sans avoir à faire de courses supplémentaires."
step={3}
delay={0.3}
/>
</div>
</div>
</section>
{/* Pricing section */}
<section className="py-12 md:py-20 flex justify-center bg-gradient-to-b from-orange-50 to-amber-50 dark:from-slate-900 dark:to-slate-900/80">
<div className="container px-4 md:px-6">
<motion.div
className="flex flex-col items-center justify-center space-y-4 text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">Plans d'abonnement</h2>
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed">
Choisissez le plan qui correspond à vos besoins culinaires
</p>
</div>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
viewport={{ once: true }}
>
<PricingCard
name="Gratuit"
description="Parfait pour découvrir l'application"
price={null}
features={[
{ text: "3 recettes par mois", included: true },
{ text: "Reconnaissance vocale des ingrédients", included: true },
{ text: "Recettes personnalisées", included: true },
{ text: "Sauvegarde des recettes", included: true },
{ text: "Reconnaissance par photo", included: false },
{ text: "Recettes premium", included: false },
{ text: "Support prioritaire", included: false },
]}
buttonText="S'inscrire gratuitement"
buttonVariant="outline"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
viewport={{ once: true }}
>
<PricingCard
name="Essentiel"
description="Pour les cuisiniers réguliers"
price="3"
features={[
{ text: "15 recettes par mois", included: true },
{ text: "Reconnaissance vocale des ingrédients", included: true },
{ text: "Recettes personnalisées", included: true },
{ text: "Sauvegarde des recettes", included: true },
{ text: "Reconnaissance par photo", included: true },
{ text: "Recettes premium", included: false },
{ text: "Support prioritaire", included: false },
]}
buttonText="Choisir Essentiel"
popular={true}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
viewport={{ once: true }}
>
<PricingCard
name="Premium"
description="Pour les passionnés de cuisine"
price="5"
features={[
{ text: "Recettes illimitées", included: true },
{ text: "Reconnaissance vocale des ingrédients", included: true },
{ text: "Recettes personnalisées", included: true },
{ text: "Sauvegarde des recettes", included: true },
{ text: "Reconnaissance par photo", included: true },
{ text: "Recettes premium", included: true },
{ text: "Support prioritaire", included: true },
]}
buttonText="Choisir Premium"
/>
</motion.div>
</div>
</div>
</section>
{/* Testimonials section */}
<section className="py-12 md:py-20 flex justify-center">
<div className="container px-4 md:px-6">
<motion.div
className="flex flex-col items-center justify-center space-y-4 text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Ce que disent nos utilisateurs
</h2>
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed">
Découvrez comment Freedge transforme la façon dont les gens cuisinent
</p>
</div>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
viewport={{ once: true }}
>
<TestimonialCard
quote="Freedge a complètement changé ma façon de cuisiner. Je ne jette plus rien et je découvre de nouvelles recettes chaque semaine !"
author="Sophie Martin"
role="Utilisatrice depuis 6 mois"
rating={5}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
viewport={{ once: true }}
>
<TestimonialCard
quote="L'IA est impressionnante ! J'ai dicté les 5 ingrédients qui me restaient et j'ai obtenu une recette délicieuse en quelques secondes."
author="Thomas Dubois"
role="Abonné Premium"
rating={5}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
viewport={{ once: true }}
>
<TestimonialCard
quote="Je fais des économies considérables sur mes courses depuis que j'utilise Freedge. Plus besoin d'acheter des ingrédients spécifiques pour chaque recette."
author="Julie Moreau"
role="Utilisatrice depuis 3 mois"
rating={4}
/>
</motion.div>
</div>
</div>
</section>
{/* CTA section */}
<section className="py-12 md:py-20 flex justify-center">
<div className="container px-4 md:px-6">
<motion.div
className="rounded-3xl bg-gradient-to-r from-orange-500 to-amber-500 p-8 md:p-12 lg:p-16 text-white relative overflow-hidden"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="absolute inset-0 bg-[url('/placeholder.svg?height=600&width=800')] opacity-10 bg-cover bg-center mix-blend-overlay" />
<div className="relative z-10 max-w-3xl mx-auto text-center space-y-6">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Prêt à transformer votre façon de cuisiner ?
</h2>
<p className="text-white/80 md:text-xl/relaxed">
Rejoignez des milliers d'utilisateurs qui économisent du temps, de l'argent et réduisent leur gaspillage
alimentaire grâce à Freedge.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link to="/auth/register">
<Button className="bg-white text-orange-600 hover:bg-white/90 hover:text-orange-700">
Commencer gratuitement
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<Link to="/recipes">
<Button variant="outline" className="border-white text-white hover:bg-white/20">
Explorer les recettes
</Button>
</Link>
</div>
</div>
</motion.div>
</div>
</section>
</div> </div>
); )
} }

View File

@ -1,7 +1,9 @@
import { useState, useEffect } from "react"; "use client"
import { useParams, useNavigate } from "react-router-dom";
import { recipeService, Recipe } from "@/api/recipe"; import { useState, useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"; import { useParams, useNavigate } from "react-router-dom"
import { recipeService, type Recipe } from "@/api/recipe"
import { Button } from "@/components/ui/button"
import { import {
Clock, Clock,
Users, Users,
@ -11,132 +13,175 @@ import {
ArrowLeft, ArrowLeft,
Trash2, Trash2,
Edit, Edit,
HeartOff Printer,
} from "lucide-react"; CheckCircle2,
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; Timer,
ChevronUp,
} from "lucide-react"
import { motion, AnimatePresence } from "framer-motion"
import { RecipeDetailLoader } from "@/components/illustrations/RecipeDetailLoader"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
export default function RecipeDetail() { export default function RecipeDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>()
const navigate = useNavigate(); const navigate = useNavigate()
const contentRef = useRef<HTMLDivElement>(null)
const [recipe, setRecipe] = useState<Recipe | null>(null); const [recipe, setRecipe] = useState<Recipe | null>(null)
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true)
const [error, setError] = useState(""); const [error, setError] = useState("")
const [isFavorite, setIsFavorite] = useState(false); const [isFavorite, setIsFavorite] = useState(false)
const [addingToFavorites, setAddingToFavorites] = useState(false); const [addingToFavorites, setAddingToFavorites] = useState(false)
const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(new Set())
const [checkedSteps, setCheckedSteps] = useState<Set<number>>(new Set())
const [showScrollTop, setShowScrollTop] = useState(false)
useEffect(() => { useEffect(() => {
const fetchRecipeDetails = async () => { const fetchRecipeDetails = async () => {
if (!id) return; if (!id) return
try { try {
setLoading(true); setLoading(true)
// ✅ GET RECIPE DETAILS // ✅ GET RECIPE DETAILS
const recipeData = await recipeService.getRecipeById(id); const recipeData = await recipeService.getRecipeById(id)
// Optionnel : conversion ingrédients / instructions si nécessaire // Optionnel : conversion ingrédients / instructions si nécessaire
let ingredients = recipeData.ingredients; let ingredients = recipeData.ingredients
if (typeof ingredients === "string") { if (typeof ingredients === "string") {
ingredients = ingredients.split("\n").filter(item => item.trim() !== ""); ingredients = ingredients.split("\n").filter((item) => item.trim() !== "")
} }
// Correction: déclarer la variable instructions // Correction: déclarer la variable instructions
let instructions: string[] = []; let instructions: string[] = []
if (recipeData.generatedRecipe) { if (recipeData.generatedRecipe) {
instructions = recipeData.generatedRecipe instructions = recipeData.generatedRecipe.split("\n").filter((item) => item.trim() !== "")
.split("\n")
.filter(item => item.trim() !== "");
} }
setRecipe({ setRecipe({
...recipeData, ...recipeData,
ingredients, ingredients,
instructions, // Ajouter instructions au recipe instructions, // Ajouter instructions au recipe
}); })
// ✅ GET FAVORITE RECIPES & CHECK IF FAVORITE // ✅ GET FAVORITE RECIPES & CHECK IF FAVORITE
try { try {
const favorites = await recipeService.getFavoriteRecipes(); const favorites = await recipeService.getFavoriteRecipes()
setIsFavorite(favorites.some(fav => fav.id === id)); setIsFavorite(favorites.some((fav) => fav.id === id))
} catch (favError) { } catch (favError) {
// Ignorer les erreurs de favoris pour ne pas bloquer l'affichage // Ignorer les erreurs de favoris pour ne pas bloquer l'affichage
console.log("Impossible de vérifier les favoris:", favError); console.log("Impossible de vérifier les favoris:", favError)
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err)
setError("Impossible de charger les détails de la recette"); setError("Impossible de charger les détails de la recette")
} finally { } finally {
setLoading(false); setLoading(false)
} }
}; }
fetchRecipeDetails(); fetchRecipeDetails()
}, [id]);
// Setup scroll listener
const handleScroll = () => {
if (window.scrollY > 300) {
setShowScrollTop(true)
} else {
setShowScrollTop(false)
}
}
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [id])
const handleToggleFavorite = async () => { const handleToggleFavorite = async () => {
if (!id || !recipe) return; if (!id || !recipe) return
try { try {
setAddingToFavorites(true); setAddingToFavorites(true)
if (isFavorite) { if (isFavorite) {
// ✅ REMOVE FROM FAVORITES // ✅ REMOVE FROM FAVORITES
await recipeService.removeFromFavorites(id); await recipeService.removeFromFavorites(id)
setIsFavorite(false); setIsFavorite(false)
} else { } else {
// ✅ ADD TO FAVORITES // ✅ ADD TO FAVORITES
await recipeService.addToFavorites(id); await recipeService.addToFavorites(id)
setIsFavorite(true); setIsFavorite(true)
} }
} catch (err) { } catch (err) {
console.error("Erreur lors de la modification des favoris:", err); console.error("Erreur lors de la modification des favoris:", err)
// Ne pas afficher d'erreur à l'utilisateur pour cette fonctionnalité // Ne pas afficher d'erreur à l'utilisateur pour cette fonctionnalité
} finally { } finally {
setAddingToFavorites(false); setAddingToFavorites(false)
} }
}; }
const handleDeleteRecipe = async () => { const handleDeleteRecipe = async () => {
if (!id) return; if (!id) return
if (!window.confirm("Êtes-vous sûr de vouloir supprimer cette recette ?")) return; if (!window.confirm("Êtes-vous sûr de vouloir supprimer cette recette ?")) return
try { try {
// ✅ DELETE RECIPE // ✅ DELETE RECIPE
await recipeService.deleteRecipe(id); await recipeService.deleteRecipe(id)
navigate("/recipes"); navigate("/recipes")
} catch (err) { } catch (err) {
console.error("Erreur lors de la suppression:", err); console.error("Erreur lors de la suppression:", err)
// Optionnel: afficher un message d'erreur à l'utilisateur // Optionnel: afficher un message d'erreur à l'utilisateur
} }
}; }
const handleShare = () => { const handleShare = () => {
if (!recipe) return; if (!recipe) return
if (navigator.share) { if (navigator.share) {
navigator.share({ navigator.share({
title: recipe.title, title: recipe.title,
text: recipe.description || "Découvrez cette recette !", text: recipe.description || "Découvrez cette recette !",
url: window.location.href, url: window.location.href,
}); })
} else { } else {
navigator.clipboard.writeText(window.location.href); navigator.clipboard.writeText(window.location.href)
alert("Lien copié dans le presse-papier !"); alert("Lien copié dans le presse-papier !")
} }
}; }
const handlePrint = () => {
window.print()
}
const toggleIngredient = (index: number) => {
const newChecked = new Set(checkedIngredients)
if (newChecked.has(index)) {
newChecked.delete(index)
} else {
newChecked.add(index)
}
setCheckedIngredients(newChecked)
}
const toggleStep = (index: number) => {
const newChecked = new Set(checkedSteps)
if (newChecked.has(index)) {
newChecked.delete(index)
} else {
newChecked.add(index)
}
setCheckedSteps(newChecked)
}
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" })
}
// ======================================== // ========================================
// === LOADING & ERROR STATES ============ // === LOADING & ERROR STATES ============
// ======================================== // ========================================
if (loading) { if (loading) {
return ( return <RecipeDetailLoader />
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
);
} }
if (error || !recipe) { if (error || !recipe) {
@ -144,195 +189,268 @@ export default function RecipeDetail() {
<div className="rounded-md bg-red-50 p-6 text-center text-red-700 dark:bg-red-900/50 dark:text-red-200"> <div className="rounded-md bg-red-50 p-6 text-center text-red-700 dark:bg-red-900/50 dark:text-red-200">
<h2 className="text-xl font-bold">Erreur</h2> <h2 className="text-xl font-bold">Erreur</h2>
<p>{error || "Recette introuvable"}</p> <p>{error || "Recette introuvable"}</p>
<Button <Button variant="outline" className="mt-4" onClick={() => navigate("/recipes")}>
variant="outline"
className="mt-4"
onClick={() => navigate("/recipes")}
>
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Retour aux recettes Retour aux recettes
</Button> </Button>
</div> </div>
); )
} }
// Calculate total time
const totalTime = (recipe.preparationTime || 0) + (recipe.cookingTime || 0)
// ======================================== // ========================================
// === MAIN COMPONENT ===================== // === MAIN COMPONENT =====================
// ======================================== // ========================================
return ( return (
<div className="space-y-8"> <div className="min-h-screen bg-gradient-to-b from-amber-50 to-orange-50 dark:from-slate-950 dark:to-slate-900 pb-16">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> {/* Header */}
<Button <header className="sticky top-0 z-10 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border-b border-slate-200 dark:border-slate-800 print:hidden">
variant="ghost" <div className="container px-4 py-3">
className="w-fit" <div className="flex items-center justify-between">
onClick={() => navigate("/recipes")} <Button variant="ghost" className="flex items-center gap-2" onClick={() => navigate("/recipes")}>
> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="mr-2 h-4 w-4" /> Retour aux recettes
Retour aux recettes </Button>
</Button>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button variant="ghost" size="icon" className="rounded-full" onClick={handlePrint}>
variant="outline" <Printer className="h-4 w-4" />
onClick={handleShare} </Button>
>
<Share2 className="mr-2 h-4 w-4" />
Partager
</Button>
<Button <Button variant="ghost" size="icon" className="rounded-full" onClick={handleShare}>
variant={isFavorite ? "default" : "outline"} <Share2 className="h-4 w-4" />
onClick={handleToggleFavorite} </Button>
disabled={addingToFavorites}
>
{isFavorite ? (
<>
<HeartOff className="mr-2 h-4 w-4" />
Retirer des favoris
</>
) : (
<>
<Heart className="mr-2 h-4 w-4" />
Ajouter aux favoris
</>
)}
</Button>
<Button <Button
variant="outline" variant={isFavorite ? "default" : "ghost"}
onClick={() => navigate(`/recipes/edit/${id}`)} size="icon"
> className={`rounded-full ${isFavorite ? "bg-red-500 hover:bg-red-600 text-white" : ""}`}
<Edit className="mr-2 h-4 w-4" /> onClick={handleToggleFavorite}
Modifier disabled={addingToFavorites}
</Button>
<Button
variant="destructive"
onClick={handleDeleteRecipe}
>
<Trash2 className="mr-2 h-4 w-4" />
Supprimer
</Button>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<div className="overflow-hidden rounded-lg">
<img
src={recipe.imageUrl || "/images/recipe-placeholder.jpg"}
alt={recipe.title}
className="h-[300px] w-full object-cover sm:h-[400px]"
/>
</div>
</div>
<div className="space-y-4 lg:col-span-2">
<h1 className="text-3xl font-bold">{recipe.title}</h1>
<p className="text-muted-foreground">
{recipe.description || "Aucune description disponible"}
</p>
<div className="flex flex-wrap gap-2">
{recipe.tags?.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary"
> >
{tag} <Heart className="h-4 w-4" />
</span> </Button>
))} </div>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{recipe.preparationTime && (
<div className="flex flex-col items-center rounded-lg border p-3">
<Clock className="mb-1 h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">Préparation</span>
<span className="text-lg font-bold">{recipe.preparationTime} min</span>
</div>
)}
{recipe.cookingTime && (
<div className="flex flex-col items-center rounded-lg border p-3">
<Clock className="mb-1 h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">Cuisson</span>
<span className="text-lg font-bold">{recipe.cookingTime} min</span>
</div>
)}
{recipe.servings && (
<div className="flex flex-col items-center rounded-lg border p-3">
<Users className="mb-1 h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">Portions</span>
<span className="text-lg font-bold">{recipe.servings}</span>
</div>
)}
{recipe.difficulty && (
<div className="flex flex-col items-center rounded-lg border p-3">
<ChefHat className="mb-1 h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">Difficulté</span>
<span className="text-lg font-bold capitalize">{recipe.difficulty}</span>
</div>
)}
</div> </div>
</div> </div>
</div> </header>
<Tabs defaultValue="ingredients" className="w-full"> {/* Main content */}
<TabsList className="grid w-full grid-cols-2"> <main className="container px-4 py-6" ref={contentRef}>
<TabsTrigger value="ingredients">Ingrédients</TabsTrigger> <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}>
<TabsTrigger value="instructions">Instructions</TabsTrigger> {/* Recipe Hero */}
</TabsList> <div className="relative rounded-xl overflow-hidden mb-8">
<img
<TabsContent value="ingredients" className="mt-6"> src={recipe.imageUrl || "/placeholder.svg?height=400&width=800"}
<div className="rounded-lg border p-6"> alt={recipe.title}
<h2 className="mb-4 text-xl font-bold">Ingrédients</h2> className="w-full h-[300px] md:h-[400px] object-cover"
<ul className="space-y-2"> />
{Array.isArray(recipe.ingredients) ? ( <div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent flex flex-col justify-end p-6">
recipe.ingredients.map((ingredient, index) => ( <div className="flex flex-wrap gap-2 mb-3">
<li key={index} className="flex items-start"> {recipe.tags?.map((tag) => (
<span className="mr-2 mt-1 h-2 w-2 rounded-full bg-primary"></span> <Badge key={tag} variant="secondary" className="bg-white/20 text-white border-none">
<span>{ingredient}</span> {tag}
</li> </Badge>
)) ))}
) : ( </div>
<li className="flex items-start"> <h1 className="text-3xl md:text-4xl font-bold text-white mb-2">{recipe.title}</h1>
<span className="mr-2 mt-1 h-2 w-2 rounded-full bg-primary"></span> <p className="text-white/80 max-w-2xl">{recipe.description || "Aucune description disponible"}</p>
<span>{recipe.ingredients}</span> </div>
</li>
)}
</ul>
</div> </div>
</TabsContent>
<TabsContent value="instructions" className="mt-6"> {/* Recipe Info Cards */}
<div className="rounded-lg border p-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<h2 className="mb-4 text-xl font-bold">Instructions</h2> <Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-none shadow-sm">
<ol className="space-y-4"> <CardContent className="flex flex-col items-center justify-center p-4">
{recipe.instructions && recipe.instructions.length > 0 ? ( <Clock className="h-6 w-6 text-orange-500 mb-2" />
recipe.instructions.map((instruction, index) => ( <span className="text-sm text-muted-foreground">Préparation</span>
<li key={index} className="flex"> <span className="text-lg font-bold">{recipe.preparationTime || 0} min</span>
<span className="mr-4 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground"> </CardContent>
{index + 1} </Card>
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-none shadow-sm">
<CardContent className="flex flex-col items-center justify-center p-4">
<Timer className="h-6 w-6 text-orange-500 mb-2" />
<span className="text-sm text-muted-foreground">Cuisson</span>
<span className="text-lg font-bold">{recipe.cookingTime || 0} min</span>
</CardContent>
</Card>
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-none shadow-sm">
<CardContent className="flex flex-col items-center justify-center p-4">
<Users className="h-6 w-6 text-orange-500 mb-2" />
<span className="text-sm text-muted-foreground">Portions</span>
<span className="text-lg font-bold">{recipe.servings || "-"}</span>
</CardContent>
</Card>
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-none shadow-sm">
<CardContent className="flex flex-col items-center justify-center p-4">
<ChefHat className="h-6 w-6 text-orange-500 mb-2" />
<span className="text-sm text-muted-foreground">Difficulté</span>
<span className="text-lg font-bold capitalize">{recipe.difficulty || "-"}</span>
</CardContent>
</Card>
</div>
{/* Recipe Content */}
<div className="grid md:grid-cols-3 gap-8">
{/* Ingredients */}
<div className="md:col-span-1">
<Card className="bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm border-none shadow-sm sticky top-[85px]">
<CardContent className="p-6">
<h2 className="text-xl font-bold mb-4 flex items-center">
<span className="bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 w-8 h-8 rounded-full flex items-center justify-center mr-2">
1
</span> </span>
<p className="pt-1">{instruction}</p> Ingrédients
</li> </h2>
))
) : ( {totalTime > 0 && (
<li className="flex"> <div className="mb-4 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<span className="mr-4 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground"> <p className="text-sm flex items-center">
1 <Clock className="h-4 w-4 mr-2 text-orange-500" />
</span> Temps total: <span className="font-bold ml-1">{totalTime} min</span>
<p className="pt-1">{recipe.generatedRecipe}</p> </p>
</li> </div>
)} )}
</ol>
<ul className="space-y-2">
{Array.isArray(recipe.ingredients) ? (
recipe.ingredients.map((ingredient, index) => (
<li key={index} className="flex items-center">
<Checkbox
id={`ingredient-${index}`}
checked={checkedIngredients.has(index)}
onCheckedChange={() => toggleIngredient(index)}
className="mr-2 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
/>
<label
htmlFor={`ingredient-${index}`}
className={`flex-1 cursor-pointer ${checkedIngredients.has(index) ? "line-through text-muted-foreground" : ""}`}
>
{ingredient}
</label>
</li>
))
) : (
<li className="flex items-center">
<Checkbox
id="ingredient-single"
checked={checkedIngredients.has(0)}
onCheckedChange={() => toggleIngredient(0)}
className="mr-2 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
/>
<label
htmlFor="ingredient-single"
className={`flex-1 cursor-pointer ${checkedIngredients.has(0) ? "line-through text-muted-foreground" : ""}`}
>
{recipe.ingredients}
</label>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
{/* Instructions */}
<div className="md:col-span-2">
<Card className="bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm border-none shadow-sm">
<CardContent className="p-6">
<h2 className="text-xl font-bold mb-6 flex items-center">
<span className="bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 w-8 h-8 rounded-full flex items-center justify-center mr-2">
2
</span>
Instructions
</h2>
<ol className="space-y-6">
{recipe.instructions && recipe.instructions.length > 0 ? (
recipe.instructions.map((instruction, index) => (
<li key={index} className="flex">
<Checkbox
id={`step-${index}`}
checked={checkedSteps.has(index)}
onCheckedChange={() => toggleStep(index)}
className="mr-3 mt-1 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
/>
<div className="flex-1">
<label
htmlFor={`step-${index}`}
className={`flex items-center font-medium mb-2 cursor-pointer ${checkedSteps.has(index) ? "line-through text-muted-foreground" : ""}`}
>
Étape {index + 1}
{checkedSteps.has(index) && <CheckCircle2 className="ml-2 h-4 w-4 text-green-500" />}
</label>
<p className={checkedSteps.has(index) ? "text-muted-foreground" : ""}>{instruction}</p>
</div>
</li>
))
) : (
<li className="flex">
<Checkbox
id="step-single"
checked={checkedSteps.has(0)}
onCheckedChange={() => toggleStep(0)}
className="mr-3 mt-1 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
/>
<div className="flex-1">
<label
htmlFor="step-single"
className={`flex items-center font-medium mb-2 cursor-pointer ${checkedSteps.has(0) ? "line-through text-muted-foreground" : ""}`}
>
Instructions
{checkedSteps.has(0) && <CheckCircle2 className="ml-2 h-4 w-4 text-green-500" />}
</label>
<p className={checkedSteps.has(0) ? "text-muted-foreground" : ""}>{recipe.generatedRecipe}</p>
</div>
</li>
)}
</ol>
</CardContent>
</Card>
</div>
</div> </div>
</TabsContent>
</Tabs> {/* Admin Actions */}
<div className="mt-8 flex justify-end gap-2 print:hidden">
<Button variant="outline" onClick={() => navigate(`/recipes/edit/${id}`)}>
<Edit className="mr-2 h-4 w-4" />
Modifier
</Button>
<Button variant="destructive" onClick={handleDeleteRecipe}>
<Trash2 className="mr-2 h-4 w-4" />
Supprimer
</Button>
</div>
</motion.div>
</main>
{/* Scroll to top button */}
<AnimatePresence>
{showScrollTop && (
<motion.div
className="fixed bottom-8 right-8 print:hidden"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<Button
onClick={scrollToTop}
className="h-12 w-12 rounded-full shadow-lg bg-white dark:bg-slate-800"
size="icon"
>
<ChevronUp className="h-6 w-6" />
</Button>
</motion.div>
)}
</AnimatePresence>
</div> </div>
); )
} }

View File

@ -99,8 +99,8 @@ export default function RecipeForm() {
} }
return ( return (
<div className="min-h-screen flex flex-col bg-gradient-to-b from-amber-50 to-orange-50 dark:from-slate-950 dark:to-slate-900"> <div className="h-full flex flex-col bg-gradient-to-b from-amber-50 to-orange-50 dark:from-slate-950 dark:to-slate-900">
<div className="p-4"> <div className="p-2 sm:p-4">
<Button <Button
variant="ghost" variant="ghost"
className="cursor-pointer flex items-center gap-2" className="cursor-pointer flex items-center gap-2"
@ -111,22 +111,27 @@ export default function RecipeForm() {
</Button> </Button>
</div> </div>
<div className="w-full space-y-6 mt-4"> <div className="w-full space-y-4 sm:space-y-6 mt-2 sm:mt-4 px-2 sm:px-4 md:px-6 lg:px-8 max-w-3xl mx-auto">
{/* Illustrations */} {/* Illustrations */}
<div className="flex justify-center mb-8"> <div className="flex flex-col items-center text-center mb-4 sm:mb-8">
<KitchenIllustration /> <KitchenIllustration
height={150}
/>
<p className="max-w-[900px] text-muted-foreground text-sm md:text-xl/relaxed mt-2 sm:mt-4">
Notre intelligence artificielle transforme votre façon de cuisiner
</p>
</div> </div>
<Card className="border-none shadow-lg"> <Card className="border-none shadow-lg">
<CardHeader> <CardHeader className="p-4 sm:p-6">
<CardTitle className="text-2xl">Créer une nouvelle recette</CardTitle> <CardTitle className="text-xl sm:text-2xl">Créer une nouvelle recette</CardTitle>
<CardDescription> <CardDescription className="text-sm">
Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles, et nous générerons une Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles, et nous générerons une
recette pour vous. recette pour vous.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-4 sm:p-6">
{error && ( {error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200 mb-4"> <div className="rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200 mb-4">
{error} {error}
@ -151,34 +156,6 @@ export default function RecipeForm() {
</div> </div>
)} )}
<div className="flex items-center gap-2">
<Input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={handleFileChange}
className="hidden"
/>
<Button
variant="outline"
className="flex-1 cursor-pointer"
onClick={() => fileInputRef.current?.click()}
disabled={isRecording}
>
<Upload className="mr-2 h-4 w-4" />
Choisir un fichier
</Button>
<Button
variant={isRecording ? "destructive" : "outline"}
className="flex-1 cursor-pointer"
onClick={isRecording ? stopRecording : startRecording}
>
<Mic className="mr-2 h-4 w-4" />
{isRecording ? "Arrêter" : "Enregistrer"}
</Button>
</div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Enregistrez-vous en listant les ingrédients que vous avez à disposition. Notre IA générera une Enregistrez-vous en listant les ingrédients que vous avez à disposition. Notre IA générera une
recette adaptée à ces ingrédients. recette adaptée à ces ingrédients.
@ -197,7 +174,7 @@ export default function RecipeForm() {
)} )}
</CardContent> </CardContent>
<CardFooter className="flex justify-between"> <CardFooter className="p-4 sm:p-6 flex justify-between">
<Button <Button
variant="outline" variant="outline"
className="cursor-pointer" className="cursor-pointer"
@ -207,56 +184,56 @@ export default function RecipeForm() {
Annuler Annuler
</Button> </Button>
<Button {!audioFile && !isRecording && recordingStatus !== "processing" && !loading && (
className="cursor-pointer" <Button
onClick={handleSubmit} variant="default"
disabled={!audioFile || loading || recordingStatus === "processing" || isRecording} className="cursor-pointer"
> onClick={startRecording}
{loading ? ( >
<> <Mic className="mr-2 h-4 w-4" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Commencer l'enregistrement
Création en cours... </Button>
</> )}
) : (
<> {isRecording && (
<Upload className="mr-2 h-4 w-4" /> <Button
Créer la recette variant="destructive"
</> className="cursor-pointer"
)} onClick={stopRecording}
</Button> >
<motion.div
className="flex items-center"
animate={{ scale: [1, 1.05, 1] }}
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1.5 }}
>
<Mic className="mr-2 h-4 w-4" />
Arrêter l'enregistrement
</motion.div>
</Button>
)}
{audioFile && !isRecording && recordingStatus !== "processing" && !loading && (
<Button
className="cursor-pointer"
onClick={handleSubmit}
>
<Upload className="mr-2 h-4 w-4" />
Créer la recette
</Button>
)}
{loading && (
<Button
className="cursor-pointer"
disabled
>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Création en cours...
</Button>
)}
</CardFooter> </CardFooter>
</Card> </Card>
</div> </div>
{/* Recording button at bottom */}
{recordingStatus !== "processing" && !loading && !audioFile && (
<div className="md:hidden fixed bottom-8 left-0 right-0 flex justify-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3 }}
>
<Button
className={`h-16 w-16 rounded-full shadow-lg cursor-pointer ${isRecording ? "bg-red-500 hover:bg-red-600" : "bg-orange-500 hover:bg-orange-600"
}`}
size="icon"
onClick={isRecording ? stopRecording : startRecording}
>
{isRecording ? (
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1.5 }}
>
<Mic className="h-6 w-6" />
</motion.div>
) : (
<Mic className="h-6 w-6" />
)}
</Button>
</motion.div>
</div>
)}
</div > </div >
) )
} }

View File

@ -89,7 +89,7 @@ export default function RecipeList() {
</motion.div> </motion.div>
)} )}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 mt-6"> <div className="flex flex-col mx-4 md:mx-4 sm:flex-row sm:items-center sm:justify-between mb-6 mt-6">
<Tabs defaultValue="all" className="w-full sm:w-auto" onValueChange={setActiveFilter}> <Tabs defaultValue="all" className="w-full sm:w-auto" onValueChange={setActiveFilter}>
<TabsList className="grid grid-cols-4 w-full sm:w-auto"> <TabsList className="grid grid-cols-4 w-full sm:w-auto">
<TabsTrigger value="all">Toutes</TabsTrigger> <TabsTrigger value="all">Toutes</TabsTrigger>
@ -116,7 +116,7 @@ export default function RecipeList() {
<EmptyRecipes onCreateRecipe={handleCreateRecipe} /> <EmptyRecipes onCreateRecipe={handleCreateRecipe} />
) : ( ) : (
<motion.div <motion.div
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3" className="grid gap-6 my-6 mx-4 sm:grid-cols-2 lg:grid-cols-3"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ staggerChildren: 0.1 }} transition={{ staggerChildren: 0.1 }}