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:
Claude
2026-08-03 14:42:44 +00:00
parent 07fbeea09b
commit 6b7f0263e4
11 changed files with 498 additions and 221 deletions
+31 -15
View File
@@ -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')}
/>
)}
</>
);
}
+53 -18
View File
@@ -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(),
);
}
+114 -38
View File
@@ -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 },
});
+80 -14
View File
@@ -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' },
});
-98
View File
@@ -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 },
});