rebours/public/main.js
2026-03-20 22:15:18 +01:00

735 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* REBOURS — Main Script
* CAD/CAO-inspired interface · GSAP ScrollTrigger · Technical drawing overlays · Ambient sound
*/
document.addEventListener('DOMContentLoaded', () => {
// ---- CONFIG ----
const isMobile = window.innerWidth <= 600;
const isTouch = 'ontouchstart' in window;
gsap.registerPlugin(ScrollTrigger);
// ---- HEADER HEIGHT → CSS VAR ----
const setHeaderHeight = () => {
const h = document.querySelector('.header')?.offsetHeight || 44;
document.documentElement.style.setProperty('--header-h', h + 'px');
};
setHeaderHeight();
window.addEventListener('resize', setHeaderHeight);
// ==========================================================
// 1. CAD CROSSHAIR CURSOR WITH X/Y COORDINATES
// ==========================================================
let attachCursorHover = () => {};
if (!isMobile && !isTouch) {
const cursorH = document.createElement('div');
cursorH.className = 'cad-h';
const cursorV = document.createElement('div');
cursorV.className = 'cad-v';
const cursorCenter = document.createElement('div');
cursorCenter.className = 'cad-center';
const cursorCoords = document.createElement('div');
cursorCoords.className = 'cad-coords';
[cursorH, cursorV, cursorCenter, cursorCoords].forEach(el => document.body.appendChild(el));
let visible = false;
window.addEventListener('mousemove', (e) => {
const x = e.clientX;
const y = e.clientY;
cursorH.style.left = (x - 20) + 'px';
cursorH.style.top = y + 'px';
cursorV.style.left = x + 'px';
cursorV.style.top = (y - 20) + 'px';
cursorCenter.style.left = x + 'px';
cursorCenter.style.top = y + 'px';
cursorCoords.style.left = (x + 16) + 'px';
cursorCoords.style.top = (y + 14) + 'px';
cursorCoords.textContent =
'X:' + String(Math.round(x)).padStart(4, '0') +
' Y:' + String(Math.round(y)).padStart(4, '0');
if (!visible) {
visible = true;
[cursorH, cursorV, cursorCenter, cursorCoords].forEach(el => {
el.style.opacity = '1';
});
}
});
attachCursorHover = (elements) => {
elements.forEach(el => {
el.addEventListener('mouseenter', () => {
cursorH.classList.add('cad-hover');
cursorV.classList.add('cad-hover');
cursorCenter.classList.add('cad-hover');
});
el.addEventListener('mouseleave', () => {
cursorH.classList.remove('cad-hover');
cursorV.classList.remove('cad-hover');
cursorCenter.classList.remove('cad-hover');
});
});
};
attachCursorHover(document.querySelectorAll(
'a, button, input, .product-card, summary, .panel-close'
));
}
// ==========================================================
// 2. INTERACTIVE GRID
// ==========================================================
const gridContainer = document.getElementById('interactive-grid');
const COLORS = [
'rgba(232,168,0,0.45)',
'rgba(232,168,0,0.32)',
'rgba(232,168,0,0.18)',
];
function buildGrid() {
if (!gridContainer) return;
gridContainer.innerHTML = '';
const CELL = isMobile ? 38 : 60;
const cols = Math.ceil(window.innerWidth / CELL);
const rows = Math.ceil(window.innerHeight / CELL);
gridContainer.style.display = 'grid';
gridContainer.style.gridTemplateColumns = `repeat(${cols}, ${CELL}px)`;
gridContainer.style.gridTemplateRows = `repeat(${rows}, ${CELL}px)`;
for (let i = 0; i < cols * rows; i++) {
const cell = document.createElement('div');
cell.className = 'grid-cell';
cell.addEventListener('mouseenter', () => {
cell.style.transition = 'none';
cell.style.backgroundColor = COLORS[Math.floor(Math.random() * COLORS.length)];
});
cell.addEventListener('mouseleave', () => {
cell.style.transition = 'background-color 1.4s ease-out';
cell.style.backgroundColor = 'transparent';
});
gridContainer.appendChild(cell);
}
}
buildGrid();
let rt;
window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(buildGrid, 150); });
// ==========================================================
// 3. GSAP SCROLL ANIMATIONS — CAD REVEAL
// ==========================================================
// ---- Hero parallax ----
const heroImg = document.querySelector('.hero-img');
if (heroImg) {
gsap.to(heroImg, {
yPercent: 15,
ease: 'none',
scrollTrigger: {
trigger: '.hero',
start: 'top top',
end: 'bottom top',
scrub: true,
},
});
}
// ---- Collection header: construction line draw-in ----
const collectionHeader = document.querySelector('.collection-header');
if (collectionHeader) {
// Add a construction line element
const line = document.createElement('div');
line.className = 'cad-construction-line';
collectionHeader.appendChild(line);
gsap.from(line, {
scaleX: 0,
transformOrigin: 'left center',
duration: 0.8,
ease: 'power2.out',
scrollTrigger: {
trigger: collectionHeader,
start: 'top 85%',
},
});
}
// ---- Product cards: staggered CAD reveal ----
const cards = document.querySelectorAll('.product-card');
cards.forEach((card, i) => {
const img = card.querySelector('.card-img-wrap img');
const meta = card.querySelector('.card-meta');
const imgWrap = card.querySelector('.card-img-wrap');
if (!img) return;
// Create a CAD scan line per card
const scanLine = document.createElement('div');
scanLine.className = 'card-scanline';
imgWrap.style.position = 'relative';
imgWrap.appendChild(scanLine);
// Create corner marks per card
const cornerOverlay = document.createElement('div');
cornerOverlay.className = 'card-corners';
cornerOverlay.innerHTML =
'<span class="cc cc-tl"></span>' +
'<span class="cc cc-tr"></span>' +
'<span class="cc cc-br"></span>' +
'<span class="cc cc-bl"></span>';
imgWrap.appendChild(cornerOverlay);
// Create coordinate annotation per card
const coordTag = document.createElement('div');
coordTag.className = 'card-coord-tag';
coordTag.textContent = `[${String(i + 1).padStart(3, '0')}] — ${img.naturalWidth || '1024'} × ${img.naturalHeight || '1024'} px`;
imgWrap.appendChild(coordTag);
// GSAP timeline triggered on scroll
const tl = gsap.timeline({
scrollTrigger: {
trigger: card,
start: 'top 88%',
toggleActions: 'play none none none',
},
});
// 1. Image clip-path reveal (bottom → top scan)
tl.fromTo(img,
{ clipPath: 'inset(100% 0 0 0)', filter: 'brightness(1.8) grayscale(100%)' },
{ clipPath: 'inset(0% 0 0 0)', filter: 'brightness(1) grayscale(15%)',
duration: 0.9, ease: 'power3.out' },
0
);
// 2. Scan line sweeps up
tl.fromTo(scanLine,
{ top: '100%', opacity: 1 },
{ top: '-2%', opacity: 0, duration: 0.9, ease: 'power3.out' },
0
);
// 3. Corner brackets appear
tl.fromTo(cornerOverlay.querySelectorAll('.cc'),
{ opacity: 0, scale: 0.5 },
{ opacity: 1, scale: 1, duration: 0.4, stagger: 0.06, ease: 'back.out(2)' },
0.3
);
// 4. Coordinate tag types in
tl.fromTo(coordTag,
{ opacity: 0, x: -10 },
{ opacity: 1, x: 0, duration: 0.4, ease: 'power2.out' },
0.5
);
// 5. Meta bar slides in
if (meta) {
tl.fromTo(meta,
{ opacity: 0, y: 8 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out' },
0.4
);
}
});
// ---- Newsletter section: slide in ----
const nlLeft = document.querySelector('.nl-left');
const nlRight = document.querySelector('.nl-right');
if (nlLeft && nlRight) {
gsap.from(nlLeft, {
opacity: 0, x: -30, duration: 0.7, ease: 'power2.out',
scrollTrigger: { trigger: '.newsletter', start: 'top 80%' },
});
gsap.from(nlRight, {
opacity: 0, x: 30, duration: 0.7, ease: 'power2.out', delay: 0.15,
scrollTrigger: { trigger: '.newsletter', start: 'top 80%' },
});
}
// ==========================================================
// 4. TECHNICAL DRAWING OVERLAY (panel)
// ==========================================================
const panelImgCol = document.querySelector('.panel-img-col');
let techOverlay = null;
let techTimeline = null;
function createTechOverlay(card) {
if (techOverlay) techOverlay.remove();
techOverlay = document.createElement('div');
techOverlay.className = 'tech-overlay';
// Corner brackets
['tl', 'tr', 'br', 'bl'].forEach(pos => {
const corner = document.createElement('div');
corner.className = `tech-corner tech-corner--${pos}`;
techOverlay.appendChild(corner);
});
// Center crosshair
const centerH = document.createElement('div');
centerH.className = 'tech-center-h';
const centerV = document.createElement('div');
centerV.className = 'tech-center-v';
techOverlay.appendChild(centerH);
techOverlay.appendChild(centerV);
// Horizontal dimension line
const dimH = document.createElement('div');
dimH.className = 'tech-dim tech-dim--h';
dimH.innerHTML =
'<span class="tech-dim-arrow">◂</span>' +
'<span class="tech-dim-line"></span>' +
'<span class="tech-dim-text">840</span>' +
'<span class="tech-dim-line"></span>' +
'<span class="tech-dim-arrow">▸</span>';
techOverlay.appendChild(dimH);
// Vertical dimension line
const dimV = document.createElement('div');
dimV.className = 'tech-dim tech-dim--v';
dimV.innerHTML =
'<span class="tech-dim-arrow">▴</span>' +
'<span class="tech-dim-line"></span>' +
'<span class="tech-dim-text">420</span>' +
'<span class="tech-dim-line"></span>' +
'<span class="tech-dim-arrow">▾</span>';
techOverlay.appendChild(dimV);
// Reference text
const ref = document.createElement('div');
ref.className = 'tech-ref';
const idx = card ? card.dataset.index : '001';
ref.textContent = `REF: ${idx} — SCALE 1:5 — UNIT: mm`;
techOverlay.appendChild(ref);
// Blueprint grid
const grid = document.createElement('div');
grid.className = 'tech-grid';
techOverlay.appendChild(grid);
// Scan line
const scanline = document.createElement('div');
scanline.className = 'tech-scanline';
techOverlay.appendChild(scanline);
if (panelImgCol) panelImgCol.appendChild(techOverlay);
}
function animateTechOverlay() {
if (!techOverlay) return;
// Kill previous timeline
if (techTimeline) techTimeline.kill();
const corners = techOverlay.querySelectorAll('.tech-corner');
const centerH = techOverlay.querySelector('.tech-center-h');
const centerV = techOverlay.querySelector('.tech-center-v');
const dimH = techOverlay.querySelector('.tech-dim--h');
const dimV = techOverlay.querySelector('.tech-dim--v');
const ref = techOverlay.querySelector('.tech-ref');
const grid = techOverlay.querySelector('.tech-grid');
const scanline = techOverlay.querySelector('.tech-scanline');
techTimeline = gsap.timeline();
// 1. Blueprint grid fades in
techTimeline.fromTo(grid,
{ opacity: 0 },
{ opacity: 1, duration: 0.6, ease: 'power1.in' },
0
);
// 2. Scan line sweeps
techTimeline.fromTo(scanline,
{ top: '0%', opacity: 1 },
{ top: '100%', opacity: 0, duration: 1.1, ease: 'power2.inOut' },
0
);
// 3. Corner brackets draw in (scale from corner)
corners.forEach((c, i) => {
const origins = ['top left', 'top right', 'bottom right', 'bottom left'];
techTimeline.fromTo(c,
{ opacity: 0, scale: 0, borderColor: 'rgba(232,168,0,0)' },
{ opacity: 1, scale: 1, borderColor: 'rgba(232,168,0,0.6)',
transformOrigin: origins[i],
duration: 0.4, ease: 'back.out(1.5)' },
0.15 + i * 0.08
);
});
// 4. Center crosshair extends
techTimeline.fromTo(centerH,
{ scaleX: 0, opacity: 0 },
{ scaleX: 1, opacity: 1, duration: 0.5, ease: 'power2.out' },
0.3
);
techTimeline.fromTo(centerV,
{ scaleY: 0, opacity: 0 },
{ scaleY: 1, opacity: 1, duration: 0.5, ease: 'power2.out' },
0.35
);
// 5. Dimension lines extend
techTimeline.fromTo(dimH,
{ opacity: 0, scaleX: 0 },
{ opacity: 1, scaleX: 1, transformOrigin: 'center center',
duration: 0.6, ease: 'power2.out' },
0.4
);
techTimeline.fromTo(dimV,
{ opacity: 0, scaleY: 0 },
{ opacity: 1, scaleY: 1, transformOrigin: 'center center',
duration: 0.6, ease: 'power2.out' },
0.45
);
// 6. Reference text types in
techTimeline.fromTo(ref,
{ opacity: 0, x: -15 },
{ opacity: 1, x: 0, duration: 0.4, ease: 'power2.out' },
0.6
);
// 7. Panel image scan reveal
const panelImg = document.getElementById('panel-img');
if (panelImg) {
techTimeline.fromTo(panelImg,
{ clipPath: 'inset(0 0 100% 0)', filter: 'brightness(1.8) grayscale(100%)' },
{ clipPath: 'inset(0 0 0% 0)', filter: 'brightness(1) contrast(1) grayscale(0%)',
duration: 1, ease: 'power3.out' },
0.05
);
}
}
function hideTechOverlay() {
if (techTimeline) techTimeline.kill();
if (techOverlay) {
gsap.to(techOverlay, { opacity: 0, duration: 0.3, onComplete: () => {
if (techOverlay) techOverlay.remove();
techOverlay = null;
}});
}
}
// ==========================================================
// 5. PRODUCT PANEL
// ==========================================================
const panel = document.getElementById('product-panel');
const panelClose = document.getElementById('panel-close');
const panelCards = document.querySelectorAll('.product-card');
const fields = {
img: document.getElementById('panel-img'),
index: document.getElementById('panel-index'),
name: document.getElementById('panel-name'),
type: document.getElementById('panel-type'),
mat: document.getElementById('panel-mat'),
year: document.getElementById('panel-year'),
status: document.getElementById('panel-status'),
desc: document.getElementById('panel-desc'),
specs: document.getElementById('panel-specs'),
notes: document.getElementById('panel-notes'),
};
// ---- CHECKOUT LOGIC ----
const checkoutSection = document.getElementById('checkout-section');
const checkoutToggleBtn = document.getElementById('checkout-toggle-btn');
const checkoutFormWrap = document.getElementById('checkout-form-wrap');
const checkoutForm = document.getElementById('checkout-form');
const checkoutSubmitBtn = document.getElementById('checkout-submit-btn');
const checkoutPriceEl = document.querySelector('.checkout-price');
let currentStripeKey = null;
function formatPrice(cents) {
return (cents / 100).toLocaleString('fr-FR') + ' €';
}
if (checkoutToggleBtn) {
checkoutToggleBtn.addEventListener('click', () => {
const isOpen = checkoutFormWrap.style.display !== 'none';
checkoutFormWrap.style.display = isOpen ? 'none' : 'block';
checkoutToggleBtn.textContent = isOpen
? '[ COMMANDER CETTE PIÈCE ]'
: '[ ANNULER ]';
});
}
if (checkoutForm) {
checkoutForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentStripeKey) return;
const email = document.getElementById('checkout-email').value;
checkoutSubmitBtn.disabled = true;
checkoutSubmitBtn.textContent = 'CONNEXION STRIPE...';
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: currentStripeKey, email }),
});
const data = await res.json();
if (data.url) {
window.location.href = data.url;
} else {
throw new Error('No URL returned');
}
} catch (err) {
checkoutSubmitBtn.disabled = false;
checkoutSubmitBtn.textContent = 'ERREUR — RÉESSAYER';
console.error(err);
}
});
}
function toSlug(name) {
return name
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/_/g, '-')
.replace(/[^a-z0-9-]/g, '');
}
function openPanel(card, pushState = true) {
fields.img.src = card.dataset.img;
fields.img.alt = card.dataset.name;
fields.index.textContent = card.dataset.index;
fields.name.textContent = card.dataset.name;
fields.type.textContent = card.dataset.type;
fields.mat.textContent = card.dataset.mat;
fields.year.textContent = card.dataset.year;
fields.status.textContent = card.dataset.status;
fields.desc.textContent = card.dataset.desc;
fields.specs.textContent = card.dataset.specs;
fields.notes.textContent = card.dataset.notes;
// Checkout
const price = card.dataset.price;
const stripeKey = card.dataset.stripeKey;
const isOrderable = price && stripeKey;
if (isOrderable) {
currentStripeKey = stripeKey;
checkoutPriceEl.textContent = formatPrice(parseInt(price, 10));
checkoutSection.style.display = 'block';
} else {
currentStripeKey = null;
checkoutSection.style.display = 'none';
}
checkoutFormWrap.style.display = 'none';
checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]';
checkoutSubmitBtn.disabled = false;
checkoutSubmitBtn.textContent = 'PROCÉDER AU PAIEMENT →';
checkoutForm.reset();
panel.querySelectorAll('details').forEach(d => d.removeAttribute('open'));
panel.classList.add('is-open');
panel.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
attachCursorHover(panel.querySelectorAll(
'summary, .panel-close, .checkout-btn, .checkout-submit'
));
// Technical drawing overlay (delayed for panel slide-in)
createTechOverlay(card);
setTimeout(() => animateTechOverlay(), 350);
if (pushState) {
const slug = toSlug(card.dataset.name);
history.pushState({ slug }, '', `/collection/${slug}`);
}
}
function closePanel(pushState = true) {
hideTechOverlay();
panel.classList.remove('is-open');
panel.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
if (pushState) {
history.pushState({}, '', '/');
}
}
panelCards.forEach(card => {
card.addEventListener('click', () => openPanel(card));
});
// Auto-open from direct URL (/collection/[slug])
if (window.__OPEN_PANEL__) {
const name = window.__OPEN_PANEL__;
const card = [...panelCards].find(c => c.dataset.name === name);
if (card) openPanel(card, false);
}
if (panelClose) panelClose.addEventListener('click', () => closePanel());
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closePanel();
});
window.addEventListener('popstate', () => {
if (panel.classList.contains('is-open')) {
closePanel(false);
} else {
const match = location.pathname.match(/^\/collection\/(.+)$/);
if (match) {
const slug = match[1];
const card = [...panelCards].find(c => toSlug(c.dataset.name) === slug);
if (card) openPanel(card, false);
}
}
});
// ==========================================================
// 6. AMBIENT WORKSHOP SOUND
// ==========================================================
let audioCtx = null;
let soundOn = false;
let soundNodes = [];
let clickTimer = null;
const headerNav = document.querySelector('.header-nav');
if (headerNav) {
const soundBtn = document.createElement('button');
soundBtn.className = 'sound-toggle';
soundBtn.setAttribute('aria-label', 'Activer le son ambiant');
soundBtn.innerHTML =
'<span class="sound-bars">' +
'<span class="sound-bar"></span>' +
'<span class="sound-bar"></span>' +
'<span class="sound-bar"></span>' +
'</span>' +
'<span class="sound-label">SON</span>';
soundBtn.addEventListener('click', toggleSound);
headerNav.appendChild(soundBtn);
attachCursorHover([soundBtn]);
}
function createAudioContext() {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function generateBrownNoise(ctx, duration) {
const bufferSize = ctx.sampleRate * duration;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
let lastOut = 0;
for (let i = 0; i < bufferSize; i++) {
const white = Math.random() * 2 - 1;
data[i] = (lastOut + (0.02 * white)) / 1.02;
lastOut = data[i];
data[i] *= 3.5;
}
return buffer;
}
function startAmbientSound() {
if (!audioCtx) createAudioContext();
if (audioCtx.state === 'suspended') audioCtx.resume();
const noiseBuffer = generateBrownNoise(audioCtx, 4);
const noiseSource = audioCtx.createBufferSource();
noiseSource.buffer = noiseBuffer;
noiseSource.loop = true;
const lowpass = audioCtx.createBiquadFilter();
lowpass.type = 'lowpass';
lowpass.frequency.value = 180;
const gain = audioCtx.createGain();
gain.gain.value = 0;
gain.gain.linearRampToValueAtTime(0.045, audioCtx.currentTime + 2);
noiseSource.connect(lowpass);
lowpass.connect(gain);
gain.connect(audioCtx.destination);
noiseSource.start();
soundNodes.push({ source: noiseSource, gain: gain });
scheduleMetalClick();
}
function playMetalClick() {
if (!audioCtx || audioCtx.state !== 'running') return;
const osc = audioCtx.createOscillator();
osc.type = 'sine';
osc.frequency.value = 600 + Math.random() * 2400;
const gain = audioCtx.createGain();
gain.gain.value = 0.006 + Math.random() * 0.014;
gain.gain.exponentialRampToValueAtTime(
0.0001,
audioCtx.currentTime + 0.08 + Math.random() * 0.25
);
const filter = audioCtx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 1000 + Math.random() * 2000;
filter.Q.value = 12 + Math.random() * 25;
osc.connect(filter);
filter.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.5);
}
function scheduleMetalClick() {
if (!soundOn) return;
const delay = 2000 + Math.random() * 7000;
clickTimer = setTimeout(() => {
if (!soundOn) return;
playMetalClick();
scheduleMetalClick();
}, delay);
}
function stopAmbientSound() {
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
soundNodes.forEach(({ source, gain }) => {
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 1);
setTimeout(() => { try { source.stop(); } catch (e) {} }, 1200);
});
soundNodes = [];
}
function toggleSound() {
soundOn = !soundOn;
const btn = document.querySelector('.sound-toggle');
if (!btn) return;
if (soundOn) {
startAmbientSound();
btn.classList.add('sound-active');
btn.setAttribute('aria-label', 'Couper le son ambiant');
} else {
stopAmbientSound();
btn.classList.remove('sound-active');
btn.setAttribute('aria-label', 'Activer le son ambiant');
}
}
});