← 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 &amp; 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,
  },
});