- Fix repeat-city travel placement: use stop indices instead of global city name matching so Follow Team trips with repeat cities show travel correctly - Add TravelPlacement helper and regression tests (7 tests) - Add alternate app icons for each theme, auto-switch on theme change - Fix index-out-of-range crash in AnimatedSportsBackground (19 configs, was iterating 20) - Add marketing video configs, engine, and new video components - Add docs and data exports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
322 lines
8.0 KiB
TypeScript
322 lines
8.0 KiB
TypeScript
import React from "react";
|
|
import {
|
|
AbsoluteFill,
|
|
useCurrentFrame,
|
|
useVideoConfig,
|
|
spring,
|
|
interpolate,
|
|
} from "remotion";
|
|
import { theme } from "../../components/shared/theme";
|
|
import { AppScreenshot, MockScreen } from "../../components/shared/AppScreenshot";
|
|
|
|
/**
|
|
* Scene 3: Road games surfaced
|
|
*
|
|
* Shows upcoming away games inside a phone frame, cards sliding in.
|
|
* On-screen text: "Plan it in seconds"
|
|
*/
|
|
|
|
type GameCard = {
|
|
opponent: string;
|
|
opponentColor: string;
|
|
date: string;
|
|
venue: string;
|
|
city: string;
|
|
};
|
|
|
|
const ROAD_GAMES: GameCard[] = [
|
|
{
|
|
opponent: "@ Dodgers",
|
|
opponentColor: "#005A9C",
|
|
date: "Fri, Jun 12",
|
|
venue: "Dodger Stadium",
|
|
city: "Los Angeles, CA",
|
|
},
|
|
{
|
|
opponent: "@ Giants",
|
|
opponentColor: "#FD5A1E",
|
|
date: "Sun, Jun 14",
|
|
venue: "Oracle Park",
|
|
city: "San Francisco, CA",
|
|
},
|
|
{
|
|
opponent: "@ Padres",
|
|
opponentColor: "#2F241D",
|
|
date: "Tue, Jun 16",
|
|
venue: "Petco Park",
|
|
city: "San Diego, CA",
|
|
},
|
|
{
|
|
opponent: "@ D-backs",
|
|
opponentColor: "#A71930",
|
|
date: "Thu, Jun 18",
|
|
venue: "Chase Field",
|
|
city: "Phoenix, AZ",
|
|
},
|
|
];
|
|
|
|
export const RoadGamesScene: React.FC = () => {
|
|
const frame = useCurrentFrame();
|
|
const { fps } = useVideoConfig();
|
|
|
|
// "Plan it in seconds" label overlay (outside phone)
|
|
const planLabelProgress = spring({
|
|
frame: frame - 2 * fps,
|
|
fps,
|
|
config: theme.animation.snappy,
|
|
});
|
|
const planLabelOpacity = interpolate(
|
|
frame - 2 * fps,
|
|
[0, 0.2 * fps],
|
|
[0, 1],
|
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
);
|
|
|
|
return (
|
|
<AbsoluteFill
|
|
style={{
|
|
background: theme.colors.background,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
{/* Phone frame with app UI */}
|
|
<AppScreenshot delay={0} scale={0.88}>
|
|
<MockScreen>
|
|
<RoadGamesScreenContent />
|
|
</MockScreen>
|
|
</AppScreenshot>
|
|
|
|
{/* "Plan it in seconds" label - overlaid outside phone */}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 120,
|
|
left: 0,
|
|
right: 0,
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
opacity: planLabelOpacity,
|
|
transform: `scale(${interpolate(planLabelProgress, [0, 1], [0.8, 1])})`,
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
background: theme.colors.accent,
|
|
padding: "14px 36px",
|
|
borderRadius: 40,
|
|
boxShadow: "0 8px 24px rgba(255, 107, 53, 0.4)",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontFamily: theme.fonts.display,
|
|
fontSize: 30,
|
|
fontWeight: 700,
|
|
color: theme.colors.text,
|
|
}}
|
|
>
|
|
Plan it in seconds
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</AbsoluteFill>
|
|
);
|
|
};
|
|
|
|
/** Inner screen content rendered inside the phone frame */
|
|
const RoadGamesScreenContent: React.FC = () => {
|
|
const frame = useCurrentFrame();
|
|
const { fps } = useVideoConfig();
|
|
|
|
// Header entrance
|
|
const headerProgress = spring({
|
|
frame,
|
|
fps,
|
|
config: theme.animation.smooth,
|
|
});
|
|
const headerOpacity = interpolate(frame, [0, 0.3 * fps], [0, 1], {
|
|
extrapolateRight: "clamp",
|
|
});
|
|
const headerY = interpolate(headerProgress, [0, 1], [20, 0]);
|
|
|
|
return (
|
|
<div style={{ padding: 12 }}>
|
|
{/* Header */}
|
|
<div
|
|
style={{
|
|
opacity: headerOpacity,
|
|
transform: `translateY(${headerY}px)`,
|
|
marginBottom: 32,
|
|
}}
|
|
>
|
|
{/* Team badge */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 10,
|
|
background: "#C9082A",
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontFamily: theme.fonts.display,
|
|
fontSize: 16,
|
|
fontWeight: 900,
|
|
color: "white",
|
|
}}
|
|
>
|
|
ATL
|
|
</span>
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontFamily: theme.fonts.display,
|
|
fontSize: 28,
|
|
fontWeight: 700,
|
|
color: theme.colors.text,
|
|
}}
|
|
>
|
|
Braves Road Games
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontFamily: theme.fonts.text,
|
|
fontSize: 18,
|
|
color: theme.colors.textSecondary,
|
|
}}
|
|
>
|
|
June 2026 away stretch
|
|
</div>
|
|
</div>
|
|
|
|
{/* Game cards */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 14,
|
|
}}
|
|
>
|
|
{ROAD_GAMES.map((game, index) => {
|
|
const cardDelay = 0.3 + index * 0.2;
|
|
const cardProgress = spring({
|
|
frame: frame - cardDelay * fps,
|
|
fps,
|
|
config: theme.animation.snappy,
|
|
});
|
|
|
|
const translateX = interpolate(cardProgress, [0, 1], [300, 0]);
|
|
const opacity = interpolate(cardProgress, [0, 1], [0, 1]);
|
|
|
|
return (
|
|
<div
|
|
key={game.venue}
|
|
style={{
|
|
background: "#1C1C1E",
|
|
borderRadius: 16,
|
|
padding: 22,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 16,
|
|
transform: `translateX(${translateX}px)`,
|
|
opacity,
|
|
borderLeft: `4px solid ${game.opponentColor}`,
|
|
}}
|
|
>
|
|
{/* Date block */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
minWidth: 60,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontFamily: theme.fonts.text,
|
|
fontSize: 13,
|
|
color: theme.colors.textMuted,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
}}
|
|
>
|
|
{game.date.split(", ")[0]}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontFamily: theme.fonts.display,
|
|
fontSize: 22,
|
|
fontWeight: 700,
|
|
color: theme.colors.text,
|
|
}}
|
|
>
|
|
{game.date.split(" ")[1].replace(",", "")}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div
|
|
style={{
|
|
width: 2,
|
|
height: 40,
|
|
background: "rgba(255,255,255,0.1)",
|
|
borderRadius: 1,
|
|
}}
|
|
/>
|
|
|
|
{/* Game info */}
|
|
<div style={{ flex: 1 }}>
|
|
<div
|
|
style={{
|
|
fontFamily: theme.fonts.display,
|
|
fontSize: 20,
|
|
fontWeight: 700,
|
|
color: theme.colors.text,
|
|
marginBottom: 3,
|
|
}}
|
|
>
|
|
{game.opponent}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontFamily: theme.fonts.text,
|
|
fontSize: 15,
|
|
color: theme.colors.textSecondary,
|
|
}}
|
|
>
|
|
{game.venue}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontFamily: theme.fonts.text,
|
|
fontSize: 13,
|
|
color: theme.colors.textMuted,
|
|
}}
|
|
>
|
|
{game.city}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|