// Step 3 — Bag → +1 tag → MORPH into 3D-tilted iPhone // Phone has continuous subtle perspective wobble. // Slide-to-answer animation → connecting → live conversation with iMessage-style bubbles. const { useState: useStateAnim, useEffect: useEffectAnim, useRef: useRefAnim } = React; const P = { IDLE: 0, // bag breathing TAG: 1, // +1 abandoned tag slides out HOLD_TAG: 2, // hold the tag visible MORPH: 3, // bag → phone shape morph HOLD_PHONE: 4, // tilted iPhone with incoming call (with subtle wobble) ANSWERING: 5, // slide-to-answer glides right CONNECTING: 6, // brief "Connecting…" TALKING: 7, // active call with conversation bubbles CONFIRMED: 8 // success state }; const DURATIONS = { [P.IDLE]: 1200, [P.TAG]: 800, [P.HOLD_TAG]: 1500, [P.MORPH]: 1100, [P.HOLD_PHONE]: 2400, [P.ANSWERING]: 900, [P.CONNECTING]: 900, [P.TALKING]: 12000, // fallback only — normally ends when the call audio finishes [P.CONFIRMED]: 3200 }; const PhoneAnim = () => { const [phase, setPhase] = useStateAnim(P.HOLD_PHONE); const [mounted, setMounted] = useStateAnim(false); useEffectAnim(() => { const t = setTimeout(() => setMounted(true), 1000); return () => clearTimeout(t); }, []); const [duration, setDuration] = useStateAnim(0); const [showAnswerHint, setShowAnswerHint] = useStateAnim(false); const [isMobile, setIsMobile] = useStateAnim(false); const transcriptRef = useRefAnim(null); const callAudioRef = useRefAnim(null); // Play the call audio once it actually connects (TALKING) — the TALKING // phase lasts exactly as long as the audio (ends on the "ended" event). // A fallback timer only kicks in if playback never actually starts // (e.g. autoplay blocked or the file fails to load). useEffectAnim(() => { const el = callAudioRef.current; if (phase !== P.TALKING) { if (el) { el.pause(); el.currentTime = 0; } return; } let advanced = false; let fallback = null; const advance = () => { if (advanced) return; advanced = true; clearTimeout(fallback); setPhase(P.CONFIRMED); }; const armFallback = () => { fallback = setTimeout(advance, DURATIONS[P.TALKING]); }; if (el) { el.currentTime = 0; el.addEventListener("ended", advance); el.addEventListener("error", armFallback); el.play().catch(armFallback); // autoplay blocked — fall back to a fixed duration } else { armFallback(); } return () => { if (el) { el.removeEventListener("ended", advance); el.removeEventListener("error", armFallback); } clearTimeout(fallback); }; }, [phase]); useEffectAnim(() => { const mq = () => setIsMobile(window.innerWidth < 980); mq(); window.addEventListener("resize", mq); return () => window.removeEventListener("resize", mq); }, []); // Show hint card 1.5s into HOLD_PHONE if user hasn't answered yet useEffectAnim(() => { if (phase !== P.HOLD_PHONE) { setShowAnswerHint(false); return; } const t = setTimeout(() => setShowAnswerHint(true), 1500); return () => clearTimeout(t); }, [phase]); // Phase progression useEffectAnim(() => { if (phase === P.TALKING) return; // talking ends when the call audio finishes if (phase === P.HOLD_PHONE) return; // wait for user to answer const t = setTimeout(() => { let next = (phase + 1) % 9; // Skip the bag-intro phases (IDLE, TAG, HOLD_TAG, MORPH) — go straight to HOLD_PHONE if (next === P.IDLE) { // looped — reset and jump to HOLD_PHONE setDuration(0); next = P.HOLD_PHONE; } if (next === P.TALKING) { setDuration(0); } setPhase(next); }, DURATIONS[phase]); return () => clearTimeout(t); }, [phase]); // Duration ticker during talking useEffectAnim(() => { if (phase !== P.TALKING && phase !== P.CONFIRMED) return; if (phase === P.CONFIRMED) return; const id = setInterval(() => setDuration((d) => d + 1), 1000); return () => clearInterval(id); }, [phase]); // ===== Visibility flags ===== const tagVisible = phase === P.TAG || phase === P.HOLD_TAG || phase === P.MORPH; const showBag = phase <= P.MORPH; const showPhone = phase >= P.MORPH; // Bag transforms during morph const bagScale = phase >= P.MORPH ? 0.3 : 1; const bagOpacity = phase >= P.MORPH ? 0 : 1; // Phone scale/opacity during morph const phoneScale = phase === P.MORPH ? 0.55 : 1; const phoneOpacity = !mounted ? 0 : phase === P.MORPH ? 0.6 : phase >= P.HOLD_PHONE ? 1 : 0; // Whether the phone is in incoming-call mode or active-call mode const isIncoming = phase === P.HOLD_PHONE || phase === P.ANSWERING; const isActive = phase === P.CONNECTING || phase === P.TALKING || phase === P.CONFIRMED; // ===== Phone tilt — base + dynamic per-phase adjustment ===== // Base: rotateX(6) rotateY(-24) rotateZ(9). Subtle wobble adds ±2-3° while idle. // During TALKING/CONFIRMED, ease toward less aggressive tilt so reading is easier. let baseRX = 6,baseRY = 24,baseRZ = -9; if (phase === P.ANSWERING) {baseRX = 5;baseRY = 20;baseRZ = -7;} if (phase === P.CONNECTING) {baseRX = 4;baseRY = 16;baseRZ = -5;} if (phase === P.TALKING) {baseRX = 3;baseRY = 12;baseRZ = -3;} if (phase === P.CONFIRMED) {baseRX = 3;baseRY = 10;baseRZ = -2;} return (