Add claude.md + beautiful smooth animations on scroll
This commit is contained in:
parent
75b8e6b7b8
commit
cfa70c4ee0
34
CLAUDE.md
Normal file
34
CLAUDE.md
Normal 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
|
||||
```
|
||||
253
src/app/page.tsx
253
src/app/page.tsx
@ -1,34 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
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() {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
const [navDark, setNavDark] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<any>(null);
|
||||
const [showAllProjects, setShowAllProjects] = useState(false);
|
||||
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(() => {
|
||||
const handleScroll = () => {
|
||||
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);
|
||||
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 (
|
||||
<div className="min-h-screen bg-white text-slate-900">
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
className={`fixed top-0 w-full z-50 transition-all duration-300 ${isScrolled
|
||||
? "bg-background-light/80 dark:bg-background-dark/80 backdrop-blur-md py-4 shadow-sm"
|
||||
: "bg-transparent py-6"
|
||||
}`}
|
||||
className={`fixed top-0 w-full z-50 transition-[padding] duration-300 ease-in-out ${isScrolled || navDark ? 'py-4' : '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">
|
||||
<Image
|
||||
src={resolveImage("{{DATA:IMAGE:IMAGE_2}}")}
|
||||
@ -41,19 +116,19 @@ export default function Home() {
|
||||
<div className="hidden md:flex items-center gap-10">
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
@ -64,12 +139,53 @@ export default function Home() {
|
||||
Let's Talk
|
||||
</button>
|
||||
</div>
|
||||
<button className="md:hidden">
|
||||
<button className="md:hidden" onClick={() => setIsMenuOpen(true)}>
|
||||
<span className="material-symbols-outlined">menu</span>
|
||||
</button>
|
||||
</div>
|
||||
</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 */}
|
||||
<section className="relative h-screen flex items-center pt-20 overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
@ -121,9 +237,9 @@ export default function Home() {
|
||||
</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="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">
|
||||
<h2 className="text-primary font-bold uppercase tracking-widest text-xs mb-4">
|
||||
Expertise
|
||||
@ -136,7 +252,7 @@ export default function Home() {
|
||||
Des solutions sur-mesure conçues avec une rigueur mathématique et
|
||||
une vision centrée utilisateur.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-10">
|
||||
{/* Service Cards */}
|
||||
@ -160,8 +276,8 @@ export default function Home() {
|
||||
img: resolveImage("SERVICE_3D"),
|
||||
},
|
||||
].map((service, idx) => (
|
||||
<Reveal key={idx} delay={idx * 80}>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -190,13 +307,12 @@ export default function Home() {
|
||||
|
||||
{/* Technologies Marquee - UPDATED LIST */}
|
||||
<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"
|
||||
>
|
||||
<div className="flex whitespace-nowrap animate-marquee">
|
||||
{[...Array(2)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-24 px-12">
|
||||
{[
|
||||
<div className="flex items-center gap-14 whitespace-nowrap animate-marquee">
|
||||
{[1, 2].flatMap((copy) =>
|
||||
[
|
||||
{ name: "Python", icon: "code" },
|
||||
{ name: "TypeScript", icon: "terminal" },
|
||||
{ name: "OpenAI", icon: "smart_toy" },
|
||||
@ -205,26 +321,21 @@ export default function Home() {
|
||||
{ name: "ASP.NET", icon: "settings_ethernet" },
|
||||
{ name: "Angular", icon: "change_history" },
|
||||
{ name: "IoT", icon: "sensors" },
|
||||
].map((tech, j) => (
|
||||
<span
|
||||
key={j}
|
||||
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 className="material-symbols-outlined text-4xl">
|
||||
{tech.icon}
|
||||
</span>{" "}
|
||||
].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 className="material-symbols-outlined text-3xl">{tech.icon}</span>
|
||||
{tech.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</span>,
|
||||
<span key={`${copy}-dot-${j}`} className="text-slate-700 text-2xl font-light select-none shrink-0">·</span>,
|
||||
])
|
||||
)}
|
||||
</div>
|
||||
</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="text-center mb-24">
|
||||
<Reveal className="text-center mb-24">
|
||||
<h2 className="text-primary font-bold uppercase tracking-widest text-sm mb-4">
|
||||
Réalisations
|
||||
</h2>
|
||||
@ -232,7 +343,7 @@ export default function Home() {
|
||||
Portfolio Sélectionné
|
||||
</h3>
|
||||
<div className="w-24 h-1.5 mx-auto bg-primary-light rounded-full"></div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{(() => {
|
||||
const allProjects = [
|
||||
@ -323,11 +434,11 @@ export default function Home() {
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 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) => (
|
||||
<Reveal key={idx} delay={idx * 80}>
|
||||
<div
|
||||
key={idx}
|
||||
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={() => {
|
||||
setSelectedProject(project);
|
||||
setCurrentImageIndex(0);
|
||||
@ -358,20 +469,21 @@ export default function Home() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Professional Reveal Zone */}
|
||||
<div className="relative mt-12 md:mt-16">
|
||||
<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"}`}
|
||||
>
|
||||
<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) => (
|
||||
<div
|
||||
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={() => {
|
||||
setSelectedProject(project);
|
||||
setCurrentImageIndex(0);
|
||||
@ -425,9 +537,9 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
{/* 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="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) */}
|
||||
<div className="md:w-[40%] bg-primary p-16 text-white flex flex-col relative overflow-hidden">
|
||||
<div className="relative z-10 flex-grow">
|
||||
@ -456,13 +568,35 @@ export default function Home() {
|
||||
</div>
|
||||
{/* Form Area (White/Right) */}
|
||||
<div className="md:w-[60%] p-16 bg-[#F9FAFB] dark:bg-zinc-800/20">
|
||||
<form className="space-y-12">
|
||||
{formStatus === 'success' ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center py-12">
|
||||
<div className="w-16 h-16 bg-primary/20 text-primary rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<span className="material-symbols-outlined text-4xl">task_alt</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-slate-900 dark:text-white mb-3">Message envoyé !</h3>
|
||||
<p className="text-slate-500 mb-8">On revient vers vous très vite.</p>
|
||||
<button onClick={() => setFormStatus('idle')} className="text-primary font-bold hover:underline">
|
||||
Envoyer un autre message
|
||||
</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"
|
||||
@ -473,6 +607,10 @@ export default function Home() {
|
||||
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"
|
||||
@ -484,7 +622,12 @@ export default function Home() {
|
||||
Service
|
||||
</label>
|
||||
<div className="relative">
|
||||
<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">
|
||||
<select
|
||||
name="service"
|
||||
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>
|
||||
@ -500,17 +643,31 @@ export default function Home() {
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
name="message"
|
||||
required
|
||||
value={formData.message}
|
||||
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 resize-none text-[#1A202C] dark:text-white"
|
||||
placeholder="Tell us about your project..."
|
||||
rows={6}
|
||||
></textarea>
|
||||
</div>
|
||||
<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">
|
||||
Envoyer la demande
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formStatus === 'loading'}
|
||||
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"
|
||||
>
|
||||
{formStatus === 'loading' ? (
|
||||
<>
|
||||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></span>
|
||||
Envoi en cours...
|
||||
</>
|
||||
) : 'Envoyer la demande'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@ -3,7 +3,37 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
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() {
|
||||
const [activeCategory, setActiveCategory] = useState("All");
|
||||
@ -114,6 +144,7 @@ export default function PortfolioPage() {
|
||||
</nav>
|
||||
|
||||
<main className="pt-32 pb-20 px-6 max-w-7xl mx-auto flex-grow w-full">
|
||||
<Reveal>
|
||||
<header className="mb-20">
|
||||
<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>
|
||||
@ -130,21 +161,23 @@ export default function PortfolioPage() {
|
||||
))}
|
||||
</div>
|
||||
</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.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">
|
||||
<div className="relative h-72 overflow-hidden">
|
||||
<Reveal key={i} delay={i * 80}>
|
||||
<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" />
|
||||
<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 className="p-12">
|
||||
<span className="text-primary font-black text-xs uppercase tracking-[0.2em] mb-4 block">
|
||||
<div className="p-8">
|
||||
<span className="text-primary font-black text-xs uppercase tracking-[0.2em] mb-3 block">
|
||||
{p.categories.join(" / ")}
|
||||
</span>
|
||||
<h2 className="text-3xl font-black mb-6 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>
|
||||
<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-6 leading-relaxed text-sm">{p.desc}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedProject(p);
|
||||
@ -157,6 +190,7 @@ export default function PortfolioPage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full py-20 text-center">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user