goon/mobile/src/screens/AgeGateScreen.tsx
goon-foss d5f4fc3f84 feat(mobile): custom fonts (General Sans + Geist Mono) + logo rework
FONTY:
- Dodane assets/fonts/: GeneralSans Regular/Medium/Semibold (Fontshare, free
  commercial) + GeistMono Regular (Vercel OFL). Pobrane jako .ttf.
- expo-font ~13.0.4 (matchuje SDK 52). Native module jest w APK bo `expo`
  ciagnie expo-font jako bezposrednia zaleznosc -> useFonts dziala przez OTA
  bez rebuildu.
- App.tsx: useFonts() gate (blokuje render do zaladowania, .ttf z bundla <100ms)
  + globalny Text.defaultProps fontFamily=GeneralSans-Regular dla body.
- theme.ts: fonts = { body, medium, display, mono }. RN nie syntezuje weightow
  dla custom fontow, wiec 4 osobne rodziny per-weight (gotcha udokumentowany).
- Jawne fonty na high-traffic: SceneTile (title=display, meta+dur=mono),
  MoviePosterCard (j.w.), navigation (taby display/medium, header display).

LOGO:
- GoonWordmark przepisany: zamiast krzywych recznych SVG path (o-ka jako
  nachodzace elipsy, zniekształcone n) renderuje PRAWDZIWY tekst w General Sans
  Semibold. Dwutonowy twist: "g[oo]n" ze srodkowym "oo" w oxblood.
- GoonMark (monogram): czysty SVG koncentryczny ring + dot (oxblood) — motyw
  soczewki/oka. Dla app-icon/splash gdzie font niedostepny.
- Wpiety na AgeGate (wordmark 40), Login (mark 44 + wordmark 44), nav header.

OTA: c986c911-0868-44f7-9f4a-fc2a74e53095 live (23 assets, 4 fonty serwuja 200).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:25:26 +02:00

295 lines
7.2 KiB
TypeScript

import React, { useState } from 'react';
import {
BackHandler,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { GoonWordmark } from '../components/GoonWordmark';
import { markAccepted } from '../lib/agegate';
import { theme } from '../theme';
interface Props {
onAccept: () => void;
}
/** First-launch (and post-ToS-bump) gate. Blocks access to the app until user
* affirms they're 18+ AND accepts the Disclaimer/Terms.
*
* Decline = exit app (Android: BackHandler.exitApp; iOS: cannot programmatically
* close, so we show a "please uninstall" sentinel — covered by `declined` state).
*
* The body text is intentionally short and non-legalese; the canonical text is
* in DISCLAIMER.md and linked via "Read full disclaimer".
*/
export function AgeGateScreen({ onAccept }: Props) {
const [ageOk, setAgeOk] = useState(false);
const [tosOk, setTosOk] = useState(false);
const [declined, setDeclined] = useState(false);
const canContinue = ageOk && tosOk;
const onDecline = () => {
if (Platform.OS === 'android') {
BackHandler.exitApp();
} else {
setDeclined(true);
}
};
const onContinue = async () => {
await markAccepted();
onAccept();
};
if (declined) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.declinedBox}>
<Text style={styles.title}>Access denied</Text>
<Text style={styles.body}>
You must accept the Disclaimer and confirm you are at least 18 years
old to use Goon. Please close and uninstall the app.
</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container}>
<ScrollView
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
>
<GoonWordmark size={40} />
<Text style={styles.subtitle}>Adult content · Self-hosted only</Text>
<View style={styles.section}>
<Text style={styles.heading}>Before you continue</Text>
<Text style={styles.body}>
Goon is an aggregator for adult-content scene metadata. It indexes
information from third-party sources and links to those sources for
playback. The app itself contains no media.
</Text>
</View>
<View style={styles.section}>
<Text style={styles.heading}>You must affirm</Text>
<Text style={styles.bullet}>
You are at least 18 years old, or the age of majority in your
jurisdiction (whichever is greater).
</Text>
<Text style={styles.bullet}>
Adult content is legal to access and store metadata about where
you live.
</Text>
<Text style={styles.bullet}>
You will use this app only on a device under your control. Do not
share access with minors.
</Text>
<Text style={styles.bullet}>
You understand that scene metadata, thumbnails, and links are
sourced from third parties and may be inaccurate or unavailable.
</Text>
</View>
<Text style={styles.fineprint}>
The full Disclaimer and Terms are shipped in the project repository
(DISCLAIMER.md). By continuing, you accept those terms.
</Text>
<View style={styles.checks}>
<CheckRow
checked={ageOk}
onToggle={() => setAgeOk((v) => !v)}
label="I am at least 18 years old (or the age of majority in my jurisdiction)."
/>
<CheckRow
checked={tosOk}
onToggle={() => setTosOk((v) => !v)}
label="I have read and accept the Disclaimer and Terms."
/>
</View>
<View style={styles.actions}>
<Pressable
style={[styles.btn, styles.btnDecline]}
onPress={onDecline}
>
<Text style={styles.btnDeclineText}>Decline</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnContinue, !canContinue && styles.btnDisabled]}
disabled={!canContinue}
onPress={onContinue}
>
<Text
style={[
styles.btnContinueText,
!canContinue && styles.btnContinueTextDisabled,
]}
>
Continue
</Text>
</Pressable>
</View>
</ScrollView>
</SafeAreaView>
);
}
function CheckRow({
checked,
onToggle,
label,
}: {
checked: boolean;
onToggle: () => void;
label: string;
}) {
return (
<Pressable style={styles.checkRow} onPress={onToggle} hitSlop={6}>
<View style={[styles.checkbox, checked && styles.checkboxOn]}>
{checked ? <Text style={styles.checkboxMark}></Text> : null}
</View>
<Text style={styles.checkLabel}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.bg,
},
scroll: {
paddingHorizontal: 24,
paddingTop: 32,
paddingBottom: 48,
},
declinedBox: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
title: {
color: theme.fg,
fontSize: 36,
fontWeight: '700',
letterSpacing: 1,
},
subtitle: {
color: theme.muted,
fontSize: 14,
marginTop: 4,
marginBottom: 28,
letterSpacing: 0.5,
},
section: {
marginBottom: 22,
},
heading: {
color: theme.fg,
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
body: {
color: theme.fg,
fontSize: 14,
lineHeight: 21,
},
bullet: {
color: theme.fg,
fontSize: 14,
lineHeight: 21,
marginBottom: 6,
},
fineprint: {
color: theme.muted,
fontSize: 12,
lineHeight: 17,
marginBottom: 24,
marginTop: 4,
fontStyle: 'italic',
},
checks: {
marginBottom: 24,
gap: 14,
},
checkRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
checkbox: {
width: 22,
height: 22,
borderRadius: 4,
borderWidth: 2,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
justifyContent: 'center',
alignItems: 'center',
marginTop: 1,
},
checkboxOn: {
borderColor: theme.accent,
backgroundColor: theme.accent,
},
checkboxMark: {
color: '#fff',
fontSize: 14,
fontWeight: '800',
lineHeight: 14,
},
checkLabel: {
flex: 1,
color: theme.fg,
fontSize: 14,
lineHeight: 20,
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
btn: {
flex: 1,
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
},
btnDecline: {
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
},
btnDeclineText: {
color: theme.muted,
fontSize: 15,
fontWeight: '600',
},
btnContinue: {
backgroundColor: theme.accent,
},
btnDisabled: {
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
},
btnContinueText: {
color: '#fff',
fontSize: 15,
fontWeight: '700',
},
btnContinueTextDisabled: {
color: theme.mutedDim,
},
});