initial commit
This commit is contained in:
17
src/components/CallToAction.jsx
Normal file
17
src/components/CallToAction.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const CallToAction = () => {
|
||||
return (
|
||||
<motion.h1
|
||||
className='text-xl font-bold text-white leading-8 w-full'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
>
|
||||
Let's turn your ideas into reality
|
||||
</motion.h1>
|
||||
);
|
||||
};
|
||||
|
||||
export default CallToAction;
|
||||
23
src/components/Footer.jsx
Normal file
23
src/components/Footer.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Music2, Heart } from 'lucide-react';
|
||||
|
||||
const Footer = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return (
|
||||
<footer className="bg-background/80 backdrop-blur-md shadow-t-lg py-8 text-center text-muted-foreground">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex justify-center items-center mb-4">
|
||||
<Music2 className="h-6 w-6 text-primary mr-2" />
|
||||
<p className="text-sm">
|
||||
Dites le en chanson © {currentYear}. Créé avec <Heart className="inline h-4 w-4 text-accent" /> par vous !
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs">
|
||||
Transformez vos histoires en mélodies inoubliables.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
31
src/components/HeroImage.jsx
Normal file
31
src/components/HeroImage.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
const HeroImage = () => {
|
||||
return (
|
||||
<div className="relative w-8 h-8 shrink-0" data-name="ic-sparkles">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-full h-full"
|
||||
>
|
||||
<path
|
||||
d="M11.787 9.5356C11.5053 8.82147 10.4947 8.82147 10.213 9.5356L8.742 13.2654C8.65601 13.4834 8.48343 13.656 8.2654 13.742L4.5356 15.213C3.82147 15.4947 3.82147 16.5053 4.5356 16.787L8.2654 18.258C8.48343 18.344 8.65601 18.5166 8.742 18.7346L10.213 22.4644C10.4947 23.1785 11.5053 23.1785 11.787 22.4644L13.258 18.7346C13.344 18.5166 13.5166 18.344 13.7346 18.258L17.4644 16.787C18.1785 16.5053 18.1785 15.4947 17.4644 15.213L13.7346 13.742C13.5166 13.656 13.344 13.4834 13.258 13.2654L11.787 9.5356Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M23.5621 2.38257C23.361 1.87248 22.639 1.87248 22.4379 2.38257L21.3871 5.04671C21.3257 5.20245 21.2024 5.32572 21.0467 5.38714L18.3826 6.43787C17.8725 6.63904 17.8725 7.36096 18.3826 7.56214L21.0467 8.61286C21.2024 8.67428 21.3257 8.79755 21.3871 8.95329L22.4379 11.6174C22.639 12.1275 23.361 12.1275 23.5621 11.6174L24.6129 8.95329C24.6743 8.79755 24.7976 8.67428 24.9533 8.61286L27.6174 7.56214C28.1275 7.36096 28.1275 6.63904 27.6174 6.43787L24.9533 5.38714C24.7976 5.32572 24.6743 5.20245 24.6129 5.04671L23.5621 2.38257Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M23.3373 22.2295C23.2166 21.9235 22.7834 21.9235 22.6627 22.2295L22.0323 23.828C21.9954 23.9215 21.9215 23.9954 21.828 24.0323L20.2295 24.6627C19.9235 24.7834 19.9235 25.2166 20.2295 25.3373L21.828 25.9677C21.9215 26.0046 21.9954 26.0785 22.0323 26.172L22.6627 27.7705C22.7834 28.0765 23.2166 28.0765 23.3373 27.7705L23.9677 26.172C24.0046 26.0785 24.0785 26.0046 24.172 25.9677L25.7705 25.3373C26.0765 25.2166 26.0765 24.7834 25.7705 24.6627L24.172 24.0323C24.0785 23.9954 24.0046 23.9215 23.9677 23.828L23.3373 22.2295Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroImage;
|
||||
108
src/components/ImageCarousel.jsx
Normal file
108
src/components/ImageCarousel.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const carouselImages = [
|
||||
{ src: "https://images.unsplash.com/photo-1470225620780-dba8ba36b745", alt: "DJ mixant de la musique lors d'un concert vibrant" },
|
||||
{ src: "https://images.unsplash.com/photo-1511379938547-c1f69419868d", alt: "Gros plan sur des écouteurs posés sur un clavier d'ordinateur portable" },
|
||||
{ src: "https://images.unsplash.com/photo-1487180144351-b8472da7d491", alt: "Personne jouant de la guitare acoustique avec un effet de lumière bokeh" },
|
||||
{ src: "https://images.unsplash.com/photo-1507838153414-b4b713384a76", alt: "Piano à queue dans une pièce faiblement éclairée avec des partitions" },
|
||||
{ src: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f", alt: "Microphone de studio vintage sur fond sombre" }
|
||||
];
|
||||
|
||||
const variants = {
|
||||
enter: (direction) => {
|
||||
return {
|
||||
x: direction > 0 ? 1000 : -1000,
|
||||
opacity: 0
|
||||
};
|
||||
},
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1
|
||||
},
|
||||
exit: (direction) => {
|
||||
return {
|
||||
zIndex: 0,
|
||||
x: direction < 0 ? 1000 : -1000,
|
||||
opacity: 0
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const swipeConfidenceThreshold = 10000;
|
||||
const swipePower = (offset, velocity) => {
|
||||
return Math.abs(offset) * velocity;
|
||||
};
|
||||
|
||||
const ImageCarousel = () => {
|
||||
const [[page, direction], setPage] = useState([0, 0]);
|
||||
const imageIndex = ((page % carouselImages.length) + carouselImages.length) % carouselImages.length;
|
||||
|
||||
const paginate = (newDirection) => {
|
||||
setPage([page + newDirection, newDirection]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
paginate(1);
|
||||
}, 5000); // Change image every 5 seconds
|
||||
return () => clearInterval(timer);
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-4xl mx-auto h-72 md:h-96 overflow-hidden rounded-xl shadow-2xl">
|
||||
<AnimatePresence initial={false} custom={direction}>
|
||||
<motion.img
|
||||
key={page}
|
||||
src={carouselImages[imageIndex].src}
|
||||
alt={carouselImages[imageIndex].alt}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 }
|
||||
}}
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={1}
|
||||
onDragEnd={(e, { offset, velocity }) => {
|
||||
const swipe = swipePower(offset.x, velocity.x);
|
||||
if (swipe < -swipeConfidenceThreshold) {
|
||||
paginate(1);
|
||||
} else if (swipe > swipeConfidenceThreshold) {
|
||||
paginate(-1);
|
||||
}
|
||||
}}
|
||||
className="absolute w-full h-full object-cover"
|
||||
/>
|
||||
</AnimatePresence>
|
||||
<div className="absolute top-1/2 left-2 transform -translate-y-1/2 z-10">
|
||||
<Button variant="outline" size="icon" onClick={() => paginate(-1)} className="rounded-full bg-black/30 hover:bg-black/50 text-white border-none">
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute top-1/2 right-2 transform -translate-y-1/2 z-10">
|
||||
<Button variant="outline" size="icon" onClick={() => paginate(1)} className="rounded-full bg-black/30 hover:bg-black/50 text-white border-none">
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex space-x-2 z-10">
|
||||
{carouselImages.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setPage([i, i > imageIndex ? 1 : -1])}
|
||||
className={`w-3 h-3 rounded-full ${i === imageIndex ? 'bg-primary' : 'bg-white/50 hover:bg-white/80'} transition-colors`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageCarousel;
|
||||
17
src/components/Layout.jsx
Normal file
17
src/components/Layout.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import Navbar from '@/components/Navbar';
|
||||
import Footer from '@/components/Footer';
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background to-secondary/30 dark:from-background dark:to-secondary/10">
|
||||
<Navbar />
|
||||
<main className="flex-grow container mx-auto px-4 py-8">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
86
src/components/Navbar.jsx
Normal file
86
src/components/Navbar.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Music2, Menu, X, Sparkles } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const Navbar = () => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuOpen(!mobileMenuOpen);
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", text: "Accueil" },
|
||||
{ to: "/creations", text: "Créations" },
|
||||
{ to: "/commander", text: "Commander une chanson", primary: true },
|
||||
];
|
||||
|
||||
const mobileMenuVariants = {
|
||||
hidden: { opacity: 0, y: -20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.3 } },
|
||||
exit: { opacity: 0, y: -20, transition: { duration: 0.2 } },
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-background/80 backdrop-blur-md shadow-lg sticky top-0 z-50">
|
||||
<div className="container mx-auto px-4 py-3 flex justify-between items-center">
|
||||
<Link to="/" className="flex items-center space-x-2 text-2xl font-bold">
|
||||
<Music2 className="h-8 w-8 text-primary" />
|
||||
<span className="gradient-text">Dites le en chanson</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex space-x-2 items-center">
|
||||
<Button variant="ghost" asChild>
|
||||
<Link to="/">Accueil</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link to="/creations" className="flex items-center">
|
||||
<Sparkles className="h-4 w-4 mr-2 text-yellow-400" />
|
||||
Créations
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity">
|
||||
<Link to="/commander">Commander une chanson</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden">
|
||||
<Button variant="ghost" onClick={toggleMobileMenu} size="icon">
|
||||
{mobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<motion.div
|
||||
variants={mobileMenuVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="md:hidden absolute top-full left-0 right-0 bg-background/95 backdrop-blur-md shadow-lg pb-4 border-t border-border"
|
||||
>
|
||||
<div className="container mx-auto px-4 py-2 flex flex-col items-end space-y-2">
|
||||
<Button variant="ghost" asChild className="w-full justify-end" onClick={toggleMobileMenu}>
|
||||
<Link to="/">Accueil</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" asChild className="w-full justify-end" onClick={toggleMobileMenu}>
|
||||
<Link to="/creations" className="flex items-center justify-end w-full">
|
||||
<Sparkles className="h-4 w-4 mr-2 text-yellow-400" />
|
||||
Créations
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="w-full justify-end bg-gradient-to-r from-primary to-accent hover:opacity-90 transition-opacity" onClick={toggleMobileMenu}>
|
||||
<Link to="/commander">Commander une chanson</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
17
src/components/WelcomeMessage.jsx
Normal file
17
src/components/WelcomeMessage.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const WelcomeMessage = () => {
|
||||
return (
|
||||
<motion.p
|
||||
className='text-sm text-white leading-5 w-full'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.8 }}
|
||||
>
|
||||
Write in the chat what you want to create.
|
||||
</motion.p>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeMessage;
|
||||
77
src/components/order/Step0ProductSelection.jsx
Normal file
77
src/components/order/Step0ProductSelection.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { products } from '@/config/orderFormConfig';
|
||||
import { CheckCircle, Tag } from 'lucide-react';
|
||||
|
||||
const Step0ProductSelection = ({ selectedProduct, onProductSelect, error }) => {
|
||||
return (
|
||||
<motion.div
|
||||
key="productSelection"
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h2 className="text-2xl font-semibold text-center gradient-text">Choisissez votre création</h2>
|
||||
{error && <p className="text-sm text-center text-destructive">{error}</p>}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{products.map((product) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<Card
|
||||
onClick={() => onProductSelect(product.id)}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all duration-300 ease-in-out h-full flex flex-col overflow-hidden',
|
||||
selectedProduct === product.id
|
||||
? 'ring-2 ring-primary shadow-primary/50 scale-105 border-primary'
|
||||
: 'hover:shadow-lg',
|
||||
error && !selectedProduct ? 'ring-2 ring-destructive border-destructive' : ''
|
||||
)}
|
||||
>
|
||||
<CardHeader className="relative p-0">
|
||||
<img
|
||||
alt={product.name}
|
||||
src={product.imageUrl || "https://images.unsplash.com/photo-1595872018818-97555653a011"}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
{selectedProduct === product.id && (
|
||||
<div className="absolute top-2 right-2 bg-primary rounded-full p-1 z-10">
|
||||
<CheckCircle className="h-6 w-6 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{product.promotionPrice && (
|
||||
<div className="absolute top-2 left-2 bg-accent text-accent-foreground px-2 py-1 rounded-md text-xs font-semibold flex items-center z-10">
|
||||
<Tag className="h-3 w-3 mr-1" /> PROMO
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 flex-grow flex flex-col justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl mb-2 !bg-none !text-foreground">{product.name}</CardTitle>
|
||||
<CardDescription className="text-sm mb-3">{product.description}</CardDescription>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
{product.promotionPrice ? (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-2xl font-bold text-primary">{product.promotionPrice.toFixed(2)} €</p>
|
||||
<p className="text-sm line-through text-muted-foreground">{product.price.toFixed(2)} €</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-primary">{product.price.toFixed(2)} €</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step0ProductSelection;
|
||||
62
src/components/order/Step1Content.jsx
Normal file
62
src/components/order/Step1Content.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { languages } from '@/config/orderFormConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Step1Content = ({ formData, handleChange, handleSelectChange, errors }) => {
|
||||
return (
|
||||
<motion.div
|
||||
key="songInfo"
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="recipientName" className={cn("text-lg", errors.recipientName && "text-destructive")}>Prénom ou surnom de la personne à qui vous offrez la chanson*</Label>
|
||||
<Input id="recipientName" name="recipientName" value={formData.recipientName} onChange={handleChange} placeholder="Ex: Chloé, Mon amour, Papi..." className={cn("mt-2 text-base p-3", errors.recipientName && "border-destructive focus-visible:ring-destructive")}/>
|
||||
{errors.recipientName && <p className="text-sm text-destructive mt-1">{errors.recipientName}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="songForWhom" className={cn("text-lg", errors.songForWhom && "text-destructive")}>C'est pour qui ?*</Label>
|
||||
<Input id="songForWhom" name="songForWhom" value={formData.songForWhom} onChange={handleChange} placeholder="Ex: Mon/ma partenaire, un(e) ami(e), ma famille..." className={cn("mt-2 text-base p-3", errors.songForWhom && "border-destructive focus-visible:ring-destructive")}/>
|
||||
{errors.songForWhom && <p className="text-sm text-destructive mt-1">{errors.songForWhom}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="occasion" className="text-lg">Occasion (si applicable)</Label>
|
||||
<Input id="occasion" name="occasion" value={formData.occasion} onChange={handleChange} placeholder="Ex: Anniversaire, mariage, déclaration..." className="mt-2 text-base p-3"/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="language" className={cn("text-lg", errors.language && "text-destructive")}>Langue de la chanson*</Label>
|
||||
<Select name="language" onValueChange={(value) => handleSelectChange('language', value)} value={formData.language}>
|
||||
<SelectTrigger className={cn("w-full mt-2 text-base p-3 h-auto", errors.language && "border-destructive focus:ring-destructive")}>
|
||||
<SelectValue placeholder="Sélectionnez une langue" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map(lang => <SelectItem key={lang} value={lang}>{lang}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.language && <p className="text-sm text-destructive mt-1">{errors.language}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="anecdote1" className={cn("text-lg", errors.anecdote1 && "text-destructive")}>Anecdote 1 - Donnez nous quelques infos pour adapter le texte !*</Label>
|
||||
<Textarea id="anecdote1" name="anecdote1" value={formData.anecdote1} onChange={handleChange} placeholder="Un souvenir marquant, une qualité spéciale, un rêve partagé..." rows={3} className={cn("mt-2 text-base p-3", errors.anecdote1 && "border-destructive focus-visible:ring-destructive")}/>
|
||||
{errors.anecdote1 && <p className="text-sm text-destructive mt-1">{errors.anecdote1}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="anecdote2" className="text-lg">Anecdote 2 - Plus d'infos pour enrichir le texte !</Label>
|
||||
<Textarea id="anecdote2" name="anecdote2" value={formData.anecdote2} onChange={handleChange} placeholder="Un détail amusant, une passion, un lieu important..." rows={3} className="mt-2 text-base p-3"/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="anecdote3" className="text-lg">Anecdote 3 - Encore une info pour une chanson parfaite !</Label>
|
||||
<Textarea id="anecdote3" name="anecdote3" value={formData.anecdote3} onChange={handleChange} placeholder="Un message secret, une private joke, un souhait..." rows={3} className="mt-2 text-base p-3"/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step1Content;
|
||||
65
src/components/order/Step2MusicalChoices.jsx
Normal file
65
src/components/order/Step2MusicalChoices.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { voiceGenders, musicalStyles, moods } from '@/config/orderFormConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Step2MusicalChoices = ({ formData, handleSelectChange, handleRadioGroupChange, errors }) => {
|
||||
return (
|
||||
<motion.div
|
||||
key="musicalChoices"
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div>
|
||||
<Label className={cn("text-lg mb-2 block", errors.voiceGender && "text-destructive")}>Genre de la voix*</Label>
|
||||
<RadioGroup
|
||||
name="voiceGender"
|
||||
value={formData.voiceGender}
|
||||
onValueChange={(value) => handleRadioGroupChange('voiceGender', value)}
|
||||
className={cn("flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-4", errors.voiceGender && "rounded-md border border-destructive p-2")}
|
||||
>
|
||||
{voiceGenders.map((gender) => (
|
||||
<div key={gender} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={gender} id={`voice-${gender.toLowerCase().replace(' ', '-')}`} />
|
||||
<Label htmlFor={`voice-${gender.toLowerCase().replace(' ', '-')}`} className="text-base font-normal">{gender}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{errors.voiceGender && <p className="text-sm text-destructive mt-1">{errors.voiceGender}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="musicalStyle" className={cn("text-lg", errors.musicalStyle && "text-destructive")}>Style musical*</Label>
|
||||
<Select name="musicalStyle" onValueChange={(value) => handleSelectChange('musicalStyle', value)} value={formData.musicalStyle}>
|
||||
<SelectTrigger className={cn("w-full mt-2 text-base p-3 h-auto", errors.musicalStyle && "border-destructive focus:ring-destructive")}>
|
||||
<SelectValue placeholder="Sélectionnez un style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{musicalStyles.map(style => <SelectItem key={style} value={style}>{style}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.musicalStyle && <p className="text-sm text-destructive mt-1">{errors.musicalStyle}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="mood" className={cn("text-lg", errors.mood && "text-destructive")}>Ambiance*</Label>
|
||||
<Select name="mood" onValueChange={(value) => handleSelectChange('mood', value)} value={formData.mood}>
|
||||
<SelectTrigger className={cn("w-full mt-2 text-base p-3 h-auto", errors.mood && "border-destructive focus:ring-destructive")}>
|
||||
<SelectValue placeholder="Sélectionnez une ambiance" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{moods.map(mood => <SelectItem key={mood} value={mood}>{mood}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.mood && <p className="text-sm text-destructive mt-1">{errors.mood}</p>}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step2MusicalChoices;
|
||||
70
src/components/order/Step3Review.jsx
Normal file
70
src/components/order/Step3Review.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { products } from '@/config/orderFormConfig';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
|
||||
const Step3Review = ({ formData }) => {
|
||||
const selectedProductDetails = products.find(p => p.id === formData.selectedProduct);
|
||||
const priceToDisplay = selectedProductDetails ? (selectedProductDetails.promotionPrice || selectedProductDetails.price) : 0;
|
||||
|
||||
const formatKey = (key) => {
|
||||
const translations = {
|
||||
selectedProduct: "Produit Sélectionné",
|
||||
recipientName: "Prénom/Surnom du destinataire",
|
||||
songForWhom: "Pour qui",
|
||||
occasion: "Occasion",
|
||||
language: "Langue",
|
||||
anecdote1: "Anecdote 1",
|
||||
anecdote2: "Anecdote 2",
|
||||
anecdote3: "Anecdote 3",
|
||||
voiceGender: "Genre de la voix",
|
||||
musicalStyle: "Style musical",
|
||||
mood: "Ambiance",
|
||||
};
|
||||
return translations[key] || key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
|
||||
};
|
||||
|
||||
const displayValue = (key, value) => {
|
||||
if (key === 'selectedProduct') {
|
||||
return selectedProductDetails?.name || 'Non sélectionné';
|
||||
}
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) return 'Non spécifié';
|
||||
return Array.isArray(value) ? value.join(', ') : value.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="review"
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<h3 className="text-2xl font-semibold gradient-text">Vérifiez votre commande</h3>
|
||||
<div className="space-y-3 text-sm p-4 border rounded-lg bg-secondary/50 dark:bg-secondary/20">
|
||||
{Object.entries(formData).map(([key, value]) => {
|
||||
if (key === 'price' || key === 'productName' || key === 'stripePriceId') return null;
|
||||
if ((!value && key !== 'selectedProduct') && !(Array.isArray(value) && value.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex justify-between items-start">
|
||||
<span className="font-medium text-muted-foreground">{formatKey(key)}:</span>
|
||||
<span className="text-right max-w-[60%] break-words pl-2">{displayValue(key, value)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-lg font-bold text-center">Prix Total : {priceToDisplay.toFixed(2)} €</p>
|
||||
<div className="text-sm text-muted-foreground p-4 border border-dashed border-primary/50 rounded-lg bg-primary/10 dark:bg-primary/5">
|
||||
<div className="flex items-center font-semibold text-primary mb-2">
|
||||
<CreditCard className="h-5 w-5 mr-2" />
|
||||
Paiement Sécurisé
|
||||
</div>
|
||||
<p>Nous utilisons Stripe pour un paiement sécurisé. Vous serez redirigé vers leur plateforme pour finaliser votre commande. Après le paiement de votre commande, vous recevrez un email de confirmation.</p>
|
||||
<p className="mt-2">Après validation de votre commande, vous recevrez un lien par email pour télécharger votre création.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step3Review;
|
||||
47
src/components/ui/button.jsx
Normal file
47
src/components/ui/button.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
60
src/components/ui/card.jsx
Normal file
60
src/components/ui/card.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card text-card-foreground shadow-lg hover:shadow-xl transition-shadow duration-300 ease-in-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight gradient-text',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
19
src/components/ui/input.jsx
Normal file
19
src/components/ui/input.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
19
src/components/ui/label.jsx
Normal file
19
src/components/ui/label.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
22
src/components/ui/progress.jsx
Normal file
22
src/components/ui/progress.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-gradient-to-r from-primary to-accent transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
36
src/components/ui/radio-group.jsx
Normal file
36
src/components/ui/radio-group.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react"
|
||||
import { Check } from "lucide-react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Check className="h-3.5 w-3.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
126
src/components/ui/select.jsx
Normal file
126
src/components/ui/select.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
18
src/components/ui/textarea.jsx
Normal file
18
src/components/ui/textarea.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
101
src/components/ui/toast.jsx
Normal file
101
src/components/ui/toast.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background border',
|
||||
destructive:
|
||||
'group destructive border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-destructive/30 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
export {
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
};
|
||||
34
src/components/ui/toaster.jsx
Normal file
34
src/components/ui/toaster.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from '@/components/ui/toast';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import React from 'react';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
103
src/components/ui/use-toast.js
Normal file
103
src/components/ui/use-toast.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
|
||||
let count = 0
|
||||
function generateId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
const toastStore = {
|
||||
state: {
|
||||
toasts: [],
|
||||
},
|
||||
listeners: [],
|
||||
|
||||
getState: () => toastStore.state,
|
||||
|
||||
setState: (nextState) => {
|
||||
if (typeof nextState === 'function') {
|
||||
toastStore.state = nextState(toastStore.state)
|
||||
} else {
|
||||
toastStore.state = { ...toastStore.state, ...nextState }
|
||||
}
|
||||
|
||||
toastStore.listeners.forEach(listener => listener(toastStore.state))
|
||||
},
|
||||
|
||||
subscribe: (listener) => {
|
||||
toastStore.listeners.push(listener)
|
||||
return () => {
|
||||
toastStore.listeners = toastStore.listeners.filter(l => l !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toast = ({ ...props }) => {
|
||||
const id = generateId()
|
||||
|
||||
const update = (props) =>
|
||||
toastStore.setState((state) => ({
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === id ? { ...t, ...props } : t
|
||||
),
|
||||
}))
|
||||
|
||||
const dismiss = () => toastStore.setState((state) => ({
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}))
|
||||
|
||||
toastStore.setState((state) => ({
|
||||
...state,
|
||||
toasts: [
|
||||
{ ...props, id, dismiss },
|
||||
...state.toasts,
|
||||
].slice(0, TOAST_LIMIT),
|
||||
}))
|
||||
|
||||
return {
|
||||
id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [state, setState] = useState(toastStore.getState())
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = toastStore.subscribe((state) => {
|
||||
setState(state)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts = []
|
||||
|
||||
state.toasts.forEach((toast) => {
|
||||
if (toast.duration === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toast.dismiss()
|
||||
}, toast.duration || 5000)
|
||||
|
||||
timeouts.push(timeout)
|
||||
})
|
||||
|
||||
return () => {
|
||||
timeouts.forEach((timeout) => clearTimeout(timeout))
|
||||
}
|
||||
}, [state.toasts])
|
||||
|
||||
return {
|
||||
toast,
|
||||
toasts: state.toasts,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user