Add claude.md + beautiful smooth animations on scroll

This commit is contained in:
Thomas Fransolet 2026-03-25 17:39:14 +01:00
parent 75b8e6b7b8
commit cfa70c4ee0
3 changed files with 333 additions and 108 deletions

34
CLAUDE.md Normal file
View File

@ -0,0 +1,34 @@
# unov-landing
Site vitrine de la société **Unov** (qui développe MyInfoMate).
## Stack
- Next.js 16 / React 19 / TypeScript
- Tailwind CSS v4
- Babel React Compiler
## Structure
```
src/
├── app/
│ ├── layout.tsx # Layout global, font "Public Sans"
│ ├── page.tsx # Page d'accueil
│ ├── globals.css # Thème Tailwind v4
│ └── portfolio/
│ └── page.tsx # Page portfolio
└── data/
└── stitch-images.ts # Assets Stitch
```
## Thème
- Couleur primaire : `#309CB0` (teal), variantes `#72B9C4`, `#ABD3DC`
- Support dark mode
- Utilities custom : `glass-card`, `material-symbols-outlined`
- Animation : `marquee` (30s linear infinite)
## Commandes
```bash
npm run dev # Dev local
npm run build # Build production
npm run lint # ESLint
```

View File

@ -1,34 +1,109 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { resolveImage } from "@/data/stitch-images"; import { resolveImage } from "@/data/stitch-images";
function Reveal({ children, delay = 0, className = '' }: { children: React.ReactNode; delay?: number; className?: string }) {
const ref = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setVisible(true); },
{ threshold: 0.12 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={className}
style={{
opacity: visible ? 1 : 0,
transform: visible ? 'translateY(0)' : 'translateY(22px)',
transition: `opacity 0.55s ease-out ${delay}ms, transform 0.55s ease-out ${delay}ms`,
}}
>
{children}
</div>
);
}
export default function Home() { export default function Home() {
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [navDark, setNavDark] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<any>(null); const [selectedProject, setSelectedProject] = useState<any>(null);
const [showAllProjects, setShowAllProjects] = useState(false); const [showAllProjects, setShowAllProjects] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0); const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [formData, setFormData] = useState({ nom: '', email: '', service: 'Software Development', message: '' });
const [formStatus, setFormStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
setIsScrolled(window.scrollY > 20); setIsScrolled(window.scrollY > 20);
const lightSections = ['services', 'technologies', 'portfolio', 'contact'];
const navH = 80;
const onLight = lightSections.some((id) => {
const el = document.getElementById(id);
if (!el) return false;
const { top, bottom } = el.getBoundingClientRect();
return top <= navH && bottom > navH;
});
setNavDark(onLight);
}; };
window.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, []); }, []);
useEffect(() => {
document.body.style.overflow = isMenuOpen ? 'hidden' : 'auto';
return () => { document.body.style.overflow = 'auto'; };
}, [isMenuOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormStatus('loading');
try {
const response = await fetch("https://formspree.io/f/xnjgewyk", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(formData),
});
if (response.ok) {
setFormStatus('success');
setFormData({ nom: '', email: '', service: 'Software Development', message: '' });
} else {
setFormStatus('error');
}
} catch {
setFormStatus('error');
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
return ( return (
<div className="min-h-screen bg-white text-slate-900"> <div className="min-h-screen bg-white text-slate-900">
{/* Navigation */} {/* Navigation */}
<nav <nav
className={`fixed top-0 w-full z-50 transition-all duration-300 ${isScrolled className={`fixed top-0 w-full z-50 transition-[padding] duration-300 ease-in-out ${isScrolled || navDark ? 'py-4' : 'py-6'}`}
? "bg-background-light/80 dark:bg-background-dark/80 backdrop-blur-md py-4 shadow-sm"
: "bg-transparent py-6"
}`}
> >
<div className="max-w-7xl mx-auto px-6 flex items-center justify-between"> {/* Subtle overlay (hero scroll) */}
<div className={`absolute inset-0 bg-black/20 backdrop-blur-sm transition-opacity duration-500 ease-in-out ${isScrolled && !navDark ? 'opacity-100' : 'opacity-0'}`} />
{/* Dark overlay */}
<div className={`absolute inset-0 bg-[#0A0F1C]/95 backdrop-blur-md shadow-lg shadow-black/20 transition-opacity duration-500 ease-in-out ${navDark ? 'opacity-100' : 'opacity-0'}`} />
<div className="relative z-10 max-w-7xl mx-auto px-6 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Image <Image
src={resolveImage("{{DATA:IMAGE:IMAGE_2}}")} src={resolveImage("{{DATA:IMAGE:IMAGE_2}}")}
@ -41,19 +116,19 @@ export default function Home() {
<div className="hidden md:flex items-center gap-10"> <div className="hidden md:flex items-center gap-10">
<a <a
href="#services" href="#services"
className="text-sm font-medium hover:text-primary transition-colors uppercase tracking-widest" className={`text-sm font-medium hover:text-primary transition-colors duration-500 uppercase tracking-widest ${navDark ? 'text-white/80' : 'text-white/90'}`}
> >
Services Services
</a> </a>
<a <a
href="#portfolio" href="#portfolio"
className="text-sm font-medium hover:text-primary transition-colors uppercase tracking-widest" className={`text-sm font-medium hover:text-primary transition-colors duration-500 uppercase tracking-widest ${navDark ? 'text-white/80' : 'text-white/90'}`}
> >
Portfolio Portfolio
</a> </a>
<a <a
href="#technologies" href="#technologies"
className="text-sm font-medium hover:text-primary transition-colors uppercase tracking-widest" className={`text-sm font-medium hover:text-primary transition-colors duration-500 uppercase tracking-widest ${navDark ? 'text-white/80' : 'text-white/90'}`}
> >
Technologies Technologies
</a> </a>
@ -64,12 +139,53 @@ export default function Home() {
Let's Talk Let's Talk
</button> </button>
</div> </div>
<button className="md:hidden"> <button className="md:hidden" onClick={() => setIsMenuOpen(true)}>
<span className="material-symbols-outlined">menu</span> <span className="material-symbols-outlined">menu</span>
</button> </button>
</div> </div>
</nav> </nav>
{/* Mobile Menu Backdrop */}
<div
className={`md:hidden fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[150] transition-opacity duration-300 ${isMenuOpen ? 'opacity-100 visible' : 'opacity-0 invisible pointer-events-none'}`}
onClick={() => setIsMenuOpen(false)}
/>
{/* Mobile Menu Panel */}
<div className={`md:hidden fixed inset-y-0 right-0 w-[300px] h-screen bg-white z-[200] shadow-2xl transition-transform duration-500 ease-in-out transform ${isMenuOpen ? 'translate-x-0' : 'translate-x-full'}`}>
<div className="flex flex-col h-full">
<div className="flex justify-end p-6 border-b border-slate-50">
<button onClick={() => setIsMenuOpen(false)} className="w-10 h-10 flex items-center justify-center text-slate-600 hover:text-slate-900 transition-colors">
<span className="material-symbols-outlined text-3xl">close</span>
</button>
</div>
<nav className="flex-1 flex flex-col p-8 gap-6">
{[
{ label: 'Services', href: '#services' },
{ label: 'Portfolio', href: '#portfolio' },
{ label: 'Technologies', href: '#technologies' },
].map((item) => (
<a
key={item.label}
href={item.href}
className="text-sm font-bold tracking-[0.2em] uppercase text-slate-900 hover:text-primary transition-colors py-3 border-b border-slate-50"
onClick={() => setIsMenuOpen(false)}
>
{item.label}
</a>
))}
<div className="mt-auto pt-8">
<button
onClick={() => { document.getElementById("contact")?.scrollIntoView({ behavior: "smooth" }); setIsMenuOpen(false); }}
className="w-full py-4 bg-primary text-white font-black text-xs tracking-widest rounded-xl shadow-xl shadow-primary/20 active:scale-95 transition-all"
>
Let's Talk
</button>
</div>
</nav>
</div>
</div>
{/* Hero Section */} {/* Hero Section */}
<section className="relative h-screen flex items-center pt-20 overflow-hidden"> <section className="relative h-screen flex items-center pt-20 overflow-hidden">
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
@ -121,9 +237,9 @@ export default function Home() {
</section> </section>
{/* Services Section */} {/* Services Section */}
<section className="bg-white dark:bg-background-dark/50 py-32" id="services"> <section className="bg-white dark:bg-background-dark/50 py-20 lg:py-28" id="services">
<div className="container mx-auto px-6"> <div className="container mx-auto px-6">
<div className="flex flex-col md:flex-row justify-between items-start mb-20 gap-8"> <Reveal className="flex flex-col md:flex-row justify-between items-start mb-12 lg:mb-16 gap-8">
<div className="max-w-xl"> <div className="max-w-xl">
<h2 className="text-primary font-bold uppercase tracking-widest text-xs mb-4"> <h2 className="text-primary font-bold uppercase tracking-widest text-xs mb-4">
Expertise Expertise
@ -136,7 +252,7 @@ export default function Home() {
Des solutions sur-mesure conçues avec une rigueur mathématique et Des solutions sur-mesure conçues avec une rigueur mathématique et
une vision centrée utilisateur. une vision centrée utilisateur.
</p> </p>
</div> </Reveal>
<div className="grid grid-cols-1 md:grid-cols-3 gap-10"> <div className="grid grid-cols-1 md:grid-cols-3 gap-10">
{/* Service Cards */} {/* Service Cards */}
@ -160,8 +276,8 @@ export default function Home() {
img: resolveImage("SERVICE_3D"), img: resolveImage("SERVICE_3D"),
}, },
].map((service, idx) => ( ].map((service, idx) => (
<Reveal key={idx} delay={idx * 80}>
<div <div
key={idx}
className="group bg-white dark:bg-white/5 rounded-[2.5rem] p-10 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.05)] hover:shadow-2xl transition-all duration-500 border border-slate-100 dark:border-white/10 hover:-translate-y-2" className="group bg-white dark:bg-white/5 rounded-[2.5rem] p-10 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.05)] hover:shadow-2xl transition-all duration-500 border border-slate-100 dark:border-white/10 hover:-translate-y-2"
> >
<div className="size-16 bg-primary/10 rounded-2xl flex items-center justify-center mb-8 group-hover:bg-primary group-hover:text-white transition-colors duration-500"> <div className="size-16 bg-primary/10 rounded-2xl flex items-center justify-center mb-8 group-hover:bg-primary group-hover:text-white transition-colors duration-500">
@ -183,6 +299,7 @@ export default function Home() {
/> />
</div> </div>
</div> </div>
</Reveal>
))} ))}
</div> </div>
</div> </div>
@ -190,41 +307,35 @@ export default function Home() {
{/* Technologies Marquee - UPDATED LIST */} {/* Technologies Marquee - UPDATED LIST */}
<section <section
className="py-24 border-y border-slate-100 dark:border-white/5 overflow-hidden bg-white dark:bg-background-dark" className="py-20 overflow-hidden bg-slate-950"
id="technologies" id="technologies"
> >
<div className="flex whitespace-nowrap animate-marquee"> <div className="flex items-center gap-14 whitespace-nowrap animate-marquee">
{[...Array(2)].map((_, i) => ( {[1, 2].flatMap((copy) =>
<div key={i} className="flex items-center gap-24 px-12"> [
{[ { name: "Python", icon: "code" },
{ name: "Python", icon: "code" }, { name: "TypeScript", icon: "terminal" },
{ name: "TypeScript", icon: "terminal" }, { name: "OpenAI", icon: "smart_toy" },
{ name: "OpenAI", icon: "smart_toy" }, { name: "Flutter", icon: "mobile_friendly" },
{ name: "Flutter", icon: "mobile_friendly" }, { name: "C#", icon: "integration_instructions" },
{ name: "C#", icon: "integration_instructions" }, { name: "ASP.NET", icon: "settings_ethernet" },
{ name: "ASP.NET", icon: "settings_ethernet" }, { name: "Angular", icon: "change_history" },
{ name: "Angular", icon: "change_history" }, { name: "IoT", icon: "sensors" },
{ name: "IoT", icon: "sensors" }, ].map((tech, j) => [
].map((tech, j) => ( <span key={`${copy}-tech-${j}`} className="text-3xl font-black text-slate-600 uppercase tracking-tighter flex items-center gap-3 hover:text-primary transition-colors cursor-default shrink-0">
<span <span className="material-symbols-outlined text-3xl">{tech.icon}</span>
key={j} {tech.name}
className="text-4xl font-black text-slate-200 dark:text-slate-800 uppercase tracking-tighter flex items-center gap-4 hover:text-primary transition-colors cursor-default" </span>,
> <span key={`${copy}-dot-${j}`} className="text-slate-700 text-2xl font-light select-none shrink-0">·</span>,
<span className="material-symbols-outlined text-4xl"> ])
{tech.icon} )}
</span>{" "}
{tech.name}
</span>
))}
</div>
))}
</div> </div>
</section> </section>
{/* Portfolio Section */} {/* Portfolio Section */}
<section className="bg-slate-50 dark:bg-background-dark/30 py-32" id="portfolio"> <section className="bg-slate-50 dark:bg-background-dark/30 py-20 lg:py-28" id="portfolio">
<div className="container mx-auto px-6"> <div className="container mx-auto px-6">
<div className="text-center mb-24"> <Reveal className="text-center mb-24">
<h2 className="text-primary font-bold uppercase tracking-widest text-sm mb-4"> <h2 className="text-primary font-bold uppercase tracking-widest text-sm mb-4">
Réalisations Réalisations
</h2> </h2>
@ -232,7 +343,7 @@ export default function Home() {
Portfolio Sélectionné Portfolio Sélectionné
</h3> </h3>
<div className="w-24 h-1.5 mx-auto bg-primary-light rounded-full"></div> <div className="w-24 h-1.5 mx-auto bg-primary-light rounded-full"></div>
</div> </Reveal>
{(() => { {(() => {
const allProjects = [ const allProjects = [
@ -323,11 +434,11 @@ export default function Home() {
return ( return (
<div className="relative"> <div className="relative">
{/* Projects Grid */} {/* Projects Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-10">
{firstProjects.map((project, idx) => ( {firstProjects.map((project, idx) => (
<Reveal key={idx} delay={idx * 80}>
<div <div
key={idx} className="group relative overflow-hidden rounded-[3rem] aspect-[16/10] cursor-pointer shadow-2xl"
className="group relative overflow-hidden rounded-[3rem] aspect-[4/3] cursor-pointer shadow-2xl"
onClick={() => { onClick={() => {
setSelectedProject(project); setSelectedProject(project);
setCurrentImageIndex(0); setCurrentImageIndex(0);
@ -358,20 +469,21 @@ export default function Home() {
</button> </button>
</div> </div>
</div> </div>
</Reveal>
))} ))}
</div> </div>
{/* Professional Reveal Zone */} {/* Professional Reveal Zone */}
<div className="relative mt-12 md:mt-16"> <div className="relative mt-12 md:mt-16">
<div <div
className={`relative w-full transition-all duration-1000 ease-[cubic-bezier(0.23,1,0.32,1)] overflow-hidden className={`relative w-full transition-all duration-[1800ms] ease-[cubic-bezier(0.16,1,0.3,1)] overflow-hidden
${showAllProjects ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0"}`} ${showAllProjects ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0"}`}
> >
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16 pb-32"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-10 pb-16">
{extraProjects.map((project, idx) => ( {extraProjects.map((project, idx) => (
<div <div
key={idx + 4} key={idx + 4}
className="group relative overflow-hidden rounded-[3rem] aspect-[4/3] cursor-pointer shadow-2xl" className="group relative overflow-hidden rounded-[3rem] aspect-[16/10] cursor-pointer shadow-2xl"
onClick={() => { onClick={() => {
setSelectedProject(project); setSelectedProject(project);
setCurrentImageIndex(0); setCurrentImageIndex(0);
@ -425,9 +537,9 @@ export default function Home() {
</div> </div>
</section> </section>
{/* Contact Form Section - REVISED LAYOUT to match Image */} {/* Contact Form Section - REVISED LAYOUT to match Image */}
<section className="bg-white dark:bg-background-dark py-32" id="contact"> <section className="bg-white dark:bg-background-dark py-20 lg:py-28" id="contact">
<div className="container mx-auto px-6"> <div className="container mx-auto px-6">
<div className="max-w-6xl mx-auto bg-white dark:bg-zinc-900 rounded-[4rem] overflow-hidden shadow-[0_50px_100px_-20px_rgba(0,0,0,0.15)] flex flex-col md:flex-row border border-slate-100 dark:border-white/5"> <Reveal className="max-w-6xl mx-auto bg-white dark:bg-zinc-900 rounded-[4rem] overflow-hidden shadow-[0_50px_100px_-20px_rgba(0,0,0,0.15)] flex flex-col md:flex-row border border-slate-100 dark:border-white/5">
{/* Sidebar (Teal) */} {/* Sidebar (Teal) */}
<div className="md:w-[40%] bg-primary p-16 text-white flex flex-col relative overflow-hidden"> <div className="md:w-[40%] bg-primary p-16 text-white flex flex-col relative overflow-hidden">
<div className="relative z-10 flex-grow"> <div className="relative z-10 flex-grow">
@ -456,61 +568,106 @@ export default function Home() {
</div> </div>
{/* Form Area (White/Right) */} {/* Form Area (White/Right) */}
<div className="md:w-[60%] p-16 bg-[#F9FAFB] dark:bg-zinc-800/20"> <div className="md:w-[60%] p-16 bg-[#F9FAFB] dark:bg-zinc-800/20">
<form className="space-y-12"> {formStatus === 'success' ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-10"> <div className="h-full flex flex-col items-center justify-center text-center py-12">
<div className="space-y-4"> <div className="w-16 h-16 bg-primary/20 text-primary rounded-full flex items-center justify-center mx-auto mb-6">
<label className="text-xs font-black uppercase tracking-widest text-[#2D3748] dark:text-white/60"> <span className="material-symbols-outlined text-4xl">task_alt</span>
Nom </div>
</label> <h3 className="text-2xl font-black text-slate-900 dark:text-white mb-3">Message envoyé !</h3>
<input <p className="text-slate-500 mb-8">On revient vers vous très vite.</p>
className="w-full bg-white dark:bg-white/5 border-none rounded-2xl p-5 shadow-sm focus:ring-4 focus:ring-primary/10 transition-all outline-none text-[#1A202C] dark:text-white" <button onClick={() => setFormStatus('idle')} className="text-primary font-bold hover:underline">
placeholder="John Doe" Envoyer un autre message
type="text" </button>
/> </div>
) : (
<form onSubmit={handleSubmit} className="space-y-12">
{formStatus === 'error' && (
<div className="p-4 bg-red-50 text-red-600 rounded-xl text-sm font-medium border border-red-100 flex items-center gap-2">
<span className="material-symbols-outlined text-sm">error</span>
Une erreur est survenue. Réessayez ou contactez-nous par email.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
<div className="space-y-4">
<label className="text-xs font-black uppercase tracking-widest text-[#2D3748] dark:text-white/60">
Nom
</label>
<input
name="nom"
required
value={formData.nom}
onChange={handleChange}
className="w-full bg-white dark:bg-white/5 border-none rounded-2xl p-5 shadow-sm focus:ring-4 focus:ring-primary/10 transition-all outline-none text-[#1A202C] dark:text-white"
placeholder="John Doe"
type="text"
/>
</div>
<div className="space-y-4">
<label className="text-xs font-black uppercase tracking-widest text-[#2D3748] dark:text-white/60">
Email
</label>
<input
name="email"
required
value={formData.email}
onChange={handleChange}
className="w-full bg-white dark:bg-white/5 border-none rounded-2xl p-5 shadow-sm focus:ring-4 focus:ring-primary/10 transition-all outline-none text-[#1A202C] dark:text-white"
placeholder="john@example.com"
type="email"
/>
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<label className="text-xs font-black uppercase tracking-widest text-[#2D3748] dark:text-white/60"> <label className="text-xs font-black uppercase tracking-widest text-[#2D3748] dark:text-white/60">
Email Service
</label> </label>
<input <div className="relative">
className="w-full bg-white dark:bg-white/5 border-none rounded-2xl p-5 shadow-sm focus:ring-4 focus:ring-primary/10 transition-all outline-none text-[#1A202C] dark:text-white" <select
placeholder="john@example.com" name="service"
type="email" value={formData.service}
/> onChange={handleChange}
className="w-full bg-white dark:bg-white/5 border-none rounded-2xl p-5 shadow-sm focus:ring-4 focus:ring-primary/10 transition-all outline-none appearance-none cursor-pointer text-[#1A202C] dark:text-white font-medium"
>
<option className="text-[#1A202C] bg-white">Software Development</option>
<option className="text-[#1A202C] bg-white">AI Integration</option>
<option className="text-[#1A202C] bg-white">3D Modeling & Printing</option>
<option className="text-[#1A202C] bg-white">Autre</option>
</select>
<span className="material-symbols-outlined absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
expand_more
</span>
</div>
</div> </div>
</div> <div className="space-y-4">
<div className="space-y-4"> <label className="text-xs font-black uppercase tracking-widest text-[#2D3748] dark:text-white/60">
<label className="text-xs font-black uppercase tracking-widest text-[#2D3748] dark:text-white/60"> Message
Service </label>
</label> <textarea
<div className="relative"> name="message"
<select className="w-full bg-white dark:bg-white/5 border-none rounded-2xl p-5 shadow-sm focus:ring-4 focus:ring-primary/10 transition-all outline-none appearance-none cursor-pointer text-[#1A202C] dark:text-white font-medium"> required
<option className="text-[#1A202C] bg-white">Software Development</option> value={formData.message}
<option className="text-[#1A202C] bg-white">AI Integration</option> onChange={handleChange}
<option className="text-[#1A202C] bg-white">3D Modeling & Printing</option> className="w-full bg-white dark:bg-white/5 border-none rounded-2xl p-5 shadow-sm focus:ring-4 focus:ring-primary/10 transition-all outline-none resize-none text-[#1A202C] dark:text-white"
<option className="text-[#1A202C] bg-white">Autre</option> placeholder="Tell us about your project..."
</select> rows={6}
<span className="material-symbols-outlined absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400"> ></textarea>
expand_more
</span>
</div> </div>
</div> <button
<div className="space-y-4"> type="submit"
<label className="text-xs font-black uppercase tracking-widest text-[#2D3748] dark:text-white/60"> disabled={formStatus === 'loading'}
Message className="w-full bg-primary text-white py-6 rounded-2xl font-black text-xl shadow-xl shadow-primary/20 hover:brightness-110 active:scale-[0.98] transition-all disabled:opacity-70 flex items-center justify-center gap-3"
</label> >
<textarea {formStatus === 'loading' ? (
className="w-full bg-white dark:bg-white/5 border-none rounded-2xl p-5 shadow-sm focus:ring-4 focus:ring-primary/10 transition-all outline-none resize-none text-[#1A202C] dark:text-white" <>
placeholder="Tell us about your project..." <span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
rows={6} Envoi en cours...
></textarea> </>
</div> ) : 'Envoyer la demande'}
<button className="w-full bg-primary text-white py-6 rounded-2xl font-black text-xl shadow-xl shadow-primary/20 hover:brightness-110 active:scale-[0.98] transition-all"> </button>
Envoyer la demande </form>
</button> )}
</form>
</div> </div>
</div> </Reveal>
</div> </div>
</section> </section>

View File

@ -3,7 +3,37 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { resolveImage } from "@/data/stitch-images"; import { resolveImage } from "@/data/stitch-images";
import { useState, useMemo } from "react"; import React, { useState, useMemo } from "react";
function Reveal({ children, delay = 0, className = '' }: { children: React.ReactNode; delay?: number; className?: string }) {
const ref = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setVisible(true); },
{ threshold: 0.12 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={className}
style={{
opacity: visible ? 1 : 0,
transform: visible ? 'translateY(0)' : 'translateY(22px)',
transition: `opacity 0.55s ease-out ${delay}ms, transform 0.55s ease-out ${delay}ms`,
}}
>
{children}
</div>
);
}
export default function PortfolioPage() { export default function PortfolioPage() {
const [activeCategory, setActiveCategory] = useState("All"); const [activeCategory, setActiveCategory] = useState("All");
@ -114,6 +144,7 @@ export default function PortfolioPage() {
</nav> </nav>
<main className="pt-32 pb-20 px-6 max-w-7xl mx-auto flex-grow w-full"> <main className="pt-32 pb-20 px-6 max-w-7xl mx-auto flex-grow w-full">
<Reveal>
<header className="mb-20"> <header className="mb-20">
<h1 className="text-5xl md:text-7xl font-black mb-8 text-slate-900 dark:text-white"> <h1 className="text-5xl md:text-7xl font-black mb-8 text-slate-900 dark:text-white">
Nos <span className="text-primary italic">Réalisations</span> Nos <span className="text-primary italic">Réalisations</span>
@ -130,21 +161,23 @@ export default function PortfolioPage() {
))} ))}
</div> </div>
</header> </header>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-12"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProjects.length > 0 ? ( {filteredProjects.length > 0 ? (
filteredProjects.map((p, i) => ( filteredProjects.map((p, i) => (
<div key={i} className="group bg-white dark:bg-slate-900 rounded-[3rem] overflow-hidden shadow-2xl border border-slate-100 dark:border-slate-800 hover:scale-[1.02] transition-all duration-500"> <Reveal key={i} delay={i * 80}>
<div className="relative h-72 overflow-hidden"> <div className="group bg-white dark:bg-slate-900 rounded-[3rem] overflow-hidden shadow-2xl border border-slate-100 dark:border-slate-800 hover:scale-[1.02] transition-all duration-500">
<div className="relative h-56 overflow-hidden">
<Image src={p.img} alt={p.title} fill className="object-cover transition-transform duration-700 group-hover:scale-110" /> <Image src={p.img} alt={p.title} fill className="object-cover transition-transform duration-700 group-hover:scale-110" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> <div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</div> </div>
<div className="p-12"> <div className="p-8">
<span className="text-primary font-black text-xs uppercase tracking-[0.2em] mb-4 block"> <span className="text-primary font-black text-xs uppercase tracking-[0.2em] mb-3 block">
{p.categories.join(" / ")} {p.categories.join(" / ")}
</span> </span>
<h2 className="text-3xl font-black mb-6 text-slate-900 dark:text-white leading-tight">{p.title}</h2> <h2 className="text-2xl font-black mb-4 text-slate-900 dark:text-white leading-tight">{p.title}</h2>
<p className="text-slate-500 dark:text-slate-400 mb-10 leading-relaxed text-sm">{p.desc}</p> <p className="text-slate-500 dark:text-slate-400 mb-6 leading-relaxed text-sm">{p.desc}</p>
<button <button
onClick={() => { onClick={() => {
setSelectedProject(p); setSelectedProject(p);
@ -157,6 +190,7 @@ export default function PortfolioPage() {
</button> </button>
</div> </div>
</div> </div>
</Reveal>
)) ))
) : ( ) : (
<div className="col-span-full py-20 text-center"> <div className="col-span-full py-20 text-center">