| import { motion, useScroll, useTransform, type MotionValue } from "framer-motion"; |
| import { useEffect, useRef, useState } from "react"; |
| import { Link, useNavigate } from "react-router-dom"; |
|
|
| import LangToggle from "../components/LangToggle"; |
| import { useT } from "../i18n"; |
| import "./presentation.css"; |
|
|
| type Progress = MotionValue<number>; |
| type Range = [number, number]; |
|
|
| const TEAM = [ |
| { roleKey: "tower1", name: "Txema Puch" }, |
| { roleKey: "pilot", name: "Mónica Gómez" }, |
| { roleKey: "tower2", name: "Roberto Molero" }, |
| ] as const; |
|
|
| const MOBILE_BREAKPOINT_QUERY = "(max-width: 900px)"; |
|
|
| function useIsMobile(): boolean { |
| const [isMobile, setIsMobile] = useState(false); |
| useEffect(() => { |
| if (typeof window === "undefined" || !window.matchMedia) return; |
| const mql = window.matchMedia(MOBILE_BREAKPOINT_QUERY); |
| const update = () => setIsMobile(mql.matches); |
| update(); |
| mql.addEventListener("change", update); |
| return () => mql.removeEventListener("change", update); |
| }, []); |
| return isMobile; |
| } |
|
|
| function useReveal(progress: Progress, range: Range, first = false): MotionValue<number> { |
| const [start, end] = range; |
| const pad = Math.min(0.04, (end - start) / 3); |
| if (first) { |
| return useTransform(progress, [start, end - pad, end], [1, 1, 0]); |
| } |
| return useTransform(progress, [start, start + pad, end - pad, end], [0, 1, 1, 0]); |
| } |
|
|
| function ImageScene({ |
| progress, |
| range, |
| image, |
| kicker, |
| title, |
| sub, |
| first, |
| }: { |
| progress: Progress; |
| range: Range; |
| image: string; |
| kicker: string; |
| title: string; |
| sub?: string; |
| first?: boolean; |
| }) { |
| const opacity = useReveal(progress, range, first); |
| return ( |
| <motion.div className="scene" style={{ opacity, backgroundImage: `url(/presentation/${image})` }}> |
| <div className="scene__veil" /> |
| <div className="scene__text"> |
| <div className="scene__kicker">{kicker}</div> |
| <h2 className="scene__title">{title}</h2> |
| {sub && <p className="scene__sub">{sub}</p>} |
| </div> |
| </motion.div> |
| ); |
| } |
|
|
| function WindowScene({ |
| progress, |
| range, |
| closing, |
| kicker, |
| title, |
| }: { |
| progress: Progress; |
| range: Range; |
| closing: boolean; |
| kicker: string; |
| title: string; |
| }) { |
| const opacity = useReveal(progress, range); |
| const shadeFrom: Range = [range[0] + 0.02, range[0] + 0.07]; |
| const shadeY = useTransform(progress, shadeFrom, closing ? ["-100%", "0%"] : ["0%", "-100%"]); |
| return ( |
| <motion.div className="scene" style={{ opacity, backgroundImage: "url(/presentation/window.jpg)" }}> |
| <div className="scene__veil" /> |
| <motion.div className="scene__shade" style={{ y: shadeY }} /> |
| <div className="scene__text"> |
| <div className="scene__kicker">{kicker}</div> |
| <h2 className="scene__title">{title}</h2> |
| </div> |
| </motion.div> |
| ); |
| } |
|
|
| function ExplainPanel({ |
| progress, |
| range, |
| title, |
| body, |
| }: { |
| progress: Progress; |
| range: Range; |
| title: string; |
| body: string; |
| }) { |
| const opacity = useReveal(progress, range); |
| const y = useTransform(progress, range, [28, -28]); |
| return ( |
| <motion.div className="explain__panel" style={{ opacity, y }}> |
| <h3>{title}</h3> |
| <p>{body}</p> |
| </motion.div> |
| ); |
| } |
|
|
| function Explain({ |
| progress, |
| range, |
| panelRanges, |
| header, |
| panels, |
| }: { |
| progress: Progress; |
| range: Range; |
| panelRanges: Range[]; |
| header: string; |
| panels: { title: string; body: string }[]; |
| }) { |
| const opacity = useReveal(progress, range); |
| return ( |
| <motion.div className="scene" style={{ opacity, backgroundImage: "url(/presentation/console.jpg)" }}> |
| <div className="scene__veil" /> |
| <div className="explain__header">{header}</div> |
| <div className="explain"> |
| {panels.map((panel, index) => ( |
| <ExplainPanel |
| key={index} |
| progress={progress} |
| range={panelRanges[index]} |
| title={panel.title} |
| body={panel.body} |
| /> |
| ))} |
| </div> |
| </motion.div> |
| ); |
| } |
|
|
| const INTRO_PANEL_RANGES: Range[] = [ |
| [0.51, 0.62], |
| [0.62, 0.74], |
| [0.74, 0.86], |
| ]; |
|
|
| export default function Presentation() { |
| const t = useT(); |
| const s = t.scenes; |
| const trackRef = useRef<HTMLDivElement>(null); |
| const navigate = useNavigate(); |
| const handedOffRef = useRef(false); |
| const { scrollYProgress } = useScroll({ |
| target: trackRef, |
| offset: ["start start", "end end"], |
| }); |
|
|
| useEffect(() => { |
| const unsub = scrollYProgress.on("change", (value) => { |
| if (value > 0.96 && !handedOffRef.current) { |
| handedOffRef.current = true; |
| navigate("/dashboard"); |
| } |
| }); |
| return () => unsub(); |
| }, [scrollYProgress, navigate]); |
|
|
| return ( |
| <div className="presentation"> |
| <motion.div |
| className="presentation__progress" |
| style={{ scaleX: scrollYProgress, width: "100%" }} |
| /> |
| <div className="presentation__skip" style={{ display: "flex", gap: 8 }}> |
| <LangToggle /> |
| <Link to="/dashboard"> |
| <button>{s.skip}</button> |
| </Link> |
| </div> |
| <div className="presentation__track" ref={trackRef}> |
| <div className="stage"> |
| <ImageScene |
| progress={scrollYProgress} |
| range={[0, 0.14]} |
| first |
| image="terminal.jpg" |
| kicker={s.terminal.kicker} |
| title={s.terminal.title} |
| sub={s.terminal.sub} |
| /> |
| <ImageScene |
| progress={scrollYProgress} |
| range={[0.13, 0.28]} |
| image="jetbridge.jpg" |
| kicker={s.jetbridge.kicker} |
| title={s.jetbridge.title} |
| /> |
| <ImageScene |
| progress={scrollYProgress} |
| range={[0.27, 0.42]} |
| image="cabin.jpg" |
| kicker={s.cabin.kicker} |
| title={s.cabin.title} |
| sub={s.cabin.sub} |
| /> |
| <WindowScene |
| progress={scrollYProgress} |
| range={[0.41, 0.52]} |
| closing |
| kicker={s.closing.kicker} |
| title={s.closing.title} |
| /> |
| <Explain |
| progress={scrollYProgress} |
| range={[0.5, 0.92]} |
| panelRanges={INTRO_PANEL_RANGES} |
| header={s.explainHeader} |
| panels={s.panels} |
| /> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| export function PresentationFinal() { |
| const t = useT(); |
| const s = t.scenes; |
| const trackRef = useRef<HTMLDivElement>(null); |
| const { scrollYProgress } = useScroll({ |
| target: trackRef, |
| offset: ["start start", "end end"], |
| }); |
| const [creditsVisible, setCreditsVisible] = useState(false); |
|
|
| useEffect(() => { |
| const unsub = scrollYProgress.on("change", (value) => { |
| setCreditsVisible(value > 0.88); |
| }); |
| return () => unsub(); |
| }, [scrollYProgress]); |
|
|
| return ( |
| <div className="presentation"> |
| <motion.div |
| className="presentation__progress" |
| style={{ scaleX: scrollYProgress, width: "100%" }} |
| /> |
| <div className="presentation__skip" style={{ display: "flex", gap: 8 }}> |
| <LangToggle /> |
| <Link to="/dashboard"> |
| <button>{s.skip}</button> |
| </Link> |
| </div> |
| <div className="presentation__track" ref={trackRef}> |
| <div className="stage"> |
| <WindowScene |
| progress={scrollYProgress} |
| range={[0, 0.34]} |
| closing={false} |
| kicker={s.opening.kicker} |
| title={s.opening.title} |
| /> |
| <ImageScene |
| progress={scrollYProgress} |
| range={[0.33, 0.66]} |
| image="leaving.jpg" |
| kicker={s.leaving.kicker} |
| title={s.leaving.title} |
| /> |
| <Credits |
| progress={scrollYProgress} |
| range={[0.65, 1.0]} |
| roles={s.ack.roles} |
| teamHeader={s.ack.teamHeader} |
| thanks={s.ack.thanks} |
| subtitle={s.ack.subtitle} |
| visible={creditsVisible} |
| /> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function Credits({ |
| progress, |
| range, |
| roles, |
| teamHeader, |
| thanks, |
| subtitle, |
| visible, |
| }: { |
| progress: Progress; |
| range: Range; |
| roles: { tower1: string; pilot: string; tower2: string }; |
| teamHeader: string; |
| thanks: string; |
| subtitle: string; |
| visible: boolean; |
| }) { |
| const [start, end] = range; |
| const pad = Math.min(0.04, (end - start) / 3); |
| const opacity = useTransform(progress, [start, start + pad], [0, 1]); |
| const textOpacity = useTransform(progress, [start + 0.03, start + 0.1], [0, 1]); |
| const isMobile = useIsMobile(); |
| const roleFontSize = isMobile ? 11 : 12; |
| const nameFontSize = isMobile ? 18 : 22; |
| const teamBlockGap = isMobile ? 16 : 22; |
| const logoWidth = isMobile ? "min(440px, 85vw)" : "min(440px, 70vw)"; |
| return ( |
| <motion.div |
| className="scene" |
| style={{ opacity, backgroundImage: "url(/presentation/acknowledgements.jpg)" }} |
| > |
| <div className="scene__veil" /> |
| <motion.div |
| className="ack" |
| style={{ |
| opacity: textOpacity, |
| display: "flex", |
| flexDirection: "column", |
| alignItems: "center", |
| gap: 18, |
| textAlign: "center", |
| padding: "24px", |
| }} |
| > |
| <img |
| src="/sadar-logo.png" |
| alt="SADAR" |
| className="radar-pulse" |
| style={{ width: logoWidth, height: "auto", display: "block" }} |
| /> |
| <p className="scene__sub" style={{ marginTop: 0 }}>{subtitle}</p> |
| |
| <div |
| style={{ |
| marginTop: 24, |
| fontFamily: "var(--mono)", |
| fontSize: 13, |
| letterSpacing: "0.32em", |
| textTransform: "uppercase", |
| color: "#ffffff", |
| textShadow: "0 1px 6px rgba(0,0,0,0.85)", |
| }} |
| > |
| {teamHeader} |
| </div> |
| |
| <div |
| style={{ |
| display: "flex", |
| flexDirection: "column", |
| gap: teamBlockGap, |
| marginTop: 10, |
| opacity: visible ? 1 : 0, |
| transition: "opacity 600ms ease", |
| }} |
| > |
| {TEAM.map((member, idx) => ( |
| <div key={idx} style={{ display: "flex", flexDirection: "column", gap: 6 }}> |
| <div |
| style={{ |
| fontFamily: "var(--mono)", |
| fontSize: roleFontSize, |
| letterSpacing: "0.22em", |
| textTransform: "uppercase", |
| color: "#7fd1c6", |
| textShadow: "0 1px 5px rgba(0,0,0,0.85)", |
| }} |
| > |
| {roles[member.roleKey]} |
| </div> |
| <div |
| className="radar-name-pulse" |
| style={{ |
| fontFamily: "var(--mono)", |
| fontSize: nameFontSize, |
| letterSpacing: "0.1em", |
| color: "#ffffff", |
| animationDelay: `${idx * 0.4}s`, |
| }} |
| > |
| {member.name} |
| </div> |
| </div> |
| ))} |
| </div> |
| |
| <div |
| style={{ |
| marginTop: 32, |
| fontFamily: "var(--mono)", |
| fontSize: 16, |
| letterSpacing: "0.2em", |
| color: "#7fd1c6", |
| textTransform: "uppercase", |
| textShadow: "0 1px 6px rgba(0,0,0,0.85)", |
| }} |
| > |
| {thanks} |
| </div> |
| </motion.div> |
| </motion.div> |
| ); |
| } |
|
|