/* NEKTAR v2 — Sections (Hero, Histoire, Sélection, Livraison, Lieu, Suivre, Footer) */
const { useState, useEffect, useRef, useCallback, useContext, createContext } = React;
const D = window.NEKTAR_DATA;
/* ============================================================
LangContext — partagé par toute l'app
============================================================ */
const LangContext = createContext({ lang: 'fr', setLang: () => {} });
window.LangContext = LangContext;
function useCopy() {
const { lang } = useContext(LangContext);
return D.COPY[lang] || D.COPY.fr;
}
window.useCopy = useCopy;
/* ============================================================
HERO
============================================================ */
function HeroSection() {
const c = useCopy();
const imgRef = useRef(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const img = imgRef.current;
if (img && img.complete && img.naturalWidth > 0) setLoaded(true);
}, []);
return (

setLoaded(true)}
loading="eager" decoding="async" />
{c.hero.eyebrow}
{c.hero.word}
);
}
/* ============================================================
NOTRE HISTOIRE
============================================================ */
function HistoireSection() {
const c = useCopy();
return (
{c.histoire.aria}
{c.histoire.signaturePrefix} {c.histoire.signatureWord}
);
}
/* ============================================================
SÉLECTION — bulles physiques
============================================================ */
function formatName(name) {
const parts = name.split(' ');
if (parts.length <= 1) return name;
if (parts.length === 2) return parts[0] + '\n' + parts[1];
const mid = Math.ceil(parts.length / 2);
return parts.slice(0, mid).join(' ') + '\n' + parts.slice(mid).join(' ');
}
function makeSrand(seed) {
let s = seed;
return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; };
}
function initPhysics({ count, r, w, h }) {
const rand = makeSrand(99);
const gap = 10;
const minD = r * 2 + gap;
const pad = r + 8;
const ctaR = Math.min(w, h) * 0.17;
const bubbles = [];
const shuffled = [...D.DOMAINS].sort(() => rand() - 0.5);
for (const name of shuffled) {
if (bubbles.length >= count) break;
for (let attempt = 0; attempt < 400; attempt++) {
const cx = pad + rand() * (w - pad * 2);
const cy = pad + rand() * (h - pad * 2);
const dx0 = cx - w / 2, dy0 = cy - h / 2;
if (dx0 * dx0 + dy0 * dy0 < (ctaR + r) * (ctaR + r)) continue;
const clash = bubbles.some(b => {
const dx = b.cx - cx, dy = b.cy - cy;
return dx * dx + dy * dy < minD * minD;
});
if (clash) continue;
const angle = rand() * Math.PI * 2;
const speed = 0.25 + rand() * 0.35;
bubbles.push({
name, formatted: formatName(name),
cx, cy,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
el: null,
});
break;
}
}
return bubbles;
}
function tickPhysics(bubbles, r, w, h) {
const ctaR = Math.min(w, h) * 0.17;
const cx0 = w / 2, cy0 = h / 2;
for (const b of bubbles) {
b.cx += b.vx; b.cy += b.vy;
if (b.cx - r < 0) { b.cx = r; b.vx = Math.abs(b.vx); }
if (b.cx + r > w) { b.cx = w - r; b.vx = -Math.abs(b.vx); }
if (b.cy - r < 0) { b.cy = r; b.vy = Math.abs(b.vy); }
if (b.cy + r > h) { b.cy = h - r; b.vy = -Math.abs(b.vy); }
const dxC = b.cx - cx0, dyC = b.cy - cy0;
const distC = Math.sqrt(dxC * dxC + dyC * dyC);
const minC = ctaR + r + 6;
if (distC < minC) {
const nx = dxC / (distC || 1), ny = dyC / (distC || 1);
const dot = b.vx * nx + b.vy * ny;
if (dot < 0) { b.vx -= 2 * dot * nx; b.vy -= 2 * dot * ny; }
b.cx += nx * (minC - distC);
b.cy += ny * (minC - distC);
}
}
for (let i = 0; i < bubbles.length; i++) {
for (let j = i + 1; j < bubbles.length; j++) {
const a = bubbles[i], b = bubbles[j];
const dx = b.cx - a.cx, dy = b.cy - a.cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const minD = r * 2 + 4;
if (dist < minD && dist > 0) {
const nx = dx / dist, ny = dy / dist;
const dvx = a.vx - b.vx, dvy = a.vy - b.vy;
const dot = dvx * nx + dvy * ny;
if (dot > 0) {
a.vx -= dot * nx; a.vy -= dot * ny;
b.vx += dot * nx; b.vy += dot * ny;
}
const push = (minD - dist) / 2;
a.cx -= nx * push; a.cy -= ny * push;
b.cx += nx * push; b.cy += ny * push;
}
}
}
}
function paramsFor(w) {
if (w >= 1200) return { size: 138, count: 12 };
if (w >= 768) return { size: 116, count: 8 };
return { size: 96, count: 6 };
}
function SelectionSection() {
const c = useCopy();
const containerRef = useRef(null);
const physRef = useRef(null);
const [ready, setReady] = useState(false);
const [version, setVersion] = useState(0);
useEffect(() => {
let animId = null;
function init() {
if (animId) cancelAnimationFrame(animId);
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
if (h < 200 || w < 200) return;
const { size, count } = paramsFor(window.innerWidth);
const r = size / 2;
const bubbles = initPhysics({ count, r, w, h });
physRef.current = { bubbles, r, w, h };
setReady(true);
setVersion(v => v + 1);
const loop = () => {
const ph = physRef.current;
if (!ph) return;
tickPhysics(ph.bubbles, ph.r, ph.w, ph.h);
for (const b of ph.bubbles) {
if (b.el) {
b.el.style.left = (b.cx - ph.r).toFixed(1) + 'px';
b.el.style.top = (b.cy - ph.r).toFixed(1) + 'px';
}
}
animId = requestAnimationFrame(loop);
};
animId = requestAnimationFrame(loop);
}
init();
let resizeTimer;
const onResize = () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(init, 80);
};
window.addEventListener('resize', onResize);
// ResizeObserver sur la section pour re-init au changement de hauteur
let ro;
if (window.ResizeObserver && containerRef.current) {
ro = new ResizeObserver(onResize);
ro.observe(containerRef.current);
}
return () => {
if (animId) cancelAnimationFrame(animId);
window.removeEventListener('resize', onResize);
clearTimeout(resizeTimer);
if (ro) ro.disconnect();
};
}, []);
const ph = physRef.current;
const bubbles = ph ? ph.bubbles : [];
const r = ph ? ph.r : 69;
return (
{c.selection.cta}
{ready && bubbles.map(b => (
{ b.el = el; }}
aria-hidden="true"
style={{
left: (b.cx - r).toFixed(1) + 'px',
top: (b.cy - r).toFixed(1) + 'px',
width: r * 2 + 'px',
height: r * 2 + 'px',
}}>
{b.formatted}
))}
);
}
/* ============================================================
LIVRAISON — formulaire Formspree
============================================================ */
function DrawerForm({ open, onClose }) {
const c = useCopy();
const lang = c.lang;
const liv = c.livraison;
const [sent, setSent] = useState(false);
const [errors, setErrors] = useState({});
const [submitError, setSubmitError] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({ nom: '', tel: '', email: '', date: '', type: '', message: '' });
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const today = new Date().toISOString().split('T')[0];
const validate = () => {
const e = {};
if (!form.nom.trim()) e.nom = true;
if (!form.tel.trim()) e.tel = true;
if (!form.email.trim() || !/^[^@]+@[^@]+\.[^@]+$/.test(form.email)) e.email = true;
if (!form.date || form.date < today) e.date = true;
if (!form.message.trim()) e.message = true;
return e;
};
const handleSubmit = async (e) => {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length) { setErrors(errs); return; }
setErrors({}); setSubmitError(false); setSubmitting(true);
try {
const res = await fetch(D.FORMSPREE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(form),
});
if (res.ok) setSent(true);
else setSubmitError(true);
} catch (err) {
setSubmitError(true);
} finally {
setSubmitting(false);
}
};
return (
<>
{liv.drawerTitle}
{sent ? (
{liv.confirm}
) : (
)}
>
);
}
function LivraisonSection() {
const c = useCopy();
const liv = c.livraison;
const [formOpen, setFormOpen] = useState(false);
const handleOption = (opt) => {
if (opt.action === 'form') setFormOpen(true);
else if (opt.action) window.open(opt.action, '_blank', 'noopener,noreferrer');
};
return (
<>
{liv.title}
{liv.titleSub}
{liv.options.map(opt => (
))}
setFormOpen(false)} />
>
);
}
/* ============================================================
LIEU — galerie 8 photos auto-défilement
============================================================ */
function ArrowSvg({ dir }) {
const points = dir === 'left' ? '20,6 12,16 20,26' : '12,6 20,16 12,26';
return (
);
}
function LieuSection() {
const c = useCopy();
const [current, setCurrent] = useState(0);
const [paused, setPaused] = useState(false);
const [progKey, setProgKey] = useState(0);
const total = D.LIEU_PHOTOS.length;
const autoRef = useRef(null);
const resumeRef = useRef(null);
const touchX = useRef(null);
const goTo = useCallback((idx) => {
setCurrent(((idx % total) + total) % total);
setProgKey(k => k + 1);
}, [total]);
const goNext = useCallback(() => goTo(current + 1), [goTo, current]);
const goPrev = useCallback(() => goTo(current - 1), [goTo, current]);
const manual = useCallback((fn) => {
setPaused(true);
clearTimeout(resumeRef.current);
fn();
resumeRef.current = setTimeout(() => setPaused(false), 4000);
}, []);
useEffect(() => {
if (paused) { clearInterval(autoRef.current); return; }
autoRef.current = setInterval(() => {
setCurrent(prev => (prev + 1) % total);
setProgKey(k => k + 1);
}, 3000);
return () => clearInterval(autoRef.current);
}, [paused, total]);
const onTouchStart = (e) => { touchX.current = e.touches[0].clientX; };
const onTouchEnd = (e) => {
if (touchX.current === null) return;
const dx = e.changedTouches[0].clientX - touchX.current;
touchX.current = null;
if (Math.abs(dx) < 44) return;
manual(dx < 0 ? goNext : goPrev);
};
return (
{c.lieu.aria}
setPaused(true)}
onMouseLeave={() => setPaused(false)}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}>
{D.LIEU_PHOTOS.map((url, i) => (
))}
{c.lieu.label}
{D.LIEU_PHOTOS.map((_, i) => (
);
}
/* ============================================================
SUIVRE — grille Instagram
============================================================ */
function SuivreSection() {
const c = useCopy();
return (
{D.INSTA_PHOTOS.map((p, i) => (
))}
);
}
/* ============================================================
FOOTER
============================================================ */
function FooterSection({ onScrollToLivraison }) {
const c = useCopy();
const f = c.footer;
return (
);
}
/* ============================================================
Expose to window for app.jsx
============================================================ */
Object.assign(window, {
HeroSection, HistoireSection, SelectionSection,
LivraisonSection, LieuSection, SuivreSection, FooterSection,
});