[object Object]

← back to Nineoh Guide

contrarian fixes: correct Expo API-base wiring (expo-constants + EXPO_PUBLIC), add AsyncStorage watchlist + spoiler tag (native value for Apple 4.2), parameterize legal emails

1b02ed5f77bb2ce6f7d36f38db83bf43935f400c · 2026-07-25 10:12:13 -0700 · Steve

Files touched

Diff

commit 1b02ed5f77bb2ce6f7d36f38db83bf43935f400c
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jul 25 10:12:13 2026 -0700

    contrarian fixes: correct Expo API-base wiring (expo-constants + EXPO_PUBLIC), add AsyncStorage watchlist + spoiler tag (native value for Apple 4.2), parameterize legal emails
---
 apps/mobile/App.tsx                 | 122 ++++++++++++++++++++++++++++++------
 apps/mobile/package.json            |   2 +
 apps/web/app/legal/dmca/page.tsx    |   6 +-
 apps/web/app/legal/privacy/page.tsx |   8 ++-
 pnpm-lock.yaml                      |  30 +++++++++
 5 files changed, 146 insertions(+), 22 deletions(-)

diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index c94e41f..fc94bb5 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -1,27 +1,63 @@
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
 import {
   ScrollView,
   Text,
   View,
   StyleSheet,
   ActivityIndicator,
+  Pressable,
 } from "react-native";
 import { StatusBar } from "expo-status-bar";
+import Constants from "expo-constants";
+import AsyncStorage from "@react-native-async-storage/async-storage";
 import { APP, EpisodeSchema, type Episode } from "@nineoh/core";
 
