// Projects, Testimonials, FAQ, CTA, Footer
const { useState: useS3, useEffect: useE3 } = React;
function ProjectVisual({ variant, accent }) {
// Abstract placeholder visuals per project
const visuals = {
dashboard: (
),
system: (
),
launch: (
),
workflow: (
),
studio: (
)
};
return visuals[variant] || visuals.dashboard;
}
function ProjectModal({ project, variant, onClose, t }) {
// Keep onClose in a ref so the scroll-lock effect only reacts to `project`
// (otherwise every parent re-render creates a new onClose fn and retriggers
// this effect, which can leave body.overflow stuck at "hidden").
const onCloseRef = React.useRef(onClose);
React.useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
useE3(() => {
if (!project) return;
function onKey(e) { if (e.key === 'Escape') onCloseRef.current(); }
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', onKey);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', onKey);
};
}, [project]);
// Extra safety net: if this component ever unmounts mid-open, restore scroll.
useE3(() => () => { document.body.style.overflow = ''; }, []);
if (!project) return null;
const description = project.description || t.projects.soon_desc;
const hasImage = Boolean(project.image);
const hasTech = Array.isArray(project.tech) && project.tech.length > 0;
const hasUrl = Boolean(project.url);
return (
e.stopPropagation()}>
{hasImage
?

:
}
{project.tag}
{project.year}
{project.name}
{description}
{hasTech && (
{t.projects.tech_label}
{project.tech.map((tag) => (
{tag}
))}
)}
{hasUrl && (
{t.projects.live_cta}
)}
);
}
function Projects({ t }) {
const variants = ['dashboard', 'system', 'launch', 'workflow', 'studio'];
const layouts = ['project-large', 'project-small', 'project-full', 'project-half', 'project-half'];
const [activeIdx, setActiveIdx] = useS3(null);
// Hover parallax
useE3(() => {
const cards = document.querySelectorAll('.project');
function onMove(e) {
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width - 0.5;
const y = (e.clientY - rect.top) / rect.height - 0.5;
const vis = e.currentTarget.querySelector('.project-visual');
if (vis) vis.style.transform = `translate(${x * 20}px, ${y * 20}px) scale(1.05)`;
}
function onLeave(e) {
const vis = e.currentTarget.querySelector('.project-visual');
if (vis) vis.style.transform = '';
}
cards.forEach(c => {
c.addEventListener('mousemove', onMove);
c.addEventListener('mouseleave', onLeave);
});
return () => cards.forEach(c => {
c.removeEventListener('mousemove', onMove);
c.removeEventListener('mouseleave', onLeave);
});
}, []);
const activeProject = activeIdx !== null ? t.projects.items[activeIdx] : null;
const activeVariant = activeIdx !== null ? variants[activeIdx] : null;
return (
setActiveIdx(null)}
t={t}
/>
);
}
function Testimonials({ t }) {
return (
{t.testimonials.items.map((item, i) => (
))}
);
}
function FAQ({ t }) {
const [open, setOpen] = useS3(0);
return (
{t.faq.items.map((item, i) => (
))}
);
}
function CTA({ t }) {
const canvasRef = React.useRef(null);
useE3(() => {
const c = canvasRef.current;
if (!c) return;
const ctx = c.getContext('2d');
let W, H;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
function resize() {
const rect = c.getBoundingClientRect();
W = rect.width; H = rect.height;
c.width = Math.max(1, Math.round(W * dpr));
c.height = Math.max(1, Math.round(H * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(c);
window.addEventListener('resize', resize);
let t0 = performance.now();
let raf;
// Orbs pushed FAR off-screen (|x|, |y| ~ 0.4–1.5) so only the smooth
// tails of the gradient are visible → no hot spots, no visible edges.
const orbs = [
{ x: -0.40, y: 0.20, r: 1.6, hue: 262, speed: 0.00022, phase: 0.0 },
{ x: 1.40, y: 0.45, r: 1.5, hue: 272, speed: 0.00026, phase: 2.3 },
{ x: 0.55, y: 1.55, r: 1.7, hue: 258, speed: 0.00020, phase: 4.1 },
{ x: 0.35, y: -0.45, r: 1.4, hue: 268, speed: 0.00024, phase: 1.2 },
{ x: 0.90, y: -0.30, r: 1.3, hue: 275, speed: 0.00028, phase: 3.5 },
];
function draw() {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
ctx.fillStyle = dark ? '#0A0A0C' : '#FAFAFA';
ctx.fillRect(0, 0, W, H);
const tt = (performance.now() - t0);
for (const o of orbs) {
const px = (o.x + Math.sin(tt * o.speed + o.phase) * 0.04) * W;
const py = (o.y + Math.cos(tt * o.speed * 1.2 + o.phase) * 0.035) * H;
const r = o.r * Math.max(W, H);
const g = ctx.createRadialGradient(px, py, 0, px, py, r);
const alpha = dark ? 0.32 : 0.16;
// Smoother 5-stop falloff → orbs blend into a uniform mesh
g.addColorStop(0.00, `hsla(${o.hue}, 78%, 72%, ${alpha})`);
g.addColorStop(0.25, `hsla(${o.hue}, 78%, 72%, ${alpha * 0.75})`);
g.addColorStop(0.50, `hsla(${o.hue}, 78%, 72%, ${alpha * 0.42})`);
g.addColorStop(0.75, `hsla(${o.hue}, 78%, 72%, ${alpha * 0.16})`);
g.addColorStop(1.00, `hsla(${o.hue}, 78%, 72%, 0)`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
raf = requestAnimationFrame(draw);
}
draw();
return () => { cancelAnimationFrame(raf); ro.disconnect(); window.removeEventListener('resize', resize); };
}, []);
return (
);
}
function Footer({ t }) {
return (
);
}
Object.assign(window, { Projects, Testimonials, FAQ, CTA, Footer });