upgrade UI de fou
This commit is contained in:
parent
9c773e8e64
commit
d5be543421
@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
|
||||
32
frontend/pnpm-lock.yaml
generated
32
frontend/pnpm-lock.yaml
generated
@ -11,6 +11,9 @@ importers:
|
||||
'@radix-ui/react-avatar':
|
||||
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)
|
||||
'@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':
|
||||
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)
|
||||
@ -475,6 +478,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==}
|
||||
peerDependencies:
|
||||
@ -2201,6 +2217,22 @@ snapshots:
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||
|
||||
BIN
frontend/src/assets/pasta-alla-vongole-home.jpeg
Normal file
BIN
frontend/src/assets/pasta-alla-vongole-home.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
36
frontend/src/components/HowItWorksStep.tsx
Normal file
36
frontend/src/components/HowItWorksStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
85
frontend/src/components/PricingCard.tsx
Normal file
85
frontend/src/components/PricingCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
44
frontend/src/components/TestimonialCard.tsx
Normal file
44
frontend/src/components/TestimonialCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,63 +1,73 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GalleryVerticalEnd, Menu, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Link, useLocation } from "react-router-dom"
|
||||
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() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
// Vérifier si l'utilisateur est authentifié
|
||||
const token = localStorage.getItem("token");
|
||||
setIsAuthenticated(!!token);
|
||||
}, [location]); // Re-vérifier à chaque changement de route
|
||||
const token = localStorage.getItem("token")
|
||||
setIsAuthenticated(!!token)
|
||||
|
||||
// Fermer le menu mobile lors d'un changement de route
|
||||
setIsMobileMenuOpen(false)
|
||||
}, [location])
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token")
|
||||
setIsAuthenticated(false)
|
||||
setIsMobileMenuOpen(false)
|
||||
window.location.href = "/auth/login"
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ name: "Accueil", path: "/", public: true },
|
||||
{ name: "Recettes", path: "/recipes", public: true },
|
||||
{ name: "Mes recettes", path: "/my-recipes", public: false },
|
||||
{ name: "Favoris", path: "/favorites", public: false },
|
||||
{ name: "Profil", path: "/profile", public: false },
|
||||
];
|
||||
{ name: "Accueil", path: "/", icon: Home, public: true },
|
||||
{ name: "Recettes", path: "/recipes", icon: BookOpen, public: true },
|
||||
// { name: "Mes recettes", path: "/my-recipes", icon: BookOpen, public: false },
|
||||
{ name: "Favoris", path: "/favorites", icon: Heart, public: false },
|
||||
{ name: "Profil", path: "/profile", icon: User, public: false },
|
||||
]
|
||||
|
||||
const filteredNavItems = navItems.filter((item) => item.public || isAuthenticated)
|
||||
|
||||
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="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/" 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">
|
||||
<GalleryVerticalEnd className="size-4" />
|
||||
</div>
|
||||
<span className="hidden sm:inline-block">Freedge</span>
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<Logo size="md" showText={true} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation desktop */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
{navItems
|
||||
.filter(item => item.public || isAuthenticated)
|
||||
.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"text-sm font-medium transition-colors hover:text-primary",
|
||||
location.pathname === item.path
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
{filteredNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"text-sm font-medium transition-colors hover:text-orange-500 flex items-center gap-1.5",
|
||||
location.pathname === item.path ? "text-orange-500" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Boutons d'authentification */}
|
||||
@ -65,91 +75,104 @@ export function Header() {
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
localStorage.removeItem("token");
|
||||
setIsAuthenticated(false);
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
onClick={handleLogout}
|
||||
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"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Déconnexion
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/auth/login">
|
||||
<Button variant="ghost">Connexion</Button>
|
||||
<Button variant="ghost" className="hover:text-orange-500">
|
||||
Connexion
|
||||
</Button>
|
||||
</Link>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bouton menu mobile */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={toggleMobileMenu}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6" />
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="md:hidden rounded-full" onClick={toggleMobileMenu}>
|
||||
{isMobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu mobile */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden border-b">
|
||||
<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">
|
||||
{navItems
|
||||
.filter(item => item.public || isAuthenticated)
|
||||
.map(item => (
|
||||
<Link
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
className="md:hidden border-b"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
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}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
"text-sm font-medium transition-colors hover:text-primary",
|
||||
location.pathname === item.path
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
{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 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
localStorage.removeItem("token");
|
||||
setIsAuthenticated(false);
|
||||
setIsMobileMenuOpen(false);
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: filteredNavItems.length * 0.05 }}
|
||||
className="pt-2"
|
||||
>
|
||||
Déconnexion
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Link to="/auth/login" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" className="w-full">Connexion</Button>
|
||||
</Link>
|
||||
<Link to="/auth/register" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button className="w-full">S'inscrire</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
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"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Déconnexion
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Link to="/auth/login" onClick={() => setIsMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" className="w-full hover:text-orange-500">
|
||||
Connexion
|
||||
</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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,23 @@
|
||||
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 (
|
||||
<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 */}
|
||||
<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" />
|
||||
|
||||
61
frontend/src/components/illustrations/Logo.tsx
Normal file
61
frontend/src/components/illustrations/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/illustrations/RecipeDetailLoader.tsx
Normal file
72
frontend/src/components/illustrations/RecipeDetailLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { login } from "@/api/auth"
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
@ -40,9 +39,9 @@ export function LoginForm({
|
||||
return (
|
||||
<form className={cn("flex flex-col gap-6", className)} {...props} onSubmit={handleSubmit}>
|
||||
<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">
|
||||
Enter your email below to login to your account
|
||||
Entrez votre email ci-dessous pour vous connecter à votre compte
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
@ -56,7 +55,7 @@ export function LoginForm({
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
placeholder="m@exemple.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
@ -64,12 +63,12 @@ export function LoginForm({
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
Mot de passe oublié ?
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
@ -81,11 +80,11 @@ export function LoginForm({
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Connexion en cours..." : "Login"}
|
||||
{loading ? "Connexion en cours..." : "Se connecter"}
|
||||
</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">
|
||||
<span className="bg-background text-muted-foreground relative z-10 px-2">
|
||||
Or continue with
|
||||
<span className="text-muted-foreground relative z-10 px-2">
|
||||
Ou continuer avec
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full">
|
||||
@ -95,13 +94,13 @@ export function LoginForm({
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Login with Google
|
||||
Se connecter avec Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
Vous n'avez pas de compte ?{" "}
|
||||
<a href="/register" className="underline underline-offset-4">
|
||||
Sign up
|
||||
S'inscrire
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
@ -14,7 +14,7 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
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:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
|
||||
30
frontend/src/components/ui/checkbox.tsx
Normal file
30
frontend/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
@ -1,10 +1,11 @@
|
||||
import { GalleryVerticalEnd } from "lucide-react"
|
||||
import { LoginForm } from "@/components/login-form"
|
||||
import PastaVongole from "@/assets/pasta-alla-vongole-home.jpeg"
|
||||
|
||||
export default function Login() {
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
@ -21,7 +22,7 @@ export default function Login() {
|
||||
</div>
|
||||
<div className="relative hidden bg-muted lg:block">
|
||||
<img
|
||||
src="/placeholder.svg"
|
||||
src={PastaVongole}
|
||||
alt="Image"
|
||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
||||
/>
|
||||
|
||||
@ -1,90 +1,444 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight, ChefHat, Heart, Search } from "lucide-react";
|
||||
"use client"
|
||||
|
||||
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() {
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
{/* Hero section */}
|
||||
<section className="py-12 md:py-20">
|
||||
<div className="md:space-y-24 space-y-4 pb-16">
|
||||
<section className="py-12 md:py-20 flex justify-center">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
|
||||
Découvrez et partagez des recettes délicieuses
|
||||
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
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>
|
||||
<p className="max-w-[600px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
|
||||
Freedge vous aide à trouver des recettes adaptées à vos ingrédients disponibles et à partager vos créations culinaires avec la communauté.
|
||||
<p className="max-w-[600px] text-lg text-muted-foreground md:text-xl">
|
||||
Freedge analyse les ingrédients de votre frigo et vous propose des recettes personnalisées en quelques
|
||||
secondes. Fini le gaspillage alimentaire !
|
||||
</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">
|
||||
<Button className="flex gap-1">
|
||||
Explorer les recettes
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<Button className="bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white">
|
||||
Commencer maintenant
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
alt="Hero Image"
|
||||
className="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center sm:w-full"
|
||||
height="310"
|
||||
src="/images/hero-image.jpg"
|
||||
width="550"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="flex items-center justify-center relative"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* Features section */}
|
||||
<section className="py-12 md:py-20">
|
||||
{/* AI Features 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">
|
||||
<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">
|
||||
<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>
|
||||
<p className="max-w-[900px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
|
||||
Découvrez tout ce que Freedge peut faire pour vous
|
||||
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed">
|
||||
Notre intelligence artificielle transforme votre façon de cuisiner
|
||||
</p>
|
||||
</div>
|
||||
</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="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Search className="h-8 w-8 text-primary" />
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
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>
|
||||
<h3 className="text-xl font-bold">Recherche intelligente</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Trouvez des recettes en fonction des ingrédients que vous avez déjà chez vous.
|
||||
</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">
|
||||
<ChefHat className="h-8 w-8 text-primary" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="order-1 md:order-2 flex justify-center"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
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>
|
||||
<h3 className="text-xl font-bold">Créez et partagez</h3>
|
||||
<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>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { recipeService, Recipe } from "@/api/recipe";
|
||||
import { Button } from "@/components/ui/button";
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useParams, useNavigate } from "react-router-dom"
|
||||
import { recipeService, type Recipe } from "@/api/recipe"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
@ -11,132 +13,175 @@ import {
|
||||
ArrowLeft,
|
||||
Trash2,
|
||||
Edit,
|
||||
HeartOff
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
Printer,
|
||||
CheckCircle2,
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const [addingToFavorites, setAddingToFavorites] = useState(false);
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState("")
|
||||
const [isFavorite, setIsFavorite] = 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(() => {
|
||||
const fetchRecipeDetails = async () => {
|
||||
if (!id) return;
|
||||
if (!id) return
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setLoading(true)
|
||||
|
||||
// ✅ GET RECIPE DETAILS
|
||||
const recipeData = await recipeService.getRecipeById(id);
|
||||
const recipeData = await recipeService.getRecipeById(id)
|
||||
|
||||
// Optionnel : conversion ingrédients / instructions si nécessaire
|
||||
let ingredients = recipeData.ingredients;
|
||||
let ingredients = recipeData.ingredients
|
||||
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
|
||||
let instructions: string[] = [];
|
||||
let instructions: string[] = []
|
||||
if (recipeData.generatedRecipe) {
|
||||
instructions = recipeData.generatedRecipe
|
||||
.split("\n")
|
||||
.filter(item => item.trim() !== "");
|
||||
instructions = recipeData.generatedRecipe.split("\n").filter((item) => item.trim() !== "")
|
||||
}
|
||||
|
||||
setRecipe({
|
||||
...recipeData,
|
||||
ingredients,
|
||||
instructions, // Ajouter instructions au recipe
|
||||
});
|
||||
})
|
||||
|
||||
// ✅ GET FAVORITE RECIPES & CHECK IF FAVORITE
|
||||
try {
|
||||
const favorites = await recipeService.getFavoriteRecipes();
|
||||
setIsFavorite(favorites.some(fav => fav.id === id));
|
||||
const favorites = await recipeService.getFavoriteRecipes()
|
||||
setIsFavorite(favorites.some((fav) => fav.id === id))
|
||||
} catch (favError) {
|
||||
// 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) {
|
||||
console.error(err);
|
||||
setError("Impossible de charger les détails de la recette");
|
||||
console.error(err)
|
||||
setError("Impossible de charger les détails de la recette")
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fetchRecipeDetails();
|
||||
}, [id]);
|
||||
fetchRecipeDetails()
|
||||
|
||||
// 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 () => {
|
||||
if (!id || !recipe) return;
|
||||
if (!id || !recipe) return
|
||||
|
||||
try {
|
||||
setAddingToFavorites(true);
|
||||
setAddingToFavorites(true)
|
||||
|
||||
if (isFavorite) {
|
||||
// ✅ REMOVE FROM FAVORITES
|
||||
await recipeService.removeFromFavorites(id);
|
||||
setIsFavorite(false);
|
||||
await recipeService.removeFromFavorites(id)
|
||||
setIsFavorite(false)
|
||||
} else {
|
||||
// ✅ ADD TO FAVORITES
|
||||
await recipeService.addToFavorites(id);
|
||||
setIsFavorite(true);
|
||||
await recipeService.addToFavorites(id)
|
||||
setIsFavorite(true)
|
||||
}
|
||||
|
||||
} 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é
|
||||
} finally {
|
||||
setAddingToFavorites(false);
|
||||
setAddingToFavorites(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDeleteRecipe = async () => {
|
||||
if (!id) return;
|
||||
if (!window.confirm("Êtes-vous sûr de vouloir supprimer cette recette ?")) return;
|
||||
if (!id) return
|
||||
if (!window.confirm("Êtes-vous sûr de vouloir supprimer cette recette ?")) return
|
||||
|
||||
try {
|
||||
// ✅ DELETE RECIPE
|
||||
await recipeService.deleteRecipe(id);
|
||||
navigate("/recipes");
|
||||
await recipeService.deleteRecipe(id)
|
||||
navigate("/recipes")
|
||||
} 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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleShare = () => {
|
||||
if (!recipe) return;
|
||||
if (!recipe) return
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: recipe.title,
|
||||
text: recipe.description || "Découvrez cette recette !",
|
||||
url: window.location.href,
|
||||
});
|
||||
})
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
alert("Lien copié dans le presse-papier !");
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
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 ============
|
||||
// ========================================
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <RecipeDetailLoader />
|
||||
}
|
||||
|
||||
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">
|
||||
<h2 className="text-xl font-bold">Erreur</h2>
|
||||
<p>{error || "Recette introuvable"}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/recipes")}
|
||||
>
|
||||
<Button variant="outline" className="mt-4" onClick={() => navigate("/recipes")}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Retour aux recettes
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate total time
|
||||
const totalTime = (recipe.preparationTime || 0) + (recipe.cookingTime || 0)
|
||||
|
||||
// ========================================
|
||||
// === MAIN COMPONENT =====================
|
||||
// ========================================
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-fit"
|
||||
onClick={() => navigate("/recipes")}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Retour aux recettes
|
||||
</Button>
|
||||
<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">
|
||||
{/* Header */}
|
||||
<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">
|
||||
<div className="container px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" className="flex items-center gap-2" onClick={() => navigate("/recipes")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Retour aux recettes
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleShare}
|
||||
>
|
||||
<Share2 className="mr-2 h-4 w-4" />
|
||||
Partager
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="icon" className="rounded-full" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={isFavorite ? "default" : "outline"}
|
||||
onClick={handleToggleFavorite}
|
||||
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 variant="ghost" size="icon" className="rounded-full" onClick={handleShare}>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
</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"
|
||||
<Button
|
||||
variant={isFavorite ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className={`rounded-full ${isFavorite ? "bg-red-500 hover:bg-red-600 text-white" : ""}`}
|
||||
onClick={handleToggleFavorite}
|
||||
disabled={addingToFavorites}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
<Heart className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs defaultValue="ingredients" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="ingredients">Ingrédients</TabsTrigger>
|
||||
<TabsTrigger value="instructions">Instructions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ingredients" className="mt-6">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-bold">Ingrédients</h2>
|
||||
<ul className="space-y-2">
|
||||
{Array.isArray(recipe.ingredients) ? (
|
||||
recipe.ingredients.map((ingredient, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span className="mr-2 mt-1 h-2 w-2 rounded-full bg-primary"></span>
|
||||
<span>{ingredient}</span>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="flex items-start">
|
||||
<span className="mr-2 mt-1 h-2 w-2 rounded-full bg-primary"></span>
|
||||
<span>{recipe.ingredients}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{/* Main content */}
|
||||
<main className="container px-4 py-6" ref={contentRef}>
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}>
|
||||
{/* Recipe Hero */}
|
||||
<div className="relative rounded-xl overflow-hidden mb-8">
|
||||
<img
|
||||
src={recipe.imageUrl || "/placeholder.svg?height=400&width=800"}
|
||||
alt={recipe.title}
|
||||
className="w-full h-[300px] md:h-[400px] object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent flex flex-col justify-end p-6">
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{recipe.tags?.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="bg-white/20 text-white border-none">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">{recipe.title}</h1>
|
||||
<p className="text-white/80 max-w-2xl">{recipe.description || "Aucune description disponible"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="instructions" className="mt-6">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-bold">Instructions</h2>
|
||||
<ol className="space-y-4">
|
||||
{recipe.instructions && recipe.instructions.length > 0 ? (
|
||||
recipe.instructions.map((instruction, index) => (
|
||||
<li key={index} className="flex">
|
||||
<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">
|
||||
{index + 1}
|
||||
{/* Recipe Info Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<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">
|
||||
<Clock className="h-6 w-6 text-orange-500 mb-2" />
|
||||
<span className="text-sm text-muted-foreground">Préparation</span>
|
||||
<span className="text-lg font-bold">{recipe.preparationTime || 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">
|
||||
<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>
|
||||
<p className="pt-1">{instruction}</p>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="flex">
|
||||
<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">
|
||||
1
|
||||
</span>
|
||||
<p className="pt-1">{recipe.generatedRecipe}</p>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
Ingrédients
|
||||
</h2>
|
||||
|
||||
{totalTime > 0 && (
|
||||
<div className="mb-4 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<p className="text-sm flex items-center">
|
||||
<Clock className="h-4 w-4 mr-2 text-orange-500" />
|
||||
Temps total: <span className="font-bold ml-1">{totalTime} min</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -99,8 +99,8 @@ export default function RecipeForm() {
|
||||
}
|
||||
|
||||
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="p-4">
|
||||
<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-2 sm:p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="cursor-pointer flex items-center gap-2"
|
||||
@ -111,22 +111,27 @@ export default function RecipeForm() {
|
||||
</Button>
|
||||
</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 */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<KitchenIllustration />
|
||||
<div className="flex flex-col items-center text-center mb-4 sm:mb-8">
|
||||
<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>
|
||||
|
||||
<Card className="border-none shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Créer une nouvelle recette</CardTitle>
|
||||
<CardDescription>
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<CardTitle className="text-xl sm:text-2xl">Créer une nouvelle recette</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles, et nous générerons une
|
||||
recette pour vous.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
{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">
|
||||
{error}
|
||||
@ -151,34 +156,6 @@ export default function RecipeForm() {
|
||||
</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">
|
||||
Enregistrez-vous en listant les ingrédients que vous avez à disposition. Notre IA générera une
|
||||
recette adaptée à ces ingrédients.
|
||||
@ -197,7 +174,7 @@ export default function RecipeForm() {
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
<CardFooter className="p-4 sm:p-6 flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
@ -207,56 +184,56 @@ export default function RecipeForm() {
|
||||
Annuler
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
onClick={handleSubmit}
|
||||
disabled={!audioFile || loading || recordingStatus === "processing" || isRecording}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Création en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Créer la recette
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{!audioFile && !isRecording && recordingStatus !== "processing" && !loading && (
|
||||
<Button
|
||||
variant="default"
|
||||
className="cursor-pointer"
|
||||
onClick={startRecording}
|
||||
>
|
||||
<Mic className="mr-2 h-4 w-4" />
|
||||
Commencer l'enregistrement
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="cursor-pointer"
|
||||
onClick={stopRecording}
|
||||
>
|
||||
<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>
|
||||
</Card>
|
||||
</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 >
|
||||
)
|
||||
}
|
||||
|
||||
@ -89,7 +89,7 @@ export default function RecipeList() {
|
||||
</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}>
|
||||
<TabsList className="grid grid-cols-4 w-full sm:w-auto">
|
||||
<TabsTrigger value="all">Toutes</TabsTrigger>
|
||||
@ -116,7 +116,7 @@ export default function RecipeList() {
|
||||
<EmptyRecipes onCreateRecipe={handleCreateRecipe} />
|
||||
) : (
|
||||
<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 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ staggerChildren: 0.1 }}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user