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,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { getAccessToken } from './src/auth/session';
|
||||
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).
|
||||
*/
|
||||
export default function App() {
|
||||
const [view, setView] = useState<View>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
getAccessToken().then((token) => setView(token ? 'chat' : 'welcome'));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="dark" />
|
||||
{view === 'welcome' && <WelcomeScreen onSignedIn={() => setView('chat')} />}
|
||||
{view === 'chat' && <ChatScreen onLimitReached={() => setView('paywall')} />}
|
||||
{view === 'paywall' && <PaywallScreen onSubscribed={() => setView('chat')} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "NeuroSemantics AI",
|
||||
"slug": "neurosemantics-ai",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"scheme": "neurosemantics",
|
||||
"userInterfaceStyle": "light",
|
||||
"backgroundColor": "#F7F6F2",
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.neurosemantics.app",
|
||||
"supportsTablet": false,
|
||||
"usesAppleSignIn": true
|
||||
},
|
||||
"android": {
|
||||
"package": "com.neurosemantics.app"
|
||||
},
|
||||
"extra": {
|
||||
"apiUrl": "https://REPLACE_WITH_API_URL",
|
||||
"cognitoDomain": "https://REPLACE_WITH_PREFIX.auth.eu-north-1.amazoncognito.com",
|
||||
"cognitoClientId": "REPLACE_WITH_CLIENT_ID",
|
||||
"iosMonthlyProductId": "neurosemantics_monthly",
|
||||
"iosYearlyProductId": "neurosemantics_yearly",
|
||||
"androidMonthlyProductId": "neurosemantics_monthly",
|
||||
"androidYearlyProductId": "neurosemantics_yearly"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
import App from './App';
|
||||
|
||||
registerRootComponent(App);
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@neurosemantics/mobile",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"expo": "~52.0.0",
|
||||
"expo-auth-session": "~6.0.0",
|
||||
"expo-crypto": "~14.0.0",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"expo-web-browser": "~14.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.5",
|
||||
"react-native-iap": "^12.15.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.3.12",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { appConfig } from '../config';
|
||||
import { getAccessToken } from '../auth/session';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
) {
|
||||
super(`API error ${status}: ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the backend contract in services/api/src/handler.ts.
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
subscriptionStatus: 'free' | 'active';
|
||||
messagesUsed: number;
|
||||
freeMessageLimit: 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 response = await fetch(`${appConfig.apiUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let code = 'unknown';
|
||||
try {
|
||||
code = ((await response.json()) as { error?: string }).error ?? 'unknown';
|
||||
} catch {
|
||||
// keep 'unknown'
|
||||
}
|
||||
throw new ApiError(response.status, code);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function fetchMe(): Promise<Me> {
|
||||
return request<Me>('/me');
|
||||
}
|
||||
|
||||
export function sendChat(messages: ChatMessage[]): Promise<{ reply: string }> {
|
||||
return request<{ reply: string }>('/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ messages }),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyPurchase(body: {
|
||||
platform: 'ios' | 'android';
|
||||
productId: string;
|
||||
receipt: string;
|
||||
}): Promise<{ subscriptionStatus: 'free' | 'active' }> {
|
||||
return request('/subscription/verify', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
AuthRequest,
|
||||
exchangeCodeAsync,
|
||||
makeRedirectUri,
|
||||
refreshAsync,
|
||||
type DiscoveryDocument,
|
||||
} from 'expo-auth-session';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { appConfig } from '../config';
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
export type Provider = 'apple' | 'google' | 'email';
|
||||
|
||||
interface StoredSession {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt: number; // epoch ms
|
||||
}
|
||||
|
||||
const STORE_KEY = 'neurosemantics.session';
|
||||
|
||||
const discovery: DiscoveryDocument = {
|
||||
authorizationEndpoint: `${appConfig.cognitoDomain}/oauth2/authorize`,
|
||||
tokenEndpoint: `${appConfig.cognitoDomain}/oauth2/token`,
|
||||
revocationEndpoint: `${appConfig.cognitoDomain}/oauth2/revoke`,
|
||||
};
|
||||
|
||||
const redirectUri = makeRedirectUri({ scheme: 'neurosemantics', path: 'redirect' });
|
||||
|
||||
/** Maps our provider names to Cognito hosted UI identity providers. */
|
||||
const IDP: Record<Provider, string | undefined> = {
|
||||
apple: 'SignInWithApple',
|
||||
google: 'Google',
|
||||
email: undefined, // Hosted UI's own email/password form
|
||||
};
|
||||
|
||||
async function persist(session: StoredSession): Promise<void> {
|
||||
await SecureStore.setItemAsync(STORE_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
/** Signs in via the Cognito hosted UI with PKCE. Returns the session. */
|
||||
export async function signIn(provider: Provider): Promise<StoredSession> {
|
||||
const request = new AuthRequest({
|
||||
clientId: appConfig.cognitoClientId,
|
||||
redirectUri,
|
||||
scopes: ['openid', 'email'],
|
||||
usePKCE: true,
|
||||
extraParams: IDP[provider] ? { identity_provider: IDP[provider] as string } : {},
|
||||
});
|
||||
|
||||
const result = await request.promptAsync(discovery);
|
||||
if (result.type !== 'success' || !result.params.code) {
|
||||
throw new Error('Sign-in was cancelled');
|
||||
}
|
||||
|
||||
const tokens = await exchangeCodeAsync(
|
||||
{
|
||||
clientId: appConfig.cognitoClientId,
|
||||
code: result.params.code,
|
||||
redirectUri,
|
||||
extraParams: { code_verifier: request.codeVerifier ?? '' },
|
||||
},
|
||||
discovery,
|
||||
);
|
||||
|
||||
const session: StoredSession = {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: Date.now() + (tokens.expiresIn ?? 3600) * 1000,
|
||||
};
|
||||
await persist(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Returns a valid access token, refreshing if needed, or null if signed out. */
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
const raw = await SecureStore.getItemAsync(STORE_KEY);
|
||||
if (!raw) return null;
|
||||
const session = JSON.parse(raw) as StoredSession;
|
||||
|
||||
if (Date.now() < session.expiresAt - 60_000) return session.accessToken;
|
||||
if (!session.refreshToken) return null;
|
||||
|
||||
try {
|
||||
const tokens = await refreshAsync(
|
||||
{ clientId: appConfig.cognitoClientId, refreshToken: session.refreshToken },
|
||||
discovery,
|
||||
);
|
||||
const refreshed: StoredSession = {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken ?? session.refreshToken,
|
||||
expiresAt: Date.now() + (tokens.expiresIn ?? 3600) * 1000,
|
||||
};
|
||||
await persist(refreshed);
|
||||
return refreshed.accessToken;
|
||||
} catch {
|
||||
await signOut();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut(): Promise<void> {
|
||||
await SecureStore.deleteItemAsync(STORE_KEY);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
const extra = (Constants.expoConfig?.extra ?? {}) as Record<string, string>;
|
||||
|
||||
function required(key: string): string {
|
||||
const value = extra[key];
|
||||
if (!value) throw new Error(`Missing "${key}" in app.json extra`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export const appConfig = {
|
||||
apiUrl: required('apiUrl'),
|
||||
cognitoDomain: required('cognitoDomain'),
|
||||
cognitoClientId: required('cognitoClientId'),
|
||||
iosMonthlyProductId: required('iosMonthlyProductId'),
|
||||
iosYearlyProductId: required('iosYearlyProductId'),
|
||||
androidMonthlyProductId: required('androidMonthlyProductId'),
|
||||
androidYearlyProductId: required('androidYearlyProductId'),
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
endConnection,
|
||||
getAvailablePurchases,
|
||||
initConnection,
|
||||
requestSubscription,
|
||||
type SubscriptionPurchase,
|
||||
} from 'react-native-iap';
|
||||
import { verifyPurchase } from '../api/client';
|
||||
import { appConfig } from '../config';
|
||||
|
||||
export type Plan = 'monthly' | 'yearly';
|
||||
|
||||
const platform: 'ios' | 'android' = Platform.OS === 'ios' ? 'ios' : 'android';
|
||||
|
||||
export function productIdFor(plan: Plan): string {
|
||||
if (platform === 'ios') {
|
||||
return plan === 'monthly' ? appConfig.iosMonthlyProductId : appConfig.iosYearlyProductId;
|
||||
}
|
||||
return plan === 'monthly' ? appConfig.androidMonthlyProductId : appConfig.androidYearlyProductId;
|
||||
}
|
||||
|
||||
function receiptOf(purchase: SubscriptionPurchase): string {
|
||||
// iOS: base64 app receipt. Android: Play Billing purchase token.
|
||||
return platform === 'ios' ? purchase.transactionReceipt : (purchase.purchaseToken ?? '');
|
||||
}
|
||||
|
||||
async function withConnection<T>(fn: () => Promise<T>): Promise<T> {
|
||||
await initConnection();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await endConnection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the native purchase flow, then lets the backend verify the receipt
|
||||
* with the store. Returns the resulting subscription status.
|
||||
*/
|
||||
export async function purchase(plan: Plan): Promise<'free' | 'active'> {
|
||||
const productId = productIdFor(plan);
|
||||
return withConnection(async () => {
|
||||
await requestSubscription({ sku: productId });
|
||||
const purchases = await getAvailablePurchases();
|
||||
const match = purchases.find((p) => p.productId === productId);
|
||||
if (!match) throw new Error('Purchase not found after transaction');
|
||||
const result = await verifyPurchase({ platform, productId, receipt: receiptOf(match) });
|
||||
return result.subscriptionStatus;
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-checks existing purchases with the store ("Restore Purchase"). */
|
||||
export async function restore(): Promise<'free' | 'active'> {
|
||||
return withConnection(async () => {
|
||||
const purchases = await getAvailablePurchases();
|
||||
for (const p of purchases) {
|
||||
const result = await verifyPurchase({
|
||||
platform,
|
||||
productId: p.productId,
|
||||
receipt: receiptOf(p),
|
||||
});
|
||||
if (result.subscriptionStatus === 'active') return 'active';
|
||||
}
|
||||
return 'free';
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The entire visual language. Off-white, near-black, one dark blue-green
|
||||
* accent. Generous whitespace. No animations, no gradients.
|
||||
*/
|
||||
export const colors = {
|
||||
background: '#F7F6F2',
|
||||
text: '#16181A',
|
||||
textMuted: '#6E7472',
|
||||
accent: '#14554C',
|
||||
accentText: '#F7F6F2',
|
||||
border: '#E4E2DB',
|
||||
surface: '#FFFFFF',
|
||||
} as const;
|
||||
|
||||
export const spacing = {
|
||||
xs: 4,
|
||||
s: 8,
|
||||
m: 16,
|
||||
l: 24,
|
||||
xl: 40,
|
||||
xxl: 64,
|
||||
} as const;
|
||||
|
||||
export const type = {
|
||||
title: { fontSize: 28, fontWeight: '600', color: colors.text } as const,
|
||||
heading: { fontSize: 17, fontWeight: '600', color: colors.text } as const,
|
||||
body: { fontSize: 17, lineHeight: 26, color: colors.text } as const,
|
||||
caption: { fontSize: 13, lineHeight: 18, color: colors.textMuted } as const,
|
||||
} as const;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["index.ts", "App.tsx", "src"]
|
||||
}
|
||||
Reference in New Issue
Block a user