← back to Nineoh Guide
apps/mobile/App.tsx
1193 lines
import { useCallback, useEffect, useState } from "react";
import {
ScrollView,
Text,
View,
StyleSheet,
ActivityIndicator,
Pressable,
Alert,
Linking,
Image,
} from "react-native";
import { StatusBar } from "expo-status-bar";
import Constants from "expo-constants";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as AppleAuthentication from "expo-apple-authentication";
import mobileAds, {
BannerAd,
BannerAdSize,
TestIds,
} from "react-native-google-mobile-ads";
import { requestTrackingPermissionsAsync } from "expo-tracking-transparency";
import { APP, EpisodeSchema, type Episode } from "@nineoh/core";
import { logScreen, enableCollectionAfterATT } from "./analytics";
// Real AdMob banner unit in production; Google's test unit in dev (so we never
// serve/click live ads while developing, per AdMob policy).
const BANNER_UNIT_ID = __DEV__
? TestIds.BANNER
: "ca-app-pub-5278231299883833/1889257150";
// API base resolution (in precedence order):
// 1. EXPO_PUBLIC_API_BASE — set as an EAS Secret for TestFlight/prod builds
// 2. app.json → extra.apiBaseUrl — read via expo-constants (ignored if placeholder)
// 3. localhost:4090 — local dev fallback
const extra = Constants.expoConfig?.extra as { apiBaseUrl?: string } | undefined;
const configured =
extra?.apiBaseUrl && !extra.apiBaseUrl.includes("REPLACE")
? extra.apiBaseUrl
: undefined;
const API_BASE =
process.env.EXPO_PUBLIC_API_BASE ??
configured ??
(__DEV__ ? "http://localhost:4090" : "https://nineoh-guide.vercel.app");
const WATCHLIST_KEY = "nineoh.watchlist.v1";
const WATCHED_KEY = "nineoh.watchedThrough.v1";
const APPLE_USER_KEY = "nineoh.appleUser.v1";
const SELECTED_SHOW_KEY = "nineoh.selectedShow.v1";
// The two shows this guide covers. `canonical_title` matches the DB seed.
const ORIGINAL_CANONICAL = "beverly-hills-90210";
type ShowInfo = {
description: string;
seasonCount: number;
episodeCount: number;
premiereDate: string | null;
finaleDate: string | null;
disclaimerText: string;
};
type Show = {
id: string;
canonicalTitle: string;
displayTitle: string;
showInfo?: ShowInfo;
};
// Short, friendly label for the show toggle. Derived from displayTitle but
// pinned to the copy the brief specifies for the two known shows.
function showLabel(s: Show): string {
if (s.canonicalTitle === ORIGINAL_CANONICAL) return "Beverly Hills, 90210 (1990)";
if (s.canonicalTitle === "90210") return "90210 (2008)";
return s.displayTitle;
}
type TabKey = "episodes" | "cast" | "media" | "news" | "saved";
const TABS: { key: TabKey; icon: string; label: string }[] = [
{ key: "episodes", icon: "📺", label: "Episodes" },
{ key: "cast", icon: "🎭", label: "Cast" },
{ key: "media", icon: "🎬", label: "Watch" },
{ key: "news", icon: "📰", label: "News" },
{ key: "saved", icon: "★", label: "Saved" },
];
// Curated external media — YouTube, podcasts, streaming & reference.
// Landing/search URLs (not fabricated deep links) so nothing 404s.
type MediaLink = { label: string; sub: string; url: string };
const MEDIA: { section: string; items: MediaLink[] }[] = [
{
section: "▶ YouTube",
items: [
{
label: "Official clips & iconic scenes",
sub: "YouTube",
url: "https://www.youtube.com/results?search_query=beverly+hills+90210+official+clip",
},
{
label: "Cast reunions & interviews",
sub: "YouTube",
url: "https://www.youtube.com/results?search_query=beverly+hills+90210+cast+reunion+interview",
},
{
label: "Opening theme & title sequences",
sub: "YouTube",
url: "https://www.youtube.com/results?search_query=beverly+hills+90210+opening+theme",
},
{
label: "Retrospectives & where-are-they-now",
sub: "YouTube",
url: "https://www.youtube.com/results?search_query=beverly+hills+90210+retrospective+where+are+they+now",
},
],
},
{
section: "🎧 More podcasts",
items: [
{
label: "Browse all 90210 podcasts",
sub: "Apple Podcasts",
url: "https://podcasts.apple.com/us/search?term=beverly%20hills%2090210",
},
{
label: "90210 podcasts on Spotify",
sub: "Spotify",
url: "https://open.spotify.com/search/beverly%20hills%2090210",
},
],
},
{
section: "📺 Where to watch",
items: [
{
label: "Streaming availability",
sub: "JustWatch",
url: "https://www.justwatch.com/us/search?q=Beverly%20Hills%2090210",
},
{
label: "Watch on Hulu",
sub: "hulu.com",
url: "https://www.hulu.com/search?q=beverly%20hills%2090210",
},
],
},
{
section: "🔎 More",
items: [
{
label: "Series overview & history",
sub: "Wikipedia",
url: "https://en.wikipedia.org/wiki/Beverly_Hills,_90210",
},
{
label: "Full cast & episode guide",
sub: "IMDb",
url: "https://www.imdb.com/find/?q=Beverly%20Hills%2090210",
},
],
},
];
// Fan Club — the REAL podcasts hosted by members of the 90210 cast.
// Every Apple Podcasts link is a verified canonical show URL (/id<number>) so
// none 404; Spotify uses a search URL (same "never fabricate a deep link" rule).
type CastPodcast = {
show: string;
host: string;
character: string;
note: string;
apple: string;
spotify: string;
};
const CAST_PODCASTS: CastPodcast[] = [
{
show: "90210MG",
host: "Jennie Garth",
character: "Kelly Taylor",
note: "The official Beverly Hills, 90210 rewatch podcast — episode-by-episode, with cast guests.",
apple: "https://podcasts.apple.com/us/podcast/90210mg/id1534142827",
spotify: "https://open.spotify.com/search/90210MG",
},
{
show: "misSPELLING",
host: "Tori Spelling",
character: "Donna Martin",
note: "Tori bares all — candid, funny, unfiltered stories from her life on and off the show.",
apple: "https://podcasts.apple.com/us/podcast/misspelling/id1738355230",
spotify: "https://open.spotify.com/search/misSPELLING%20Tori%20Spelling",
},
{
show: "I Choose Me with Jennie Garth",
host: "Jennie Garth",
character: "Kelly Taylor",
note: "Jennie puts herself first — weekly conversations on reinvention, family and self-worth.",
apple: "https://podcasts.apple.com/us/podcast/id1347626043",
spotify: "https://open.spotify.com/search/I%20Choose%20Me%20Jennie%20Garth",
},
{
show: "Oldish",
host: "Brian Austin Green",
character: "David Silver",
note: "Brian co-hosts with Randy Spelling & Sharna Burgess — age-old questions, new-age answers.",
apple: "https://podcasts.apple.com/us/podcast/oldish/id1706570295",
spotify: "https://open.spotify.com/search/Oldish%20Brian%20Austin%20Green",
},
{
show: "Let's Be Clear with Shannen Doherty",
host: "Shannen Doherty",
character: "Brenda Walsh · 1971–2024",
note: "Shannen's own words — honest, moving conversations she recorded and shared with fans.",
apple: "https://podcasts.apple.com/us/podcast/lets-be-clear-with-shannen-doherty/id1718531401",
spotify: "https://open.spotify.com/search/Let's%20Be%20Clear%20Shannen%20Doherty",
},
];
type CastMember = {
id: string;
showId: string | null;
name: string;
characterName: string | null;
bio: string | null;
photoUrl: string | null;
attribution: string | null;
kind?: string; // "main-cast" | "recurring"
};
type NewsItem = {
id: string;
headline: string;
publisher: string | null;
url: string;
publishedAt: string | null;
};
function isAhead(
p: { season: number; episode: number } | null,
s: number,
e: number
) {
if (!p) return false;
return s > p.season || (s === p.season && e > p.episode);
}
export default function App() {
const [tab, setTab] = useState<TabKey>("episodes");
// Firebase Analytics (→ GA4): log a screen view whenever the visible tab
// changes (also fires once on mount for the initial "episodes" tab).
useEffect(() => {
logScreen(tab);
}, [tab]);
// Show selector — the guide covers two shows; default to the ORIGINAL.
const [shows, setShows] = useState<Show[]>([]);
const [selectedShowId, setSelectedShowId] = useState<string | null>(null);
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [cast, setCast] = useState<CastMember[]>([]);
const [news, setNews] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [castLoaded, setCastLoaded] = useState(false);
const [newsLoaded, setNewsLoaded] = useState(false);
const [saved, setSaved] = useState<Set<string>>(new Set());
const [watchedThrough, setWatchedThrough] = useState<{
season: number;
episode: number;
} | null>(null);
const [revealed, setRevealed] = useState<Set<string>>(new Set());
const [eggTaps, setEggTaps] = useState(0);
const [egg, setEgg] = useState(false);
const [showInfoExpanded, setShowInfoExpanded] = useState(false);
const [appleUser, setAppleUser] = useState<{ id: string; name: string | null } | null>(
null
);
// Easter egg #1: tap the UNOFFICIAL badge 5× to unlock the hidden 90210 card.
const tapBadge = useCallback(() => {
setEggTaps((n) => {
if (n + 1 >= 5) {
setEgg(true);
return 0;
}
return n + 1;
});
}, []);
// Easter egg #2: long-press the title for a ZIP-code fun fact.
const titleFact = useCallback(() => {
Alert.alert(
"📟 90210",
"It's a real ZIP code — Beverly Hills, California — and one of the most famous postal codes in the world."
);
}, []);
// Ads — ask for tracking permission (ATT), then initialize the Google Mobile Ads SDK.
useEffect(() => {
(async () => {
try {
await requestTrackingPermissionsAsync();
} catch {
/* user can decline; ads still serve non-personalized */
}
// ATT has now resolved (allowed or denied). ONLY now enable Firebase
// Analytics + Crashlytics collection, so no event transmits pre-consent
// (fixes the App Store 5.1.2 flag: a screen_view firing before ATT).
await enableCollectionAfterATT();
try {
await mobileAds().initialize();
} catch {
/* ads are best-effort; never block the app */
}
})();
}, []);
// Sign in with Apple — optional account so the watchlist can sync across devices.
useEffect(() => {
AsyncStorage.getItem(APPLE_USER_KEY).then((raw) => {
if (raw) {
try {
setAppleUser(JSON.parse(raw));
} catch {
/* ignore corrupt store */
}
}
});
}, []);
const signInWithApple = useCallback(async () => {
try {
const cred = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
const name = cred.fullName?.givenName
? `${cred.fullName.givenName}${
cred.fullName.familyName ? " " + cred.fullName.familyName : ""
}`
: null;
const u = { id: cred.user, name };
setAppleUser(u);
AsyncStorage.setItem(APPLE_USER_KEY, JSON.stringify(u));
} catch (e: any) {
if (e?.code !== "ERR_REQUEST_CANCELED") {
Alert.alert("Sign in failed", "Could not complete Sign in with Apple.");
}
}
}, []);
const signOutApple = useCallback(() => {
setAppleUser(null);
AsyncStorage.removeItem(APPLE_USER_KEY);
}, []);
// Native feature: persisted watchlist + spoiler cutoff (survive restarts).
useEffect(() => {
AsyncStorage.getItem(WATCHLIST_KEY).then((raw) => {
if (raw) {
try {
setSaved(new Set(JSON.parse(raw)));
} catch {
/* ignore corrupt store */
}
}
});
AsyncStorage.getItem(WATCHED_KEY).then((raw) => {
if (raw) {
try {
setWatchedThrough(JSON.parse(raw));
} catch {
/* ignore */
}
}
});
}, []);
const markWatched = useCallback((season: number, episode: number) => {
const p = { season, episode };
setWatchedThrough(p);
AsyncStorage.setItem(WATCHED_KEY, JSON.stringify(p));
setRevealed(new Set());
}, []);
const reveal = useCallback((id: string) => {
setRevealed((prev) => new Set(prev).add(id));
}, []);
const toggleSave = useCallback((id: string) => {
setSaved((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
AsyncStorage.setItem(WATCHLIST_KEY, JSON.stringify([...next]));
return next;
});
}, []);
// Show list loads on mount. Default to the ORIGINAL (or the persisted choice
// if it's still a valid show). The choice persists across restarts.
useEffect(() => {
let alive = true;
(async () => {
let saved: string | null = null;
try {
saved = await AsyncStorage.getItem(SELECTED_SHOW_KEY);
} catch {
/* ignore corrupt store */
}
try {
const r = await fetch(`${API_BASE}/api/shows`);
const data = r.ok ? await r.json() : { shows: [] };
const list: Show[] = data.shows ?? [];
if (!alive) return;
setShows(list);
if (list.length > 0) {
const savedValid = saved && list.some((s) => s.id === saved);
const original = list.find((s) => s.canonicalTitle === ORIGINAL_CANONICAL);
setSelectedShowId(
savedValid ? saved : original ? original.id : list[0].id
);
}
} catch {
if (alive) setShows([]);
}
})();
return () => {
alive = false;
};
}, []);
const selectShow = useCallback((id: string) => {
setSelectedShowId(id);
AsyncStorage.setItem(SELECTED_SHOW_KEY, id).catch(() => {
/* best-effort persistence */
});
}, []);
// Episodes load on mount; cast + news lazy-load the first time their tab opens.
useEffect(() => {
fetch(`${API_BASE}/api/episodes`)
.then((r) => (r.ok ? r.json() : { episodes: [] }))
.then((data) => {
const parsed = (data.episodes ?? [])
.map((e: unknown) => EpisodeSchema.safeParse(e))
.filter((p: any) => p.success)
.map((p: any) => p.data);
setEpisodes(parsed);
})
.catch(() => setEpisodes([]))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (tab === "cast" && !castLoaded) {
setCastLoaded(true);
fetch(`${API_BASE}/api/cast`)
.then((r) => (r.ok ? r.json() : { cast: [] }))
.then((d) => setCast(d.cast ?? []))
.catch(() => setCast([]));
}
if (tab === "news" && !newsLoaded) {
setNewsLoaded(true);
fetch(`${API_BASE}/api/news`)
.then((r) => (r.ok ? r.json() : { news: [] }))
.then((d) => setNews(d.news ?? []))
.catch(() => setNews([]));
}
}, [tab, castLoaded, newsLoaded]);
// Only show content for the selected show. Until a show is chosen (or if the
// API is unavailable), fall back to showing everything so the app is never blank.
const showEpisodes = selectedShowId
? episodes.filter((e) => e.showId === selectedShowId)
: episodes;
const showCast = selectedShowId
? cast.filter((p) => p.showId === selectedShowId)
: cast;
const savedEpisodes = showEpisodes.filter((e) => saved.has(e.id));
const renderEpisode = (ep: Episode) => {
const isSaved = saved.has(ep.id);
return (
<BlurView key={ep.id} intensity={28} tint="light" style={styles.card}>
<View style={styles.cardRow}>
<Text style={styles.cardTitle}>
S{ep.seasonNumber}E{ep.episodeNumber} · {ep.title}
{ep.spoilerRating > 0 ? (
<Text style={styles.spoiler}> ⚠ spoilers</Text>
) : null}
</Text>
<Pressable onPress={() => toggleSave(ep.id)} hitSlop={10}>
<Text style={styles.star}>{isSaved ? "★" : "☆"}</Text>
</Pressable>
</View>
{ep.summaryShortOriginal ? (
isAhead(watchedThrough, ep.seasonNumber, ep.episodeNumber) &&
!revealed.has(ep.id) ? (
<Pressable onPress={() => reveal(ep.id)}>
<Text style={styles.hidden}>
⚠ Recap hidden (ahead of your progress) — tap to reveal
</Text>
</Pressable>
) : (
<Text style={styles.cardBody}>{ep.summaryShortOriginal}</Text>
)
) : (
<Text style={styles.pending}>Recap coming soon</Text>
)}
{ep.cast && ep.cast.length > 0 ? (
<Text style={styles.epCast}>
<Text style={styles.epCastLabel}>★ </Text>
{ep.cast
.slice(0, 8)
.map((c) => c.name)
.join(", ")}
{ep.cast.length > 8 ? ` +${ep.cast.length - 8} more` : ""}
</Text>
) : null}
<View style={styles.epActions}>
<Pressable
onPress={() => markWatched(ep.seasonNumber, ep.episodeNumber)}
hitSlop={6}
>
<Text style={styles.markWatched}>✓ watched to here</Text>
</Pressable>
{ep.externalIds?.tvmaze ? (
<Pressable
onPress={() =>
Linking.openURL(
`https://www.tvmaze.com/episodes/${ep.externalIds.tvmaze}`
)
}
hitSlop={6}
>
<Text style={styles.epLink}>▶ View this episode ↗</Text>
</Pressable>
) : null}
</View>
</BlurView>
);
};
const renderCastCard = (p: CastMember) => (
<BlurView key={p.id} intensity={28} tint="light" style={styles.card}>
<View style={styles.castRow}>
{p.photoUrl ? (
<Image source={{ uri: p.photoUrl }} style={styles.headshot} />
) : (
<View style={[styles.headshot, styles.headshotBlank]}>
<Text style={styles.headshotInitial}>{p.name.slice(0, 1)}</Text>
</View>
)}
<View style={{ flex: 1 }}>
<Text style={styles.cardTitle}>{p.name}</Text>
{p.characterName ? (
<Text style={styles.castChar}>as {p.characterName}</Text>
) : null}
</View>
</View>
{p.bio ? (
<Text style={styles.cardBody}>{p.bio}</Text>
) : (
<Text style={styles.pending}>Bio coming soon</Text>
)}
{p.attribution ? (
<Text style={styles.attribution}>{p.attribution}</Text>
) : null}
</BlurView>
);
const mainCast = showCast.filter((p) => p.kind === "main-cast");
const recurringCast = showCast.filter((p) => p.kind !== "main-cast");
return (
<View style={styles.root}>
<LinearGradient
colors={["#f4e9f2", "#faf1ea", "#fdf3e0"]}
style={StyleSheet.absoluteFill}
/>
<StatusBar style="light" />
<LinearGradient
colors={["#2b1e3a", "#7b3f6e", "#d9668a"]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.header}
>
<Pressable onLongPress={titleFact} delayLongPress={450}>
<Text style={styles.title}>{APP.displayName}</Text>
</Pressable>
<Pressable onPress={tapBadge} hitSlop={8}>
<Text style={styles.badge}>UNOFFICIAL</Text>
</Pressable>
</LinearGradient>
{/* Show selector — pick which series to browse (defaults to the Original) */}
{shows.length > 1 ? (
<View style={styles.showSelector}>
{shows.map((s) => {
const active = s.id === selectedShowId;
return (
<Pressable
key={s.id}
onPress={() => selectShow(s.id)}
style={[styles.showSeg, active && styles.showSegActive]}
>
<Text
style={[styles.showSegText, active && styles.showSegTextActive]}
numberOfLines={2}
>
{showLabel(s)}
</Text>
</Pressable>
);
})}
</View>
) : null}
{/* Show info banner — subtle strip below the selector showing original show details */}
{(() => {
const sel = shows.find((s) => s.id === selectedShowId);
const info = sel?.showInfo;
if (!info) return null;
// Split description into sentences; show first 2, rest behind "more"
const sentences = info.description.match(/[^.!?]+[.!?]+/g) ?? [info.description];
const preview = sentences.slice(0, 2).join(" ").trim();
const rest = sentences.slice(2).join(" ").trim();
const premiereYear = info.premiereDate ? info.premiereDate.slice(0, 4) : null;
const finaleYear = info.finaleDate ? info.finaleDate.slice(0, 4) : null;
const yearRange =
premiereYear && finaleYear ? `${premiereYear}–${finaleYear}` : premiereYear ?? null;
return (
<View style={styles.showInfoBanner}>
<View style={styles.showInfoChips}>
{info.seasonCount > 0 && (
<View style={styles.showInfoChip}>
<Text style={styles.showInfoChipText}>
📺 {info.seasonCount} Season{info.seasonCount !== 1 ? "s" : ""}
</Text>
</View>
)}
{info.episodeCount > 0 && (
<View style={styles.showInfoChip}>
<Text style={styles.showInfoChipText}>🎬 {info.episodeCount} Episodes</Text>
</View>
)}
{yearRange && (
<View style={styles.showInfoChip}>
<Text style={styles.showInfoChipText}>📅 {yearRange}</Text>
</View>
)}
</View>
<Text style={styles.showInfoDesc}>
{preview}
{rest && !showInfoExpanded ? " " : ""}
{rest && !showInfoExpanded ? (
<Text
style={styles.showInfoMore}
onPress={() => setShowInfoExpanded(true)}
>
more
</Text>
) : null}
{rest && showInfoExpanded ? ` ${rest}` : ""}
{rest && showInfoExpanded ? (
<Text
style={styles.showInfoMore}
onPress={() => setShowInfoExpanded(false)}
>
{" "}less
</Text>
) : null}
</Text>
<Text style={styles.showInfoDisclaimer}>{info.disclaimerText}</Text>
</View>
);
})()}
{egg ? (
<Pressable style={styles.egg} onPress={() => setEgg(false)}>
<Text style={styles.eggEmoji}>📟✨</Text>
<Text style={styles.eggTitle}>You found the 90210 easter egg!</Text>
<Text style={styles.eggBody}>
"We're not in Kansas anymore." Thanks for tapping around — you're a real
fan. Tap anywhere to close.
</Text>
</Pressable>
) : null}
{/* Spoiler-guard status bar (only on episode-based tabs) */}
{(tab === "episodes" || tab === "saved") && watchedThrough ? (
<View style={styles.toolbar}>
<Pressable
onPress={() => {
setWatchedThrough(null);
AsyncStorage.removeItem(WATCHED_KEY);
}}
style={[styles.chip, styles.chipGuard]}
>
<Text style={styles.chipGuardText}>
🛡 spoilers hidden after S{watchedThrough.season}E
{watchedThrough.episode} ✕
</Text>
</Pressable>
</View>
) : null}
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.body}
showsVerticalScrollIndicator={false}
>
{tab === "episodes" ? (
<>
<Text style={styles.disclaimer}>{APP.disclaimerShort}</Text>
{loading ? (
<ActivityIndicator style={{ marginTop: 24 }} />
) : showEpisodes.length === 0 ? (
<Text style={styles.empty}>
Content is being curated. Original recaps will appear here.
</Text>
) : (
showEpisodes.map(renderEpisode)
)}
</>
) : null}
{tab === "saved" ? (
<>
<View style={styles.signInCard}>
{appleUser ? (
<View style={styles.signedInRow}>
<Text style={styles.signedInText}>
Signed in{appleUser.name ? ` as ${appleUser.name}` : " with Apple"}
</Text>
<Pressable onPress={signOutApple} hitSlop={8}>
<Text style={styles.signOutLink}>Sign out</Text>
</Pressable>
</View>
) : (
<>
<Text style={styles.signInBlurb}>
Sign in to sync your watchlist across your devices.
</Text>
<AppleAuthentication.AppleAuthenticationButton
buttonType={
AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN
}
buttonStyle={
AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
}
cornerRadius={10}
style={styles.appleBtn}
onPress={signInWithApple}
/>
</>
)}
</View>
<Text style={styles.sectionTitle}>★ Your watchlist</Text>
{savedEpisodes.length === 0 ? (
<Text style={styles.empty}>
No saved episodes yet — tap the ☆ star on any episode to add it.
</Text>
) : (
savedEpisodes.map(renderEpisode)
)}
</>
) : null}
{tab === "cast" ? (
<>
<Text style={styles.sectionTitle}>🎭 Cast</Text>
{!castLoaded || showCast.length === 0 ? (
!castLoaded ? (
<ActivityIndicator style={{ marginTop: 24 }} />
) : (
<Text style={styles.empty}>
Cast profiles are being written. Bios are original; photos appear
only once their licenses are cleared.
</Text>
)
) : (
<>
{mainCast.length > 0 ? (
<Text style={styles.castGroup}>Main cast</Text>
) : null}
{mainCast.map(renderCastCard)}
{recurringCast.length > 0 ? (
<Text style={styles.castGroup}>
Recurring & guest stars ({recurringCast.length})
</Text>
) : null}
{recurringCast.map(renderCastCard)}
</>
)}
</>
) : null}
{tab === "news" ? (
<>
<Text style={styles.sectionTitle}>📰 90210 in the news</Text>
{!newsLoaded ? (
<ActivityIndicator style={{ marginTop: 24 }} />
) : news.length === 0 ? (
<Text style={styles.empty}>No news right now — check back soon.</Text>
) : (
news.map((n) => (
<Pressable
key={n.id}
onPress={() => Linking.openURL(n.url)}
style={styles.newsCard}
>
<Text style={styles.newsHeadline}>{n.headline}</Text>
<Text style={styles.newsMeta}>
{n.publisher ?? "Source"}
{n.publishedAt
? " · " +
new Date(n.publishedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})
: ""}
{" ↗"}
</Text>
</Pressable>
))
)}
</>
) : null}
{tab === "media" ? (
<>
<Text style={styles.sectionTitle}>🎬 Watch, listen & explore</Text>
<Text style={styles.disclaimer}>
Curated links out to YouTube, podcast apps and streaming guides. An
unofficial fan guide — not affiliated with or endorsed by the show.
</Text>
{/* Fan Club — the real podcasts hosted by the 90210 cast */}
<Text style={styles.fanClubTitle}>🎙 Fan Club</Text>
<Text style={styles.fanClubBlurb}>
Podcasts hosted by the cast themselves — tap to listen.
</Text>
{CAST_PODCASTS.map((p) => (
<View key={p.show} style={styles.podCard}>
<Text style={styles.podShow}>{p.show}</Text>
<Text style={styles.podHost}>
{p.host} <Text style={styles.podChar}>· {p.character}</Text>
</Text>
<Text style={styles.podNote}>{p.note}</Text>
<View style={styles.podLinks}>
<Pressable
onPress={() => Linking.openURL(p.apple)}
style={[styles.podBtn, styles.podBtnApple]}
hitSlop={6}
>
<Text style={styles.podBtnAppleText}>Apple Podcasts ↗</Text>
</Pressable>
<Pressable
onPress={() => Linking.openURL(p.spotify)}
style={[styles.podBtn, styles.podBtnSpotify]}
hitSlop={6}
>
<Text style={styles.podBtnSpotifyText}>♫ Spotify ↗</Text>
</Pressable>
</View>
</View>
))}
{MEDIA.map((g) => (
<View key={g.section}>
<Text style={styles.mediaGroup}>{g.section}</Text>
{g.items.map((m) => (
<Pressable
key={m.url}
onPress={() => Linking.openURL(m.url)}
style={styles.newsCard}
>
<Text style={styles.newsHeadline}>{m.label}</Text>
<Text style={styles.newsMeta}>
{m.sub}
{" ↗"}
</Text>
</Pressable>
))}
</View>
))}
</>
) : null}
</ScrollView>
{/* Banner ad — anchored above the tab bar */}
<View style={styles.adSlot}>
<BannerAd
unitId={BANNER_UNIT_ID}
size={BannerAdSize.ANCHORED_ADAPTIVE_BANNER}
/>
</View>
{/* Bottom tab bar */}
<View style={styles.tabbar}>
{TABS.map((t) => {
const active = tab === t.key;
const count = t.key === "saved" ? saved.size : 0;
return (
<Pressable
key={t.key}
style={styles.tab}
onPress={() => setTab(t.key)}
>
<Text style={[styles.tabIcon, active && styles.tabIconActive]}>
{t.icon}
</Text>
<Text style={[styles.tabLabel, active && styles.tabLabelActive]}>
{t.label}
{count > 0 ? ` (${count})` : ""}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "transparent" },
adSlot: { alignItems: "center", justifyContent: "center", backgroundColor: "transparent" },
header: {
paddingTop: 64,
paddingHorizontal: 20,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
flexDirection: "row",
alignItems: "baseline",
gap: 10,
},
title: { fontSize: 20, fontWeight: "700", color: "#fff8f0" },
badge: { fontSize: 11, fontWeight: "700", color: "#ffd27a" },
showSelector: {
flexDirection: "row",
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: "rgba(43,30,58,0.06)",
},
showSeg: {
flex: 1,
borderWidth: 1,
borderColor: "#d8c4d4",
borderRadius: 10,
paddingVertical: 8,
paddingHorizontal: 8,
backgroundColor: "rgba(255,255,255,0.6)",
alignItems: "center",
justifyContent: "center",
},
showSegActive: { backgroundColor: "#7b3f6e", borderColor: "#7b3f6e" },
showSegText: {
fontSize: 12,
fontWeight: "600",
color: "#5a4a66",
textAlign: "center",
},
showSegTextActive: { color: "#fff8f0" },
toolbar: {
paddingHorizontal: 20,
paddingVertical: 8,
flexDirection: "row",
gap: 8,
},
chip: {
borderWidth: 1,
borderColor: "#ccc",
borderRadius: 16,
paddingHorizontal: 12,
paddingVertical: 5,
},
scroll: { flex: 1 },
body: { padding: 20, paddingTop: 12, paddingBottom: 28 },
sectionTitle: {
fontSize: 17,
fontWeight: "700",
color: "#3a2540",
marginBottom: 12,
},
disclaimer: { fontSize: 12, color: "#666", marginBottom: 16 },
mediaGroup: {
fontSize: 14,
fontWeight: "700",
color: "#7b3f6e",
marginTop: 16,
marginBottom: 8,
},
// Fan Club — cast-hosted podcasts
fanClubTitle: {
fontSize: 16,
fontWeight: "800",
color: "#7b3f6e",
marginTop: 4,
marginBottom: 2,
},
fanClubBlurb: { fontSize: 12, color: "#8a6d8a", marginBottom: 10 },
podCard: {
backgroundColor: "rgba(255,255,255,0.6)",
borderWidth: 1,
borderColor: "rgba(123,63,110,0.25)",
borderRadius: 14,
padding: 14,
marginBottom: 10,
},
podShow: { fontSize: 15, fontWeight: "700", color: "#2b1e3a" },
podHost: { fontSize: 13, fontWeight: "600", color: "#7b3f6e", marginTop: 3 },
podChar: { fontSize: 12, fontWeight: "400", color: "#8a6d8a" },
podNote: { fontSize: 12.5, color: "#4a3a52", marginTop: 6, lineHeight: 18 },
podLinks: { flexDirection: "row", gap: 8, marginTop: 10 },
podBtn: {
borderRadius: 8,
paddingVertical: 7,
paddingHorizontal: 12,
alignItems: "center",
justifyContent: "center",
},
podBtnApple: { backgroundColor: "#2b1e3a" },
podBtnAppleText: { color: "#fff6ec", fontSize: 12, fontWeight: "700" },
podBtnSpotify: { backgroundColor: "#1DB954" },
podBtnSpotifyText: { color: "#08240f", fontSize: 12, fontWeight: "700" },
empty: { fontSize: 14, color: "#8a1c1c", lineHeight: 20 },
signInCard: {
backgroundColor: "rgba(30,20,48,0.04)",
borderRadius: 14,
padding: 16,
marginBottom: 20,
},
signInBlurb: { fontSize: 13, color: "#5a4a66", marginBottom: 12, lineHeight: 18 },
appleBtn: { height: 46, width: "100%" },
signedInRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
signedInText: { fontSize: 15, fontWeight: "600", color: "#3a2540" },
signOutLink: { fontSize: 14, color: "#7b3f6e", fontWeight: "600" },
card: {
backgroundColor: "rgba(255,255,255,0.5)",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.65)",
borderRadius: 14,
padding: 14,
marginBottom: 10,
overflow: "hidden",
},
cardRow: { flexDirection: "row", justifyContent: "space-between", gap: 8 },
cardTitle: { fontSize: 15, fontWeight: "600", flex: 1 },
spoiler: { fontSize: 11, color: "#b8860b", fontWeight: "400" },
star: { fontSize: 18, color: "#8a1c1c" },
cardBody: { fontSize: 13, color: "#444", marginTop: 4, lineHeight: 18 },
pending: { fontSize: 12, color: "#aaa", marginTop: 4 },
hidden: {
fontSize: 12,
color: "#8a1c1c",
marginTop: 4,
borderWidth: 1,
borderColor: "#d8b4b4",
borderStyle: "dashed",
borderRadius: 4,
padding: 6,
},
epActions: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: 8,
gap: 12,
},
markWatched: { fontSize: 11, color: "#888" },
epLink: { fontSize: 12, color: "#7b3f6e", fontWeight: "700" },
epCast: {
fontSize: 12,
color: "#5a4a56",
marginTop: 8,
lineHeight: 17,
},
epCastLabel: { color: "#b8860b", fontWeight: "700" },
castGroup: {
fontSize: 13,
fontWeight: "700",
color: "#7b3f6e",
marginTop: 10,
marginBottom: 8,
textTransform: "uppercase",
letterSpacing: 0.5,
},
chipGuard: { backgroundColor: "#f3e9e9", borderColor: "#d8b4b4" },
chipGuardText: { fontSize: 13, color: "#8a1c1c" },
// cast
castRow: { flexDirection: "row", alignItems: "center", gap: 12 },
headshot: { width: 52, height: 52, borderRadius: 26, backgroundColor: "#e8dce6" },
headshotBlank: { alignItems: "center", justifyContent: "center" },
headshotInitial: { fontSize: 22, fontWeight: "700", color: "#7b3f6e" },
castChar: { fontSize: 12, color: "#7b3f6e", marginTop: 2 },
attribution: { fontSize: 10, color: "#999", marginTop: 8, lineHeight: 14 },
// news
newsCard: {
backgroundColor: "rgba(255,255,255,0.55)",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.7)",
borderRadius: 12,
padding: 13,
marginBottom: 9,
},
newsHeadline: { fontSize: 14, fontWeight: "600", color: "#2b1e3a", lineHeight: 19 },
newsMeta: { fontSize: 11, color: "#8a6d8a", marginTop: 6 },
// tab bar
tabbar: {
flexDirection: "row",
borderTopWidth: 1,
borderTopColor: "#e3d6de",
backgroundColor: "rgba(255,255,255,0.92)",
paddingBottom: 26,
paddingTop: 8,
},
tab: { flex: 1, alignItems: "center", gap: 3 },
tabIcon: { fontSize: 20, opacity: 0.5 },
tabIconActive: { opacity: 1 },
tabLabel: { fontSize: 10, color: "#9a8a96" },
tabLabelActive: { color: "#7b3f6e", fontWeight: "700" },
egg: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
backgroundColor: "rgba(43,30,58,0.96)",
alignItems: "center",
justifyContent: "center",
padding: 36,
},
eggEmoji: { fontSize: 60, marginBottom: 12 },
eggTitle: {
color: "#ffd27a",
fontSize: 22,
fontWeight: "700",
textAlign: "center",
marginBottom: 10,
},
eggBody: { color: "#fff6ec", fontSize: 14, textAlign: "center", lineHeight: 21 },
// Show info banner (below the show selector)
showInfoBanner: {
paddingHorizontal: 14,
paddingTop: 6,
paddingBottom: 8,
backgroundColor: "rgba(123,63,110,0.06)",
borderBottomWidth: 1,
borderBottomColor: "rgba(123,63,110,0.10)",
},
showInfoChips: {
flexDirection: "row",
flexWrap: "wrap",
gap: 5,
marginBottom: 6,
},
showInfoChip: {
backgroundColor: "rgba(123,63,110,0.12)",
borderRadius: 10,
paddingHorizontal: 8,
paddingVertical: 3,
},
showInfoChipText: {
fontSize: 11,
fontWeight: "600",
color: "#5a3a5a",
},
showInfoDesc: {
fontSize: 12,
color: "#4a3a52",
lineHeight: 18,
marginBottom: 4,
},
showInfoMore: {
fontSize: 12,
color: "#7b3f6e",
fontWeight: "600",
},
showInfoDisclaimer: {
fontSize: 10,
color: "#999",
lineHeight: 14,
},
});