← back to CelebritySignatures

apps/mobile/App.tsx

148 lines

import React, { useCallback, useEffect, useState } from 'react';
import {
  ActivityIndicator, Linking, Modal, Pressable, SafeAreaView, StyleSheet, Text, View,
} from 'react-native';
import { StatusBar } from 'expo-status-bar';
import * as Haptics from 'expo-haptics';
import { WebView } from 'react-native-webview';
import { api, WEB_BASE, Evolution, Signature } from './api';
import { NO_TRACK } from './ui/no-track';
import { GLASS, GlassBar, GlassScene } from './ui/glass';
import GalleryScreen from './screens/GalleryScreen';
import SignatureDetail from './screens/SignatureDetail';
import GameScreen from './screens/GameScreen';
import MuralsScreen from './screens/MuralsScreen';
import AccountScreen from './screens/AccountScreen';

// Native shell: the gallery, game, murals-browse, and account are all real
// native screens over the existing celebsignatures.com JSON APIs (the App
// Review 4.2 mitigation — not a WebView wrapper). The only WebView is the
// mural print CHECKOUT, which is a physical good (IAP-exempt) and rides the
// site's redirect-based Stripe flow; NO_TRACK strips its ads/analytics so the
// binary does no tracking (App Privacy = "not used to track you").

type Tab = 'gallery' | 'game' | 'murals' | 'account';
const TABS: { key: Tab; label: string; glyph: string }[] = [
  { key: 'gallery', label: 'Gallery', glyph: '✒︎' },
  { key: 'game', label: 'Game', glyph: '◆' },
  { key: 'murals', label: 'Murals', glyph: '▦' },
  { key: 'account', label: 'Account', glyph: '☺' },
];

export default function App() {
  const [tab, setTab] = useState<Tab>('gallery');
  const [selected, setSelected] = useState<Signature | null>(null);
  const [evolution, setEvolution] = useState<Evolution | null>(null);
  const [webUrl, setWebUrl] = useState<string | null>(null);

  const openSignature = useCallback((sig: Signature) => {
    Haptics.selectionAsync().catch(() => {});
    setSelected(sig);
  }, []);

  // Lazy-load the evolution map once, the first time a detail is opened.
  // (A useEffect, not a side effect inside a setState updater — updaters must be pure.)
  useEffect(() => {
    if (selected && !evolution) api.evolution().then(setEvolution).catch(() => {});
  }, [selected, evolution]);

  // App Store 3.1.1 guard: the mural checkout is a PHYSICAL print (IAP-exempt),
  // but the site also exposes DIGITAL signature-file downloads. Block any in-
  // WebView navigation that could reach a digital purchase, so a reviewer can't
  // complete a digital sale without IAP. Allow the murals page, same-origin
  // assets, and Stripe (physical checkout); kick true externals to Safari.
  const allowWebNav = useCallback((url: string): boolean => {
    if (/signature-(checkout|file)|\/api\/signature-/.test(url)) return false;
    if (url.startsWith(WEB_BASE) || /^https:\/\/(checkout|js|hooks)\.stripe\.com/.test(url) || url.startsWith('about:')) {
      return true;
    }
    if (/^https?:/.test(url)) { Linking.openURL(url).catch(() => {}); return false; }
    return true;
  }, []);

  const orderMural = useCallback((sig: Signature) => {
    setSelected(null);
    setWebUrl(`${WEB_BASE}/murals?name=${encodeURIComponent(sig.full_name)}`);
  }, []);

  const selectTab = useCallback((t: Tab) => {
    Haptics.selectionAsync().catch(() => {});
    setTab(t);
  }, []);

  return (
    <GlassScene>
      <SafeAreaView style={styles.safe}>
        <StatusBar style="dark" />

        <View style={styles.body}>
          {tab === 'gallery' && <GalleryScreen onOpen={openSignature} />}
          {tab === 'game' && <GameScreen />}
          {tab === 'murals' && <MuralsScreen onOpenWeb={setWebUrl} />}
          {tab === 'account' && <AccountScreen />}
        </View>

        {/* Native bottom tab bar */}
        <View style={styles.tabbar}>
          {TABS.map((t) => {
            const active = t.key === tab;
            return (
              <Pressable key={t.key} style={styles.tab} onPress={() => selectTab(t.key)} hitSlop={8}>
                <Text style={[styles.tabGlyph, active && styles.tabActive]}>{t.glyph}</Text>
                <Text style={[styles.tabLabel, active && styles.tabActive]}>{t.label}</Text>
              </Pressable>
            );
          })}
        </View>

        {/* Signature detail */}
        <Modal visible={!!selected} animationType="slide" onRequestClose={() => setSelected(null)}>
          <GlassScene>
            <SafeAreaView style={styles.modalSafe}>
              <GlassBar title={selected?.full_name ?? 'Signature'} onDone={() => setSelected(null)} />
              {selected && (
                <SignatureDetail sig={selected} evolution={evolution} onOrderMural={orderMural} />
              )}
            </SafeAreaView>
          </GlassScene>
        </Modal>

        {/* Web checkout (physical mural print — IAP-exempt) */}
        <Modal visible={!!webUrl} animationType="slide" onRequestClose={() => setWebUrl(null)}>
          <SafeAreaView style={styles.webSafe}>
            <GlassBar light title="Order a mural" onDone={() => setWebUrl(null)} />
            {webUrl && (
              <WebView
                source={{ uri: webUrl }}
                startInLoadingState
                sharedCookiesEnabled
                onShouldStartLoadWithRequest={(req) => allowWebNav(req.url)}
                injectedJavaScriptBeforeContentLoaded={NO_TRACK}
                renderLoading={() => (
                  <ActivityIndicator style={styles.webLoading} size="large" color={GLASS.accent} />
                )}
              />
            )}
          </SafeAreaView>
        </Modal>
      </SafeAreaView>
    </GlassScene>
  );
}

const styles = StyleSheet.create({
  safe: { flex: 1 },
  body: { flex: 1 },
  tabbar: {
    flexDirection: 'row', paddingVertical: 8, paddingHorizontal: 8,
    borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: GLASS.edgeFaint,
  },
  tab: { flex: 1, alignItems: 'center', paddingVertical: 4, gap: 2 },
  tabGlyph: { fontSize: 18, color: GLASS.textDim },
  tabLabel: { fontSize: 11, color: GLASS.textDim, fontWeight: '600', letterSpacing: 0.2 },
  tabActive: { color: GLASS.accent },
  modalSafe: { flex: 1 },
  webSafe: { flex: 1, backgroundColor: '#fff' },
  webLoading: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
});