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