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";
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>

View File

@ -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">