// components.jsx — shared bits for Visimatic
// Exports to window: VM (namespace with constants + components)
const { useState, useEffect, useRef, useCallback, useMemo } = React;
function useMobile(bp = 768) {
const [mobile, setMobile] = useState(window.innerWidth < bp);
useEffect(() => {
const check = () => setMobile(window.innerWidth < bp);
window.addEventListener("resize", check, { passive: true });
return () => window.removeEventListener("resize", check);
}, [bp]);
return mobile;
}
/* ─── data ──────────────────────────────────────────────────────────── */
const R2 = "https://pub-e01667d961094e52be5860918f659f12.r2.dev";
const SHOWREEL = {
full: `${R2}/Logo%20Animation%20Clean.mp4`,
loop: `${R2}/short%20loop%20Logo%20Animation%20Clean%203.mp4`
};
const PORTFOLIO = [
{ id: "embark1", title: "Embark", client: "Embark", style: "Kinetic", year: "2025", src: `${R2}/Embark%20(1).mp4`, aspect: "16/9" },
{ id: "helvetic", title: "Helvetik", client: "Helvetik", style: "Minimal", year: "2025", src: `${R2}/HELVETIC.mp4`, aspect: "16/9" },
{ id: "gokaido", title: "GOkaido", client: "GOkaido", style: "3D", year: "2024", src: `${R2}/GOkaido.mp4`, aspect: "16/9" },
{ id: "ganymede", title: "Ganymede", client: "Ganymede Labs", style: "Minimal", year: "2025", src: `${R2}/Ganymede%20(1).mp4`, aspect: "16/9" },
{ id: "grippi", title: "Grippi Media", client: "Grippi Media", style: "Minimal", year: "2024", src: `${R2}/Grippi%20Media%20%20(1).mp4`, aspect: "16/9" },
{ id: "onpath", title: "On Path Studio", client: "On Path", style: "Kinetic", year: "2024", src: `${R2}/On%20Path%20Studio%202.mp4`, aspect: "16/9" },
{ id: "embark2", title: "Embark — alt", client: "Embark", style: "Kinetic", year: "2024", src: `${R2}/Embark%20(2).mp4`, aspect: "16/9" },
{ id: "ae", title: "AE Basics", client: "Personal", style: "Minimal", year: "2024", src: `${R2}/AE%20Basics.mp4`, aspect: "16/9" }];
const STYLES = [
{ id: "minimal", label: "Minimal & clean", blurb: "Smooth, elegant, premium.", note: "best for SaaS, finance, premium brands" },
{ id: "glitch", label: "Glitch / digital", blurb: "Tech, edgy, futuristic.", note: "best for AI, dev tools, web3" },
{ id: "cartoon", label: "2D cartoon", blurb: "Playful, expressive, fun.", note: "best for kids, creators, mascots" },
{ id: "unsure", label: "Leave it to you", blurb: "Trust the creative direction.", note: "I'll pick what fits your brand" }];
const PACKAGES = [
{ id: "starter", name: "Starter", price: 150, delivery: "5 days", revisions: 1, popular: false,
includes: ["Simple custom animation", "Custom colours & text", "MP4 export", "Commercial rights"] },
{ id: "standard", name: "Standard", price: 225, delivery: "4 days", revisions: 1, popular: true,
includes: ["Detailed animation", "Music + sound effects", "Logo transparency (PNG/MOV)", "Everything in Starter"] },
{ id: "pro", name: "Pro", price: 300, delivery: "2 days", revisions: 2, popular: false,
includes: ["Perfect loop", "Priority 2-day delivery", "2 revisions", "Everything in Standard"] }];
const ADDONS = [
{ id: "transparency", label: "Logo transparency", price: 20, excludes: ["standard", "pro"] },
{ id: "soundfx", label: "Sound effects", price: 25, excludes: ["standard", "pro"] },
{ id: "loop", label: "Perfect loop", price: 30, excludes: ["pro"] },
{ id: "revision", label: "Extra revision", price: 20, excludes: [] },
{ id: "rush", label: "Rush delivery", price: 50, excludes: [] },
{ id: "vectorize", label: "Logo vectorisation", price: 30, excludes: [] },
{ id: "source", label: "Source file (AE)", price: 50, excludes: [] }];
const REVIEWS = [
{ id: 1, name: "Maya C", role: "Founder", rating: 5,
text: "Came back with a reveal that made our investors stop and rewind. Worth ten times what we paid.",
delivery: "3 days" },
{ id: 2, name: "Tomás R", role: "Marketing", rating: 5,
text: "The first cut was the cut. Felt like he'd been inside the brand for a year.",
delivery: "4 days" },
{ id: 3, name: "Priya A", role: "Creator", rating: 5,
text: "I have this on the top of every video now. People DM me about the logo, not the content.",
delivery: "2 days" },
{ id: 4, name: "Jules H", role: "Studio lead", rating: 5,
text: "Got our brand mark moving in a way our in-house team had been chasing for months.",
delivery: "3 days" }];
/* ─── prism (logo) ──────────────────────────────────────────────────── */
function Prism({ size = 28, animate = false, style = {} }) {
return (
);
}
/* ─── nav ───────────────────────────────────────────────────────────── */
function Nav({ route, setRoute }) {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const mobile = useMobile();
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 16);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => { setOpen(false); }, [route]);
const links = [
{ id: "work", label: "Work" },
{ id: "pricing", label: "Pricing" },
{ id: "about", label: "About" },
{ id: "contact", label: "Contact" },
];
const logoBg = scrolled ? "rgba(8,8,16,0.82)" : "rgba(8,8,16,0.52)";
if (mobile) {
return (
);
}
return (
);
}
/* ─── footer ────────────────────────────────────────────────────────── */
function Footer({ setRoute }) {
const mobile = useMobile();
return (
);
}
function FooterCol({ title, links }) {
return (
{title}
{links.map((l, i) =>
)}
);
}
/* ─── video tile (auto-loop preview, hover scrub) ──────────────────── */
function VideoTile({ item, aspect = "16/9", fill = false, onClick, big = false }) {
const ref = useRef(null);
const [hover, setHover] = useState(false);
useEffect(() => {
const v = ref.current;if (!v) return;
v.muted = true;v.loop = true;v.playsInline = true;
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) v.play().catch(() => {});else v.pause();
}, { threshold: 0.15 });
io.observe(v);
return () => io.disconnect();
}, []);
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
onClick={onClick}
style={{
position: "relative",
...(fill ? { height: "100%" } : { aspectRatio: aspect }),
borderRadius: big ? "var(--radius-lg)" : "var(--radius)",
overflow: "hidden",
border: "1px solid var(--line)",
background: "var(--void-2)",
cursor: onClick ? "pointer" : "default",
transition: "transform 360ms cubic-bezier(.2,.7,.2,1), border-color 240ms",
transform: hover ? "translateY(-3px)" : "translateY(0)",
borderColor: hover ? "var(--line-2)" : "var(--line)"
}}>
{/* chromatic edge wash on hover */}
{/* meta overlay */}
{item.client} · {item.year}
{item.title}
{item.style}
{/* play marker, top-right */}
);
}
/* ─── showreel card ────────────────────────────────────────────────── */
function ShowreelCard({ onPlay }) {
const loopRef = useRef(null);
const fullRef = useRef(null);
const [hover, setHover] = useState(false);
const [playing, setPlaying] = useState(false);
const mobile = useMobile();
useEffect(() => {
const v = loopRef.current;if (!v) return;
v.muted = true;v.loop = true;v.playsInline = true;
v.play().catch(() => {});
}, []);
const handlePlay = (e) => {
e.stopPropagation();
setPlaying(true);
requestAnimationFrame(() => {
const f = fullRef.current;
if (f) { f.muted = false; f.play().catch(() => {}); }
});
};
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
onClick={!playing ? handlePlay : undefined}
style={{
position: "relative",
borderRadius: "var(--radius-lg)",
overflow: "hidden",
border: "1px solid var(--line-2)",
background: "var(--void-2)",
cursor: playing ? "default" : "pointer",
aspectRatio: mobile ? "4/3" : "21/9"
}}>
{!playing && (
)}
{playing && (
);
}
function CornerMark({ pos }) {
const map = {
tl: { top: 16, left: 16, borderTop: "1px solid white", borderLeft: "1px solid white" },
tr: { top: 16, right: 16, borderTop: "1px solid white", borderRight: "1px solid white" },
bl: { bottom: 16, left: 16, borderBottom: "1px solid white", borderLeft: "1px solid white" },
br: { bottom: 16, right: 16, borderBottom: "1px solid white", borderRight: "1px solid white" }
};
return (
);
}
/* ─── lightbox (full showreel) ─────────────────────────────────────── */
function Lightbox({ src, onClose }) {
useEffect(() => {
const k = (e) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
document.body.style.overflow = "hidden";
return () => {document.removeEventListener("keydown", k);document.body.style.overflow = "";};
}, [onClose]);
return ReactDOM.createPortal(
,
document.body
);
}
/* ─── scroll reveal ────────────────────────────────────────────────── */
function Reveal({ children, delay = 0, y = 28, as = "div", style = {}, ...rest }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;if (!el) return;
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) {setVisible(true);io.disconnect();}
}, { threshold: 0.08, rootMargin: "0px 0px -40px 0px" });
io.observe(el);
return () => io.disconnect();
}, []);
const As = as;
return (
{children}
);
}
/* ─── marquee ──────────────────────────────────────────────────────── */
function Marquee({ items, gap = 56 }) {
const content = items.map((it, i) =>
{it}
✦
);
return (
{content}{content}{content}
);
}
/* ─── accordion ────────────────────────────────────────────────────── */
function Accordion({ items, defaultOpen = -1 }) {
const [open, setOpen] = useState(defaultOpen);
const mobile = useMobile();
return (
{items.map((it, i) => {
const isOpen = open === i;
return (
);
})}
);
}
/* ─── export ───────────────────────────────────────────────────────── */
Object.assign(window, {
VM_R2: R2,
VM_SHOWREEL: SHOWREEL,
VM_PORTFOLIO: PORTFOLIO,
VM_STYLES: STYLES,
VM_PACKAGES: PACKAGES,
VM_ADDONS: ADDONS,
VM_REVIEWS: REVIEWS,
useMobile,
Prism, Nav, Footer, VideoTile, ShowreelCard, Lightbox, Reveal, Marquee, Accordion
});