-// In prod this comes from app.json → extra.apiBaseUrl (the Vercel URL).
+// 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 ?? "http://localhost:4090";
+  process.env.EXPO_PUBLIC_API_BASE ?? configured ?? "http://localhost:4090";
+
+const WATCHLIST_KEY = "nineoh.watchlist.v1";
 
 export default function App() {
   const [episodes, setEpisodes] = useState<Episode[]>([]);
   const [loading, setLoading] = useState(true);
+  const [saved, setSaved] = useState<Set<string>>(new Set());
+  const [savedOnly, setSavedOnly] = useState(false);
+
+  // Native feature #1: persisted watchlist (AsyncStorage) — survives app restarts.
+  useEffect(() => {
+    AsyncStorage.getItem(WATCHLIST_KEY).then((raw) => {
+      if (raw) {
+        try {
+          setSaved(new Set(JSON.parse(raw)));
+        } catch {
+          /* ignore corrupt store */
+        }
+      }
+    });
+  }, []);
+
+  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;
+    });
+  }, []);
 
   useEffect(() => {
     fetch(`${API_BASE}/api/episodes`)
       .then((r) => (r.ok ? r.json() : { episodes: [] }))
       .then((data) => {
-        // Validate against the SHARED contract — same zod schema as the web app.
         const parsed = (data.episodes ?? [])
           .map((e: unknown) => EpisodeSchema.safeParse(e))
           .filter((p: any) => p.success)
@@ -32,6 +68,8 @@ export default function App() {
       .finally(() => setLoading(false));
   }, []);
 
+  const shown = savedOnly ? episodes.filter((e) => saved.has(e.id)) : episodes;
+
   return (
     <View style={styles.root}>
       <StatusBar style="auto" />
@@ -39,26 +77,52 @@ export default function App() {
         <Text style={styles.title}>{APP.displayName}</Text>
         <Text style={styles.badge}>UNOFFICIAL</Text>
       </View>
+
+      <View style={styles.toolbar}>
+        <Pressable
+          onPress={() => setSavedOnly((v) => !v)}
+          style={[styles.chip, savedOnly && styles.chipActive]}
+        >
+          <Text style={[styles.chipText, savedOnly && styles.chipTextActive]}>
+            {savedOnly ? "★ Saved" : "☆ Saved only"} ({saved.size})
+          </Text>
+        </Pressable>
+      </View>
+
       <ScrollView contentContainerStyle={styles.body}>
         <Text style={styles.disclaimer}>{APP.disclaimerShort}</Text>
         {loading ? (
           <ActivityIndicator style={{ marginTop: 24 }} />
-        ) : episodes.length === 0 ? (
+        ) : shown.length === 0 ? (
           <Text style={styles.empty}>
-            Content is being curated. Original recaps and cast profiles will
-            appear here as editors write them and image rights are cleared.
+            {savedOnly
+              ? "No saved episodes yet — tap the star on any episode."
+              : "Content is being curated. Original recaps and cast profiles will appear here as editors write them."}
           </Text>
         ) : (
-          episodes.map((ep) => (
-            <View key={ep.id} style={styles.card}>
-              <Text style={styles.cardTitle}>
-                S{ep.seasonNumber}E{ep.episodeNumber} · {ep.title}
-              </Text>
-              {ep.summaryShortOriginal ? (
-                <Text style={styles.cardBody}>{ep.summaryShortOriginal}</Text>
-              ) : null}
-            </View>
-          ))
+          shown.map((ep) => {
+            const isSaved = saved.has(ep.id);
+            return (
+              <View key={ep.id} 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 ? (
+                  <Text style={styles.cardBody}>{ep.summaryShortOriginal}</Text>
+                ) : (
+                  <Text style={styles.pending}>Recap coming soon</Text>
+                )}
+              </View>
+            );
+          })
         )}
       </ScrollView>
     </View>
@@ -79,7 +143,23 @@ const styles = StyleSheet.create({
   },
   title: { fontSize: 20, fontWeight: "700" },
   badge: { fontSize: 11, fontWeight: "700", color: "#8a1c1c" },
-  body: { padding: 20 },
+  toolbar: {
+    paddingHorizontal: 20,
+    paddingVertical: 8,
+    flexDirection: "row",
+    gap: 8,
+  },
+  chip: {
+    borderWidth: 1,
+    borderColor: "#ccc",
+    borderRadius: 16,
+    paddingHorizontal: 12,
+    paddingVertical: 5,
+  },
+  chipActive: { backgroundColor: "#1a1a1a", borderColor: "#1a1a1a" },
+  chipText: { fontSize: 13, color: "#333" },
+  chipTextActive: { color: "#fff" },
+  body: { padding: 20, paddingTop: 4 },
   disclaimer: { fontSize: 12, color: "#666", marginBottom: 16 },
   empty: { fontSize: 14, color: "#8a1c1c", lineHeight: 20 },
   card: {
@@ -90,6 +170,10 @@ const styles = StyleSheet.create({
     padding: 14,
     marginBottom: 10,
   },
-  cardTitle: { fontSize: 15, fontWeight: "600" },
+  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 },
 });
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 2881ada..49a669d 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -11,7 +11,9 @@
   },
   "dependencies": {
     "@nineoh/core": "workspace:*",
+    "@react-native-async-storage/async-storage": "1.23.1",
     "expo": "~52.0.0",
+    "expo-constants": "~17.0.0",
     "expo-status-bar": "~2.0.0",
     "react": "18.3.1",
     "react-native": "0.76.5"
diff --git a/apps/web/app/legal/dmca/page.tsx b/apps/web/app/legal/dmca/page.tsx
index 0fd618a..fcb3397 100644
--- a/apps/web/app/legal/dmca/page.tsx
+++ b/apps/web/app/legal/dmca/page.tsx
@@ -12,7 +12,11 @@ export default function Dmca() {
       <h2>Designated agent</h2>
       <p>
         Send notices to our designated DMCA contact:{" "}
-        <strong>dmca@[app-domain]</strong> (set at launch).
+        <strong>
+          {process.env.NEXT_PUBLIC_DMCA_EMAIL ??
+            "our published legal contact (configured before public launch)"}
+        </strong>
+        .
       </p>
       <h2>A valid notice must include</h2>
       <ol>
diff --git a/apps/web/app/legal/privacy/page.tsx b/apps/web/app/legal/privacy/page.tsx
index b1df225..07423c8 100644
--- a/apps/web/app/legal/privacy/page.tsx
+++ b/apps/web/app/legal/privacy/page.tsx
@@ -26,8 +26,12 @@ export default function Privacy() {
       <h2>Your rights</h2>
       <p>
         Request access, correction, or deletion via{" "}
-        <strong>privacy@[app-domain]</strong> (set at launch). We honor
-        applicable CCPA/CPRA rights and do not sell personal information.
+        <strong>
+          {process.env.NEXT_PUBLIC_PRIVACY_EMAIL ??
+            "our published privacy contact (configured before public launch)"}
+        </strong>
+        . We honor applicable CCPA/CPRA rights and do not sell personal
+        information.
       </p>
       <h2>Children</h2>
       <p>
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 42a1355..8276148 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -21,9 +21,15 @@ importers:
       '@nineoh/core':
         specifier: workspace:*
         version: link:../../packages/core
+      '@react-native-async-storage/async-storage':
+        specifier: 1.23.1
+        version: 1.23.1(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))
       expo:
         specifier: ~52.0.0
         version: 52.0.49(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1)(supports-color@8.1.1)
+      expo-constants:
+        specifier: ~17.0.0
+        version: 17.0.8(expo@52.0.49(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1)(supports-color@8.1.1))(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(supports-color@8.1.1)
       expo-status-bar:
         specifier: ~2.0.0
         version: 2.0.1(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))(react@18.3.1)
@@ -1190,6 +1196,11 @@ packages:
     resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
     engines: {node: '>=14'}
 
+  '@react-native-async-storage/async-storage@1.23.1':
+    resolution: {integrity: sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==}
+    peerDependencies:
+      react-native: ^0.0.0-0 || >=0.60 <1.0
+
   '@react-native/assets-registry@0.76.5':
     resolution: {integrity: sha512-MN5dasWo37MirVcKWuysRkRr4BjNc81SXwUtJYstwbn8oEkfnwR9DaqdDTo/hHOnTdhafffLIa2xOOHcjDIGEw==}
     engines: {node: '>=18'}
@@ -2346,6 +2357,10 @@ packages:
     resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
     engines: {node: '>=8'}
 
+  is-plain-obj@2.1.0:
+    resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
+    engines: {node: '>=8'}
+
   is-plain-object@2.0.4:
     resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
     engines: {node: '>=0.10.0'}
@@ -2607,6 +2622,10 @@ packages:
   memoize-one@5.2.1:
     resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
 
+  merge-options@3.0.4:
+    resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
+    engines: {node: '>=10'}
+
   merge-stream@2.0.0:
     resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
 
@@ -5208,6 +5227,11 @@ snapshots:
   '@pkgjs/parseargs@0.11.0':
     optional: true
 
+  '@react-native-async-storage/async-storage@1.23.1(react-native@0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1))':
+    dependencies:
+      merge-options: 3.0.4
+      react-native: 0.76.5(@babel/core@7.29.7(supports-color@8.1.1))(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@18.3.31)(react@18.3.1)(supports-color@8.1.1)
+
   '@react-native/assets-registry@0.76.5': {}
 
   '@react-native/babel-plugin-codegen@0.76.5(@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)':
@@ -6577,6 +6601,8 @@ snapshots:
 
   is-path-inside@3.0.3: {}
 
+  is-plain-obj@2.1.0: {}
+
   is-plain-object@2.0.4:
     dependencies:
       isobject: 3.0.1
@@ -6859,6 +6885,10 @@ snapshots:
 
   memoize-one@5.2.1: {}
 
+  merge-options@3.0.4:
+    dependencies:
+      is-plain-obj: 2.1.0
+
   merge-stream@2.0.0: {}
 
   merge2@1.4.1: {}

← a3ffaa4 gitignore + untrack tsconfig.tsbuildinfo (build artifact)  ·  back to Nineoh Guide  ·  ingest factual cast + character names (TVmaze, names-only, n fc01815 →