← back to Nineoh Guide

apps/mobile/App.tsx

708 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 { APP, EpisodeSchema, type Episode } from "@nineoh/core";

// 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 ?? "http://localhost:4090";

const WATCHLIST_KEY = "nineoh.watchlist.v1";
const WATCHED_KEY = "nineoh.watchedThrough.v1";

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: "🎧  Podcasts",
    items: [
      {
        label: "9021OMG — Jennie Garth & Tori Spelling",
        sub: "The official rewatch podcast",
        url: "https://podcasts.apple.com/us/search?term=9021OMG",
      },
      {
        label: "All 90210 rewatch podcasts",
        sub: "Apple Podcasts",
        url: "https://podcasts.apple.com/us/search?term=beverly%20hills%2090210",
      },
      {
        label: "Listen on Spotify",
        sub: "Spotify",
        url: "https://open.spotify.com/search/9021OMG",
      },
    ],
  },
  {
    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",
      },
    ],
  },
];

type CastMember = {
  id: string;
  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");

  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);

  // 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."
    );
  }, []);

  // 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;
    });
  }, []);

  // 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]);

  const savedEpisodes = episodes.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 = cast.filter((p) => p.kind === "main-cast");
  const recurringCast = cast.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>

      {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 }} />
            ) : episodes.length === 0 ? (
              <Text style={styles.empty}>
                Content is being curated. Original recaps will appear here.
              </Text>
            ) : (
              episodes.map(renderEpisode)
            )}
          </>
        ) : null}

        {tab === "saved" ? (
          <>
            <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 || cast.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>
            {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>

      {/* 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" },
  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" },
  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,
  },
  empty: { fontSize: 14, color: "#8a1c1c", lineHeight: 20 },
  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 },
});