Bygg NeuroSemantics AI: minimal mobilapp, en backend, IaC och CI/CD
Ersätter den tidigare webappen på denna branch med ett fokuserat monorepo: - apps/mobile: Expo/React Native med tre vyer (Welcome, Chat, Paywall), Cognito hosted UI-inloggning (Apple/Google/e-post) och In-App Purchase/Play Billing via en gemensam purchases-modul. - services/api: en enda Lambda-backend — OpenAI Responses API med Markdown-kunskapsbas som systeminstruktioner, free tier-gräns i PostgreSQL (HTTP 402 -> paywall) och kvittoverifiering bakom ett delat PaymentProvider-interface (Apple/Google, Stripe kan läggas till för webb senare). - infra: AWS CDK-stack med API Gateway (JWT-authorizer), Lambda, Cognito, Aurora Serverless v2 och Secrets Manager. - db/migrations: minimal datamodell (users + usage), inga konversationer sparas. - GitHub Actions: CI (lint, typecheck, test) och deploy från main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118DaxZR36RpnY524vRqx3z
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { ApiError, sendChat, type ChatMessage } from '../api/client';
|
||||
import { colors, spacing, type } from '../theme';
|
||||
|
||||
interface Props {
|
||||
onLimitReached: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole product: one heading, the conversation, a text field and send.
|
||||
* Conversations live in memory only — nothing is stored.
|
||||
*/
|
||||
export function ChatScreen({ onLimitReached }: Props) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
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 }];
|
||||
setMessages(next);
|
||||
setDraft('');
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { reply } = 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);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<Text style={styles.heading}>NeuroSemantics AI</Text>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
data={messages}
|
||||
keyExtractor={(_, index) => String(index)}
|
||||
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
|
||||
ListEmptyComponent={<Text style={styles.empty}>What would you like to explore today?</Text>}
|
||||
renderItem={({ item }) => (
|
||||
<View style={[styles.message, item.role === 'user' ? styles.user : styles.assistant]}>
|
||||
<Text style={item.role === 'user' ? styles.userText : styles.assistantText}>
|
||||
{item.content}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
heading: {
|
||||
...type.heading,
|
||||
textAlign: 'center',
|
||||
paddingTop: spacing.xxl,
|
||||
paddingBottom: spacing.m,
|
||||
},
|
||||
list: { flex: 1 },
|
||||
listContent: { paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.s },
|
||||
empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xxl },
|
||||
message: { maxWidth: '85%', borderRadius: 12, padding: spacing.m },
|
||||
user: { alignSelf: 'flex-end', backgroundColor: colors.accent },
|
||||
assistant: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
userText: { ...type.body, color: colors.accentText },
|
||||
assistantText: { ...type.body },
|
||||
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center', marginBottom: spacing.s },
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: spacing.s,
|
||||
paddingHorizontal: spacing.l,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
...type.body,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: spacing.m,
|
||||
paddingVertical: spacing.s + 2,
|
||||
maxHeight: 120,
|
||||
},
|
||||
send: {
|
||||
backgroundColor: colors.accent,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: spacing.l,
|
||||
paddingVertical: spacing.m - 2,
|
||||
},
|
||||
sendDisabled: { opacity: 0.4 },
|
||||
sendText: { ...type.heading, color: colors.accentText },
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { purchase, restore, type Plan } from '../purchases';
|
||||
import { colors, spacing, type } from '../theme';
|
||||
|
||||
interface Props {
|
||||
onSubscribed: () => void;
|
||||
}
|
||||
|
||||
export function PaywallScreen({ onSubscribed }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run(action: () => Promise<'free' | 'active'>) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const status = await action();
|
||||
if (status === 'active') {
|
||||
onSubscribed();
|
||||
} else {
|
||||
setError('No active subscription was found.');
|
||||
}
|
||||
} catch {
|
||||
setError('The purchase did not complete. Please try again.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const buy = (plan: Plan) => run(() => purchase(plan));
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<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.
|
||||
</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>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
</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 },
|
||||
restore: { alignItems: 'center', paddingVertical: spacing.m },
|
||||
restoreText: { ...type.body, color: colors.accent },
|
||||
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center' },
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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