Add chat-first onboarding and intelligent context-based paywall
Replaces the hardcoded message limit with a model-driven paywall and removes registration before the first question: - Onboarding: the app opens directly into the chat. Dynamic conversation starters are served by GET /suggestions (services/api/suggestions.json), updatable with a deploy — no app release needed. Guests chat via POST /guest/chat, identified by an app-generated device id; sign-in moves to the paywall, where a purchase must attach to an account. - Free experience: the model runs in discovery mode — follow-up questions, pattern identification, visible understanding — building an analysis without delivering the full solution. - Intelligent paywall: the model returns structured output (reply + analysis_ready). Only when the problem is described, the information is sufficient and an action plan is ready does it write a calm transition and pause the conversation. Manufactured urgency, emotional pressure, fake readiness and mid-answer stops are explicitly forbidden. - Premium: on unlock the app resends the transcript and the backend immediately delivers the full analysis, strategies and exercises, then the dialogue continues without restriction. - The old FREE_MESSAGE_LIMIT becomes MESSAGE_CAP (default 200/30 days), kept purely as an abuse backstop — it is not the paywall. - WelcomeScreen removed; the app is now two views (Chat, Paywall). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
+31
-15
@@ -1,29 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { getAccessToken } from './src/auth/session';
|
||||
import type { ChatMessage } from './src/api/client';
|
||||
import { ChatScreen } from './src/screens/ChatScreen';
|
||||
import { PaywallScreen } from './src/screens/PaywallScreen';
|
||||
import { WelcomeScreen } from './src/screens/WelcomeScreen';
|
||||
|
||||
type View = 'loading' | 'welcome' | 'chat' | 'paywall';
|
||||
|
||||
/**
|
||||
* Three views, no navigation library. The backend decides when the paywall
|
||||
* appears (HTTP 402 on /chat).
|
||||
* Two views, no navigation library. The user lands directly in the chat —
|
||||
* no registration before the first question. The backend decides the paywall
|
||||
* moment (the analysis is ready); after unlock the conversation continues
|
||||
* with the full analysis delivered immediately.
|
||||
*/
|
||||
export default function App() {
|
||||
const [view, setView] = useState<View>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
getAccessToken().then((token) => setView(token ? 'chat' : 'welcome'));
|
||||
}, []);
|
||||
const [view, setView] = useState<'chat' | 'paywall'>('chat');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [paywalled, setPaywalled] = useState(false);
|
||||
const [deliverAnalysis, setDeliverAnalysis] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="dark" />
|
||||
{view === 'welcome' && <WelcomeScreen onSignedIn={() => setView('chat')} />}
|
||||
{view === 'chat' && <ChatScreen onLimitReached={() => setView('paywall')} />}
|
||||
{view === 'paywall' && <PaywallScreen onSubscribed={() => setView('chat')} />}
|
||||
{view === 'chat' && (
|
||||
<ChatScreen
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
paywalled={paywalled}
|
||||
onPaywallTriggered={() => setPaywalled(true)}
|
||||
onContinueWithPremium={() => setView('paywall')}
|
||||
deliverAnalysis={deliverAnalysis}
|
||||
onAnalysisDelivered={() => setDeliverAnalysis(false)}
|
||||
/>
|
||||
)}
|
||||
{view === 'paywall' && (
|
||||
<PaywallScreen
|
||||
onSubscribed={() => {
|
||||
setPaywalled(false);
|
||||
setDeliverAnalysis(true);
|
||||
setView('chat');
|
||||
}}
|
||||
onDismiss={() => setView('chat')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as Crypto from 'expo-crypto';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { appConfig } from '../config';
|
||||
import { getAccessToken } from '../auth/session';
|
||||
|
||||
@@ -16,23 +18,38 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatReply {
|
||||
reply: string;
|
||||
/** True when the backend judged the analysis ready — the paywall moment. */
|
||||
paywall: boolean;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
subscriptionStatus: 'free' | 'active';
|
||||
messagesUsed: number;
|
||||
freeMessageLimit: number;
|
||||
messageCap: number;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new ApiError(401, 'not_signed_in');
|
||||
const DEVICE_KEY = 'neurosemantics.device';
|
||||
|
||||
/** Stable anonymous id so guests can chat before signing in. */
|
||||
async function getDeviceId(): Promise<string> {
|
||||
let id = await SecureStore.getItemAsync(DEVICE_KEY);
|
||||
if (!id) {
|
||||
id = Crypto.randomUUID();
|
||||
await SecureStore.setItemAsync(DEVICE_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${appConfig.apiUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -47,21 +64,39 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function fetchMe(): Promise<Me> {
|
||||
return request<Me>('/me');
|
||||
async function authHeader(): Promise<Record<string, string>> {
|
||||
const token = await getAccessToken();
|
||||
if (!token) throw new ApiError(401, 'not_signed_in');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export function sendChat(messages: ChatMessage[]): Promise<{ reply: string }> {
|
||||
return request<{ reply: string }>('/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ messages }),
|
||||
});
|
||||
export async function fetchSuggestions(): Promise<string[]> {
|
||||
const data = await request<{ suggestions: string[] }>('/suggestions');
|
||||
return data.suggestions;
|
||||
}
|
||||
|
||||
export function verifyPurchase(body: {
|
||||
/** Signed-in users chat via /chat; guests via /guest/chat with a device id. */
|
||||
export async function sendChat(messages: ChatMessage[]): Promise<ChatReply> {
|
||||
const body = { method: 'POST', body: JSON.stringify({ messages }) };
|
||||
const token = await getAccessToken();
|
||||
if (token) {
|
||||
return request<ChatReply>('/chat', body, { Authorization: `Bearer ${token}` });
|
||||
}
|
||||
return request<ChatReply>('/guest/chat', body, { 'X-Device-Id': await getDeviceId() });
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<Me> {
|
||||
return request<Me>('/me', {}, await authHeader());
|
||||
}
|
||||
|
||||
export async function verifyPurchase(body: {
|
||||
platform: 'ios' | 'android';
|
||||
productId: string;
|
||||
receipt: string;
|
||||
}): Promise<{ subscriptionStatus: 'free' | 'active' }> {
|
||||
return request('/subscription/verify', { method: 'POST', body: JSON.stringify(body) });
|
||||
return request(
|
||||
'/subscription/verify',
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
await authHeader(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
@@ -9,54 +9,104 @@ import {
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { ApiError, sendChat, type ChatMessage } from '../api/client';
|
||||
import { fetchSuggestions, sendChat, type ChatMessage } from '../api/client';
|
||||
import { colors, spacing, type } from '../theme';
|
||||
|
||||
interface Props {
|
||||
onLimitReached: () => void;
|
||||
messages: ChatMessage[];
|
||||
setMessages: (messages: ChatMessage[]) => void;
|
||||
/** The analysis is ready behind Premium; input is replaced by the unlock button. */
|
||||
paywalled: boolean;
|
||||
onPaywallTriggered: () => void;
|
||||
onContinueWithPremium: () => void;
|
||||
/** Set right after unlock: fetch the promised analysis automatically. */
|
||||
deliverAnalysis: boolean;
|
||||
onAnalysisDelivered: () => void;
|
||||
}
|
||||
|
||||
const SUGGESTIONS_SHOWN = 4;
|
||||
|
||||
/**
|
||||
* The whole product: one heading, the conversation, a text field and send.
|
||||
* Conversations live in memory only — nothing is stored.
|
||||
* The user lands here directly — no registration before the first question.
|
||||
* Conversations live in memory only; nothing is stored.
|
||||
*/
|
||||
export function ChatScreen({ onLimitReached }: Props) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
export function ChatScreen({
|
||||
messages,
|
||||
setMessages,
|
||||
paywalled,
|
||||
onPaywallTriggered,
|
||||
onContinueWithPremium,
|
||||
deliverAnalysis,
|
||||
onAnalysisDelivered,
|
||||
}: Props) {
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const listRef = useRef<FlatList<ChatMessage>>(null);
|
||||
|
||||
async function send() {
|
||||
const content = draft.trim();
|
||||
if (!content || busy) return;
|
||||
const next: ChatMessage[] = [...messages, { role: 'user', content }];
|
||||
useEffect(() => {
|
||||
fetchSuggestions()
|
||||
.then((all) =>
|
||||
setSuggestions([...all].sort(() => Math.random() - 0.5).slice(0, SUGGESTIONS_SHOWN)),
|
||||
)
|
||||
.catch(() => setSuggestions([]));
|
||||
}, []);
|
||||
|
||||
// After unlocking Premium the transcript ends with the assistant's
|
||||
// transition message; resend it so the backend delivers the full analysis.
|
||||
useEffect(() => {
|
||||
if (!deliverAnalysis || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
sendChat(messages)
|
||||
.then(({ reply }) => {
|
||||
setMessages([...messages, { role: 'assistant', content: reply }]);
|
||||
onAnalysisDelivered();
|
||||
})
|
||||
.catch(() => setError('Something went wrong. Please try again.'))
|
||||
.finally(() => setBusy(false));
|
||||
}, [deliverAnalysis]);
|
||||
|
||||
async function send(content: string) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || busy || paywalled) return;
|
||||
const next: ChatMessage[] = [...messages, { role: 'user', content: trimmed }];
|
||||
setMessages(next);
|
||||
setDraft('');
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { reply } = await sendChat(next);
|
||||
const { reply, paywall } = await sendChat(next);
|
||||
setMessages([...next, { role: 'assistant', content: reply }]);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 402) {
|
||||
onLimitReached();
|
||||
} else {
|
||||
setError('Something went wrong. Please try again.');
|
||||
setMessages(messages);
|
||||
setDraft(content);
|
||||
}
|
||||
if (paywall) onPaywallTriggered();
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.');
|
||||
setMessages(messages);
|
||||
setDraft(trimmed);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const showSuggestions = messages.length === 0 && suggestions.length > 0;
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<Text style={styles.heading}>NeuroSemantics AI</Text>
|
||||
{showSuggestions ? (
|
||||
<View style={styles.suggestions}>
|
||||
{suggestions.map((s) => (
|
||||
<Pressable key={s} style={styles.suggestion} onPress={() => send(s)} disabled={busy}>
|
||||
<Text style={styles.suggestionText}>{s}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
style={styles.list}
|
||||
@@ -74,24 +124,32 @@ export function ChatScreen({ onLimitReached }: Props) {
|
||||
)}
|
||||
/>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Write a message"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline
|
||||
editable={!busy}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]}
|
||||
onPress={send}
|
||||
disabled={!draft.trim() || busy}
|
||||
>
|
||||
<Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{paywalled ? (
|
||||
<View style={styles.inputRow}>
|
||||
<Pressable style={styles.unlock} onPress={onContinueWithPremium}>
|
||||
<Text style={styles.unlockText}>Continue with Premium</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.inputRow}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Write a message"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
multiline
|
||||
editable={!busy}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.send, (!draft.trim() || busy) && styles.sendDisabled]}
|
||||
onPress={() => send(draft)}
|
||||
disabled={!draft.trim() || busy}
|
||||
>
|
||||
<Text style={styles.sendText}>{busy ? '…' : 'Send'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -104,9 +162,19 @@ const styles = StyleSheet.create({
|
||||
paddingTop: spacing.xxl,
|
||||
paddingBottom: spacing.m,
|
||||
},
|
||||
suggestions: { paddingHorizontal: spacing.l, gap: spacing.s, marginBottom: spacing.m },
|
||||
suggestion: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingVertical: spacing.s + 2,
|
||||
paddingHorizontal: spacing.m,
|
||||
},
|
||||
suggestionText: { ...type.body, color: colors.accent },
|
||||
list: { flex: 1 },
|
||||
listContent: { paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.s },
|
||||
empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xxl },
|
||||
empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xl },
|
||||
message: { maxWidth: '85%', borderRadius: 12, padding: spacing.m },
|
||||
user: { alignSelf: 'flex-end', backgroundColor: colors.accent },
|
||||
assistant: {
|
||||
@@ -144,4 +212,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
sendDisabled: { opacity: 0.4 },
|
||||
sendText: { ...type.heading, color: colors.accentText },
|
||||
unlock: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.accent,
|
||||
borderRadius: 12,
|
||||
paddingVertical: spacing.m,
|
||||
alignItems: 'center',
|
||||
},
|
||||
unlockText: { ...type.heading, color: colors.accentText },
|
||||
});
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { getAccessToken, signIn, type Provider } from '../auth/session';
|
||||
import { purchase, restore, type Plan } from '../purchases';
|
||||
import { colors, spacing, type } from '../theme';
|
||||
|
||||
interface Props {
|
||||
onSubscribed: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function PaywallScreen({ onSubscribed }: Props) {
|
||||
/**
|
||||
* Shown when the backend has a complete analysis ready. Guests sign in
|
||||
* first (purchases must attach to an account), then choose a plan.
|
||||
*/
|
||||
export function PaywallScreen({ onSubscribed, onDismiss }: Props) {
|
||||
const [signedIn, setSignedIn] = useState<boolean | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getAccessToken().then((token) => setSignedIn(token !== null));
|
||||
}, []);
|
||||
|
||||
async function handleSignIn(provider: Provider) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await signIn(provider);
|
||||
setSignedIn(true);
|
||||
} catch {
|
||||
setError('Sign-in did not complete. Please try again.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function run(action: () => Promise<'free' | 'active'>) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -35,20 +59,52 @@ export function PaywallScreen({ onSubscribed }: Props) {
|
||||
<View style={styles.top}>
|
||||
<Text style={styles.title}>Continue your journey</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
You have used your free conversations. Subscribe to continue.
|
||||
Your analysis and recommended steps are ready. Premium unlocks them and lets the
|
||||
conversation continue without limits.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.primaryButton} onPress={() => buy('monthly')} disabled={busy}>
|
||||
<Text style={styles.primaryButtonText}>Monthly</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}>
|
||||
<Text style={styles.primaryButtonText}>Yearly</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.restore} onPress={() => run(restore)} disabled={busy}>
|
||||
<Text style={styles.restoreText}>Restore Purchase</Text>
|
||||
</Pressable>
|
||||
{signedIn === false ? (
|
||||
<>
|
||||
<Pressable
|
||||
style={styles.primaryButton}
|
||||
onPress={() => handleSignIn('apple')}
|
||||
disabled={busy}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Continue with Apple</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => handleSignIn('google')}
|
||||
disabled={busy}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Continue with Google</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => handleSignIn('email')}
|
||||
disabled={busy}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Continue with Email</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : signedIn === true ? (
|
||||
<>
|
||||
<Pressable style={styles.primaryButton} onPress={() => buy('monthly')} disabled={busy}>
|
||||
<Text style={styles.primaryButtonText}>Monthly</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.primaryButton} onPress={() => buy('yearly')} disabled={busy}>
|
||||
<Text style={styles.primaryButtonText}>Yearly</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.textButton} onPress={() => run(restore)} disabled={busy}>
|
||||
<Text style={styles.textButtonText}>Restore Purchase</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Pressable style={styles.textButton} onPress={onDismiss} disabled={busy}>
|
||||
<Text style={styles.dismissText}>Not now</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -77,7 +133,17 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButtonText: { ...type.heading, color: colors.accentText },
|
||||
restore: { alignItems: 'center', paddingVertical: spacing.m },
|
||||
restoreText: { ...type.body, color: colors.accent },
|
||||
secondaryButton: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
paddingVertical: spacing.m,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryButtonText: { ...type.heading },
|
||||
textButton: { alignItems: 'center', paddingVertical: spacing.m },
|
||||
textButtonText: { ...type.body, color: colors.accent },
|
||||
dismissText: { ...type.caption },
|
||||
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center' },
|
||||
});
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { signIn, type Provider } from '../auth/session';
|
||||
import { colors, spacing, type } from '../theme';
|
||||
|
||||
interface Props {
|
||||
onSignedIn: () => void;
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ onSignedIn }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSignIn(provider: Provider) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await signIn(provider);
|
||||
onSignedIn();
|
||||
} catch {
|
||||
setError('Sign-in did not complete. Please try again.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.top}>
|
||||
<Text style={styles.title}>NeuroSemantics AI</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
A quiet conversation partner for neurosemantics and NLP.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={styles.primaryButton}
|
||||
onPress={() => handleSignIn('apple')}
|
||||
disabled={busy}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Continue with Apple</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => handleSignIn('google')}
|
||||
disabled={busy}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Continue with Google</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => handleSignIn('email')}
|
||||
disabled={busy}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Continue with Email</Text>
|
||||
</Pressable>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Text style={styles.footnote}>Start free. No card required.</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: spacing.l,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
top: { marginTop: spacing.xxl * 2 },
|
||||
title: { ...type.title, textAlign: 'center' },
|
||||
subtitle: {
|
||||
...type.body,
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.m,
|
||||
},
|
||||
actions: { marginBottom: spacing.xxl, gap: spacing.s },
|
||||
primaryButton: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingVertical: spacing.m,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButtonText: { ...type.heading, color: colors.accentText },
|
||||
secondaryButton: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
paddingVertical: spacing.m,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryButtonText: { ...type.heading },
|
||||
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center', marginTop: spacing.s },
|
||||
footnote: { ...type.caption, textAlign: 'center', marginTop: spacing.m },
|
||||
});
|
||||
Reference in New Issue
Block a user