This commit is contained in:
Rustico77
2026-05-12 10:37:39 +00:00
64 changed files with 9280 additions and 423 deletions

2
.gitignore vendored
View File

@@ -73,3 +73,5 @@ yarn-error.log
!.yarn/releases !.yarn/releases
!.yarn/sdks !.yarn/sdks
!.yarn/versions !.yarn/versions
.claude

73
App.tsx
View File

@@ -1,45 +1,44 @@
/** /**
* Sample React Native App * Contexta Mobile App
* https://github.com/facebook/react-native
*
* @format
*/ */
import { NewAppScreen } from '@react-native/new-app-screen'; import React from 'react';
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native'; import { StatusBar, useColorScheme } from 'react-native';
import { import { SafeAreaProvider } from 'react-native-safe-area-context';
SafeAreaProvider, import { NavigationContainer } from '@react-navigation/native';
useSafeAreaInsets, import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
} from 'react-native-safe-area-context'; import { ToastProvider } from './src/contexts/ToastContext';
import { RootNavigator } from './src/navigation/RootNavigator';
function App() { const queryClient = new QueryClient({
const isDarkMode = useColorScheme() === 'dark'; defaultOptions: {
queries: {
return ( retry: 1,
<SafeAreaProvider> staleTime: 30 * 1000,
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} /> gcTime: 5 * 60 * 1000,
<AppContent /> },
</SafeAreaProvider>
);
}
function AppContent() {
const safeAreaInsets = useSafeAreaInsets();
return (
<View style={styles.container}>
<NewAppScreen
templateFileName="App.tsx"
safeAreaInsets={safeAreaInsets}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
}, },
}); });
function App() {
const isDark = useColorScheme() === 'dark';
return (
<QueryClientProvider client={queryClient}>
<SafeAreaProvider>
<NavigationContainer>
<StatusBar
barStyle={isDark ? 'light-content' : 'dark-content'}
backgroundColor="transparent"
translucent
/>
<ToastProvider>
<RootNavigator />
</ToastProvider>
</NavigationContainer>
</SafeAreaProvider>
</QueryClientProvider>
);
}
export default App; export default App;

View File

@@ -107,6 +107,8 @@ android {
} }
} }
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")
dependencies { dependencies {
// The version of react-native is set by the React Native Gradle Plugin // The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android") implementation("com.facebook.react:react-android")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">contexta_mb</string> <string name="app_name">Contexta</string>
</resources> </resources>

View File

@@ -1,53 +1,62 @@
{ {
"images" : [ "images": [
{ {
"idiom" : "iphone", "filename": "icon_20@2x.png",
"scale" : "2x", "idiom": "iphone",
"size" : "20x20" "scale": "2x",
"size": "20x20"
}, },
{ {
"idiom" : "iphone", "filename": "icon_20@3x.png",
"scale" : "3x", "idiom": "iphone",
"size" : "20x20" "scale": "3x",
"size": "20x20"
}, },
{ {
"idiom" : "iphone", "filename": "icon_29@2x.png",
"scale" : "2x", "idiom": "iphone",
"size" : "29x29" "scale": "2x",
"size": "29x29"
}, },
{ {
"idiom" : "iphone", "filename": "icon_29@3x.png",
"scale" : "3x", "idiom": "iphone",
"size" : "29x29" "scale": "3x",
"size": "29x29"
}, },
{ {
"idiom" : "iphone", "filename": "icon_40@2x.png",
"scale" : "2x", "idiom": "iphone",
"size" : "40x40" "scale": "2x",
"size": "40x40"
}, },
{ {
"idiom" : "iphone", "filename": "icon_40@3x.png",
"scale" : "3x", "idiom": "iphone",
"size" : "40x40" "scale": "3x",
"size": "40x40"
}, },
{ {
"idiom" : "iphone", "filename": "icon_60@2x.png",
"scale" : "2x", "idiom": "iphone",
"size" : "60x60" "scale": "2x",
"size": "60x60"
}, },
{ {
"idiom" : "iphone", "filename": "icon_60@3x.png",
"scale" : "3x", "idiom": "iphone",
"size" : "60x60" "scale": "3x",
"size": "60x60"
}, },
{ {
"idiom" : "ios-marketing", "filename": "icon_1024@1x.png",
"scale" : "1x", "idiom": "ios-marketing",
"size" : "1024x1024" "scale": "1x",
"size": "1024x1024"
} }
], ],
"info" : { "info": {
"author" : "xcode", "author": "xcode",
"version" : 1 "version": 1
} }
} }

View File

@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>contexta_mb</string> <string>Contexta</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>

1242
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,21 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@react-native-async-storage/async-storage": "^1.24.0",
"@react-native/new-app-screen": "0.84.1",
"@react-navigation/bottom-tabs": "^7.15.7",
"@react-navigation/native": "^7.2.0",
"@react-navigation/native-stack": "^7.14.7",
"@tanstack/react-query": "^5.95.2",
"@types/react-native-vector-icons": "^6.4.18",
"axios": "^1.13.6",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.84.1", "react-native": "0.84.1",
"@react-native/new-app-screen": "0.84.1", "react-native-gesture-handler": "^2.30.0",
"react-native-safe-area-context": "^5.5.2" "react-native-safe-area-context": "^5.5.2",
"react-native-screens": "^4.24.0",
"react-native-vector-icons": "^10.3.0",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",

BIN
src/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

View File

@@ -0,0 +1,306 @@
import React, { useState, useRef, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
Animated,
} from 'react-native';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../theme';
import { useTheme } from '../theme';
import { chatAPI } from '../services/api';
interface ChatInterfaceProps {
chatbotId: string;
welcomeMessage?: string;
primaryColor?: string;
}
interface LocalMessage {
id: string;
role: 'user' | 'assistant';
content: string;
pending?: boolean;
time?: string;
}
function getTime() {
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function ChatInterface({ chatbotId, welcomeMessage, primaryColor }: ChatInterfaceProps) {
const { theme, isDark } = useTheme();
const accentColor = primaryColor ?? COLORS.primary;
const flatRef = useRef<FlatList>(null);
const [messages, setMessages] = useState<LocalMessage[]>(() =>
welcomeMessage
? [{ id: 'welcome', role: 'assistant', content: welcomeMessage, time: getTime() }]
: [],
);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>();
const scrollToBottom = useCallback(() => {
setTimeout(() => flatRef.current?.scrollToEnd({ animated: true }), 80);
}, []);
const sendMessage = async () => {
const text = input.trim();
if (!text || sending) return;
const userMsg: LocalMessage = { id: Date.now().toString(), role: 'user', content: text, time: getTime() };
const loadingMsg: LocalMessage = { id: 'loading', role: 'assistant', content: '', pending: true };
setMessages(prev => [...prev, userMsg, loadingMsg]);
setInput('');
setSending(true);
scrollToBottom();
try {
const data = await chatAPI.sendMessage(chatbotId, text, sessionId);
if (!sessionId) setSessionId(data.session_id);
setMessages(prev => [
...prev.filter(m => m.id !== 'loading'),
{ id: (Date.now() + 1).toString(), role: 'assistant', content: data.response, time: getTime() },
]);
} catch {
setMessages(prev => [
...prev.filter(m => m.id !== 'loading'),
{ id: (Date.now() + 1).toString(), role: 'assistant', content: "Sorry, I couldn't process your message. Please try again.", time: getTime() },
]);
} finally {
setSending(false);
scrollToBottom();
}
};
const renderMessage = ({ item }: { item: LocalMessage }) => {
const isUser = item.role === 'user';
return (
<MessageBubble
message={item}
isUser={isUser}
accentColor={accentColor}
theme={theme}
isDark={isDark}
/>
);
};
return (
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: theme.bg }]}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}>
<FlatList
ref={flatRef}
data={messages}
keyExtractor={item => item.id}
renderItem={renderMessage}
contentContainerStyle={styles.messageList}
showsVerticalScrollIndicator={false}
onContentSizeChange={scrollToBottom}
/>
{/* Input bar */}
<View style={[styles.inputBar, { backgroundColor: theme.surface, borderTopColor: theme.border }]}>
<View style={[styles.inputWrapper, { backgroundColor: theme.bg, borderColor: theme.border }]}>
<TextInput
style={[styles.textInput, { color: theme.text }]}
value={input}
onChangeText={setInput}
placeholder="Message..."
placeholderTextColor={theme.placeholder}
multiline
maxLength={2000}
onSubmitEditing={sendMessage}
blurOnSubmit={false}
/>
</View>
<TouchableOpacity
style={[
styles.sendBtn,
{ backgroundColor: accentColor },
(sending || !input.trim()) && styles.sendBtnDisabled,
SHADOWS.primary,
]}
onPress={sendMessage}
disabled={sending || !input.trim()}>
<Text style={styles.sendIcon}></Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
function MessageBubble({
message,
isUser,
accentColor,
theme,
isDark,
}: {
message: LocalMessage;
isUser: boolean;
accentColor: string;
theme: any;
isDark: boolean;
}) {
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(8)).current;
React.useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 250, useNativeDriver: true }),
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 2 }),
]).start();
}, []);
return (
<Animated.View
style={[
styles.msgRow,
isUser && styles.msgRowUser,
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] },
]}>
{!isUser && (
<View style={[styles.avatar, { backgroundColor: accentColor }]}>
<Text style={styles.avatarText}>AI</Text>
</View>
)}
<View style={styles.bubbleWrapper}>
<View
style={[
styles.bubble,
isUser
? [styles.bubbleUser, { backgroundColor: accentColor }]
: [styles.bubbleBot, { backgroundColor: theme.surface, borderColor: theme.border }],
]}>
{message.pending ? (
<TypingIndicator />
) : (
<Text style={[styles.bubbleText, { color: isUser ? COLORS.white : theme.text }]}>
{message.content}
</Text>
)}
</View>
{message.time && !message.pending ? (
<Text style={[styles.timestamp, { color: theme.textMuted, alignSelf: isUser ? 'flex-end' : 'flex-start' }]}>
{message.time}
</Text>
) : null}
</View>
</Animated.View>
);
}
function TypingIndicator() {
const dot1 = useRef(new Animated.Value(0.3)).current;
const dot2 = useRef(new Animated.Value(0.3)).current;
const dot3 = useRef(new Animated.Value(0.3)).current;
React.useEffect(() => {
const pulse = (dot: Animated.Value, delay: number) =>
Animated.loop(
Animated.sequence([
Animated.delay(delay),
Animated.timing(dot, { toValue: 1, duration: 300, useNativeDriver: true }),
Animated.timing(dot, { toValue: 0.3, duration: 300, useNativeDriver: true }),
Animated.delay(600 - delay),
]),
);
const a1 = pulse(dot1, 0);
const a2 = pulse(dot2, 200);
const a3 = pulse(dot3, 400);
a1.start(); a2.start(); a3.start();
return () => { a1.stop(); a2.stop(); a3.stop(); };
}, []);
return (
<View style={typingStyles.row}>
{[dot1, dot2, dot3].map((dot, i) => (
<Animated.View key={i} style={[typingStyles.dot, { opacity: dot }]} />
))}
</View>
);
}
const typingStyles = StyleSheet.create({
row: { flexDirection: 'row', gap: 5, paddingVertical: 4, paddingHorizontal: 2 },
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: COLORS.primary },
});
const styles = StyleSheet.create({
container: { flex: 1 },
messageList: {
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.lg,
gap: SPACING.md,
paddingBottom: SPACING.xl,
},
msgRow: { flexDirection: 'row', gap: SPACING.sm, alignItems: 'flex-end', maxWidth: '100%' },
msgRowUser: { flexDirection: 'row-reverse' },
avatar: {
width: 32,
height: 32,
borderRadius: RADIUS.full,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
avatarText: { color: COLORS.white, fontSize: 10, fontWeight: '800' },
bubbleWrapper: { flex: 1, gap: 4 },
bubble: { maxWidth: '80%', borderRadius: RADIUS.xl, padding: SPACING.md },
bubbleUser: { alignSelf: 'flex-end', borderBottomRightRadius: RADIUS.xs },
bubbleBot: {
alignSelf: 'flex-start',
borderWidth: 1,
borderBottomLeftRadius: RADIUS.xs,
},
bubbleText: { fontSize: FONT_SIZE.md, lineHeight: 22 },
timestamp: { fontSize: 10, paddingHorizontal: 2 },
inputBar: {
flexDirection: 'row',
alignItems: 'flex-end',
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.md,
borderTopWidth: 1,
gap: SPACING.sm,
},
inputWrapper: {
flex: 1,
borderRadius: RADIUS.xl,
borderWidth: 1.5,
paddingHorizontal: SPACING.md,
paddingVertical: Platform.OS === 'ios' ? SPACING.sm : 0,
maxHeight: 120,
},
textInput: {
fontSize: FONT_SIZE.md,
minHeight: 40,
paddingTop: Platform.OS === 'android' ? SPACING.sm : 0,
},
sendBtn: {
width: 46,
height: 46,
borderRadius: RADIUS.full,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
sendBtnDisabled: { opacity: 0.45 },
sendIcon: { color: COLORS.white, fontSize: 20, fontWeight: '700', marginTop: -2 },
});

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { COLORS, RADIUS, FONT_SIZE } from '../../theme';
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'purple' | 'plan';
interface BadgeProps {
label: string;
variant?: BadgeVariant;
}
const BG: Record<BadgeVariant, string> = {
default: '#f3f4f6',
success: '#d1fae5',
warning: '#fef3c7',
error: '#fee2e2',
info: '#dbeafe',
purple: '#ede9fe',
plan: COLORS.primaryUltraLight,
};
const FG: Record<BadgeVariant, string> = {
default: '#374151',
success: '#065f46',
warning: '#92400e',
error: '#991b1b',
info: '#1e40af',
purple: '#5b21b6',
plan: COLORS.primaryDark,
};
export function Badge({ label, variant = 'default' }: BadgeProps) {
return (
<View style={[styles.badge, { backgroundColor: BG[variant] }]}>
<Text style={[styles.label, { color: FG[variant] }]}>{label}</Text>
</View>
);
}
export function PlanBadge({ plan }: { plan: string }) {
const variant =
plan === 'free' ? 'default'
: plan === 'starter' ? 'info'
: plan === 'business' ? 'success'
: plan === 'agency' ? 'purple'
: 'plan';
return <Badge label={plan.charAt(0).toUpperCase() + plan.slice(1)} variant={variant as BadgeVariant} />;
}
export function StatusBadge({ status }: { status: string }) {
const variant =
status === 'completed' || status === 'active' || status === 'published' ? 'success'
: status === 'processing' ? 'info'
: status === 'pending' ? 'warning'
: status === 'failed' ? 'error'
: 'default';
return <Badge label={status} variant={variant as BadgeVariant} />;
}
const styles = StyleSheet.create({
badge: {
borderRadius: RADIUS.full,
paddingVertical: 2,
paddingHorizontal: 8,
alignSelf: 'flex-start',
},
label: {
fontSize: FONT_SIZE.xs,
fontWeight: '600',
textTransform: 'capitalize',
},
});

View File

@@ -0,0 +1,132 @@
import React, { useRef } from 'react';
import {
Animated,
TouchableWithoutFeedback,
Text,
StyleSheet,
ActivityIndicator,
ViewStyle,
TextStyle,
View,
} from 'react-native';
import { COLORS, RADIUS, SPACING, FONT_SIZE, FONT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
type Variant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
type Size = 'sm' | 'md' | 'lg';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: Variant;
size?: Size;
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
fullWidth?: boolean;
}
export function Button({
title,
onPress,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
style,
textStyle,
fullWidth = false,
}: ButtonProps) {
const { theme, isDark } = useTheme();
const scale = useRef(new Animated.Value(1)).current;
const onPressIn = () =>
Animated.spring(scale, { toValue: 0.96, useNativeDriver: true, speed: 50, bounciness: 0 }).start();
const onPressOut = () =>
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 30, bounciness: 4 }).start();
const containerBg = getContainerBg(variant, theme, isDark);
const labelColor = getLabelColor(variant);
const shadow = variant === 'primary' ? SHADOWS.primary : variant === 'danger' ? SHADOWS.sm : {};
return (
<TouchableWithoutFeedback
onPress={onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
disabled={disabled || loading}>
<Animated.View
style={[
styles.base,
styles[`size_${size}`],
containerBg,
shadow,
fullWidth && styles.fullWidth,
(disabled || loading) && styles.disabled,
{ transform: [{ scale }] },
style,
]}>
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'primary' || variant === 'danger' ? COLORS.white : COLORS.primary}
/>
) : (
<Text style={[styles.label, styles[`label_${size}`], { color: labelColor }, textStyle]}>
{title}
</Text>
)}
</Animated.View>
</TouchableWithoutFeedback>
);
}
function getContainerBg(variant: Variant, theme: any, isDark: boolean): ViewStyle {
switch (variant) {
case 'primary':
return { backgroundColor: COLORS.primary };
case 'secondary':
return { backgroundColor: isDark ? theme.surfaceHover : theme.bgSecondary };
case 'outline':
return { backgroundColor: 'transparent', borderWidth: 1.5, borderColor: COLORS.primary };
case 'ghost':
return { backgroundColor: 'transparent' };
case 'danger':
return { backgroundColor: COLORS.error };
}
}
function getLabelColor(variant: Variant): string {
switch (variant) {
case 'primary':
case 'danger':
return COLORS.white;
case 'secondary':
return COLORS.primary;
case 'outline':
case 'ghost':
return COLORS.primary;
}
}
const styles = StyleSheet.create({
base: {
borderRadius: RADIUS.md,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
fullWidth: { width: '100%' },
disabled: { opacity: 0.55 },
size_sm: { paddingVertical: SPACING.xs + 2, paddingHorizontal: SPACING.md, borderRadius: RADIUS.sm },
size_md: { paddingVertical: SPACING.md - 1, paddingHorizontal: SPACING.xl, borderRadius: RADIUS.md },
size_lg: { paddingVertical: SPACING.md + 1, paddingHorizontal: SPACING.xxl, borderRadius: RADIUS.lg, minHeight: 52 },
label: { fontWeight: FONT.semibold as any, letterSpacing: 0.1 },
label_sm: { fontSize: FONT_SIZE.sm },
label_md: { fontSize: FONT_SIZE.md },
label_lg: { fontSize: FONT_SIZE.md },
});

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native';
import { RADIUS, SPACING, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
type Elevation = 'none' | 'xs' | 'sm' | 'md' | 'lg';
interface CardProps {
children: React.ReactNode;
style?: ViewStyle;
padding?: number;
elevation?: Elevation;
}
export function Card({ children, style, padding, elevation = 'sm' }: CardProps) {
const { theme } = useTheme();
const shadow = SHADOWS[elevation];
return (
<View
style={[
styles.card,
shadow,
{ backgroundColor: theme.surface, borderColor: theme.border },
padding !== undefined ? { padding } : null,
style,
]}>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: RADIUS.xl,
padding: SPACING.lg,
borderWidth: 1,
},
});

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { FONT_SIZE, SPACING } from '../../theme';
import { useTheme } from '../../theme';
import { Button } from './Button';
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: { label: string; onPress: () => void };
}
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
const { theme } = useTheme();
return (
<View style={styles.container}>
{icon ? <View style={styles.icon}>{icon}</View> : null}
<Text style={[styles.title, { color: theme.text }]}>{title}</Text>
{description ? (
<Text style={[styles.desc, { color: theme.textSecondary }]}>{description}</Text>
) : null}
{action ? (
<Button
title={action.label}
onPress={action.onPress}
style={styles.action}
/>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: SPACING.xxxl,
gap: SPACING.md,
},
icon: { marginBottom: SPACING.sm },
title: { fontSize: FONT_SIZE.lg, fontWeight: '600', textAlign: 'center' },
desc: { fontSize: FONT_SIZE.md, textAlign: 'center', lineHeight: 22 },
action: { marginTop: SPACING.sm },
});

130
src/components/ui/Input.tsx Normal file
View File

@@ -0,0 +1,130 @@
import React, { useState, useRef } from 'react';
import {
View,
Text,
TextInput,
TextInputProps,
StyleSheet,
TouchableOpacity,
Animated,
} from 'react-native';
import { COLORS, RADIUS, SPACING, FONT_SIZE, FONT } from '../../theme';
import { useTheme } from '../../theme';
interface InputProps extends TextInputProps {
label?: string;
error?: string;
hint?: string;
rightIcon?: React.ReactNode;
}
export function Input({ label, error, hint, rightIcon, style, onFocus, onBlur, ...props }: InputProps) {
const { theme } = useTheme();
const [focused, setFocused] = useState(false);
const borderAnim = useRef(new Animated.Value(0)).current;
const handleFocus = (e: any) => {
setFocused(true);
Animated.timing(borderAnim, { toValue: 1, duration: 180, useNativeDriver: false }).start();
onFocus?.(e);
};
const handleBlur = (e: any) => {
setFocused(false);
Animated.timing(borderAnim, { toValue: 0, duration: 180, useNativeDriver: false }).start();
onBlur?.(e);
};
const borderColor = borderAnim.interpolate({
inputRange: [0, 1],
outputRange: [error ? COLORS.error : theme.border, error ? COLORS.error : COLORS.primary],
});
const borderWidth = borderAnim.interpolate({
inputRange: [0, 1],
outputRange: [1.5, 2],
});
return (
<View style={styles.wrapper}>
{label ? (
<Text style={[styles.label, { color: focused ? COLORS.primary : theme.textSecondary }]}>
{label}
</Text>
) : null}
<Animated.View
style={[
styles.container,
{ backgroundColor: theme.inputBg, borderColor, borderWidth },
]}>
<TextInput
style={[styles.input, { color: theme.text }, style]}
placeholderTextColor={theme.placeholder}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
{rightIcon ? <View style={styles.rightIcon}>{rightIcon}</View> : null}
</Animated.View>
{error ? (
<Text style={styles.error}>{error}</Text>
) : hint ? (
<Text style={[styles.hint, { color: theme.textMuted }]}>{hint}</Text>
) : null}
</View>
);
}
export function SecureInput({ showToggle = true, ...props }: InputProps & { showToggle?: boolean }) {
const [visible, setVisible] = useState(false);
const { theme } = useTheme();
return (
<Input
{...props}
secureTextEntry={!visible}
rightIcon={
showToggle ? (
<TouchableOpacity onPress={() => setVisible(v => !v)} style={styles.toggleBtn}>
<Text style={[styles.toggleText, { color: COLORS.primary }]}>
{visible ? 'Hide' : 'Show'}
</Text>
</TouchableOpacity>
) : undefined
}
/>
);
}
const styles = StyleSheet.create({
wrapper: { marginBottom: SPACING.md },
label: {
fontSize: FONT_SIZE.sm,
fontWeight: FONT.medium as any,
marginBottom: SPACING.xs,
letterSpacing: 0.1,
},
container: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: RADIUS.md,
paddingHorizontal: SPACING.md,
minHeight: 50,
overflow: 'hidden',
},
input: {
flex: 1,
fontSize: FONT_SIZE.md,
paddingVertical: SPACING.sm,
},
rightIcon: { marginLeft: SPACING.xs },
error: {
fontSize: FONT_SIZE.xs,
color: COLORS.error,
marginTop: SPACING.xs,
fontWeight: FONT.medium as any,
},
hint: { fontSize: FONT_SIZE.xs, marginTop: SPACING.xs },
toggleBtn: { padding: SPACING.xs },
toggleText: { fontSize: FONT_SIZE.sm, fontWeight: FONT.semibold as any },
});

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { ActivityIndicator, View, StyleSheet, Text } from 'react-native';
import { COLORS, FONT_SIZE, SPACING } from '../../theme';
import { useTheme } from '../../theme';
interface SpinnerProps {
size?: 'small' | 'large';
color?: string;
centered?: boolean;
label?: string;
}
export function Spinner({ size = 'large', color, centered = false, label }: SpinnerProps) {
const { theme } = useTheme();
const spinnerColor = color ?? COLORS.primary;
if (centered) {
return (
<View style={styles.centered}>
<ActivityIndicator size={size} color={spinnerColor} />
{label ? <Text style={[styles.label, { color: theme.textSecondary }]}>{label}</Text> : null}
</View>
);
}
return <ActivityIndicator size={size} color={spinnerColor} />;
}
const styles = StyleSheet.create({
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: SPACING.xxxl,
gap: SPACING.md,
},
label: { fontSize: FONT_SIZE.sm },
});

View File

@@ -0,0 +1,6 @@
export { Button } from './Button';
export { Input, SecureInput } from './Input';
export { Card } from './Card';
export { Badge, PlanBadge, StatusBadge } from './Badge';
export { Spinner } from './Spinner';
export { EmptyState } from './EmptyState';

View File

@@ -0,0 +1,97 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import { View, Text, StyleSheet, Animated } from 'react-native';
import { COLORS, RADIUS, SPACING, FONT_SIZE } from '../theme';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: number;
message: string;
type: ToastType;
}
interface ToastContextType {
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextType>({
success: () => {},
error: () => {},
info: () => {},
});
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const counter = useRef(0);
const show = useCallback((message: string, type: ToastType) => {
const id = counter.current++;
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 3500);
}, []);
const success = useCallback((msg: string) => show(msg, 'success'), [show]);
const error = useCallback((msg: string) => show(msg, 'error'), [show]);
const info = useCallback((msg: string) => show(msg, 'info'), [show]);
return (
<ToastContext.Provider value={{ success, error, info }}>
{children}
<View style={styles.container} pointerEvents="none">
{toasts.map(toast => (
<ToastItem key={toast.id} toast={toast} />
))}
</View>
</ToastContext.Provider>
);
}
function ToastItem({ toast }: { toast: Toast }) {
const opacity = useRef(new Animated.Value(0)).current;
React.useEffect(() => {
Animated.sequence([
Animated.timing(opacity, { toValue: 1, duration: 250, useNativeDriver: true }),
Animated.delay(2800),
Animated.timing(opacity, { toValue: 0, duration: 300, useNativeDriver: true }),
]).start();
}, [opacity]);
const bg =
toast.type === 'success' ? COLORS.success
: toast.type === 'error' ? COLORS.error
: COLORS.info;
return (
<Animated.View style={[styles.toast, { backgroundColor: bg, opacity }]}>
<Text style={styles.toastText}>{toast.message}</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 90,
left: SPACING.lg,
right: SPACING.lg,
zIndex: 9999,
gap: SPACING.sm,
},
toast: {
borderRadius: RADIUS.md,
paddingVertical: SPACING.md,
paddingHorizontal: SPACING.lg,
},
toastText: {
color: '#fff',
fontSize: FONT_SIZE.md,
fontWeight: '500',
},
});
export const useToast = () => useContext(ToastContext);

83
src/data/templates.ts Normal file
View File

@@ -0,0 +1,83 @@
export interface ChatbotTemplate {
id: string;
name: string;
description: string;
icon: string;
category: string;
system_prompt: string;
welcome_message: string;
lead_capture_enabled: boolean;
}
export const CHATBOT_TEMPLATES: ChatbotTemplate[] = [
{
id: 'customer-support',
name: 'Customer Support',
description: 'Handle customer inquiries, returns, and product questions',
icon: '🎧',
category: 'Customer Support',
system_prompt: 'You are a friendly and helpful customer support assistant. Help customers with their inquiries, returns, product questions, and order issues. Be empathetic, professional, and solution-focused. If you cannot resolve an issue, offer to escalate to a human agent.',
welcome_message: "Hi there! I'm your customer support assistant. How can I help you today?",
lead_capture_enabled: false,
},
{
id: 'sales-assistant',
name: 'Sales Assistant',
description: 'Qualify leads, answer product questions, and book demos',
icon: '💼',
category: 'Sales',
system_prompt: 'You are an enthusiastic sales assistant. Help prospects understand our products and services, qualify their needs, and guide them toward the right solution. Collect their contact information so our sales team can follow up.',
welcome_message: "Welcome! I'm here to help you find the perfect solution. What are you looking to achieve?",
lead_capture_enabled: true,
},
{
id: 'hr-onboarding',
name: 'HR Onboarding',
description: 'Answer employee questions about policies, benefits, and procedures',
icon: '👥',
category: 'HR',
system_prompt: 'You are an HR onboarding assistant. Help new and existing employees with questions about company policies, benefits, procedures, time-off requests, and workplace guidelines. Be accurate and direct employees to HR for complex matters.',
welcome_message: "Hello! I'm your HR assistant. I can help with policies, benefits, and onboarding questions. What do you need?",
lead_capture_enabled: false,
},
{
id: 'ecommerce',
name: 'E-commerce Helper',
description: 'Guide shoppers through products, shipping, and returns',
icon: '🛍️',
category: 'E-commerce',
system_prompt: 'You are a helpful shopping assistant. Help customers find products, answer questions about shipping times, return policies, product specifications, and availability. Make shopping easy and enjoyable.',
welcome_message: "Welcome to our store! I'm here to help you find exactly what you're looking for. What can I help you with?",
lead_capture_enabled: false,
},
{
id: 'real-estate',
name: 'Real Estate Agent',
description: 'Answer questions about listings, viewings, and the buying process',
icon: '🏠',
category: 'Real Estate',
system_prompt: 'You are a knowledgeable real estate assistant. Help potential buyers and renters with property listings, neighborhood information, pricing guidance, and the buying/renting process. Collect contact details to schedule viewings.',
welcome_message: "Hello! Looking for your dream home? I can help you explore properties and answer any questions. Where shall we start?",
lead_capture_enabled: true,
},
{
id: 'restaurant',
name: 'Restaurant Assistant',
description: 'Share menu info, hours, and take reservation inquiries',
icon: '🍽️',
category: 'Food & Beverage',
system_prompt: 'You are a friendly restaurant assistant. Help guests with menu questions, dietary restrictions, opening hours, location information, and reservation inquiries. Be warm and welcoming.',
welcome_message: "Welcome! I'm here to help with our menu, reservations, and any questions. What can I do for you?",
lead_capture_enabled: false,
},
{
id: 'healthcare-faq',
name: 'Healthcare FAQ',
description: 'Answer general health questions and help with appointment booking',
icon: '🏥',
category: 'Healthcare',
system_prompt: 'You are a helpful healthcare information assistant. Provide general health information, answer questions about services, help with appointment scheduling inquiries, and direct patients to appropriate resources. Always clarify that you provide general information only and patients should consult a qualified healthcare professional for medical advice.',
welcome_message: "Hello! I can help with general health information, appointment questions, and our services. How can I assist you?",
lead_capture_enabled: false,
},
];

343
src/i18n/en.ts Normal file
View File

@@ -0,0 +1,343 @@
export const en = {
// Navigation tabs
nav: {
explore: 'Explore',
chatbots: 'Chatbots',
inbox: 'Inbox',
account: 'Account',
},
// Auth
auth: {
login_title: 'Welcome back',
login_subtitle: 'Sign in to your Contexta account',
signup_title: 'Create your account',
signup_subtitle: 'Start building AI chatbots — free forever',
email: 'Email',
password: 'Password',
company_name: 'Company name',
sign_in: 'Sign In',
create_account: 'Create free account',
no_account: "Don't have an account?",
already_account: 'Already have an account?',
sign_up_free: 'Sign up free',
forgot_password: 'Forgot password?',
forgot_title: 'Reset your password',
forgot_subtitle: "Enter your email and we'll send a reset link.",
send_reset: 'Send reset link',
back_to_signin: 'Back to sign in',
check_email_title: 'Check your email',
check_email_desc: 'A reset link has been sent if that address is registered.',
},
// Dashboard
dashboard: {
title: 'My Chatbots',
new: '+ New',
empty_title: 'No chatbots yet',
empty_desc: 'Create your first AI chatbot powered by your documents.',
create_first: 'Create Chatbot',
delete_title: 'Delete Chatbot',
delete_confirm: (name: string) => `Delete "${name}"? This cannot be undone.`,
edit: 'Edit',
preview: 'Preview',
publish: 'Publish',
unpublish: 'Unpublish',
docs: 'docs',
chats: 'chats',
},
// Onboarding checklist
onboarding: {
title: 'Getting started',
step_create: 'Create your first chatbot',
step_docs: 'Add knowledge documents or URLs',
step_publish: 'Publish your chatbot',
},
// Chatbot builder
builder: {
title_new: 'Chatbot Builder',
title_edit: 'Chatbot Builder',
tab_settings: 'Settings',
tab_docs: 'Docs',
tab_preview: 'Preview',
tab_testing: 'Testing',
tab_deploy: 'Deploy',
save_first: 'Save the chatbot first.',
save_first_preview: 'Save the chatbot first to preview it.',
save_first_test: 'Save the chatbot first to run tests.',
section_basic: 'Basic Info',
name_label: 'Chatbot Name *',
name_placeholder: 'My Support Bot',
description_label: 'Description',
description_placeholder: 'What does this chatbot do?',
section_behavior: 'Behavior',
system_prompt_label: 'System Prompt',
system_prompt_placeholder: 'You are a helpful assistant...',
section_model: 'AI Model',
section_temperature: 'Temperature',
temp_hint: '0 = focused, 1 = creative',
section_appearance: 'Appearance',
color_label: 'Primary Color',
welcome_label: 'Welcome Message',
section_classification: 'Classification',
category_label: 'Category',
section_advanced: 'Advanced',
branding_label: 'Show Branding',
branding_desc: "Display 'Powered by Contexta' in the chat widget",
lead_label: 'Lead Capture',
lead_desc: 'Collect visitor contact info during chats',
handoff_label: 'Human Handoff',
handoff_desc: 'Escalate conversations to a human agent',
handoff_email_label: 'Handoff Email',
save: 'Save Changes',
create: 'Create Chatbot',
saving: 'Saving...',
// Templates
template_title: 'Choose a template',
template_skip: 'Start blank →',
// Testing tab
testing_title: 'Test Questions',
testing_desc: 'Enter up to 10 questions to test how your chatbot responds.',
testing_placeholder: 'Ask a question...',
testing_add: '+ Add question',
testing_run: '▶ Run Tests',
testing_running: 'Running...',
testing_results: (n: number) => `${n} RESULT${n !== 1 ? 'S' : ''}`,
testing_sources: 'SOURCES',
testing_model: 'Model',
testing_error: 'Test failed. Make sure your chatbot has a knowledge base.',
},
// Documents
documents: {
title: 'Documents',
add_url: 'Add URL',
url_placeholder: 'https://...',
upload: 'Upload Document',
empty_title: 'No documents yet',
empty_desc: 'Upload files or add URLs to build the knowledge base.',
delete_title: 'Delete Document',
delete_confirm: 'Remove this document from the knowledge base?',
status_pending: 'Pending',
status_processing: 'Processing...',
status_completed: 'Ready',
status_failed: 'Failed',
chunks: (n: number) => `${n} chunks`,
retry: 'Retry',
url_sources: 'URL Sources',
refreshing: 'Refreshing...',
},
// Deploy tab
deploy: {
publish_label: 'Published',
publish_desc: 'Make this chatbot publicly accessible',
chat_link: 'Public Chat Link',
chat_link_desc: 'Share this link directly with anyone',
copy: 'Copy',
copied: 'Copied!',
publish_first: 'Publish your chatbot to get a public link.',
embed_title: 'Embed Code',
embed_desc: 'Add the chat widget to any website',
telegram_title: 'Telegram',
telegram_connected: 'Connected',
telegram_disconnect: 'Disconnect',
telegram_token_placeholder: 'Bot token from @BotFather',
telegram_connect: 'Connect',
whatsapp_title: 'WhatsApp',
whatsapp_keyword: 'Keyword (optional)',
whatsapp_connect: 'Connect',
section_lead: 'Lead Capture',
section_handoff: 'Human Handoff',
section_booking: 'Appointments',
booking_enable: 'Enable appointment booking',
booking_enable_sub: 'Chatbot will guide users to book appointments',
},
// Inbox
inbox: {
title: 'Inbox',
filter_all: 'All',
filter_open: 'Open',
filter_handling: 'Handling',
filter_resolved: 'Resolved',
empty_title: 'No conversations',
empty_desc: 'Conversations with your chatbots will appear here.',
empty_filtered: (status: string) => `No ${status} conversations.`,
no_messages: 'No messages yet',
type_reply: 'Write a reply as agent...',
send: 'Send',
status_open: 'Open',
status_agent: 'Handling',
status_resolved: 'Resolved',
take_over: 'Take over',
resolve: 'Resolve',
reopen: 'Reopen',
delete: 'Delete',
delete_confirm: 'Delete this conversation?',
},
// Leads
leads: {
title: 'Leads',
subtitle: (n: number) => `${n} contact${n !== 1 ? 's' : ''} collected`,
export: '⬆ Export',
empty_title: 'No leads yet',
empty_desc: 'Enable lead capture on your chatbots to collect visitor contact info.',
no_export: 'There are no leads to export yet.',
detail_title: 'Lead Details',
contact_info: 'Contact Info',
status_section: 'Status',
notes_section: 'Notes',
notes_placeholder: 'Add notes about this lead...',
cancel: 'Cancel',
save: 'Save',
status_new: 'New',
status_contacted: 'Contacted',
status_qualified: 'Qualified',
status_closed: 'Closed',
status_lost: 'Lost',
field_name: 'Name',
field_email: 'Email',
field_phone: 'Phone',
field_company: 'Company',
field_chatbot: 'Chatbot',
field_date: 'Date',
},
// Analytics
analytics: {
title: 'Analytics',
subtitle: 'Overview of your chatbot performance',
conversations: 'Conversations',
messages: 'Messages',
chatbots: 'Chatbots',
avg_conv: 'Avg / Conv',
by_chatbot: 'By Chatbot',
confidence: 'Confidence',
empty_title: 'No data yet',
empty_desc: 'Analytics will appear once your chatbots start receiving conversations.',
unanswered: (n: number) => `${n} unanswered`,
show_gaps: 'Show gaps ▼',
hide_gaps: 'Hide ▲',
},
// Campaigns
campaigns: {
title: 'Campaigns',
new: 'New Campaign',
empty_title: 'No campaigns yet',
empty_desc: 'Create a campaign to broadcast a message to all your Telegram subscribers at once.',
select_chatbot: 'Select chatbot',
campaign_name: 'Campaign Name',
name_placeholder: 'e.g. Summer promo, New menu announcement...',
message_label: 'Message',
message_placeholder: 'Write your broadcast message here...',
create: 'Create Campaign',
send: 'Send',
delete: 'Delete',
send_confirm: (title: string, n: number) => `Send "${title}" to ${n} subscriber${n !== 1 ? 's' : ''}?`,
send_warning: 'This cannot be undone. The message will be delivered immediately.',
send_now: 'Send Now',
cancel: 'Cancel',
status_draft: 'Draft',
status_sending: 'Sending...',
status_sent: 'Sent',
status_failed: 'Failed',
subscribers: (n: number) => `${n} subscriber${n !== 1 ? 's' : ''}`,
delivered: 'delivered',
},
// Appointments
appointments: {
title: 'Appointments',
subtitle: 'Bookings made via your chatbots',
empty_title: 'No appointments yet',
empty_desc: 'Once customers book via your chatbot, appointments will appear here.',
configure_hours: 'Configure Hours',
save_hours: 'Save Hours',
status_pending: 'Pending',
status_confirmed: 'Confirmed',
status_cancelled: 'Cancelled',
status_completed: 'Completed',
confirm: 'Confirm',
decline: 'Decline',
complete: 'Complete',
cancel: 'Cancel',
days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
closed: 'Closed',
open_from: 'Open from',
to: 'to',
},
// Settings
settings: {
title: 'Settings',
subscription: 'Subscription',
reports: 'Reports',
analytics: 'Analytics',
leads: 'Leads',
campaigns: 'Campaigns',
appointments: 'Appointments',
appearance: 'Appearance',
theme_system: 'System',
theme_light: 'Light',
theme_dark: 'Dark',
profile: 'Profile',
company_label: 'Company / Team Name',
company_placeholder: 'Acme Inc.',
language_label: 'Language',
change_password: 'Change Password',
current_password: 'Current Password',
new_password: 'New Password',
save_profile: 'Save Profile',
account: 'Account',
sign_out: 'Sign Out',
sign_out_confirm: 'Are you sure you want to sign out?',
delete_account: 'Delete Account',
delete_confirm: 'This will permanently delete your account and all chatbots. This cannot be undone.',
renews: 'Renews',
version: 'Contexta v1.0.0',
profile_updated: 'Profile updated!',
update_failed: 'Failed to update profile',
company_required: 'Company name is required',
},
// Marketplace
marketplace: {
title: 'Marketplace',
search_placeholder: 'Search chatbots...',
empty_title: 'No chatbots found',
empty_desc: 'Try adjusting your search or filters.',
chat_now: 'Chat Now →',
by: (name: string) => `by ${name}`,
conversations: (n: number) => `${n} conversations`,
rating: 'Your rating',
rate_submit: 'Submit',
login_to_rate: 'Sign in to rate',
},
// Guest screen
guest: {
sign_in: 'Sign In',
sign_up: 'Create Account',
or: 'or',
},
// Common
common: {
loading: 'Loading...',
cancel: 'Cancel',
delete: 'Delete',
save: 'Save',
close: 'Close',
confirm: 'Confirm',
retry: 'Retry',
refresh: 'Refresh',
error: 'Something went wrong. Please try again.',
},
};
export type Translations = typeof en;

325
src/i18n/fr.ts Normal file
View File

@@ -0,0 +1,325 @@
import type { Translations } from './en';
export const fr: Translations = {
nav: {
explore: 'Explorer',
chatbots: 'Chatbots',
inbox: 'Boîte de réception',
account: 'Compte',
},
auth: {
login_title: 'Bon retour',
login_subtitle: 'Connectez-vous à votre compte Contexta',
signup_title: 'Créez votre compte',
signup_subtitle: 'Commencez à créer des chatbots IA — gratuit pour toujours',
email: 'E-mail',
password: 'Mot de passe',
company_name: "Nom de l'entreprise",
sign_in: 'Se connecter',
create_account: 'Créer un compte gratuit',
no_account: 'Pas de compte ?',
already_account: 'Déjà un compte ?',
sign_up_free: "S'inscrire gratuitement",
forgot_password: 'Mot de passe oublié ?',
forgot_title: 'Réinitialisez votre mot de passe',
forgot_subtitle: 'Entrez votre e-mail, nous vous enverrons un lien de réinitialisation.',
send_reset: 'Envoyer le lien',
back_to_signin: 'Retour à la connexion',
check_email_title: 'Vérifiez votre boîte mail',
check_email_desc: "Un lien de réinitialisation a été envoyé si cette adresse est enregistrée.",
},
dashboard: {
title: 'Mes Chatbots',
new: '+ Nouveau',
empty_title: 'Aucun chatbot pour l\'instant',
empty_desc: 'Créez votre premier chatbot IA alimenté par vos documents.',
create_first: 'Créer un chatbot',
delete_title: 'Supprimer le chatbot',
delete_confirm: (name: string) => `Supprimer "${name}" ? Cette action est irréversible.`,
edit: 'Modifier',
preview: 'Aperçu',
publish: 'Publier',
unpublish: 'Dépublier',
docs: 'docs',
chats: 'chats',
},
onboarding: {
title: 'Premiers pas 🚀',
step_create: 'Créez votre premier chatbot',
step_docs: 'Ajoutez des documents ou des URLs',
step_publish: 'Publiez votre chatbot',
},
builder: {
title_new: 'Créer un chatbot',
title_edit: 'Modifier le chatbot',
tab_settings: 'Paramètres',
tab_docs: 'Documents',
tab_preview: 'Aperçu',
tab_testing: 'Tests',
tab_deploy: 'Déploiement',
save_first: 'Enregistrez le chatbot d\'abord.',
save_first_preview: 'Enregistrez le chatbot d\'abord pour l\'aperçu.',
save_first_test: 'Enregistrez le chatbot d\'abord pour lancer des tests.',
section_basic: 'Informations de base',
name_label: 'Nom du chatbot *',
name_placeholder: 'Mon Bot Support',
description_label: 'Description',
description_placeholder: 'Que fait ce chatbot ?',
section_behavior: 'Comportement',
system_prompt_label: 'Invite système',
system_prompt_placeholder: 'Vous êtes un assistant utile...',
section_model: 'Modèle IA',
section_temperature: 'Température',
temp_hint: '0 = précis, 1 = créatif',
section_appearance: 'Apparence',
color_label: 'Couleur principale',
welcome_label: "Message d'accueil",
section_classification: 'Classification',
category_label: 'Catégorie',
section_advanced: 'Avancé',
branding_label: 'Afficher la marque',
branding_desc: "Afficher « Propulsé par Contexta » dans le widget",
lead_label: 'Capture de prospects',
lead_desc: 'Collecter les coordonnées des visiteurs',
handoff_label: 'Transfert humain',
handoff_desc: 'Escalader les conversations à un agent humain',
handoff_email_label: 'E-mail de transfert',
save: 'Enregistrer',
create: 'Créer le chatbot',
saving: 'Enregistrement...',
template_title: 'Choisir un modèle',
template_skip: 'Commencer de zéro →',
testing_title: 'Questions de test',
testing_desc: "Entrez jusqu'à 10 questions pour tester les réponses de votre chatbot.",
testing_placeholder: "ex. Quels sont vos horaires d'ouverture ?",
testing_add: '+ Ajouter une question',
testing_run: '▶ Lancer les tests',
testing_running: 'En cours...',
testing_results: (n: number) => `${n} RÉSULTAT${n !== 1 ? 'S' : ''}`,
testing_sources: 'SOURCES',
testing_model: 'Modèle',
testing_error: 'Test échoué. Vérifiez que votre chatbot a une base de connaissances.',
},
documents: {
title: 'Documents',
add_url: 'Ajouter une URL',
url_placeholder: 'https://...',
upload: 'Importer un document',
empty_title: 'Aucun document pour l\'instant',
empty_desc: 'Importez des fichiers ou ajoutez des URLs pour construire la base de connaissances.',
delete_title: 'Supprimer le document',
delete_confirm: 'Retirer ce document de la base de connaissances ?',
status_pending: 'En attente',
status_processing: 'Traitement...',
status_completed: 'Prêt',
status_failed: 'Échec',
chunks: (n: number) => `${n} fragments`,
retry: 'Réessayer',
url_sources: 'Sources URL',
refreshing: 'Actualisation...',
},
deploy: {
publish_label: 'Publié',
publish_desc: 'Rendre ce chatbot accessible publiquement',
chat_link: 'Lien de chat public',
chat_link_desc: "Partagez ce lien directement avec n'importe qui",
copy: 'Copier',
copied: 'Copié !',
publish_first: 'Publiez votre chatbot pour obtenir un lien public.',
embed_title: "Code d'intégration",
embed_desc: 'Ajoutez le widget de chat à n\'importe quel site web',
telegram_title: 'Telegram',
telegram_connected: 'Connecté',
telegram_disconnect: 'Déconnecter',
telegram_token_placeholder: 'Token du bot depuis @BotFather',
telegram_connect: 'Connecter',
whatsapp_title: 'WhatsApp',
whatsapp_keyword: 'Mot-clé (optionnel)',
whatsapp_connect: 'Connecter',
section_lead: 'Capture de prospects',
section_handoff: 'Transfert humain',
section_booking: 'Rendez-vous',
booking_enable: 'Activer la prise de rendez-vous',
booking_enable_sub: 'Le chatbot guidera les utilisateurs pour réserver',
},
inbox: {
title: 'Boîte de réception',
filter_all: 'Tous',
filter_open: 'Ouvert',
filter_handling: 'Agent',
filter_resolved: 'Résolu',
empty_title: 'Aucune conversation',
empty_desc: 'Les conversations avec vos chatbots apparaîtront ici.',
empty_filtered: (status: string) => `Aucune conversation « ${status} ».`,
no_messages: 'Aucun message',
type_reply: 'Écrire une réponse en tant qu\'agent...',
send: 'Envoyer',
status_open: 'Ouvert',
status_agent: 'Agent',
status_resolved: 'Résolu',
take_over: 'Prendre en charge',
resolve: 'Résoudre',
reopen: 'Rouvrir',
delete: 'Supprimer',
delete_confirm: 'Supprimer cette conversation ?',
},
leads: {
title: 'Prospects',
subtitle: (n: number) => `${n} contact${n !== 1 ? 's' : ''} collecté${n !== 1 ? 's' : ''}`,
export: '⬆ Exporter',
empty_title: 'Aucun prospect pour l\'instant',
empty_desc: 'Activez la capture de prospects sur vos chatbots pour collecter des contacts.',
no_export: 'Aucun prospect à exporter pour l\'instant.',
detail_title: 'Détails du prospect',
contact_info: 'Informations de contact',
status_section: 'Statut',
notes_section: 'Notes',
notes_placeholder: 'Ajouter des notes sur ce prospect...',
cancel: 'Annuler',
save: 'Enregistrer',
status_new: 'Nouveau',
status_contacted: 'Contacté',
status_qualified: 'Qualifié',
status_closed: 'Fermé',
status_lost: 'Perdu',
field_name: 'Nom',
field_email: 'E-mail',
field_phone: 'Téléphone',
field_company: 'Entreprise',
field_chatbot: 'Chatbot',
field_date: 'Date',
},
analytics: {
title: 'Analytiques',
subtitle: 'Vue d\'ensemble des performances de vos chatbots',
conversations: 'Conversations',
messages: 'Messages',
chatbots: 'Chatbots',
avg_conv: 'Moy. / Conv.',
by_chatbot: 'Par chatbot',
confidence: 'Confiance',
empty_title: 'Aucune donnée pour l\'instant',
empty_desc: 'Les analytiques apparaîtront dès que vos chatbots recevront des conversations.',
unanswered: (n: number) => `${n} sans réponse`,
show_gaps: 'Voir les lacunes ▼',
hide_gaps: 'Masquer ▲',
},
campaigns: {
title: 'Campagnes',
new: 'Nouvelle campagne',
empty_title: 'Aucune campagne pour l\'instant',
empty_desc: 'Créez une campagne pour diffuser un message à tous vos abonnés Telegram en une fois.',
select_chatbot: 'Sélectionner un chatbot',
campaign_name: 'Nom de la campagne',
name_placeholder: 'ex. Promotion estivale, Annonce du nouveau menu...',
message_label: 'Message',
message_placeholder: 'Rédigez votre message de diffusion ici...',
create: 'Créer la campagne',
send: 'Envoyer',
delete: 'Supprimer',
send_confirm: (title: string, n: number) => `Envoyer "${title}" à ${n} abonné${n !== 1 ? 's' : ''} ?`,
send_warning: 'Cette action est irréversible. Le message sera délivré immédiatement.',
send_now: 'Envoyer maintenant',
cancel: 'Annuler',
status_draft: 'Brouillon',
status_sending: 'Envoi en cours...',
status_sent: 'Envoyé',
status_failed: 'Échec',
subscribers: (n: number) => `${n} abonné${n !== 1 ? 's' : ''}`,
delivered: 'délivrés',
},
appointments: {
title: 'Rendez-vous',
subtitle: 'Réservations effectuées via vos chatbots',
empty_title: 'Aucun rendez-vous pour l\'instant',
empty_desc: 'Une fois que des clients réservent via votre chatbot, les rendez-vous apparaîtront ici.',
configure_hours: 'Configurer les horaires',
save_hours: 'Enregistrer les horaires',
status_pending: 'En attente',
status_confirmed: 'Confirmé',
status_cancelled: 'Annulé',
status_completed: 'Terminé',
confirm: 'Confirmer',
decline: 'Refuser',
complete: 'Terminer',
cancel: 'Annuler',
days: ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'],
closed: 'Fermé',
open_from: 'Ouvert de',
to: 'à',
},
settings: {
title: 'Paramètres',
subscription: 'Abonnement',
reports: 'Rapports',
analytics: 'Analytiques',
leads: 'Prospects',
campaigns: 'Campagnes',
appointments: 'Rendez-vous',
appearance: 'Apparence',
theme_system: 'Système',
theme_light: 'Clair',
theme_dark: 'Sombre',
profile: 'Profil',
company_label: 'Nom de l\'entreprise',
company_placeholder: 'Acme Inc.',
language_label: 'Langue',
change_password: 'Modifier le mot de passe',
current_password: 'Mot de passe actuel',
new_password: 'Nouveau mot de passe',
save_profile: 'Enregistrer',
account: 'Compte',
sign_out: 'Se déconnecter',
sign_out_confirm: 'Voulez-vous vraiment vous déconnecter ?',
delete_account: 'Supprimer le compte',
delete_confirm: 'Cela supprimera définitivement votre compte et tous vos chatbots. Cette action est irréversible.',
renews: 'Renouvellement',
version: 'Contexta v1.0.0',
profile_updated: 'Profil mis à jour !',
update_failed: 'Échec de la mise à jour du profil',
company_required: "Le nom de l'entreprise est requis",
},
marketplace: {
title: 'Marketplace',
search_placeholder: 'Rechercher des chatbots...',
empty_title: 'Aucun chatbot trouvé',
empty_desc: 'Essayez d\'ajuster votre recherche ou vos filtres.',
chat_now: 'Démarrer →',
by: (name: string) => `par ${name}`,
conversations: (n: number) => `${n} conversations`,
rating: 'Votre note',
rate_submit: 'Soumettre',
login_to_rate: 'Connectez-vous pour noter',
},
guest: {
sign_in: 'Se connecter',
sign_up: 'Créer un compte',
or: 'ou',
},
common: {
loading: 'Chargement...',
cancel: 'Annuler',
delete: 'Supprimer',
save: 'Enregistrer',
close: 'Fermer',
confirm: 'Confirmer',
retry: 'Réessayer',
refresh: 'Actualiser',
error: 'Une erreur s\'est produite. Veuillez réessayer.',
},
};

11
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,11 @@
import { useLanguageStore } from '../stores/languageStore';
import { en } from './en';
import { fr } from './fr';
const translations = { en, fr };
export function useTranslation() {
const language = useLanguageStore(s => s.language);
const t = translations[language] ?? en;
return { t, language };
}

View File

@@ -0,0 +1,147 @@
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useColorScheme } from 'react-native';
import { COLORS } from '../theme';
import { CustomTabBar } from './CustomTabBar';
import { useAuthStore } from '../stores/authStore';
import {
AppTabParamList,
MarketplaceStackParamList,
DashboardStackParamList,
InboxStackParamList,
AccountStackParamList,
} from './types';
// Public screens
import { MarketplaceScreen } from '../screens/marketplace/MarketplaceScreen';
import { ChatbotDetailScreen } from '../screens/marketplace/ChatbotDetailScreen';
import { PublicChatScreen } from '../screens/marketplace/PublicChatScreen';
// Auth-required screens
import { DashboardScreen } from '../screens/dashboard/DashboardScreen';
import { ChatbotBuilderScreen } from '../screens/chatbots/ChatbotBuilderScreen';
import { ChatPreviewScreen } from '../screens/chatbots/ChatPreviewScreen';
import { InboxScreen } from '../screens/inbox/InboxScreen';
import { ConversationScreen } from '../screens/inbox/ConversationScreen';
import { SettingsScreen } from '../screens/settings/SettingsScreen';
import { LeadsScreen } from '../screens/leads/LeadsScreen';
import { AnalyticsScreen } from '../screens/analytics/AnalyticsScreen';
import { CampaignsScreen } from '../screens/campaigns/CampaignsScreen';
import { AppointmentsScreen } from '../screens/appointments/AppointmentsScreen';
// Guest screen for unauthenticated users on protected tabs
import { GuestScreen } from '../screens/GuestScreen';
const Tab = createBottomTabNavigator<AppTabParamList>();
const MktStack = createNativeStackNavigator<MarketplaceStackParamList>();
const DashStack = createNativeStackNavigator<DashboardStackParamList>();
const InboxStack = createNativeStackNavigator<InboxStackParamList>();
const AccStack = createNativeStackNavigator<AccountStackParamList>();
// ─── Marketplace (always public) ─────────────────────────────────────────────
function MarketplaceNavigator() {
return (
<MktStack.Navigator>
<MktStack.Screen name="MarketplaceList" component={MarketplaceScreen} options={{ title: 'Marketplace' }} />
<MktStack.Screen name="MarketplaceDetail" component={ChatbotDetailScreen} options={{ title: 'Chatbot Details' }} />
<MktStack.Screen
name="PublicChat"
component={PublicChatScreen}
options={({ route }) => ({ title: route.params.chatbotName })}
/>
</MktStack.Navigator>
);
}
// ─── Dashboard (requires auth) ────────────────────────────────────────────────
function DashboardNavigator() {
const isAuthenticated = useAuthStore(s => s.isAuthenticated);
if (!isAuthenticated) {
return (
<GuestScreen
icon="🤖"
title="Your chatbots live here"
description="Sign in to create, manage and deploy AI chatbots powered by your documents."
/>
);
}
return (
<DashStack.Navigator>
<DashStack.Screen name="ChatbotList" component={DashboardScreen} options={{ title: 'My Chatbots' }} />
<DashStack.Screen name="ChatbotBuilder" component={ChatbotBuilderScreen} options={{ title: 'Chatbot Builder' }} />
<DashStack.Screen
name="ChatPreview"
component={ChatPreviewScreen}
options={({ route }) => ({ title: route.params.chatbotName })}
/>
</DashStack.Navigator>
);
}
// ─── Inbox (requires auth) ────────────────────────────────────────────────────
function InboxNavigator() {
const isAuthenticated = useAuthStore(s => s.isAuthenticated);
if (!isAuthenticated) {
return (
<GuestScreen
icon="✉️"
title="Your inbox is empty"
description="Sign in to view and manage conversations your chatbots have with visitors."
/>
);
}
return (
<InboxStack.Navigator>
<InboxStack.Screen name="InboxList" component={InboxScreen} options={{ title: 'Inbox' }} />
<InboxStack.Screen
name="Conversation"
component={ConversationScreen}
options={({ route }) => ({ title: route.params.chatbotName ?? 'Conversation' })}
/>
</InboxStack.Navigator>
);
}
// ─── Account (guest CTA or full settings) ────────────────────────────────────
function AccountNavigator() {
const isAuthenticated = useAuthStore(s => s.isAuthenticated);
if (!isAuthenticated) {
return (
<GuestScreen
icon="👤"
title="Manage your account"
description="Sign in to access your profile, subscription, leads, and analytics."
/>
);
}
return (
<AccStack.Navigator>
<AccStack.Screen name="AccountHome" component={SettingsScreen} options={{ title: 'Settings' }} />
<AccStack.Screen name="Leads" component={LeadsScreen} options={{ title: 'Leads' }} />
<AccStack.Screen name="Analytics" component={AnalyticsScreen} options={{ title: 'Analytics' }} />
<AccStack.Screen name="Campaigns" component={CampaignsScreen} options={{ title: 'Campaigns' }} />
<AccStack.Screen name="Appointments" component={AppointmentsScreen} options={{ title: 'Appointments' }} />
</AccStack.Navigator>
);
}
// ─── Main Tab Navigator ───────────────────────────────────────────────────────
export function AppNavigator() {
return (
<Tab.Navigator
initialRouteName="MarketplaceTab"
tabBar={props => <CustomTabBar {...props} />}
screenOptions={{ headerShown: false }}>
<Tab.Screen name="MarketplaceTab" component={MarketplaceNavigator} options={{ title: 'Explore' }} />
<Tab.Screen name="DashboardTab" component={DashboardNavigator} options={{ title: 'Chatbots' }} />
<Tab.Screen name="InboxTab" component={InboxNavigator} options={{ title: 'Inbox' }} />
<Tab.Screen name="AccountTab" component={AccountNavigator} options={{ title: 'Account' }} />
</Tab.Navigator>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { AuthStackParamList } from './types';
import { LoginScreen } from '../screens/auth/LoginScreen';
import { SignupScreen } from '../screens/auth/SignupScreen';
import { ForgotPasswordScreen } from '../screens/auth/ForgotPasswordScreen';
const Stack = createNativeStackNavigator<AuthStackParamList>();
export function AuthNavigator() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Signup" component={SignupScreen} />
<Stack.Screen name="ForgotPassword" component={ForgotPasswordScreen} />
</Stack.Navigator>
);
}

View File

@@ -0,0 +1,160 @@
import React, { useRef } from 'react';
import {
View,
Text,
TouchableWithoutFeedback,
Animated,
StyleSheet,
Platform,
} from 'react-native';
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { COLORS, SPACING, RADIUS, FONT_SIZE, FONT, SHADOWS } from '../theme';
import { useTheme } from '../theme';
const TAB_CONFIG: Record<string, { icon: string; label: string }> = {
MarketplaceTab: { icon: '🧭', label: 'Explore' },
DashboardTab: { icon: '⊞', label: 'Chatbots' },
InboxTab: { icon: '✉', label: 'Inbox' },
AccountTab: { icon: '◎', label: 'Account' },
};
export function CustomTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const { theme, isDark } = useTheme();
const insets = useSafeAreaInsets();
return (
<View
style={[
styles.wrapper,
{ paddingBottom: insets.bottom || SPACING.sm, backgroundColor: theme.tabBar },
]}>
<View style={[styles.tabBar, isDark ? styles.tabBarDark : styles.tabBarLight]}>
{state.routes.map((route, index) => {
const isFocused = state.index === index;
const config = TAB_CONFIG[route.name] ?? { icon: '•', label: route.name };
const onPress = () => {
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(route.name);
}
};
return (
<TabItem
key={route.key}
icon={config.icon}
label={config.label}
isFocused={isFocused}
onPress={onPress}
/>
);
})}
</View>
</View>
);
}
function TabItem({
icon,
label,
isFocused,
onPress,
}: {
icon: string;
label: string;
isFocused: boolean;
onPress: () => void;
}) {
const { theme } = useTheme();
const scale = useRef(new Animated.Value(1)).current;
const bgOpacity = useRef(new Animated.Value(isFocused ? 1 : 0)).current;
React.useEffect(() => {
Animated.timing(bgOpacity, {
toValue: isFocused ? 1 : 0,
duration: 200,
useNativeDriver: false,
}).start();
}, [isFocused, bgOpacity]);
const onPressIn = () =>
Animated.spring(scale, { toValue: 0.88, useNativeDriver: true, speed: 60, bounciness: 0 }).start();
const onPressOut = () =>
Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 30, bounciness: 6 }).start();
const pillBg = bgOpacity.interpolate({
inputRange: [0, 1],
outputRange: ['rgba(99,102,241,0)', 'rgba(99,102,241,0.1)'],
});
return (
<TouchableWithoutFeedback onPress={onPress} onPressIn={onPressIn} onPressOut={onPressOut}>
<Animated.View style={[styles.tabItem, { transform: [{ scale }] }]}>
<Animated.View style={[styles.pill, { backgroundColor: pillBg }]}>
<Text style={[styles.icon, { opacity: isFocused ? 1 : 0.45 }]}>{icon}</Text>
<Text
style={[
styles.label,
{
color: isFocused ? COLORS.primary : theme.textMuted,
fontWeight: isFocused ? (FONT.semibold as any) : (FONT.regular as any),
},
]}>
{label}
</Text>
</Animated.View>
</Animated.View>
</TouchableWithoutFeedback>
);
}
const styles = StyleSheet.create({
wrapper: {
borderTopWidth: 1,
borderTopColor: 'transparent',
},
tabBar: {
flexDirection: 'row',
marginHorizontal: SPACING.md,
marginTop: SPACING.xs,
borderRadius: RADIUS.xl,
padding: SPACING.xs,
...SHADOWS.md,
},
tabBarLight: {
backgroundColor: COLORS.white,
borderWidth: 1,
borderColor: COLORS.light.border,
...Platform.select({
ios: { shadowColor: '#6366f1', shadowOpacity: 0.12, shadowRadius: 20, shadowOffset: { width: 0, height: 6 } },
android: { elevation: 8 },
}),
},
tabBarDark: {
backgroundColor: COLORS.dark.surface,
borderWidth: 1,
borderColor: COLORS.dark.border,
...Platform.select({
ios: { shadowColor: '#000', shadowOpacity: 0.4, shadowRadius: 20, shadowOffset: { width: 0, height: 6 } },
android: { elevation: 8 },
}),
},
tabItem: {
flex: 1,
alignItems: 'center',
},
pill: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: SPACING.sm,
paddingHorizontal: SPACING.sm,
borderRadius: RADIUS.lg,
width: '100%',
gap: 3,
},
icon: { fontSize: 22 },
label: { fontSize: FONT_SIZE.xs, letterSpacing: 0.2 },
});

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { RootStackParamList } from './types';
import { AppNavigator } from './AppNavigator';
import { LoginScreen } from '../screens/auth/LoginScreen';
import { SignupScreen } from '../screens/auth/SignupScreen';
import { ForgotPasswordScreen } from '../screens/auth/ForgotPasswordScreen';
import { useTheme } from '../theme';
import { COLORS } from '../theme';
const Stack = createNativeStackNavigator<RootStackParamList>();
export function RootNavigator() {
const { theme } = useTheme();
return (
<Stack.Navigator>
{/* Tabs are always the base — marketplace is public */}
<Stack.Screen name="Main" component={AppNavigator} options={{ headerShown: false }} />
{/* Auth screens slide up as modals from any tab */}
<Stack.Screen
name="Login"
component={LoginScreen}
options={{
presentation: 'modal',
headerShown: true,
title: 'Sign In',
headerStyle: { backgroundColor: theme.surface },
headerTintColor: COLORS.primary,
headerTitleStyle: { color: theme.text },
}}
/>
<Stack.Screen
name="Signup"
component={SignupScreen}
options={{
presentation: 'modal',
headerShown: true,
title: 'Create Account',
headerStyle: { backgroundColor: theme.surface },
headerTintColor: COLORS.primary,
headerTitleStyle: { color: theme.text },
}}
/>
<Stack.Screen
name="ForgotPassword"
component={ForgotPasswordScreen}
options={{
presentation: 'modal',
headerShown: true,
title: 'Reset Password',
headerStyle: { backgroundColor: theme.surface },
headerTintColor: COLORS.primary,
headerTitleStyle: { color: theme.text },
}}
/>
</Stack.Navigator>
);
}

76
src/navigation/types.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
// Root stack — tabs always visible, auth screens are modals on top
export type RootStackParamList = {
Main: undefined;
Login: undefined;
Signup: undefined;
ForgotPassword: undefined;
};
// App tabs
export type AppTabParamList = {
MarketplaceTab: undefined;
DashboardTab: undefined;
InboxTab: undefined;
AccountTab: undefined;
};
// Marketplace stack (fully public)
export type MarketplaceStackParamList = {
MarketplaceList: undefined;
MarketplaceDetail: { chatbotId: string };
PublicChat: { chatbotId: string; chatbotName: string };
};
// Dashboard stack (requires auth)
export type DashboardStackParamList = {
ChatbotList: undefined;
ChatbotBuilder: { chatbotId?: string };
ChatPreview: { chatbotId: string; chatbotName: string };
};
// Inbox stack (requires auth)
export type InboxStackParamList = {
InboxList: undefined;
Conversation: { conversationId: string; chatbotName?: string };
};
// Account stack (guest screen or settings)
export type AccountStackParamList = {
AccountHome: undefined;
Leads: undefined;
Analytics: undefined;
Campaigns: undefined;
Appointments: undefined;
};
// Screen prop helpers
export type RootScreenProps<T extends keyof RootStackParamList> =
NativeStackScreenProps<RootStackParamList, T>;
export type MarketplaceScreenProps<T extends keyof MarketplaceStackParamList> =
NativeStackScreenProps<MarketplaceStackParamList, T>;
export type DashboardScreenProps<T extends keyof DashboardStackParamList> =
NativeStackScreenProps<DashboardStackParamList, T>;
export type InboxScreenProps<T extends keyof InboxStackParamList> =
NativeStackScreenProps<InboxStackParamList, T>;
export type AccountScreenProps<T extends keyof AccountStackParamList> =
NativeStackScreenProps<AccountStackParamList, T>;
// Auth screen props (kept for backward compat)
export type AuthStackParamList = {
Login: undefined;
Signup: undefined;
ForgotPassword: undefined;
};
export type AuthScreenProps<T extends keyof AuthStackParamList> =
NativeStackScreenProps<AuthStackParamList, T>;
// Legacy aliases
export type SettingsStackParamList = AccountStackParamList;
export type SettingsScreenProps<T extends keyof AccountStackParamList> =
AccountScreenProps<T>;

131
src/screens/GuestScreen.tsx Normal file
View File

@@ -0,0 +1,131 @@
import React, { useEffect, useRef } from 'react';
import { View, Text, StyleSheet, Animated } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { SafeAreaView } from 'react-native-safe-area-context';
import { RootStackParamList } from '../navigation/types';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../theme';
import { useTheme } from '../theme';
import { Button } from '../components/ui';
interface GuestScreenProps {
icon?: string;
title?: string;
description?: string;
}
const FEATURES = [
{ icon: '🤖', text: 'Build AI chatbots from your documents' },
{ icon: '📊', text: 'Analytics, leads & conversation history' },
{ icon: '🚀', text: 'Deploy to Telegram, WhatsApp & web' },
{ icon: '🔌', text: 'Free plan available — no credit card needed' },
];
export function GuestScreen({
icon = '🔐',
title = 'Sign in to continue',
description = 'Create a free account to build and manage your AI chatbots.',
}: GuestScreenProps) {
const { theme } = useTheme();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(24)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, speed: 16, bounciness: 4 }),
]).start();
}, []);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]}>
<Animated.View
style={[styles.container, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
{/* Logo */}
<View style={styles.logoRow}>
<View style={[styles.logoCircle, SHADOWS.primary]}>
<Text style={styles.logoText}>C</Text>
</View>
<Text style={[styles.appName, { color: theme.text }]}>Contexta</Text>
</View>
{/* Card */}
<View style={[styles.card, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.md]}>
<Text style={styles.cardIcon}>{icon}</Text>
<Text style={[styles.cardTitle, { color: theme.text }]}>{title}</Text>
<Text style={[styles.cardDesc, { color: theme.textSecondary }]}>{description}</Text>
<View style={styles.btnGroup}>
<Button title="Sign In" onPress={() => navigation.navigate('Login')} fullWidth size="lg" />
<Button title="Create Free Account" variant="outline" onPress={() => navigation.navigate('Signup')} fullWidth size="lg" />
</View>
</View>
{/* Features */}
<View style={[styles.features, { backgroundColor: theme.surface, borderColor: theme.border }]}>
{FEATURES.map((f, i) => (
<View key={i} style={[styles.featureRow, i < FEATURES.length - 1 && { borderBottomWidth: 1, borderBottomColor: theme.borderLight }]}>
<View style={styles.featureIcon}>
<Text style={{ fontSize: 18 }}>{f.icon}</Text>
</View>
<Text style={[styles.featureText, { color: theme.textSecondary }]}>{f.text}</Text>
</View>
))}
</View>
</Animated.View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
container: {
flex: 1,
paddingHorizontal: SPACING.xl,
paddingVertical: SPACING.xl,
justifyContent: 'center',
gap: SPACING.xl,
},
logoRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: SPACING.md },
logoCircle: {
width: 52,
height: 52,
borderRadius: RADIUS.xl,
backgroundColor: COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
},
logoText: { color: COLORS.white, fontSize: 22, fontWeight: '800' },
appName: { ...TEXT.h2 },
card: {
borderRadius: RADIUS.xxl,
padding: SPACING.xxl,
borderWidth: 1,
alignItems: 'center',
gap: SPACING.md,
},
cardIcon: { fontSize: 44 },
cardTitle: { ...TEXT.h3, textAlign: 'center' },
cardDesc: { ...TEXT.body, textAlign: 'center', lineHeight: 22 },
btnGroup: { width: '100%', gap: SPACING.sm, marginTop: SPACING.xs },
features: {
borderRadius: RADIUS.xl,
borderWidth: 1,
overflow: 'hidden',
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING.md,
paddingHorizontal: SPACING.lg,
gap: SPACING.md,
},
featureIcon: { width: 32, alignItems: 'center' },
featureText: { ...TEXT.small, flex: 1, lineHeight: 20 },
});

View File

@@ -0,0 +1,254 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
TouchableOpacity,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery } from '@tanstack/react-query';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { Spinner, EmptyState } from '../../components/ui';
import { analyticsAPI } from '../../services/api';
import { useTranslation } from '../../i18n';
import { ChatbotAnalytics } from '../../types';
export function AnalyticsScreen() {
const { theme } = useTheme();
const { t } = useTranslation();
const { data, isLoading, refetch } = useQuery({
queryKey: ['analytics-overview'],
queryFn: analyticsAPI.overview,
});
if (isLoading) return <Spinner centered label={t.common.loading} />;
const overview = data;
const chatbots: ChatbotAnalytics[] = overview?.chatbots ?? [];
const maxConvos = Math.max(...chatbots.map(c => c.conversations), 1);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refetch} tintColor={COLORS.primary} />
}>
{/* Header */}
<View style={styles.pageHeader}>
<Text style={[styles.pageTitle, { color: theme.text }]}>{t.analytics.title}</Text>
<Text style={[styles.pageSub, { color: theme.textSecondary }]}>{t.analytics.subtitle}</Text>
</View>
{/* Summary grid */}
<View style={styles.summaryGrid}>
<SummaryCard label={t.analytics.conversations} value={overview?.total_conversations ?? 0} icon="💬" color="#6366f1" theme={theme} />
<SummaryCard label={t.analytics.messages} value={overview?.total_messages ?? 0} icon="✉️" color="#0ea5e9" theme={theme} />
<SummaryCard label={t.analytics.chatbots} value={overview?.total_chatbots ?? 0} icon="🤖" color="#10b981" theme={theme} />
<SummaryCard
label={t.analytics.avg_conv}
value={Number((overview?.avg_messages_per_conversation ?? 0).toFixed(1))}
icon="📊"
color="#f59e0b"
theme={theme}
/>
</View>
{/* Per-chatbot breakdown */}
{chatbots.length > 0 && (
<>
<Text style={[styles.sectionTitle, { color: theme.text }]}>{t.analytics.by_chatbot}</Text>
{chatbots.map(bot => (
<BotCard key={bot.chatbot_id} bot={bot} maxConvos={maxConvos} theme={theme} />
))}
</>
)}
{chatbots.length === 0 && (
<EmptyState
icon={<Text style={{ fontSize: 48 }}>📊</Text>}
title={t.analytics.empty_title}
description={t.analytics.empty_desc}
/>
)}
</ScrollView>
</SafeAreaView>
);
}
function SummaryCard({
label,
value,
icon,
color,
theme,
}: {
label: string;
value: number;
icon: string;
color: string;
theme: any;
}) {
return (
<View style={[styles.summaryCard, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<View style={[styles.summaryIconBg, { backgroundColor: color + '18' }]}>
<Text style={styles.summaryIcon}>{icon}</Text>
</View>
<Text style={[styles.summaryValue, { color: theme.text }]}>{value.toLocaleString()}</Text>
<Text style={[styles.summaryLabel, { color: theme.textSecondary }]}>{label}</Text>
</View>
);
}
function BotCard({
bot,
maxConvos,
theme,
}: {
bot: ChatbotAnalytics;
maxConvos: number;
theme: any;
}) {
const { t } = useTranslation();
const [showGaps, setShowGaps] = React.useState(false);
const pct = maxConvos > 0 ? (bot.conversations / maxConvos) * 100 : 0;
const confidence = ((bot.avg_confidence ?? 0) * 100).toFixed(0);
const unanswered = bot.unanswered_queries ?? [];
return (
<View style={[styles.botCard, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<View style={styles.botCardHeader}>
<View style={[styles.botAvatar, { backgroundColor: COLORS.primaryUltraLight }]}>
<Text style={[styles.botAvatarText, { color: COLORS.primaryDark }]}>
{bot.chatbot_name[0]?.toUpperCase()}
</Text>
</View>
<Text style={[styles.botName, { color: theme.text }]} numberOfLines={1}>{bot.chatbot_name}</Text>
</View>
<View style={styles.botStats}>
<StatChip label={t.analytics.conversations} value={bot.conversations} theme={theme} />
<StatChip label={t.analytics.messages} value={bot.messages} theme={theme} />
<StatChip label={t.analytics.confidence} value={`${confidence}%`} theme={theme} />
</View>
{/* Bar */}
<View>
<View style={[styles.barTrack, { backgroundColor: theme.bgSecondary }]}>
<View style={[styles.barFill, { width: `${pct}%` as any, backgroundColor: COLORS.primary }]} />
</View>
<Text style={[styles.barLabel, { color: theme.textMuted }]}>{bot.conversations} {t.analytics.conversations.toLowerCase()}</Text>
</View>
{/* Gap analysis */}
{unanswered.length > 0 && (
<View style={[gapStyles.section, { borderTopColor: theme.border }]}>
<TouchableOpacity style={gapStyles.toggle} onPress={() => setShowGaps(v => !v)}>
<View style={[gapStyles.badge, { backgroundColor: COLORS.warningBg }]}>
<Text style={[gapStyles.badgeText, { color: COLORS.warning }]}>
{t.analytics.unanswered(unanswered.length)}
</Text>
</View>
<Text style={[gapStyles.toggleLabel, { color: theme.textSecondary }]}>
{showGaps ? t.analytics.hide_gaps : t.analytics.show_gaps}
</Text>
</TouchableOpacity>
{showGaps && unanswered.map((q, i) => (
<View key={i} style={[gapStyles.queryRow, { borderBottomColor: theme.borderLight }]}>
<Text style={[gapStyles.queryNum, { color: theme.textMuted }]}>{i + 1}.</Text>
<Text style={[gapStyles.queryText, { color: theme.text }]}>{q.query}</Text>
<View style={[gapStyles.countBadge, { backgroundColor: theme.bgSecondary }]}>
<Text style={[gapStyles.countText, { color: theme.textSecondary }]}>{q.count}×</Text>
</View>
</View>
))}
</View>
)}
</View>
);
}
function StatChip({ label, value, theme }: { label: string; value: number | string; theme: any }) {
return (
<View style={[styles.statChip, { backgroundColor: theme.bgSecondary }]}>
<Text style={[styles.statValue, { color: theme.text }]}>{value}</Text>
<Text style={[styles.statLabel, { color: theme.textMuted }]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
scroll: { padding: SPACING.lg, gap: SPACING.lg, paddingBottom: SPACING.xxxl },
pageHeader: { gap: 4 },
pageTitle: { ...TEXT.h3 },
pageSub: { ...TEXT.small },
summaryGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING.sm },
summaryCard: {
width: '47.5%',
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.md,
gap: SPACING.xs,
alignItems: 'flex-start',
},
summaryIconBg: {
width: 40,
height: 40,
borderRadius: RADIUS.md,
alignItems: 'center',
justifyContent: 'center',
},
summaryIcon: { fontSize: 20 },
summaryValue: { fontSize: FONT_SIZE.xxl, fontWeight: '800', marginTop: SPACING.xs },
summaryLabel: { ...TEXT.small },
sectionTitle: { ...TEXT.h4 },
botCard: {
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.lg,
gap: SPACING.md,
},
botCardHeader: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm },
botAvatar: { width: 38, height: 38, borderRadius: RADIUS.full, alignItems: 'center', justifyContent: 'center' },
botAvatarText: { fontSize: FONT_SIZE.md, fontWeight: '800' },
botName: { ...TEXT.bodyM, flex: 1 },
botStats: { flexDirection: 'row', gap: SPACING.sm },
statChip: { flex: 1, alignItems: 'center', borderRadius: RADIUS.md, paddingVertical: SPACING.sm, gap: 2 },
statValue: { fontSize: FONT_SIZE.lg, fontWeight: '700' },
statLabel: { fontSize: FONT_SIZE.xs, textAlign: 'center' },
barTrack: { height: 8, borderRadius: RADIUS.full, overflow: 'hidden' },
barFill: { height: '100%', borderRadius: RADIUS.full },
barLabel: { ...TEXT.caption, marginTop: 4 },
});
const gapStyles = StyleSheet.create({
section: { borderTopWidth: 1, paddingTop: SPACING.md, gap: SPACING.xs },
toggle: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
badge: { borderRadius: RADIUS.full, paddingVertical: 3, paddingHorizontal: SPACING.sm },
badgeText: { fontSize: FONT_SIZE.xs, fontWeight: '700' },
toggleLabel: { fontSize: FONT_SIZE.xs, fontWeight: '500' },
queryRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: SPACING.xs,
paddingVertical: SPACING.xs,
borderBottomWidth: 1,
},
queryNum: { fontSize: FONT_SIZE.xs, width: 18, marginTop: 1 },
queryText: { flex: 1, fontSize: FONT_SIZE.sm, lineHeight: 18 },
countBadge: { borderRadius: RADIUS.sm, paddingHorizontal: SPACING.xs, paddingVertical: 2 },
countText: { fontSize: FONT_SIZE.xs, fontWeight: '600' },
});

View File

@@ -0,0 +1,608 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
RefreshControl,
Modal,
Switch,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { Button, Spinner, EmptyState } from '../../components/ui';
import { useToast } from '../../contexts/ToastContext';
import { appointmentsAPI, chatbotsAPI } from '../../services/api';
import { useTranslation } from '../../i18n';
import type { Appointment, Chatbot, BusinessHoursEntry } from '../../types';
const STATUS_COLORS_APPT: Record<string, { color: string; bg: string; emoji: string }> = {
pending: { color: '#d97706', bg: '#fef3c7', emoji: '⏳' },
confirmed: { color: '#16a34a', bg: '#dcfce7', emoji: '✅' },
cancelled: { color: '#dc2626', bg: '#fee2e2', emoji: '❌' },
completed: { color: '#6b7280', bg: '#f3f4f6', emoji: '✔' },
};
// Day labels are sourced from translations at render time
const DEFAULT_HOURS: BusinessHoursEntry[] = Array.from({ length: 7 }, (_, i) => ({
day_of_week: i,
is_open: i < 5,
open_time: '09:00',
close_time: '17:00',
slot_duration_minutes: 60,
}));
// ── Business Hours Modal ──────────────────────────────────────────────────────
function BusinessHoursModal({
visible,
chatbotId,
onClose,
}: {
visible: boolean;
chatbotId: string;
onClose: () => void;
}) {
const { theme } = useTheme();
const { t } = useTranslation();
const toast = useToast();
const qc = useQueryClient();
const [hours, setHours] = useState<BusinessHoursEntry[]>(DEFAULT_HOURS);
const { isLoading } = useQuery<BusinessHoursEntry[]>({
queryKey: ['business-hours', chatbotId],
queryFn: () => appointmentsAPI.getHours(chatbotId),
enabled: visible && !!chatbotId,
onSuccess: (data: BusinessHoursEntry[]) => {
if (data && data.length > 0) {
const merged = DEFAULT_HOURS.map(d => {
const found = data.find(h => h.day_of_week === d.day_of_week);
return found ? { ...d, ...found } : d;
});
setHours(merged);
}
},
} as any);
const save = useMutation({
mutationFn: () => appointmentsAPI.saveHours(chatbotId, hours),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['business-hours', chatbotId] });
toast.success(t.appointments.save_hours);
onClose();
},
onError: (err: any) => toast.error(err?.response?.data?.detail ?? t.common.error),
});
const toggle = (idx: number, field: keyof BusinessHoursEntry, value: any) => {
setHours(prev => prev.map((h, i) => i === idx ? { ...h, [field]: value } : h));
};
const SLOT_OPTIONS = [
{ label: '15 min', value: 15 },
{ label: '30 min', value: 30 },
{ label: '1 hr', value: 60 },
{ label: '1.5 hr', value: 90 },
{ label: '2 hr', value: 120 },
];
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<SafeAreaView style={[styles.modalSafe, { backgroundColor: theme.bg }]}>
<ScrollView contentContainerStyle={styles.modalScroll}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>Business Hours</Text>
<TouchableOpacity onPress={onClose} style={[styles.closeBtn, { backgroundColor: theme.bgSecondary }]}>
<Text style={[styles.closeBtnText, { color: theme.textSecondary }]}></Text>
</TouchableOpacity>
</View>
<Text style={[styles.modalDesc, { color: theme.textSecondary }]}>
Set when appointments can be booked for this chatbot.
</Text>
{isLoading ? (
<Spinner centered />
) : (
<View style={styles.daysList}>
{hours.map((h, i) => (
<View key={i} style={[styles.dayRow, { borderBottomColor: theme.borderLight }]}>
<View style={styles.dayToggleRow}>
<Text style={[styles.dayLabel, { color: h.is_open ? theme.text : theme.textMuted }]}>
{t.appointments.days[i]}
</Text>
<Switch
value={h.is_open}
onValueChange={v => toggle(i, 'is_open', v)}
trackColor={{ false: theme.border, true: COLORS.primary }}
thumbColor={COLORS.white}
/>
</View>
{h.is_open && (
<View style={styles.timeRow}>
<View style={[styles.timeInput, { backgroundColor: theme.surface, borderColor: theme.border }]}>
<Text style={[styles.timeText, { color: theme.text }]}>{h.open_time}</Text>
</View>
<Text style={[styles.timeSep, { color: theme.textMuted }]}></Text>
<View style={[styles.timeInput, { backgroundColor: theme.surface, borderColor: theme.border }]}>
<Text style={[styles.timeText, { color: theme.text }]}>{h.close_time}</Text>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{SLOT_OPTIONS.map(opt => (
<TouchableOpacity
key={opt.value}
onPress={() => toggle(i, 'slot_duration_minutes', opt.value)}
style={[
styles.slotChip,
{
backgroundColor: h.slot_duration_minutes === opt.value ? COLORS.primary : theme.bgSecondary,
borderColor: h.slot_duration_minutes === opt.value ? COLORS.primary : theme.border,
},
]}>
<Text style={[
styles.slotText,
{ color: h.slot_duration_minutes === opt.value ? COLORS.white : theme.textSecondary },
]}>
{opt.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
)}
</View>
))}
</View>
)}
<Button
title={t.appointments.save_hours}
onPress={() => save.mutate()}
loading={save.isPending}
fullWidth
/>
</ScrollView>
</SafeAreaView>
</Modal>
);
}
// ── Appointment Card ──────────────────────────────────────────────────────────
function AppointmentCard({
appt,
onUpdateStatus,
updating,
theme,
}: {
appt: Appointment;
onUpdateStatus: (id: string, status: string) => void;
updating: boolean;
theme: any;
}) {
const { t } = useTranslation();
const STATUS_LABELS: Record<string, string> = {
pending: t.appointments.status_pending,
confirmed: t.appointments.status_confirmed,
cancelled: t.appointments.status_cancelled,
completed: t.appointments.status_completed,
};
const sc = STATUS_COLORS_APPT[appt.status] ?? STATUS_COLORS_APPT.pending;
const statusLabel = STATUS_LABELS[appt.status] ?? appt.status;
const slotDate = new Date(appt.slot_start);
const slotEnd = new Date(appt.slot_end);
const isToday = slotDate.toDateString() === new Date().toDateString();
const monthLabel = slotDate.toLocaleDateString(undefined, { month: 'short' });
const dayNum = slotDate.getDate();
const timeRange = `${slotDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} ${slotEnd.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
return (
<View style={[styles.apptCard, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<View style={styles.apptRow}>
{/* Date block */}
<View style={[styles.dateBlock, { backgroundColor: COLORS.primaryUltraLight }]}>
<Text style={[styles.dateMonth, { color: COLORS.primary }]}>{monthLabel}</Text>
<Text style={[styles.dateDay, { color: COLORS.primaryDark }]}>{dayNum}</Text>
{isToday && <Text style={[styles.todayLabel, { color: COLORS.primary }]}>Today</Text>}
</View>
{/* Details */}
<View style={styles.apptDetails}>
<View style={styles.apptTopRow}>
<Text style={[styles.customerName, { color: theme.text }]} numberOfLines={1}>
{appt.customer_name}
</Text>
<View style={[styles.statusBadge, { backgroundColor: sc.bg }]}>
<Text style={[styles.statusText, { color: sc.color }]}>{sc.emoji} {statusLabel}</Text>
</View>
</View>
{appt.service && (
<Text style={[styles.serviceText, { color: theme.textSecondary }]}>{appt.service}</Text>
)}
<View style={styles.metaRow}>
<Text style={[styles.metaText, { color: theme.textMuted }]}>🕐 {timeRange}</Text>
<Text style={[styles.metaText, { color: theme.textMuted }]}>📞 {appt.customer_contact}</Text>
</View>
{appt.notes && (
<Text style={[styles.notesText, { color: theme.textMuted }]} numberOfLines={2}>
"{appt.notes}"
</Text>
)}
{/* Actions */}
{appt.status === 'pending' && (
<View style={styles.actionRow}>
<TouchableOpacity
style={[styles.actionBtn, styles.confirmBtn]}
onPress={() => onUpdateStatus(appt.id, 'confirmed')}
disabled={updating}>
<Text style={styles.confirmBtnText}> {t.appointments.confirm}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.declineBtn, { borderColor: COLORS.error + '40' }]}
onPress={() => onUpdateStatus(appt.id, 'cancelled')}
disabled={updating}>
<Text style={[styles.declineBtnText, { color: COLORS.error }]}> {t.appointments.decline}</Text>
</TouchableOpacity>
</View>
)}
{appt.status === 'confirmed' && (
<View style={styles.actionRow}>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: theme.bgSecondary, borderColor: theme.border, borderWidth: 1 }]}
onPress={() => onUpdateStatus(appt.id, 'completed')}
disabled={updating}>
<Text style={[styles.actionBtnText, { color: theme.text }]}> {t.appointments.complete}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.declineBtn, { borderColor: COLORS.error + '40' }]}
onPress={() => onUpdateStatus(appt.id, 'cancelled')}
disabled={updating}>
<Text style={[styles.declineBtnText, { color: COLORS.error }]}> {t.appointments.cancel}</Text>
</TouchableOpacity>
</View>
)}
{appt.status === 'cancelled' && (
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: theme.bgSecondary, borderColor: theme.border, borderWidth: 1, alignSelf: 'flex-start' }]}
onPress={() => onUpdateStatus(appt.id, 'pending')}
disabled={updating}>
<Text style={[styles.actionBtnText, { color: theme.text }]}> {t.appointments.status_pending}</Text>
</TouchableOpacity>
)}
</View>
</View>
</View>
);
}
// ── Main Screen ───────────────────────────────────────────────────────────────
export function AppointmentsScreen() {
const { theme } = useTheme();
const { t } = useTranslation();
const qc = useQueryClient();
const [chatbotFilter, setChatbotFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [hoursModalChatbotId, setHoursModalChatbotId] = useState<string | null>(null);
const { data: chatbots = [] } = useQuery<Chatbot[]>({
queryKey: ['chatbots'],
queryFn: chatbotsAPI.list,
});
const { data: appointments = [], isLoading, refetch, error } = useQuery<Appointment[]>({
queryKey: ['appointments', chatbotFilter, statusFilter],
queryFn: () => appointmentsAPI.list({
chatbot_id: chatbotFilter || undefined,
status: statusFilter || undefined,
}),
retry: false,
});
const updateStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
appointmentsAPI.updateStatus(id, status),
onSuccess: () => qc.invalidateQueries({ queryKey: ['appointments'] }),
});
const isPlanError = (error as any)?.response?.status === 402;
const bookingEnabledChatbots = chatbots.filter(c => c.booking_enabled);
const now = new Date();
const upcoming = appointments.filter(a => new Date(a.slot_start) >= now && a.status !== 'cancelled');
const today = appointments.filter(a => new Date(a.slot_start).toDateString() === now.toDateString());
const STATUS_FILTERS = [
{ value: '', label: t.inbox.filter_all },
{ value: 'pending', label: `${t.appointments.status_pending}` },
{ value: 'confirmed', label: `${t.appointments.status_confirmed}` },
{ value: 'cancelled', label: `${t.appointments.status_cancelled}` },
{ value: 'completed', label: `${t.appointments.status_completed}` },
];
if (isPlanError) {
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<View style={styles.upgradeContainer}>
<Text style={styles.upgradeEmoji}>🔒</Text>
<Text style={[styles.upgradeTitle, { color: theme.text }]}>Appointments Require Starter+</Text>
<Text style={[styles.upgradeDesc, { color: theme.textSecondary }]}>
Upgrade your plan to enable appointment booking through your chatbots.
</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isLoading} onRefresh={refetch} tintColor={COLORS.primary} />}>
{/* Header */}
<View style={styles.headerRow}>
<View>
<Text style={[styles.pageTitle, { color: theme.text }]}>{t.appointments.title}</Text>
<Text style={[styles.pageSub, { color: theme.textSecondary }]}>
{t.appointments.subtitle}
</Text>
</View>
{bookingEnabledChatbots.length > 0 && (
<TouchableOpacity
style={[styles.hoursBtn, { backgroundColor: theme.bgSecondary, borderColor: theme.border }]}
onPress={() => setHoursModalChatbotId(bookingEnabledChatbots[0].id)}>
<Text style={[styles.hoursBtnText, { color: theme.text }]}> {t.appointments.configure_hours}</Text>
</TouchableOpacity>
)}
</View>
{/* Setup prompt when no chatbots have booking enabled */}
{!isLoading && bookingEnabledChatbots.length === 0 && (
<View style={[styles.setupCard, { backgroundColor: '#fffbeb', borderColor: '#fde68a' }]}>
<Text style={styles.setupEmoji}>📅</Text>
<View style={styles.setupText}>
<Text style={[styles.setupTitle, { color: '#92400e' }]}>Enable booking on a chatbot</Text>
<Text style={[styles.setupDesc, { color: '#b45309' }]}>
Go to your chatbot's settings and enable the booking feature to start accepting appointments.
</Text>
</View>
</View>
)}
{/* Stats */}
{appointments.length > 0 && (
<View style={styles.statsGrid}>
{[
{ label: t.analytics.conversations, value: today.length, emoji: '📆', color: '#2563eb' },
{ label: t.appointments.subtitle, value: upcoming.length, emoji: '🗓', color: COLORS.primary },
{ label: t.appointments.status_confirmed, value: appointments.filter(a => a.status === 'confirmed').length, emoji: '', color: '#16a34a' },
{ label: t.appointments.status_pending, value: appointments.filter(a => a.status === 'pending').length, emoji: '', color: '#d97706' },
].map(s => (
<View key={s.label} style={[styles.statCard, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<Text style={styles.statEmoji}>{s.emoji}</Text>
<Text style={[styles.statValue, { color: s.color }]}>{s.value}</Text>
<Text style={[styles.statLabel, { color: theme.textMuted }]}>{s.label}</Text>
</View>
))}
</View>
)}
{/* Filters */}
<View style={styles.filtersSection}>
{/* Status filter */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.filterRow}>
{STATUS_FILTERS.map(f => (
<TouchableOpacity
key={f.value}
onPress={() => setStatusFilter(f.value)}
style={[
styles.filterChip,
{
backgroundColor: statusFilter === f.value ? COLORS.primary : theme.bgSecondary,
borderColor: statusFilter === f.value ? COLORS.primary : theme.border,
},
]}>
<Text style={[styles.filterText, { color: statusFilter === f.value ? COLORS.white : theme.text }]}>
{f.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* Chatbot filter */}
{bookingEnabledChatbots.length > 1 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.filterRow}>
{[{ id: '', name: 'All chatbots' }, ...bookingEnabledChatbots].map(c => (
<TouchableOpacity
key={c.id}
onPress={() => setChatbotFilter(c.id)}
style={[
styles.filterChip,
{
backgroundColor: chatbotFilter === c.id ? COLORS.primaryLight : theme.bgSecondary,
borderColor: chatbotFilter === c.id ? COLORS.primary : theme.border,
},
]}>
<Text style={[styles.filterText, { color: chatbotFilter === c.id ? COLORS.primary : theme.text }]}>
{c.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
)}
</View>
{/* Appointments list */}
{isLoading ? (
<Spinner centered />
) : appointments.length === 0 ? (
<EmptyState
icon={<Text style={{ fontSize: 48 }}>📅</Text>}
title={t.appointments.empty_title}
description={t.appointments.empty_desc}
/>
) : (
<View style={styles.list}>
{appointments.map(appt => (
<AppointmentCard
key={appt.id}
appt={appt}
onUpdateStatus={(id, status) => updateStatus.mutate({ id, status })}
updating={updateStatus.isPending}
theme={theme}
/>
))}
</View>
)}
</ScrollView>
{hoursModalChatbotId && (
<BusinessHoursModal
visible={!!hoursModalChatbotId}
chatbotId={hoursModalChatbotId}
onClose={() => setHoursModalChatbotId(null)}
/>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
scroll: { padding: SPACING.lg, gap: SPACING.lg, paddingBottom: SPACING.xxxl },
headerRow: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: SPACING.md },
pageTitle: { ...TEXT.h3 },
pageSub: { ...TEXT.small, marginTop: 2 },
hoursBtn: {
borderWidth: 1,
borderRadius: RADIUS.lg,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.xs,
flexShrink: 0,
},
hoursBtnText: { ...TEXT.small },
setupCard: {
borderWidth: 1,
borderRadius: RADIUS.xl,
padding: SPACING.lg,
flexDirection: 'row',
alignItems: 'flex-start',
gap: SPACING.md,
},
setupEmoji: { fontSize: 28 },
setupText: { flex: 1, gap: 4 },
setupTitle: { ...TEXT.bodyM },
setupDesc: { ...TEXT.small },
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING.sm },
statCard: {
width: '47.5%',
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.md,
alignItems: 'center',
gap: 4,
},
statEmoji: { fontSize: 22 },
statValue: { fontSize: FONT_SIZE.xxl, fontWeight: '800' },
statLabel: { ...TEXT.caption },
filtersSection: { gap: SPACING.sm },
filterRow: {},
filterChip: {
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.xs,
borderRadius: RADIUS.full,
borderWidth: 1,
marginRight: SPACING.xs,
},
filterText: { ...TEXT.small },
list: { gap: SPACING.md },
apptCard: {
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.lg,
},
apptRow: { flexDirection: 'row', gap: SPACING.md },
dateBlock: {
width: 56,
borderRadius: RADIUS.lg,
padding: SPACING.sm,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
dateMonth: { fontSize: FONT_SIZE.xs, fontWeight: '700', textTransform: 'uppercase' },
dateDay: { fontSize: FONT_SIZE.xxl, fontWeight: '900', lineHeight: 28 },
todayLabel: { fontSize: FONT_SIZE.xs, fontWeight: '600' },
apptDetails: { flex: 1, gap: SPACING.xs },
apptTopRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: SPACING.sm },
customerName: { ...TEXT.bodyM, flex: 1 },
statusBadge: { borderRadius: RADIUS.full, paddingHorizontal: SPACING.sm, paddingVertical: 3, flexShrink: 0 },
statusText: { fontSize: FONT_SIZE.xs, fontWeight: '600' },
serviceText: { ...TEXT.small },
metaRow: { gap: 4 },
metaText: { ...TEXT.caption },
notesText: { ...TEXT.caption, fontStyle: 'italic' },
actionRow: { flexDirection: 'row', gap: SPACING.sm, marginTop: SPACING.xs },
actionBtn: { borderRadius: RADIUS.md, paddingVertical: SPACING.xs, paddingHorizontal: SPACING.md, alignItems: 'center' },
actionBtnText: { fontWeight: '600', fontSize: FONT_SIZE.sm },
confirmBtn: { backgroundColor: COLORS.success },
confirmBtnText: { color: COLORS.white, fontWeight: '600', fontSize: FONT_SIZE.sm },
declineBtn: { backgroundColor: '#fee2e210', borderWidth: 1 },
declineBtnText: { fontWeight: '600', fontSize: FONT_SIZE.sm },
// Modal
modalSafe: { flex: 1 },
modalScroll: { padding: SPACING.xl, gap: SPACING.md, paddingBottom: SPACING.xxxl },
modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: SPACING.sm },
modalTitle: { ...TEXT.h3 },
closeBtn: { width: 32, height: 32, borderRadius: RADIUS.full, alignItems: 'center', justifyContent: 'center' },
closeBtnText: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
modalDesc: { ...TEXT.small, marginBottom: SPACING.sm },
daysList: { gap: 0 },
dayRow: { paddingVertical: SPACING.md, borderBottomWidth: 1, gap: SPACING.sm },
dayToggleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
dayLabel: { ...TEXT.bodyM, width: 40 },
timeRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, flexWrap: 'wrap' },
timeInput: { borderWidth: 1, borderRadius: RADIUS.md, paddingHorizontal: SPACING.sm, paddingVertical: SPACING.xs },
timeText: { fontSize: FONT_SIZE.sm, fontWeight: '500' },
timeSep: { fontSize: FONT_SIZE.sm },
slotChip: {
paddingHorizontal: SPACING.sm,
paddingVertical: 4,
borderRadius: RADIUS.full,
borderWidth: 1,
marginRight: SPACING.xs,
},
slotText: { fontSize: FONT_SIZE.xs, fontWeight: '500' },
// Upgrade
upgradeContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: SPACING.xxxl, gap: SPACING.md },
upgradeEmoji: { fontSize: 48 },
upgradeTitle: { ...TEXT.h3, textAlign: 'center' },
upgradeDesc: { ...TEXT.body, textAlign: 'center' },
});

View File

@@ -0,0 +1,145 @@
import React, { useState, useRef, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
TouchableOpacity,
Animated,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { RootScreenProps } from '../../navigation/types';
import { COLORS, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { Button, Input } from '../../components/ui';
import { useToast } from '../../contexts/ToastContext';
import { authAPI } from '../../services/api';
import { useTranslation } from '../../i18n';
type Props = RootScreenProps<'ForgotPassword'>;
export function ForgotPasswordScreen({ navigation }: Props) {
const { theme } = useTheme();
const { t } = useTranslation();
const toast = useToast();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(32)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 350, useNativeDriver: true }),
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, speed: 14, bounciness: 3 }),
]).start();
}, []);
const handleSubmit = async () => {
if (!email.trim()) {
toast.error(t.auth.email + ' is required');
return;
}
setLoading(true);
try {
await authAPI.forgotPassword(email.trim());
setSent(true);
} catch {
toast.error(t.common.error);
} finally {
setLoading(false);
}
};
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.kav}>
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}>
<Animated.View style={[styles.content, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.back} hitSlop={{ top: 8, bottom: 8 }}>
<Text style={[styles.backText, { color: COLORS.primary }]}> {t.auth.back_to_signin}</Text>
</TouchableOpacity>
<View style={[styles.card, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.md]}>
{sent ? (
<View style={styles.successContent}>
<Text style={styles.successIcon}></Text>
<Text style={[styles.cardTitle, { color: theme.text }]}>{t.auth.check_email_title}</Text>
<Text style={[styles.cardSub, { color: theme.textSecondary }]}>
{t.auth.check_email_desc}{'\n'}
<Text style={[TEXT.bodyM, { color: theme.text }]}>{email}</Text>
</Text>
<Button
title={t.auth.back_to_signin}
variant="outline"
fullWidth
onPress={() => navigation.navigate('Login')}
/>
</View>
) : (
<>
<Text style={styles.headerIcon}>🔐</Text>
<Text style={[styles.cardTitle, { color: theme.text }]}>{t.auth.forgot_title}</Text>
<Text style={[styles.cardSub, { color: theme.textSecondary }]}>
{t.auth.forgot_subtitle}
</Text>
<View style={styles.fields}>
<Input
label={t.auth.email}
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
<Button
title={t.auth.send_reset}
onPress={handleSubmit}
loading={loading}
fullWidth
size="lg"
/>
</View>
</>
)}
</View>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
kav: { flex: 1 },
scroll: { flexGrow: 1, justifyContent: 'center', paddingHorizontal: SPACING.xl, paddingVertical: SPACING.xxxl },
content: { gap: SPACING.xl },
back: { alignSelf: 'flex-start' },
backText: { ...TEXT.bodyM },
card: {
borderRadius: RADIUS.xxl,
padding: SPACING.xxl,
borderWidth: 1,
gap: SPACING.lg,
alignItems: 'center',
},
headerIcon: { fontSize: 44, textAlign: 'center' },
cardTitle: { ...TEXT.h3, textAlign: 'center' },
cardSub: { ...TEXT.body, textAlign: 'center', lineHeight: 22 },
fields: { width: '100%', gap: SPACING.md },
successContent: { alignItems: 'center', gap: SPACING.lg, width: '100%' },
successIcon: { fontSize: 56, textAlign: 'center' },
});

View File

@@ -0,0 +1,169 @@
import React, { useState, useRef, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
Animated,
Image,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQueryClient } from '@tanstack/react-query';
import { RootScreenProps } from '../../navigation/types';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { Button, Input, SecureInput } from '../../components/ui';
import { useToast } from '../../contexts/ToastContext';
import { authAPI } from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
import { useTranslation } from '../../i18n';
type Props = RootScreenProps<'Login'>;
export function LoginScreen({ navigation }: Props) {
const { theme } = useTheme();
const { t } = useTranslation();
const toast = useToast();
const setAuth = useAuthStore(s => s.setAuth);
const qc = useQueryClient();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(32)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 350, useNativeDriver: true }),
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, speed: 14, bounciness: 3 }),
]).start();
}, []);
const validate = () => {
const e: typeof errors = {};
if (!email.trim()) e.email = t.auth.email + ' is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) e.email = 'Invalid email';
if (!password) e.password = t.auth.password + ' is required';
setErrors(e);
return Object.keys(e).length === 0;
};
const handleLogin = async () => {
if (!validate()) return;
setLoading(true);
try {
const data = await authAPI.login(email.trim(), password);
setAuth(data.user, data.access_token);
qc.clear();
navigation.goBack();
} catch (err: any) {
toast.error(err?.response?.data?.detail ?? t.common.error);
} finally {
setLoading(false);
}
};
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.kav}>
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}>
<Animated.View style={[styles.content, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
{/* Logo */}
<View style={styles.brand}>
<Image source={require('../../assets/logo.png')} style={styles.logoImage} resizeMode="contain" />
</View>
{/* Form */}
<View style={[styles.card, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.md]}>
<Text style={[styles.formTitle, { color: theme.text }]}>{t.auth.login_title} 👋</Text>
<Text style={[styles.formSub, { color: theme.textSecondary }]}>
{t.auth.login_subtitle}
</Text>
<View style={styles.fields}>
<Input
label={t.auth.email}
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
error={errors.email}
/>
<SecureInput
label={t.auth.password}
value={password}
onChangeText={setPassword}
placeholder={t.auth.password}
error={errors.password}
/>
<TouchableOpacity
onPress={() => navigation.navigate('ForgotPassword')}
style={styles.forgotRow}
hitSlop={{ top: 8, bottom: 8 }}>
<Text style={[styles.forgotText, { color: COLORS.primary }]}>{t.auth.forgot_password}</Text>
</TouchableOpacity>
<Button
title={t.auth.sign_in}
onPress={handleLogin}
loading={loading}
fullWidth
size="lg"
/>
</View>
</View>
{/* Footer */}
<View style={styles.footer}>
<Text style={[styles.footerText, { color: theme.textSecondary }]}>
{t.auth.no_account}{' '}
</Text>
<TouchableOpacity onPress={() => navigation.navigate('Signup')}>
<Text style={[styles.footerLink, { color: COLORS.primary }]}>{t.auth.sign_up_free}</Text>
</TouchableOpacity>
</View>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
kav: { flex: 1 },
scroll: { flexGrow: 1, justifyContent: 'center', paddingHorizontal: SPACING.xl, paddingVertical: SPACING.xxxl },
content: { gap: SPACING.xxl },
brand: { alignItems: 'center' },
logoImage: { width: 200, height: 100 },
card: {
borderRadius: RADIUS.xxl,
padding: SPACING.xxl,
borderWidth: 1,
gap: SPACING.lg,
},
formTitle: { ...TEXT.h3 },
formSub: { ...TEXT.body, marginTop: -SPACING.sm },
fields: { gap: SPACING.xs },
forgotRow: { alignSelf: 'flex-end', marginTop: -SPACING.xs, marginBottom: SPACING.xs },
forgotText: { ...TEXT.smallM },
footer: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center' },
footerText: { ...TEXT.body },
footerLink: { ...TEXT.bodyM },
});

View File

@@ -0,0 +1,183 @@
import React, { useState, useRef, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
Animated,
Image,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQueryClient } from '@tanstack/react-query';
import { RootScreenProps } from '../../navigation/types';
import { COLORS, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { Button, Input, SecureInput } from '../../components/ui';
import { useToast } from '../../contexts/ToastContext';
import { authAPI } from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
import { useTranslation } from '../../i18n';
type Props = RootScreenProps<'Signup'>;
export function SignupScreen({ navigation }: Props) {
const { theme } = useTheme();
const { t } = useTranslation();
const toast = useToast();
const setAuth = useAuthStore(s => s.setAuth);
const qc = useQueryClient();
const [companyName, setCompanyName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(32)).current;
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 350, useNativeDriver: true }),
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, speed: 14, bounciness: 3 }),
]).start();
}, []);
const validate = () => {
const e: Record<string, string> = {};
if (!companyName.trim()) e.companyName = t.settings.company_required;
if (!email.trim()) e.email = t.auth.email + ' is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) e.email = 'Invalid email';
if (!password) e.password = t.auth.password + ' is required';
else if (password.length < 8) e.password = 'At least 8 characters';
if (password !== confirmPassword) e.confirmPassword = 'Passwords do not match';
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSignup = async () => {
if (!validate()) return;
setLoading(true);
try {
const data = await authAPI.signup(email.trim(), password, companyName.trim());
setAuth(data.user, data.access_token);
qc.clear();
} catch (err: any) {
toast.error(err?.response?.data?.detail ?? t.common.error);
} finally {
setLoading(false);
}
};
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.kav}>
<ScrollView
contentContainerStyle={styles.scroll}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}>
<Animated.View style={[styles.content, { opacity: fadeAnim, transform: [{ translateY: slideAnim }] }]}>
{/* Logo */}
<View style={styles.brand}>
<Image source={require('../../assets/logo.png')} style={styles.logoImage} resizeMode="contain" />
</View>
{/* Form */}
<View style={[styles.card, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.md]}>
<Text style={[styles.formTitle, { color: theme.text }]}>{t.auth.signup_title}</Text>
<Text style={[styles.formSub, { color: theme.textSecondary }]}>
{t.auth.signup_subtitle}
</Text>
<View style={styles.fields}>
<Input
label={t.auth.company_name}
value={companyName}
onChangeText={setCompanyName}
placeholder={t.settings.company_placeholder}
error={errors.companyName}
/>
<Input
label={t.auth.email}
value={email}
onChangeText={setEmail}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
error={errors.email}
/>
<SecureInput
label={t.auth.password}
value={password}
onChangeText={setPassword}
placeholder={t.settings.new_password}
error={errors.password}
/>
<SecureInput
label={t.auth.password}
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder={t.auth.password}
error={errors.confirmPassword}
/>
<Button
title={t.auth.create_account}
onPress={handleSignup}
loading={loading}
fullWidth
size="lg"
/>
<Text style={[styles.terms, { color: theme.textMuted }]}>
By signing up, you agree to our Terms of Service and Privacy Policy.
</Text>
</View>
</View>
{/* Footer */}
<View style={styles.footer}>
<Text style={[styles.footerText, { color: theme.textSecondary }]}>
{t.auth.already_account}{' '}
</Text>
<TouchableOpacity onPress={() => navigation.navigate('Login')}>
<Text style={[styles.footerLink, { color: COLORS.primary }]}>{t.auth.sign_in}</Text>
</TouchableOpacity>
</View>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
kav: { flex: 1 },
scroll: { flexGrow: 1, justifyContent: 'center', paddingHorizontal: SPACING.xl, paddingVertical: SPACING.xxxl },
content: { gap: SPACING.xxl },
brand: { alignItems: 'center' },
logoImage: { width: 200, height: 100 },
card: {
borderRadius: RADIUS.xxl,
padding: SPACING.xxl,
borderWidth: 1,
gap: SPACING.lg,
},
formTitle: { ...TEXT.h3 },
formSub: { ...TEXT.body, marginTop: -SPACING.sm },
fields: { gap: SPACING.xs },
terms: { ...TEXT.caption, textAlign: 'center', lineHeight: 18 },
footer: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center' },
footerText: { ...TEXT.body },
footerLink: { ...TEXT.bodyM },
});

View File

@@ -0,0 +1,498 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
FlatList,
TextInput,
TouchableOpacity,
Alert,
RefreshControl,
Modal,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { Button, Spinner, EmptyState } from '../../components/ui';
import { useToast } from '../../contexts/ToastContext';
import { campaignsAPI, chatbotsAPI } from '../../services/api';
import { useTranslation } from '../../i18n';
import type { Campaign, Chatbot } from '../../types';
const STATUS_COLORS: Record<string, { color: string; bg: string; emoji: string }> = {
draft: { color: '#6b7280', bg: '#f3f4f6', emoji: '📝' },
sending: { color: '#2563eb', bg: '#dbeafe', emoji: '⏳' },
sent: { color: '#16a34a', bg: '#dcfce7', emoji: '✅' },
failed: { color: '#dc2626', bg: '#fee2e2', emoji: '❌' },
};
// ── New Campaign Modal ────────────────────────────────────────────────────────
function NewCampaignModal({
visible,
chatbots,
onClose,
onCreate,
creating,
}: {
visible: boolean;
chatbots: Chatbot[];
onClose: () => void;
onCreate: (data: { chatbot_id: string; title: string; message: string }) => void;
creating: boolean;
}) {
const { theme } = useTheme();
const { t } = useTranslation();
const [chatbotIdx, setChatbotIdx] = useState(0);
const [title, setTitle] = useState('');
const [message, setMessage] = useState('');
const handleCreate = () => {
if (!title.trim() || !message.trim()) return;
const chatbot_id = chatbots[chatbotIdx]?.id ?? '';
if (!chatbot_id) return;
onCreate({ chatbot_id, title: title.trim(), message: message.trim() });
setTitle('');
setMessage('');
setChatbotIdx(0);
};
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<SafeAreaView style={[styles.modalSafe, { backgroundColor: theme.bg }]}>
<ScrollView contentContainerStyle={styles.modalScroll} keyboardShouldPersistTaps="handled">
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: theme.text }]}>{t.campaigns.new}</Text>
<TouchableOpacity onPress={onClose} style={[styles.closeBtn, { backgroundColor: theme.bgSecondary }]}>
<Text style={[styles.closeBtnText, { color: theme.textSecondary }]}></Text>
</TouchableOpacity>
</View>
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>{t.campaigns.select_chatbot}</Text>
{chatbots.length === 0 ? (
<Text style={[styles.hintText, { color: theme.textMuted }]}>
{t.campaigns.empty_desc}
</Text>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.chipRow}>
{chatbots.map((c, idx) => (
<TouchableOpacity
key={c.id}
onPress={() => setChatbotIdx(idx)}
style={[
styles.chip,
{
backgroundColor: idx === chatbotIdx ? COLORS.primary : theme.bgSecondary,
borderColor: idx === chatbotIdx ? COLORS.primary : theme.border,
},
]}>
<Text style={[styles.chipText, { color: idx === chatbotIdx ? COLORS.white : theme.text }]}>
{c.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
)}
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>{t.campaigns.campaign_name}</Text>
<TextInput
style={[styles.textInput, { backgroundColor: theme.surface, borderColor: theme.border, color: theme.text }]}
placeholder={t.campaigns.name_placeholder}
placeholderTextColor={theme.textMuted}
value={title}
onChangeText={setTitle}
/>
<Text style={[styles.fieldLabel, { color: theme.textSecondary }]}>{t.campaigns.message_label}</Text>
<TextInput
style={[styles.textInput, styles.textArea, { backgroundColor: theme.surface, borderColor: theme.border, color: theme.text }]}
placeholder={t.campaigns.message_placeholder}
placeholderTextColor={theme.textMuted}
value={message}
onChangeText={setMessage}
multiline
numberOfLines={5}
textAlignVertical="top"
/>
<Text style={[styles.charCount, { color: theme.textMuted }]}>{message.length} characters</Text>
<View style={styles.modalActions}>
<Button title={t.campaigns.cancel} variant="outline" onPress={onClose} style={styles.modalActionBtn} />
<Button
title={t.campaigns.create}
onPress={handleCreate}
loading={creating}
disabled={!title.trim() || !message.trim() || chatbots.length === 0}
style={styles.modalActionBtn}
/>
</View>
</ScrollView>
</SafeAreaView>
</Modal>
);
}
// ── Campaign Card ─────────────────────────────────────────────────────────────
function CampaignCard({
campaign,
chatbotName,
onSend,
onDelete,
theme,
}: {
campaign: Campaign;
chatbotName: string;
onSend: () => void;
onDelete: () => void;
theme: any;
}) {
const { t } = useTranslation();
const STATUS_LABELS: Record<string, string> = {
draft: t.campaigns.status_draft,
sending: t.campaigns.status_sending,
sent: t.campaigns.status_sent,
failed: t.campaigns.status_failed,
};
const sc = STATUS_COLORS[campaign.status] ?? STATUS_COLORS.draft;
const statusLabel = STATUS_LABELS[campaign.status] ?? campaign.status;
const createdAt = campaign.created_at ? new Date(campaign.created_at).toLocaleDateString() : '';
const sentAt = campaign.sent_at ? new Date(campaign.sent_at).toLocaleDateString() : '';
return (
<View style={[styles.campaignCard, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<View style={styles.cardTopRow}>
<Text style={[styles.campaignTitle, { color: theme.text }]} numberOfLines={1}>{campaign.title}</Text>
<View style={[styles.statusBadge, { backgroundColor: sc.bg }]}>
<Text style={[styles.statusText, { color: sc.color }]}>{sc.emoji} {statusLabel}</Text>
</View>
</View>
<Text style={[styles.cardMeta, { color: theme.textMuted }]}>
{chatbotName} · {createdAt}
</Text>
<View style={[styles.messageBox, { backgroundColor: theme.bgSecondary }]}>
<Text style={[styles.messageText, { color: theme.textSecondary }]} numberOfLines={3}>
{campaign.message}
</Text>
</View>
<View style={styles.statsRow}>
<Text style={[styles.statText, { color: theme.textMuted }]}>
👥 {t.campaigns.subscribers(campaign.recipients_count)}
</Text>
{campaign.status === 'sent' && (
<Text style={[styles.statText, { color: COLORS.success }]}>
{campaign.sent_count} {t.campaigns.delivered} · {sentAt}
</Text>
)}
</View>
{campaign.status === 'draft' && (
<View style={styles.actionRow}>
<TouchableOpacity style={styles.sendBtn} onPress={onSend}>
<Text style={styles.sendBtnText}>📤 {t.campaigns.send}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.deleteBtn, { borderColor: COLORS.error + '40', backgroundColor: '#fee2e210' }]} onPress={onDelete}>
<Text style={[styles.deleteBtnText, { color: COLORS.error }]}>🗑 {t.campaigns.delete}</Text>
</TouchableOpacity>
</View>
)}
{campaign.status === 'sent' && (
<TouchableOpacity style={styles.deleteSentBtn} onPress={onDelete}>
<Text style={[styles.deleteSentText, { color: theme.textMuted }]}>🗑 {t.campaigns.delete}</Text>
</TouchableOpacity>
)}
</View>
);
}
// ── Main Screen ───────────────────────────────────────────────────────────────
export function CampaignsScreen() {
const { theme } = useTheme();
const { t } = useTranslation();
const toast = useToast();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [chatbotFilter, setChatbotFilter] = useState('');
const { data: chatbots = [] } = useQuery<Chatbot[]>({
queryKey: ['chatbots'],
queryFn: chatbotsAPI.list,
});
const { data: campaigns = [], isLoading, refetch, error } = useQuery<Campaign[]>({
queryKey: ['campaigns', chatbotFilter],
queryFn: () => campaignsAPI.list(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
retry: false,
refetchInterval: 8000,
});
const createCampaign = useMutation({
mutationFn: campaignsAPI.create,
onSuccess: () => {
setShowForm(false);
qc.invalidateQueries({ queryKey: ['campaigns'] });
toast.success(t.campaigns.create);
},
onError: (err: any) => toast.error(err?.response?.data?.detail ?? t.common.error),
});
const sendCampaign = useMutation({
mutationFn: (id: string) => campaignsAPI.send(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaigns'] });
toast.success(t.campaigns.send);
},
onError: (err: any) => toast.error(err?.response?.data?.detail ?? t.common.error),
});
const deleteCampaign = useMutation({
mutationFn: (id: string) => campaignsAPI.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaigns'] });
toast.success(t.campaigns.delete);
},
});
const isPlanError = (error as any)?.response?.status === 402;
const chatbotMap = Object.fromEntries(chatbots.map(c => [c.id, c.name]));
const sentCampaigns = campaigns.filter(c => c.status === 'sent');
const totalDelivered = sentCampaigns.reduce((sum, c) => sum + c.sent_count, 0);
const handleSend = (campaign: Campaign) => {
Alert.alert(
t.campaigns.send,
t.campaigns.send_confirm(campaign.title, campaign.recipients_count),
[
{ text: t.campaigns.cancel, style: 'cancel' },
{ text: t.campaigns.send_now, style: 'default', onPress: () => sendCampaign.mutate(campaign.id) },
],
);
};
const handleDelete = (campaign: Campaign) => {
Alert.alert(t.campaigns.delete, `"${campaign.title}"?`, [
{ text: t.campaigns.cancel, style: 'cancel' },
{ text: t.campaigns.delete, style: 'destructive', onPress: () => deleteCampaign.mutate(campaign.id) },
]);
};
if (isLoading) return <Spinner centered label={t.common.loading} />;
if (isPlanError) {
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<View style={styles.upgradeContainer}>
<Text style={styles.upgradeEmoji}>🔒</Text>
<Text style={[styles.upgradeTitle, { color: theme.text }]}>Campaigns Require Starter+</Text>
<Text style={[styles.upgradeDesc, { color: theme.textSecondary }]}>
Upgrade your plan to send broadcast messages to chatbot subscribers.
</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
showsVerticalScrollIndicator={false}
refreshControl={<RefreshControl refreshing={isLoading} onRefresh={refetch} tintColor={COLORS.primary} />}>
{/* Header */}
<View style={styles.headerRow}>
<View>
<Text style={[styles.pageTitle, { color: theme.text }]}>{t.campaigns.title}</Text>
<Text style={[styles.pageSub, { color: theme.textSecondary }]}>
{t.campaigns.empty_desc}
</Text>
</View>
<Button title={t.campaigns.new} onPress={() => setShowForm(true)} size="sm" />
</View>
{/* Stats */}
{campaigns.length > 0 && (
<View style={styles.statsGrid}>
{[
{ label: t.campaigns.title, value: campaigns.length, emoji: '📣' },
{ label: t.campaigns.status_sent, value: sentCampaigns.length, emoji: '✅' },
{ label: t.campaigns.delivered, value: totalDelivered, emoji: '📬' },
].map(s => (
<View key={s.label} style={[styles.statCard, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<Text style={styles.statEmoji}>{s.emoji}</Text>
<Text style={[styles.statValue, { color: theme.text }]}>{s.value.toLocaleString()}</Text>
<Text style={[styles.statLabel, { color: theme.textMuted }]}>{s.label}</Text>
</View>
))}
</View>
)}
{/* Chatbot filter */}
{chatbots.length > 1 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.filterRow}>
{[{ id: '', name: 'All' }, ...chatbots].map(c => (
<TouchableOpacity
key={c.id}
onPress={() => setChatbotFilter(c.id)}
style={[
styles.filterChip,
{
backgroundColor: chatbotFilter === c.id ? COLORS.primary : theme.bgSecondary,
borderColor: chatbotFilter === c.id ? COLORS.primary : theme.border,
},
]}>
<Text style={[styles.filterText, { color: chatbotFilter === c.id ? COLORS.white : theme.text }]}>
{c.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
)}
{/* Campaign list */}
{campaigns.length === 0 ? (
<EmptyState
icon={<Text style={{ fontSize: 48 }}>📣</Text>}
title={t.campaigns.empty_title}
description={t.campaigns.empty_desc}
/>
) : (
<View style={styles.list}>
{campaigns.map(campaign => (
<CampaignCard
key={campaign.id}
campaign={campaign}
chatbotName={chatbotMap[campaign.chatbot_id] ?? 'Unknown'}
onSend={() => handleSend(campaign)}
onDelete={() => handleDelete(campaign)}
theme={theme}
/>
))}
</View>
)}
</ScrollView>
<NewCampaignModal
visible={showForm}
chatbots={chatbots}
onClose={() => setShowForm(false)}
onCreate={data => createCampaign.mutate(data)}
creating={createCampaign.isPending}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
scroll: { padding: SPACING.lg, gap: SPACING.lg, paddingBottom: SPACING.xxxl },
headerRow: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', gap: SPACING.md },
pageTitle: { ...TEXT.h3 },
pageSub: { ...TEXT.small, marginTop: 2 },
statsGrid: { flexDirection: 'row', gap: SPACING.sm },
statCard: {
flex: 1,
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.md,
alignItems: 'center',
gap: 4,
},
statEmoji: { fontSize: 20 },
statValue: { fontSize: FONT_SIZE.xl, fontWeight: '800' },
statLabel: { ...TEXT.caption },
filterRow: { marginBottom: -SPACING.sm },
filterChip: {
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.xs,
borderRadius: RADIUS.full,
borderWidth: 1,
marginRight: SPACING.xs,
},
filterText: { ...TEXT.small },
list: { gap: SPACING.md },
campaignCard: {
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.lg,
gap: SPACING.sm,
},
cardTopRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: SPACING.sm },
campaignTitle: { ...TEXT.bodyM, flex: 1 },
statusBadge: { borderRadius: RADIUS.full, paddingHorizontal: SPACING.sm, paddingVertical: 3 },
statusText: { fontSize: FONT_SIZE.xs, fontWeight: '600' },
cardMeta: { ...TEXT.caption },
messageBox: { borderRadius: RADIUS.md, padding: SPACING.md },
messageText: { ...TEXT.small },
statsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING.md },
statText: { ...TEXT.caption },
actionRow: { flexDirection: 'row', gap: SPACING.sm, marginTop: SPACING.xs },
sendBtn: {
flex: 1,
backgroundColor: COLORS.success,
borderRadius: RADIUS.md,
paddingVertical: SPACING.sm,
alignItems: 'center',
},
sendBtnText: { color: COLORS.white, fontWeight: '600', fontSize: FONT_SIZE.sm },
deleteBtn: {
borderWidth: 1,
borderRadius: RADIUS.md,
paddingVertical: SPACING.sm,
paddingHorizontal: SPACING.md,
alignItems: 'center',
},
deleteBtnText: { fontWeight: '600', fontSize: FONT_SIZE.sm },
deleteSentBtn: { paddingVertical: SPACING.xs, alignSelf: 'flex-start' },
deleteSentText: { ...TEXT.small },
// Modal
modalSafe: { flex: 1 },
modalScroll: { padding: SPACING.xl, gap: SPACING.md, paddingBottom: SPACING.xxxl },
modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: SPACING.sm },
modalTitle: { ...TEXT.h3 },
closeBtn: { width: 32, height: 32, borderRadius: RADIUS.full, alignItems: 'center', justifyContent: 'center' },
closeBtnText: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
fieldLabel: { ...TEXT.smallM, marginBottom: -SPACING.xs },
hintText: { ...TEXT.small },
chipRow: { marginBottom: SPACING.xs },
chip: {
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.xs,
borderRadius: RADIUS.full,
borderWidth: 1,
marginRight: SPACING.xs,
},
chipText: { ...TEXT.small },
textInput: {
borderWidth: 1,
borderRadius: RADIUS.lg,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
fontSize: FONT_SIZE.sm,
},
textArea: { height: 120, paddingTop: SPACING.sm },
charCount: { ...TEXT.caption, textAlign: 'right', marginTop: -SPACING.xs },
modalActions: { flexDirection: 'row', gap: SPACING.sm, marginTop: SPACING.sm },
modalActionBtn: { flex: 1 },
// Upgrade
upgradeContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: SPACING.xxxl, gap: SPACING.md },
upgradeEmoji: { fontSize: 48 },
upgradeTitle: { ...TEXT.h3, textAlign: 'center' },
upgradeDesc: { ...TEXT.body, textAlign: 'center' },
});

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery } from '@tanstack/react-query';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { DashboardStackParamList } from '../../navigation/types';
import { chatbotsAPI } from '../../services/api';
import { ChatInterface } from '../../components/ChatInterface';
import { Spinner } from '../../components/ui';
import { useTheme } from '../../theme';
type Props = NativeStackScreenProps<DashboardStackParamList, 'ChatPreview'>;
export function ChatPreviewScreen({ route }: Props) {
const { chatbotId } = route.params;
const { theme } = useTheme();
const { data: chatbot, isLoading } = useQuery({
queryKey: ['chatbot', chatbotId],
queryFn: () => chatbotsAPI.get(chatbotId),
});
if (isLoading) return <Spinner centered />;
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<ChatInterface
chatbotId={chatbotId}
welcomeMessage={chatbot?.welcome_message}
primaryColor={chatbot?.primary_color}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
});

View File

@@ -0,0 +1,602 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Switch,
Alert,
TextInput,
ActivityIndicator,
Modal,
FlatList,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { DashboardStackParamList } from '../../navigation/types';
import { COLORS, FONT_SIZE, SPACING, RADIUS } from '../../theme';
import { useTheme } from '../../theme';
import { Button, Input, Card, Spinner } from '../../components/ui';
import { useToast } from '../../contexts/ToastContext';
import { chatbotsAPI, modelsAPI, documentsAPI, urlSourcesAPI, channelsAPI } from '../../services/api';
import { Chatbot, Document, URLSource, AIModel, ChannelConnection } from '../../types';
import { DocumentsTab } from './tabs/DocumentsTab';
import { DeployTab } from './tabs/DeployTab';
import { TestingTab } from './tabs/TestingTab';
import { ChatInterface } from '../../components/ChatInterface';
import { CHATBOT_TEMPLATES, ChatbotTemplate } from '../../data/templates';
type Props = NativeStackScreenProps<DashboardStackParamList, 'ChatbotBuilder'>;
type Tab = 'settings' | 'documents' | 'preview' | 'testing' | 'deploy';
const TABS: { key: Tab; label: string }[] = [
{ key: 'settings', label: 'Settings' },
{ key: 'documents', label: 'Docs' },
{ key: 'preview', label: 'Preview' },
{ key: 'testing', label: 'Testing' },
{ key: 'deploy', label: 'Deploy' },
];
const CATEGORIES = ['Customer Support', 'Sales', 'HR', 'Education', 'Healthcare', 'Finance', 'Legal', 'Other'];
const INDUSTRIES = ['Technology', 'Retail', 'Healthcare', 'Finance', 'Education', 'Manufacturing', 'Other'];
const TEMPERATURES = [0, 0.3, 0.5, 0.7, 1.0];
export function ChatbotBuilderScreen({ route, navigation }: Props) {
const { chatbotId } = route.params ?? {};
const isEditing = Boolean(chatbotId);
const { theme } = useTheme();
const toast = useToast();
const qc = useQueryClient();
const [activeTab, setActiveTab] = useState<Tab>('settings');
const [showTemplates, setShowTemplates] = useState(!isEditing);
// Form state
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [systemPrompt, setSystemPrompt] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [temperature, setTemperature] = useState(0.7);
const [maxTokens, setMaxTokens] = useState(1024);
const [primaryColor, setPrimaryColor] = useState('#6366f1');
const [welcomeMessage, setWelcomeMessage] = useState('Hello! How can I help you today?');
const [category, setCategory] = useState('');
const [industry, setIndustry] = useState('');
const [showBranding, setShowBranding] = useState(true);
const [leadCaptureEnabled, setLeadCaptureEnabled] = useState(false);
const [handoffEnabled, setHandoffEnabled] = useState(false);
const [handoffEmail, setHandoffEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
// Load existing chatbot
const { data: chatbot, isLoading: loadingBot } = useQuery({
queryKey: ['chatbot', chatbotId],
queryFn: () => chatbotsAPI.get(chatbotId!),
enabled: isEditing,
});
// Load available models
const { data: modelsData } = useQuery({
queryKey: ['models'],
queryFn: modelsAPI.available,
});
useEffect(() => {
if (chatbot) {
setName(chatbot.name ?? '');
setDescription(chatbot.description ?? '');
setSystemPrompt(chatbot.system_prompt ?? '');
setSelectedModel(chatbot.model ?? '');
setTemperature(chatbot.temperature ?? 0.7);
setMaxTokens(chatbot.max_tokens ?? 1024);
setPrimaryColor(chatbot.primary_color ?? '#6366f1');
setWelcomeMessage(chatbot.welcome_message ?? '');
setCategory(chatbot.category ?? '');
setIndustry(chatbot.industry ?? '');
setShowBranding(chatbot.show_branding ?? true);
setLeadCaptureEnabled(chatbot.lead_capture_enabled ?? false);
setHandoffEnabled(chatbot.handoff_enabled ?? false);
setHandoffEmail(chatbot.handoff_email ?? '');
} else if (!isEditing && modelsData?.default_model) {
setSelectedModel(modelsData.default_model);
}
}, [chatbot, modelsData, isEditing]);
const saveMutation = useMutation({
mutationFn: (payload: Partial<Chatbot>) =>
isEditing
? chatbotsAPI.update(chatbotId!, payload)
: chatbotsAPI.create(payload),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['chatbots'] });
qc.invalidateQueries({ queryKey: ['chatbot', chatbotId] });
toast.success(isEditing ? 'Chatbot updated!' : 'Chatbot created!');
if (!isEditing) {
navigation.replace('ChatbotBuilder', { chatbotId: data.id });
}
},
onError: (err: any) => toast.error(err?.response?.data?.detail ?? 'Save failed'),
});
const validate = () => {
const e: Record<string, string> = {};
if (!name.trim()) e.name = 'Name is required';
setErrors(e);
return Object.keys(e).length === 0;
};
const applyTemplate = (tpl: ChatbotTemplate) => {
setSystemPrompt(tpl.system_prompt);
setWelcomeMessage(tpl.welcome_message);
setCategory(tpl.category);
setLeadCaptureEnabled(tpl.lead_capture_enabled);
setShowTemplates(false);
};
const handleSave = () => {
if (!validate()) return;
saveMutation.mutate({
name: name.trim(),
description: description.trim(),
system_prompt: systemPrompt.trim(),
model: selectedModel,
temperature,
max_tokens: maxTokens,
primary_color: primaryColor,
welcome_message: welcomeMessage.trim(),
category,
industry,
show_branding: showBranding,
lead_capture_enabled: leadCaptureEnabled,
handoff_enabled: handoffEnabled,
handoff_email: handoffEmail.trim(),
});
};
if (isEditing && loadingBot) {
return <Spinner centered label="Loading chatbot..." />;
}
const PRESET_COLORS = ['#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#14b8a6'];
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
{/* Template picker modal (only for new chatbots) */}
<Modal
visible={showTemplates}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowTemplates(false)}>
<SafeAreaView style={[tplStyles.safe, { backgroundColor: theme.bg }]}>
<View style={[tplStyles.header, { borderBottomColor: theme.border }]}>
<Text style={[tplStyles.title, { color: theme.text }]}>Choose a template</Text>
<TouchableOpacity onPress={() => setShowTemplates(false)}>
<Text style={[tplStyles.skip, { color: COLORS.primary }]}>Start blank </Text>
</TouchableOpacity>
</View>
<FlatList
data={CHATBOT_TEMPLATES}
keyExtractor={item => item.id}
contentContainerStyle={tplStyles.list}
renderItem={({ item }) => (
<TouchableOpacity
style={[tplStyles.card, { backgroundColor: theme.surface, borderColor: theme.border }]}
onPress={() => applyTemplate(item)}>
<Text style={tplStyles.icon}>{item.icon}</Text>
<View style={tplStyles.cardInfo}>
<Text style={[tplStyles.cardName, { color: theme.text }]}>{item.name}</Text>
<Text style={[tplStyles.cardDesc, { color: theme.textSecondary }]}>{item.description}</Text>
</View>
<Text style={[tplStyles.chevron, { color: theme.textMuted }]}></Text>
</TouchableOpacity>
)}
/>
</SafeAreaView>
</Modal>
{/* Tab bar */}
<View style={[styles.tabBar, { backgroundColor: theme.surface, borderBottomColor: theme.border }]}>
{TABS.map(tab => (
<TouchableOpacity
key={tab.key}
style={[styles.tab, activeTab === tab.key && styles.tabActive]}
onPress={() => setActiveTab(tab.key)}>
<Text style={[
styles.tabText,
{ color: activeTab === tab.key ? COLORS.primary : theme.textSecondary },
activeTab === tab.key && styles.tabTextActive,
]}>
{tab.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Settings Tab */}
{activeTab === 'settings' && (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}>
<SectionHeader title="Basic Info" />
<Input
label="Chatbot Name *"
value={name}
onChangeText={setName}
placeholder="My Support Bot"
error={errors.name}
/>
<Input
label="Description"
value={description}
onChangeText={setDescription}
placeholder="What does this chatbot do?"
multiline
numberOfLines={3}
/>
<SectionHeader title="Behavior" />
<View style={styles.field}>
<Text style={[styles.label, { color: theme.text }]}>System Prompt</Text>
<TextInput
style={[styles.textarea, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
value={systemPrompt}
onChangeText={setSystemPrompt}
placeholder="You are a helpful assistant..."
placeholderTextColor={theme.placeholder}
multiline
numberOfLines={5}
textAlignVertical="top"
/>
</View>
<SectionHeader title="AI Model" />
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.modelsScroll}>
<View style={styles.modelsRow}>
{(modelsData?.models ?? []).map((m: AIModel) => (
<TouchableOpacity
key={m.id}
style={[
styles.modelPill,
{ borderColor: selectedModel === m.id ? COLORS.primary : theme.border },
selectedModel === m.id && { backgroundColor: COLORS.primaryUltraLight },
!m.available && styles.modelDisabled,
]}
onPress={() => m.available && setSelectedModel(m.id)}
disabled={!m.available}>
<Text style={[
styles.modelPillText,
{ color: selectedModel === m.id ? COLORS.primaryDark : theme.text },
!m.available && { color: theme.textMuted },
]}>
{m.name}
</Text>
{m.upgrade_required ? (
<Text style={styles.modelUpgrade}>{m.upgrade_required}</Text>
) : null}
</TouchableOpacity>
))}
</View>
</ScrollView>
<SectionHeader title="Temperature" />
<View style={styles.tempRow}>
{TEMPERATURES.map(t => (
<TouchableOpacity
key={t}
style={[
styles.tempPill,
{ borderColor: temperature === t ? COLORS.primary : theme.border },
temperature === t && { backgroundColor: COLORS.primaryUltraLight },
]}
onPress={() => setTemperature(t)}>
<Text style={[styles.tempText, { color: temperature === t ? COLORS.primaryDark : theme.text }]}>
{t}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={[styles.hint, { color: theme.textMuted }]}>
0 = focused, 1 = creative
</Text>
<SectionHeader title="Appearance" />
<Text style={[styles.label, { color: theme.text }]}>Primary Color</Text>
<View style={styles.colorRow}>
{PRESET_COLORS.map(c => (
<TouchableOpacity
key={c}
style={[styles.colorSwatch, { backgroundColor: c }, primaryColor === c && styles.colorSwatchSelected]}
onPress={() => setPrimaryColor(c)}
/>
))}
</View>
<Input
label="Welcome Message"
value={welcomeMessage}
onChangeText={setWelcomeMessage}
placeholder="Hello! How can I help you today?"
/>
<SectionHeader title="Classification" />
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={[styles.label, { color: theme.text }]}>Category</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.pillRow}>
{CATEGORIES.map(c => (
<TouchableOpacity
key={c}
style={[styles.pill, { borderColor: category === c ? COLORS.primary : theme.border }, category === c && { backgroundColor: COLORS.primaryUltraLight }]}
onPress={() => setCategory(c === category ? '' : c)}>
<Text style={[styles.pillText, { color: category === c ? COLORS.primaryDark : theme.text }]}>
{c}
</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
</View>
</View>
<SectionHeader title="Advanced" />
<ToggleRow
label="Show Branding"
description="Display 'Powered by Contexta' in the chat widget"
value={showBranding}
onValueChange={setShowBranding}
/>
<ToggleRow
label="Lead Capture"
description="Collect visitor contact info during chats"
value={leadCaptureEnabled}
onValueChange={setLeadCaptureEnabled}
/>
<ToggleRow
label="Human Handoff"
description="Escalate conversations to a human agent"
value={handoffEnabled}
onValueChange={setHandoffEnabled}
/>
{handoffEnabled && (
<Input
label="Handoff Email"
value={handoffEmail}
onChangeText={setHandoffEmail}
placeholder="agent@yourcompany.com"
keyboardType="email-address"
autoCapitalize="none"
/>
)}
<Button
title={saveMutation.isPending ? 'Saving...' : (isEditing ? 'Save Changes' : 'Create Chatbot')}
onPress={handleSave}
loading={saveMutation.isPending}
fullWidth
size="lg"
style={styles.saveBtn}
/>
</ScrollView>
)}
{/* Documents Tab */}
{activeTab === 'documents' && chatbotId && (
<DocumentsTab chatbotId={chatbotId} />
)}
{activeTab === 'documents' && !chatbotId && (
<View style={styles.noIdState}>
<Text style={[styles.noIdText, { color: theme.textSecondary }]}>
Save the chatbot first to add documents.
</Text>
</View>
)}
{/* Testing Tab */}
{activeTab === 'testing' && chatbotId && (
<TestingTab chatbotId={chatbotId} />
)}
{activeTab === 'testing' && !chatbotId && (
<View style={styles.noIdState}>
<Text style={[styles.noIdText, { color: theme.textSecondary }]}>
Save the chatbot first to run tests.
</Text>
</View>
)}
{/* Preview Tab */}
{activeTab === 'preview' && chatbotId && (
<ChatInterface
chatbotId={chatbotId}
welcomeMessage={welcomeMessage}
primaryColor={primaryColor}
/>
)}
{activeTab === 'preview' && !chatbotId && (
<View style={styles.noIdState}>
<Text style={[styles.noIdText, { color: theme.textSecondary }]}>
Save the chatbot first to preview it.
</Text>
</View>
)}
{/* Deploy Tab */}
{activeTab === 'deploy' && chatbotId && (
<DeployTab chatbotId={chatbotId} chatbotName={name} />
)}
{activeTab === 'deploy' && !chatbotId && (
<View style={styles.noIdState}>
<Text style={[styles.noIdText, { color: theme.textSecondary }]}>
Save the chatbot first to view deploy options.
</Text>
</View>
)}
</SafeAreaView>
);
}
function SectionHeader({ title }: { title: string }) {
const { theme } = useTheme();
return (
<Text style={[sectionStyles.header, { color: theme.textMuted }]}>{title.toUpperCase()}</Text>
);
}
function ToggleRow({
label,
description,
value,
onValueChange,
}: {
label: string;
description: string;
value: boolean;
onValueChange: (v: boolean) => void;
}) {
const { theme } = useTheme();
return (
<View style={toggleStyles.row}>
<View style={toggleStyles.labelCol}>
<Text style={[toggleStyles.label, { color: theme.text }]}>{label}</Text>
<Text style={[toggleStyles.desc, { color: theme.textMuted }]}>{description}</Text>
</View>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ true: COLORS.primary, false: '#ccc' }}
thumbColor="#fff"
/>
</View>
);
}
const sectionStyles = StyleSheet.create({
header: {
fontSize: FONT_SIZE.xs,
fontWeight: '700',
letterSpacing: 0.8,
marginTop: SPACING.xl,
marginBottom: SPACING.sm,
},
});
const toggleStyles = StyleSheet.create({
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: SPACING.sm,
gap: SPACING.md,
},
labelCol: { flex: 1 },
label: { fontSize: FONT_SIZE.md, fontWeight: '500' },
desc: { fontSize: FONT_SIZE.xs, marginTop: 2 },
});
const styles = StyleSheet.create({
safe: { flex: 1 },
tabBar: {
flexDirection: 'row',
borderBottomWidth: 1,
paddingHorizontal: SPACING.sm,
},
tab: {
flex: 1,
paddingVertical: SPACING.md,
alignItems: 'center',
borderBottomWidth: 2,
borderBottomColor: 'transparent',
},
tabActive: { borderBottomColor: COLORS.primary },
tabText: { fontSize: FONT_SIZE.sm, fontWeight: '500' },
tabTextActive: { fontWeight: '700' },
scroll: { flex: 1 },
scrollContent: { padding: SPACING.lg, paddingBottom: SPACING.xxxl },
field: { marginBottom: SPACING.md },
label: { fontSize: FONT_SIZE.sm, fontWeight: '500', marginBottom: SPACING.xs },
textarea: {
borderWidth: 1.5,
borderRadius: RADIUS.md,
padding: SPACING.md,
fontSize: FONT_SIZE.md,
minHeight: 110,
},
hint: { fontSize: FONT_SIZE.xs, marginTop: -SPACING.xs, marginBottom: SPACING.sm },
modelsScroll: { marginBottom: SPACING.md },
modelsRow: { flexDirection: 'row', gap: SPACING.sm, paddingBottom: SPACING.xs },
modelPill: {
borderWidth: 1.5,
borderRadius: RADIUS.full,
paddingVertical: SPACING.xs,
paddingHorizontal: SPACING.md,
gap: 2,
},
modelPillText: { fontSize: FONT_SIZE.sm, fontWeight: '500' },
modelDisabled: { opacity: 0.4 },
modelUpgrade: { fontSize: 9, color: COLORS.warning, fontWeight: '600' },
tempRow: { flexDirection: 'row', gap: SPACING.sm, flexWrap: 'wrap', marginBottom: SPACING.xs },
tempPill: {
borderWidth: 1.5,
borderRadius: RADIUS.full,
paddingVertical: SPACING.xs,
paddingHorizontal: SPACING.lg,
},
tempText: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
colorRow: { flexDirection: 'row', gap: SPACING.sm, marginBottom: SPACING.lg },
colorSwatch: { width: 32, height: 32, borderRadius: RADIUS.full },
colorSwatchSelected: { borderWidth: 3, borderColor: '#fff', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.3, shadowRadius: 4, elevation: 4 },
pillRow: { flexDirection: 'row', gap: SPACING.sm },
pill: {
borderWidth: 1.5,
borderRadius: RADIUS.full,
paddingVertical: 4,
paddingHorizontal: SPACING.md,
},
pillText: { fontSize: FONT_SIZE.xs, fontWeight: '500' },
row: { marginBottom: SPACING.md },
saveBtn: { marginTop: SPACING.xxxl },
noIdState: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: SPACING.xxxl },
noIdText: { fontSize: FONT_SIZE.md, textAlign: 'center' },
});
const tplStyles = StyleSheet.create({
safe: { flex: 1 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: SPACING.lg,
borderBottomWidth: 1,
},
title: { fontSize: FONT_SIZE.lg, fontWeight: '700' },
skip: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
list: { padding: SPACING.lg, gap: SPACING.sm },
card: {
flexDirection: 'row',
alignItems: 'center',
gap: SPACING.md,
padding: SPACING.lg,
borderRadius: RADIUS.xl,
borderWidth: 1,
},
icon: { fontSize: 32 },
cardInfo: { flex: 1 },
cardName: { fontSize: FONT_SIZE.md, fontWeight: '700' },
cardDesc: { fontSize: FONT_SIZE.sm, marginTop: 2, lineHeight: 18 },
chevron: { fontSize: 22, fontWeight: '300' },
});

View File

@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Clipboard,
Platform,
} from 'react-native';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { COLORS, FONT_SIZE, SPACING, RADIUS } from '../../../theme';
import { useTheme } from '../../../theme';
import { Card, Button, Input } from '../../../components/ui';
import { useToast } from '../../../contexts/ToastContext';
import { chatbotsAPI, channelsAPI } from '../../../services/api';
import { ChannelConnection } from '../../../types';
interface Props {
chatbotId: string;
chatbotName: string;
}
export function DeployTab({ chatbotId, chatbotName }: Props) {
const { theme } = useTheme();
const toast = useToast();
const qc = useQueryClient();
const [telegramToken, setTelegramToken] = useState('');
const [connectingTelegram, setConnectingTelegram] = useState(false);
const [showTelegramForm, setShowTelegramForm] = useState(false);
const { data: embedData } = useQuery({
queryKey: ['embed', chatbotId],
queryFn: () => chatbotsAPI.getEmbed(chatbotId),
});
const { data: channels } = useQuery({
queryKey: ['channels', chatbotId],
queryFn: () => channelsAPI.list(chatbotId),
});
const disconnectMutation = useMutation({
mutationFn: channelsAPI.disconnect,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['channels', chatbotId] });
toast.success('Channel disconnected');
},
});
const handleCopy = (text: string, label: string) => {
Clipboard.setString(text);
toast.success(`${label} copied!`);
};
const handleConnectTelegram = async () => {
if (!telegramToken.trim()) {
toast.error('Enter your Telegram bot token');
return;
}
setConnectingTelegram(true);
try {
await channelsAPI.connectTelegram(chatbotId, telegramToken.trim());
qc.invalidateQueries({ queryKey: ['channels', chatbotId] });
setTelegramToken('');
setShowTelegramForm(false);
toast.success('Telegram bot connected!');
} catch (err: any) {
toast.error(err?.response?.data?.detail ?? 'Failed to connect Telegram');
} finally {
setConnectingTelegram(false);
}
};
const telegramConnection = (channels as ChannelConnection[] ?? []).find(c => c.channel === 'telegram');
const whatsappConnection = (channels as ChannelConnection[] ?? []).find(c => c.channel === 'whatsapp');
return (
<ScrollView
style={[styles.scroll, { backgroundColor: theme.bg }]}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}>
{/* Web Embed */}
<Card style={styles.section}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>🌐 Web Embed</Text>
<Text style={[styles.sectionDesc, { color: theme.textSecondary }]}>
Paste this script on your website to add the chat widget.
</Text>
{embedData?.embed_script ? (
<View style={[styles.codeBlock, { backgroundColor: theme.surfaceHover, borderColor: theme.border }]}>
<Text style={[styles.codeText, { color: theme.textSecondary }]} numberOfLines={4}>
{embedData.embed_script}
</Text>
<TouchableOpacity
style={styles.copyBtn}
onPress={() => handleCopy(embedData.embed_script, 'Embed script')}>
<Text style={[styles.copyBtnText, { color: COLORS.primary }]}>Copy</Text>
</TouchableOpacity>
</View>
) : null}
{embedData?.chat_url ? (
<View style={styles.urlRow}>
<Text style={[styles.urlLabel, { color: theme.textSecondary }]}>Direct link:</Text>
<Text style={[styles.urlText, { color: COLORS.primary }]} numberOfLines={1} selectable>
{embedData.chat_url}
</Text>
<TouchableOpacity onPress={() => handleCopy(embedData.chat_url, 'Chat URL')}>
<Text style={[styles.copyBtnText, { color: COLORS.primary }]}>Copy</Text>
</TouchableOpacity>
</View>
) : null}
</Card>
{/* Telegram */}
<Card style={styles.section}>
<View style={styles.channelHeader}>
<Text style={styles.channelIcon}></Text>
<View style={styles.channelTitleCol}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>Telegram</Text>
<Text style={[styles.sectionDesc, { color: theme.textSecondary }]}>
Connect a Telegram bot to use your chatbot
</Text>
</View>
</View>
{telegramConnection ? (
<View style={styles.connectedRow}>
<View style={styles.connectedInfo}>
<View style={styles.connectedDot} />
<Text style={[styles.connectedText, { color: theme.text }]}>
@{telegramConnection.bot_username ?? 'Connected'}
</Text>
</View>
<Button
title="Disconnect"
variant="danger"
size="sm"
onPress={() => disconnectMutation.mutate(telegramConnection.id)}
/>
</View>
) : (
<>
{showTelegramForm ? (
<View style={styles.connectForm}>
<Input
label="Bot Token"
value={telegramToken}
onChangeText={setTelegramToken}
placeholder="1234567890:ABC..."
autoCapitalize="none"
/>
<View style={styles.formActions}>
<Button title="Cancel" variant="ghost" size="sm" onPress={() => setShowTelegramForm(false)} />
<Button title="Connect" size="sm" onPress={handleConnectTelegram} loading={connectingTelegram} />
</View>
</View>
) : (
<Button title="Connect Telegram Bot" variant="outline" onPress={() => setShowTelegramForm(true)} />
)}
</>
)}
</Card>
{/* WhatsApp */}
<Card style={styles.section}>
<View style={styles.channelHeader}>
<Text style={styles.channelIcon}>💬</Text>
<View style={styles.channelTitleCol}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>WhatsApp</Text>
<Text style={[styles.sectionDesc, { color: theme.textSecondary }]}>
Connect via Meta Cloud API (Business+ plan)
</Text>
</View>
</View>
{whatsappConnection ? (
<View style={styles.connectedRow}>
<View style={styles.connectedInfo}>
<View style={styles.connectedDot} />
<Text style={[styles.connectedText, { color: theme.text }]}>
Keyword: {whatsappConnection.wa_keyword}
</Text>
</View>
<Button
title="Disconnect"
variant="danger"
size="sm"
onPress={() => disconnectMutation.mutate(whatsappConnection.id)}
/>
</View>
) : (
<Text style={[styles.upgradeNote, { color: theme.textMuted }]}>
Requires Business plan or higher
</Text>
)}
</Card>
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1 },
content: { padding: SPACING.lg, gap: SPACING.lg },
section: { gap: SPACING.md },
sectionTitle: { fontSize: FONT_SIZE.lg, fontWeight: '600' },
sectionDesc: { fontSize: FONT_SIZE.sm, lineHeight: 20 },
codeBlock: {
borderRadius: RADIUS.md,
padding: SPACING.md,
borderWidth: 1,
gap: SPACING.sm,
},
codeText: { fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace' },
copyBtn: { alignSelf: 'flex-end' },
copyBtnText: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
urlRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, flexWrap: 'wrap' },
urlLabel: { fontSize: FONT_SIZE.sm },
urlText: { flex: 1, fontSize: FONT_SIZE.sm },
channelHeader: { flexDirection: 'row', gap: SPACING.md, alignItems: 'flex-start' },
channelIcon: { fontSize: 28 },
channelTitleCol: { flex: 1, gap: 2 },
connectedRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
connectedInfo: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm },
connectedDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: COLORS.success },
connectedText: { fontSize: FONT_SIZE.sm, fontWeight: '500' },
connectForm: { gap: SPACING.sm },
formActions: { flexDirection: 'row', gap: SPACING.sm, justifyContent: 'flex-end' },
upgradeNote: { fontSize: FONT_SIZE.sm, fontStyle: 'italic' },
});

View File

@@ -0,0 +1,286 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
Alert,
} from 'react-native';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { COLORS, FONT_SIZE, SPACING, RADIUS } from '../../../theme';
import { useTheme } from '../../../theme';
import { Card, StatusBadge, Spinner, EmptyState, Button, Input } from '../../../components/ui';
import { useToast } from '../../../contexts/ToastContext';
import { documentsAPI, urlSourcesAPI } from '../../../services/api';
import { Document, URLSource } from '../../../types';
interface Props {
chatbotId: string;
}
export function DocumentsTab({ chatbotId }: Props) {
const { theme } = useTheme();
const toast = useToast();
const qc = useQueryClient();
const [urlInput, setUrlInput] = useState('');
const [addingUrl, setAddingUrl] = useState(false);
const [showUrlInput, setShowUrlInput] = useState(false);
const { data: documents, isLoading: loadingDocs } = useQuery({
queryKey: ['documents', chatbotId],
queryFn: () => documentsAPI.list(chatbotId),
});
const { data: urlSources } = useQuery({
queryKey: ['urlSources', chatbotId],
queryFn: () => urlSourcesAPI.list(chatbotId),
});
const deleteMutation = useMutation({
mutationFn: (docId: string) => documentsAPI.delete(chatbotId, docId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documents', chatbotId] });
toast.success('Document deleted');
},
onError: () => toast.error('Failed to delete document'),
});
const retryMutation = useMutation({
mutationFn: (docId: string) => documentsAPI.retry(chatbotId, docId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['documents', chatbotId] });
toast.success('Retry started');
},
});
const deleteUrlMutation = useMutation({
mutationFn: (sourceId: string) => urlSourcesAPI.delete(chatbotId, sourceId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['urlSources', chatbotId] });
toast.success('URL source removed');
},
});
const handleAddUrl = async () => {
const url = urlInput.trim();
if (!url) return;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
toast.error('Enter a valid URL starting with http:// or https://');
return;
}
setAddingUrl(true);
try {
await urlSourcesAPI.add(chatbotId, url);
qc.invalidateQueries({ queryKey: ['urlSources', chatbotId] });
setUrlInput('');
setShowUrlInput(false);
toast.success('URL added! Scraping in background...');
} catch (err: any) {
toast.error(err?.response?.data?.detail ?? 'Failed to add URL');
} finally {
setAddingUrl(false);
}
};
const confirmDelete = (doc: Document) => {
Alert.alert('Delete Document', `Remove "${doc.file_name}"?`, [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => deleteMutation.mutate(doc.id) },
]);
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
type ListItem =
| { type: 'doc'; data: Document }
| { type: 'url'; data: URLSource }
| { type: 'upload-tip' };
const listData: ListItem[] = [
{ type: 'upload-tip' },
...(documents ?? []).map((d: Document) => ({ type: 'doc' as const, data: d })),
...(urlSources ?? []).map((u: URLSource) => ({ type: 'url' as const, data: u })),
];
return (
<View style={[styles.container, { backgroundColor: theme.bg }]}>
{/* Add URL bar */}
<View style={[styles.toolbar, { backgroundColor: theme.surface, borderBottomColor: theme.border }]}>
<Button
title="+ Add URL"
size="sm"
onPress={() => setShowUrlInput(v => !v)}
/>
<Text style={[styles.toolbarHint, { color: theme.textMuted }]}>
{(documents ?? []).length + (urlSources ?? []).length} sources
</Text>
</View>
{showUrlInput && (
<View style={[styles.urlBar, { backgroundColor: theme.surface, borderBottomColor: theme.border }]}>
<Input
value={urlInput}
onChangeText={setUrlInput}
placeholder="https://example.com/page"
keyboardType="url"
autoCapitalize="none"
autoCorrect={false}
style={styles.urlInput}
/>
<View style={styles.urlActions}>
<Button title="Cancel" variant="ghost" size="sm" onPress={() => { setShowUrlInput(false); setUrlInput(''); }} />
<Button title="Scrape" size="sm" onPress={handleAddUrl} loading={addingUrl} />
</View>
</View>
)}
<FlatList
data={listData}
keyExtractor={(item, i) => (item.type === 'upload-tip' ? 'tip' : item.type === 'doc' ? item.data.id : item.data.id)}
contentContainerStyle={styles.list}
renderItem={({ item }) => {
if (item.type === 'upload-tip') {
return (
<Card style={[styles.tipCard, { borderColor: COLORS.primaryUltraLight }] as any}>
<Text style={styles.tipIcon}>💡</Text>
<View style={styles.tipBody}>
<Text style={[styles.tipTitle, { color: theme.text }]}>Upload files from the web app</Text>
<Text style={[styles.tipDesc, { color: theme.textSecondary }]}>
PDF, DOCX, CSV, XLSX, TXT and MD files can be uploaded from the Contexta web dashboard. Use "Add URL" here to scrape web pages directly.
</Text>
</View>
</Card>
);
}
if (item.type === 'doc') {
const doc = item.data as Document;
const icon = doc.file_type?.includes('pdf') ? '📄'
: doc.file_type?.includes('csv') || doc.file_type?.includes('xlsx') ? '📊'
: '📝';
return (
<View style={[styles.docItem, { backgroundColor: theme.surface, borderColor: theme.border }]}>
<View style={[styles.docIcon, { backgroundColor: COLORS.primaryUltraLight }]}>
<Text style={styles.docIconText}>{icon}</Text>
</View>
<View style={styles.docInfo}>
<Text style={[styles.docName, { color: theme.text }]} numberOfLines={1}>{doc.file_name}</Text>
<View style={styles.docMeta}>
<Text style={[styles.docSize, { color: theme.textMuted }]}>{formatSize(doc.file_size)}</Text>
{doc.chunk_count > 0 && (
<Text style={[styles.docSize, { color: theme.textMuted }]}> · {doc.chunk_count} chunks</Text>
)}
</View>
<View style={styles.docFooter}>
<StatusBadge status={doc.status} />
{doc.status === 'failed' && (
<TouchableOpacity onPress={() => retryMutation.mutate(doc.id)}>
<Text style={[styles.retryText, { color: COLORS.primary }]}>Retry</Text>
</TouchableOpacity>
)}
</View>
</View>
<TouchableOpacity onPress={() => confirmDelete(doc)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={styles.deleteIcon}></Text>
</TouchableOpacity>
</View>
);
}
// url source
const src = item.data as URLSource;
return (
<View style={[styles.docItem, { backgroundColor: theme.surface, borderColor: theme.border }]}>
<View style={[styles.docIcon, { backgroundColor: '#e0f2fe' }]}>
<Text style={styles.docIconText}>🌐</Text>
</View>
<View style={styles.docInfo}>
<Text style={[styles.docName, { color: theme.text }]} numberOfLines={1}>
{src.page_title ?? src.url}
</Text>
<Text style={[styles.docSize, { color: theme.textMuted }]} numberOfLines={1}>{src.url}</Text>
<View style={styles.docFooter}>
<StatusBadge status={src.status} />
{src.chunk_count ? (
<Text style={[styles.docSize, { color: theme.textMuted }]}>{src.chunk_count} chunks</Text>
) : null}
</View>
</View>
<TouchableOpacity onPress={() => deleteUrlMutation.mutate(src.id)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={styles.deleteIcon}></Text>
</TouchableOpacity>
</View>
);
}}
ListEmptyComponent={
loadingDocs ? <Spinner centered /> : null
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
toolbar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
borderBottomWidth: 1,
},
toolbarHint: { fontSize: FONT_SIZE.xs },
urlBar: {
paddingHorizontal: SPACING.md,
paddingBottom: SPACING.sm,
borderBottomWidth: 1,
gap: SPACING.xs,
},
urlInput: { marginBottom: 0 },
urlActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: SPACING.sm },
list: { padding: SPACING.md, gap: SPACING.sm },
tipCard: {
flexDirection: 'row',
gap: SPACING.md,
backgroundColor: '#f0f4ff',
borderWidth: 1,
},
tipIcon: { fontSize: 22 },
tipBody: { flex: 1, gap: 4 },
tipTitle: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
tipDesc: { fontSize: FONT_SIZE.xs, lineHeight: 18 },
docItem: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: RADIUS.lg,
padding: SPACING.md,
borderWidth: 1,
gap: SPACING.sm,
},
docIcon: {
width: 40,
height: 40,
borderRadius: RADIUS.md,
alignItems: 'center',
justifyContent: 'center',
},
docIconText: { fontSize: 20 },
docInfo: { flex: 1, gap: 3 },
docName: { fontSize: FONT_SIZE.sm, fontWeight: '500' },
docMeta: { flexDirection: 'row', alignItems: 'center' },
docSize: { fontSize: FONT_SIZE.xs },
docFooter: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, marginTop: 2 },
retryText: { fontSize: FONT_SIZE.xs, fontWeight: '600' },
deleteIcon: { fontSize: 16, color: '#9ca3af', padding: SPACING.xs },
});

View File

@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TextInput,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../../../theme';
import { useTheme } from '../../../theme';
import { chatAPI } from '../../../services/api';
interface TestResult {
question: string;
response: string;
confidence_score: number;
sources: { document_name: string; chunk_text: string; score: number }[];
model_used: string;
}
export function TestingTab({ chatbotId }: { chatbotId: string }) {
const { theme } = useTheme();
const [questions, setQuestions] = useState<string[]>(['']);
const [results, setResults] = useState<TestResult[]>([]);
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState('');
const addQuestion = () => {
if (questions.length < 10) setQuestions(prev => [...prev, '']);
};
const updateQuestion = (idx: number, val: string) => {
setQuestions(prev => prev.map((q, i) => (i === idx ? val : q)));
};
const removeQuestion = (idx: number) => {
setQuestions(prev => prev.filter((_, i) => i !== idx));
};
const runTests = async () => {
const valid = questions.map(q => q.trim()).filter(Boolean);
if (!valid.length) return;
setRunning(true);
setError('');
setResults([]);
setExpandedIdx(null);
try {
const data = await chatAPI.test(chatbotId, valid);
setResults(data);
setExpandedIdx(0);
} catch (e: any) {
setError(e?.response?.data?.detail ?? 'Test failed. Please try again.');
} finally {
setRunning(false);
}
};
const confidenceColor = (score: number) => {
if (score >= 0.7) return COLORS.success;
if (score >= 0.4) return COLORS.warning;
return COLORS.error;
};
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}>
<View style={[styles.card, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<Text style={[styles.cardTitle, { color: theme.text }]}>Test Questions</Text>
<Text style={[styles.cardDesc, { color: theme.textSecondary }]}>
Enter up to 10 questions to test how your chatbot responds.
</Text>
{questions.map((q, idx) => (
<View key={idx} style={styles.questionRow}>
<Text style={[styles.questionNum, { color: theme.textMuted }]}>{idx + 1}.</Text>
<TextInput
style={[styles.questionInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
value={q}
onChangeText={val => updateQuestion(idx, val)}
placeholder="Ask a question..."
placeholderTextColor={theme.placeholder}
/>
{questions.length > 1 && (
<TouchableOpacity onPress={() => removeQuestion(idx)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={styles.removeBtn}></Text>
</TouchableOpacity>
)}
</View>
))}
<View style={styles.actions}>
{questions.length < 10 && (
<TouchableOpacity onPress={addQuestion}>
<Text style={[styles.addBtn, { color: COLORS.primary }]}>+ Add question</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[
styles.runBtn,
{ backgroundColor: COLORS.primary },
(running || !questions.some(q => q.trim())) && styles.runBtnDisabled,
]}
onPress={runTests}
disabled={running || !questions.some(q => q.trim())}>
{running
? <ActivityIndicator size="small" color="#fff" />
: <Text style={styles.runBtnText}> Run Tests</Text>
}
</TouchableOpacity>
</View>
{error ? (
<Text style={[styles.error, { color: COLORS.error }]}>{error}</Text>
) : null}
</View>
{results.length > 0 && (
<>
<Text style={[styles.resultsLabel, { color: theme.textMuted }]}>
{results.length} RESULT{results.length !== 1 ? 'S' : ''}
</Text>
{results.map((r, idx) => (
<View key={idx} style={[styles.resultCard, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<TouchableOpacity
style={styles.resultHeader}
onPress={() => setExpandedIdx(expandedIdx === idx ? null : idx)}>
<View style={[styles.confBadge, { backgroundColor: confidenceColor(r.confidence_score) + '22' }]}>
<Text style={[styles.confText, { color: confidenceColor(r.confidence_score) }]}>
{Math.round(r.confidence_score * 100)}%
</Text>
</View>
<Text style={[styles.resultQuestion, { color: theme.text }]} numberOfLines={1}>{r.question}</Text>
<Text style={[styles.chevron, { color: theme.textMuted }]}>{expandedIdx === idx ? '▲' : '▼'}</Text>
</TouchableOpacity>
{expandedIdx === idx && (
<View style={[styles.resultBody, { borderTopColor: theme.border }]}>
<Text style={[styles.responseText, { color: theme.text }]}>{r.response}</Text>
{r.sources.length > 0 && (
<>
<Text style={[styles.sourcesLabel, { color: theme.textMuted }]}>SOURCES</Text>
{r.sources.map((src, si) => (
<View key={si} style={[styles.sourceChip, { backgroundColor: theme.bgSecondary, borderColor: theme.border }]}>
<Text style={[styles.sourceName, { color: theme.textSecondary }]}>
{src.document_name}
<Text style={{ color: theme.textMuted }}> · {Math.round(src.score * 100)}%</Text>
</Text>
<Text style={[styles.sourceChunk, { color: theme.textMuted }]} numberOfLines={2}>{src.chunk_text}</Text>
</View>
))}
</>
)}
<Text style={[styles.modelLabel, { color: theme.textMuted }]}>Model: {r.model_used}</Text>
</View>
)}
</View>
))}
</>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1 },
scrollContent: { padding: SPACING.lg, gap: SPACING.lg, paddingBottom: SPACING.xxxl },
card: {
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.lg,
gap: SPACING.md,
},
cardTitle: { ...TEXT.h4 },
cardDesc: { ...TEXT.small },
questionRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm },
questionNum: { width: 18, fontSize: FONT_SIZE.sm, textAlign: 'right' },
questionInput: {
flex: 1,
borderWidth: 1.5,
borderRadius: RADIUS.md,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
fontSize: FONT_SIZE.md,
},
removeBtn: { color: '#ccc', fontSize: FONT_SIZE.md, paddingHorizontal: SPACING.xs },
actions: { flexDirection: 'row', alignItems: 'center', marginTop: SPACING.xs },
addBtn: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
runBtn: {
marginLeft: 'auto',
flexDirection: 'row',
alignItems: 'center',
gap: SPACING.xs,
borderRadius: RADIUS.md,
paddingVertical: SPACING.sm,
paddingHorizontal: SPACING.lg,
minWidth: 100,
justifyContent: 'center',
},
runBtnDisabled: { opacity: 0.5 },
runBtnText: { color: COLORS.white, fontSize: FONT_SIZE.sm, fontWeight: '700' },
error: { fontSize: FONT_SIZE.sm },
resultsLabel: { fontSize: FONT_SIZE.xs, fontWeight: '700', letterSpacing: 0.8 },
resultCard: { borderRadius: RADIUS.xl, borderWidth: 1, overflow: 'hidden' },
resultHeader: { flexDirection: 'row', alignItems: 'center', padding: SPACING.md, gap: SPACING.sm },
confBadge: { borderRadius: RADIUS.sm, paddingVertical: 3, paddingHorizontal: SPACING.sm },
confText: { fontSize: FONT_SIZE.xs, fontWeight: '800' },
resultQuestion: { flex: 1, ...TEXT.smallM },
chevron: { fontSize: FONT_SIZE.sm },
resultBody: { borderTopWidth: 1, padding: SPACING.md, gap: SPACING.sm },
responseText: { ...TEXT.body, lineHeight: 22 },
sourcesLabel: { fontSize: FONT_SIZE.xs, fontWeight: '700', letterSpacing: 0.8, marginTop: SPACING.xs },
sourceChip: {
borderRadius: RADIUS.md,
borderWidth: 1,
padding: SPACING.sm,
gap: 3,
},
sourceName: { fontSize: FONT_SIZE.xs, fontWeight: '600' },
sourceChunk: { fontSize: FONT_SIZE.xs, lineHeight: 16 },
modelLabel: { fontSize: FONT_SIZE.xs, marginTop: SPACING.xs },
});

View File

@@ -0,0 +1,406 @@
import React, { useCallback, useRef, useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
Alert,
RefreshControl,
Animated,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { DashboardStackParamList } from '../../navigation/types';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { PlanBadge, Spinner, EmptyState, Button } from '../../components/ui';
import { useToast } from '../../contexts/ToastContext';
import { chatbotsAPI } from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
import { useTranslation } from '../../i18n';
import { Chatbot } from '../../types';
type Props = NativeStackScreenProps<DashboardStackParamList, 'ChatbotList'>;
function OnboardingChecklist({
chatbots,
userId,
onNavigateCreate,
}: {
chatbots: Chatbot[];
userId: string;
onNavigateCreate: () => void;
}) {
const { theme } = useTheme();
const { t } = useTranslation();
const storageKey = `onboarding_v1_${userId}`;
const [dismissed, setDismissed] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
AsyncStorage.getItem(storageKey).then(val => {
if (val === 'dismissed') setDismissed(true);
setLoaded(true);
});
}, [storageKey]);
const dismiss = async () => {
setDismissed(true);
await AsyncStorage.setItem(storageKey, 'dismissed');
};
if (!loaded || dismissed) return null;
const steps = [
{
id: 'create',
label: t.onboarding.step_create,
done: chatbots.length > 0,
action: onNavigateCreate,
},
{
id: 'docs',
label: t.onboarding.step_docs,
done: chatbots.some(c => (c.document_count ?? 0) > 0),
action: chatbots[0]
? () => {}
: onNavigateCreate,
},
{
id: 'publish',
label: t.onboarding.step_publish,
done: chatbots.some(c => c.is_published),
action: undefined,
},
];
const completedCount = steps.filter(s => s.done).length;
const allDone = completedCount === steps.length;
if (allDone) {
dismiss();
return null;
}
const progress = (completedCount / steps.length) * 100;
return (
<View style={[onboardStyles.card, { backgroundColor: theme.surface, borderColor: theme.border }]}>
<View style={onboardStyles.cardHeader}>
<Text style={[onboardStyles.title, { color: theme.text }]}>
🚀 {t.onboarding.title}
</Text>
<View style={[onboardStyles.progressLabel, { backgroundColor: COLORS.primaryUltraLight }]}>
<Text style={[onboardStyles.progressLabelText, { color: COLORS.primaryDark }]}>
{completedCount}/{steps.length}
</Text>
</View>
<TouchableOpacity onPress={dismiss} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={[onboardStyles.dismiss, { color: theme.textMuted }]}></Text>
</TouchableOpacity>
</View>
<View style={[onboardStyles.bar, { backgroundColor: theme.bgSecondary }]}>
<View style={[onboardStyles.barFill, { width: `${progress}%` as any }]} />
</View>
{steps.map((step, i) => (
<View key={step.id} style={onboardStyles.step}>
<View style={[
onboardStyles.stepDot,
{ borderColor: step.done ? COLORS.success : theme.border },
step.done && { backgroundColor: COLORS.success },
]}>
{step.done && <Text style={onboardStyles.checkmark}></Text>}
{!step.done && (
<Text style={[onboardStyles.stepNum, { color: theme.textMuted }]}>{i + 1}</Text>
)}
</View>
<Text style={[
onboardStyles.stepLabel,
{ color: step.done ? theme.textMuted : theme.text },
step.done && onboardStyles.stepLabelDone,
]}>{step.label}</Text>
</View>
))}
</View>
);
}
export function DashboardScreen({ navigation }: Props) {
const { theme, isDark } = useTheme();
const { t } = useTranslation();
const toast = useToast();
const user = useAuthStore(s => s.user);
const qc = useQueryClient();
const { data: chatbots, isLoading, refetch } = useQuery({
queryKey: ['chatbots'],
queryFn: chatbotsAPI.list,
});
const publishMutation = useMutation({
mutationFn: ({ id, published }: { id: string; published: boolean }) =>
published ? chatbotsAPI.unpublish(id) : chatbotsAPI.publish(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chatbots'] }),
onError: (err: any) => toast.error(err?.response?.data?.detail ?? 'Action failed'),
});
const deleteMutation = useMutation({
mutationFn: chatbotsAPI.delete,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['chatbots'] }); toast.success(t.dashboard.delete_title); },
onError: (err: any) => toast.error(err?.response?.data?.detail ?? t.common.error),
});
const handleDelete = useCallback((bot: Chatbot) => {
Alert.alert(
t.dashboard.delete_title,
t.dashboard.delete_confirm(bot.name),
[
{ text: t.common.cancel, style: 'cancel' },
{ text: t.common.delete, style: 'destructive', onPress: () => deleteMutation.mutate(bot.id) },
],
);
}, [deleteMutation]);
const renderItem = ({ item, index }: { item: Chatbot; index: number }) => (
<ChatbotCard
bot={item}
index={index}
onEdit={() => navigation.navigate('ChatbotBuilder', { chatbotId: item.id })}
onPreview={() => navigation.navigate('ChatPreview', { chatbotId: item.id, chatbotName: item.name })}
onTogglePublish={() => publishMutation.mutate({ id: item.id, published: item.is_published })}
onDelete={() => handleDelete(item)}
publishLoading={publishMutation.isPending}
/>
);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['top', 'bottom']}>
{/* Header */}
<View style={[styles.header, { borderBottomColor: theme.border }]}>
<View style={styles.headerLeft}>
<Text style={[styles.headerTitle, { color: theme.text }]}>{t.dashboard.title}</Text>
<View style={styles.headerMeta}>
<Text style={[styles.headerSub, { color: theme.textSecondary }]}>{user?.company_name}</Text>
<PlanBadge plan={user?.plan ?? 'free'} />
</View>
</View>
<Button title={t.dashboard.new} size="sm" onPress={() => navigation.navigate('ChatbotBuilder', {})} />
</View>
{isLoading ? (
<Spinner centered label={t.common.loading} />
) : (
<FlatList
data={chatbots ?? []}
keyExtractor={item => item.id}
renderItem={renderItem}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
user ? (
<OnboardingChecklist
chatbots={chatbots ?? []}
userId={user.id}
onNavigateCreate={() => navigation.navigate('ChatbotBuilder', {})}
/>
) : null
}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refetch} tintColor={COLORS.primary} />
}
ListEmptyComponent={
<EmptyState
icon={<Text style={{ fontSize: 52 }}>🤖</Text>}
title={t.dashboard.empty_title}
description={t.dashboard.empty_desc}
action={{ label: t.dashboard.create_first, onPress: () => navigation.navigate('ChatbotBuilder', {}) }}
/>
}
/>
)}
</SafeAreaView>
);
}
function ChatbotCard({
bot, index, onEdit, onPreview, onTogglePublish, onDelete, publishLoading,
}: {
bot: Chatbot;
index: number;
onEdit: () => void;
onPreview: () => void;
onTogglePublish: () => void;
onDelete: () => void;
publishLoading: boolean;
}) {
const { theme } = useTheme();
const { t } = useTranslation();
const scale = useRef(new Animated.Value(1)).current;
const accentColor = bot.primary_color || COLORS.primary;
return (
<Animated.View style={[styles.card, { backgroundColor: theme.surface, borderColor: theme.border, transform: [{ scale }] }, SHADOWS.sm]}>
{/* Accent strip */}
<View style={[styles.accentStrip, { backgroundColor: accentColor }]} />
<View style={styles.cardBody}>
{/* Top row */}
<View style={styles.cardTop}>
<View style={[styles.botAvatar, { backgroundColor: accentColor + '22' }]}>
<Text style={[styles.botAvatarText, { color: accentColor }]}>
{bot.name[0]?.toUpperCase()}
</Text>
</View>
<View style={styles.botInfo}>
<Text style={[styles.botName, { color: theme.text }]} numberOfLines={1}>{bot.name}</Text>
{bot.description ? (
<Text style={[styles.botDesc, { color: theme.textSecondary }]} numberOfLines={1}>
{bot.description}
</Text>
) : null}
</View>
<View style={[styles.statusDot, { backgroundColor: bot.is_published ? COLORS.success : theme.border }]} />
</View>
{/* Stats row */}
<View style={styles.statsRow}>
<Stat icon="📄" value={bot.document_count ?? 0} label={t.dashboard.docs} theme={theme} />
<Stat icon="💬" value={bot.conversation_count ?? 0} label={t.dashboard.chats} theme={theme} />
{bot.model ? (
<View style={[styles.modelPill, { backgroundColor: theme.bgSecondary }]}>
<Text style={[styles.modelText, { color: theme.textMuted }]} numberOfLines={1}>
{bot.model.split('/').pop() ?? bot.model}
</Text>
</View>
) : null}
</View>
{/* Actions */}
<View style={styles.actions}>
<Button title={t.dashboard.edit} variant="outline" size="sm" onPress={onEdit} style={styles.actionBtn} />
<Button title={t.dashboard.preview} variant="secondary" size="sm" onPress={onPreview} style={styles.actionBtn} />
<Button
title={bot.is_published ? t.dashboard.unpublish : t.dashboard.publish}
variant={bot.is_published ? 'ghost' : 'primary'}
size="sm"
loading={publishLoading}
onPress={onTogglePublish}
style={styles.actionBtn}
/>
<TouchableOpacity
onPress={onDelete}
style={styles.deleteBtn}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Text style={styles.deleteIcon}>🗑</Text>
</TouchableOpacity>
</View>
</View>
</Animated.View>
);
}
function Stat({ icon, value, label, theme }: { icon: string; value: number; label: string; theme: any }) {
return (
<View style={[statStyles.chip, { backgroundColor: theme.bgSecondary }]}>
<Text style={statStyles.icon}>{icon}</Text>
<Text style={[statStyles.value, { color: theme.text }]}>{value}</Text>
<Text style={[statStyles.label, { color: theme.textMuted }]}>{label}</Text>
</View>
);
}
const statStyles = StyleSheet.create({
chip: { flexDirection: 'row', alignItems: 'center', borderRadius: RADIUS.sm, paddingVertical: 5, paddingHorizontal: SPACING.sm, gap: 3 },
icon: { fontSize: 12 },
value: { ...TEXT.smallM },
label: { ...TEXT.caption },
});
const styles = StyleSheet.create({
safe: { flex: 1 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
},
headerLeft: { flex: 1, marginRight: SPACING.md },
headerTitle: { ...TEXT.h3 },
headerMeta: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, marginTop: 3 },
headerSub: { ...TEXT.small },
list: { padding: SPACING.lg, gap: SPACING.md, flexGrow: 1, paddingBottom: SPACING.xxxl },
card: {
borderRadius: RADIUS.xl,
borderWidth: 1,
overflow: 'hidden',
flexDirection: 'row',
},
accentStrip: { width: 4 },
cardBody: { flex: 1, padding: SPACING.lg, gap: SPACING.md },
cardTop: { flexDirection: 'row', alignItems: 'center', gap: SPACING.md },
botAvatar: { width: 42, height: 42, borderRadius: RADIUS.md, alignItems: 'center', justifyContent: 'center' },
botAvatarText: { fontSize: FONT_SIZE.lg, fontWeight: '700' },
botInfo: { flex: 1 },
botName: { ...TEXT.h4 },
botDesc: { ...TEXT.small, marginTop: 2 },
statusDot: { width: 9, height: 9, borderRadius: RADIUS.full },
statsRow: { flexDirection: 'row', gap: SPACING.sm, flexWrap: 'wrap', alignItems: 'center' },
modelPill: { borderRadius: RADIUS.sm, paddingVertical: 5, paddingHorizontal: SPACING.sm },
modelText: { fontSize: FONT_SIZE.xs },
actions: { flexDirection: 'row', gap: SPACING.sm, alignItems: 'center', flexWrap: 'wrap' },
actionBtn: {},
deleteBtn: { marginLeft: 'auto', padding: SPACING.xs },
deleteIcon: { fontSize: 17 },
});
const onboardStyles = StyleSheet.create({
card: {
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.lg,
marginBottom: SPACING.md,
gap: SPACING.md,
},
cardHeader: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm },
title: { flex: 1, fontSize: FONT_SIZE.md, fontWeight: '700' },
progressLabel: {
borderRadius: RADIUS.full,
paddingVertical: 2,
paddingHorizontal: SPACING.sm,
},
progressLabelText: { fontSize: FONT_SIZE.xs, fontWeight: '700' },
dismiss: { fontSize: FONT_SIZE.md, paddingHorizontal: SPACING.xs },
bar: { height: 6, borderRadius: RADIUS.full, overflow: 'hidden' },
barFill: {
height: '100%',
borderRadius: RADIUS.full,
backgroundColor: COLORS.primary,
},
step: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm },
stepDot: {
width: 22,
height: 22,
borderRadius: RADIUS.full,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
checkmark: { color: COLORS.white, fontSize: 12, fontWeight: '800' },
stepNum: { fontSize: FONT_SIZE.xs, fontWeight: '700' },
stepLabel: { flex: 1, fontSize: FONT_SIZE.sm, fontWeight: '500' },
stepLabelDone: { textDecorationLine: 'line-through' },
});

View File

@@ -0,0 +1,157 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { InboxStackParamList } from '../../navigation/types';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT } from '../../theme';
import { useTheme } from '../../theme';
import { Spinner, EmptyState, Button } from '../../components/ui';
import { inboxAPI } from '../../services/api';
import { Message } from '../../types';
import { useToast } from '../../contexts/ToastContext';
import { useTranslation } from '../../i18n';
type Props = NativeStackScreenProps<InboxStackParamList, 'Conversation'>;
export function ConversationScreen({ route, navigation }: Props) {
const { conversationId } = route.params;
const { theme } = useTheme();
const { t } = useTranslation();
const toast = useToast();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['conversation', conversationId],
queryFn: () => inboxAPI.getConversation(conversationId),
});
const deleteMutation = useMutation({
mutationFn: () => inboxAPI.deleteConversation(conversationId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['inbox'] });
toast.success(t.inbox.delete);
navigation.goBack();
},
});
const handleDelete = () => {
Alert.alert(t.inbox.delete, t.inbox.delete_confirm, [
{ text: t.common.cancel, style: 'cancel' },
{ text: t.common.delete, style: 'destructive', onPress: () => deleteMutation.mutate() },
]);
};
const messages: Message[] = data?.messages ?? [];
const renderMessage = ({ item }: { item: Message }) => {
const isUser = item.role === 'user';
return (
<View style={[styles.msgRow, isUser && styles.msgRowUser]}>
{!isUser && (
<View style={[styles.botAvatar, { backgroundColor: COLORS.primaryUltraLight }]}>
<Text style={[styles.botAvatarText, { color: COLORS.primaryDark }]}>AI</Text>
</View>
)}
<View style={[
styles.bubble,
isUser
? [styles.bubbleUser, { backgroundColor: COLORS.primary }]
: [styles.bubbleBot, { backgroundColor: theme.surface, borderColor: theme.border }],
]}>
<Text style={[styles.bubbleText, { color: isUser ? '#fff' : theme.text }]}>
{item.content}
</Text>
{!isUser && item.confidence_score != null && (
<Text style={[styles.confidence, { color: theme.textMuted }]}>
Confidence: {(item.confidence_score * 100).toFixed(0)}%
</Text>
)}
{item.is_handoff && (
<Text style={styles.handoffTag}> Handoff requested</Text>
)}
{item.created_at ? (
<Text style={[styles.timestamp, { color: isUser ? 'rgba(255,255,255,0.6)' : theme.textMuted }]}>
{new Date(item.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
) : null}
</View>
</View>
);
};
if (isLoading) return <Spinner centered />;
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<View style={[styles.header, { borderBottomColor: theme.border, backgroundColor: theme.surface }]}>
<View>
<Text style={[styles.headerTitle, { color: theme.text }]}>
{data?.chatbot_name ?? 'Conversation'}
</Text>
<Text style={[styles.headerSub, { color: theme.textSecondary }]}>
{messages.length} {t.inbox.filter_all.toLowerCase()}
</Text>
</View>
<Button
title={t.inbox.delete}
variant="danger"
size="sm"
onPress={handleDelete}
loading={deleteMutation.isPending}
/>
</View>
<FlatList
data={messages}
keyExtractor={item => item.id}
renderItem={renderMessage}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<EmptyState
title={t.inbox.no_messages}
description={t.inbox.empty_desc}
/>
}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
},
headerTitle: { ...TEXT.h4 },
headerSub: { ...TEXT.small },
list: { padding: SPACING.md, gap: SPACING.md, paddingBottom: SPACING.xl },
msgRow: { flexDirection: 'row', gap: SPACING.sm, alignItems: 'flex-end' },
msgRowUser: { flexDirection: 'row-reverse' },
botAvatar: { width: 30, height: 30, borderRadius: RADIUS.full, alignItems: 'center', justifyContent: 'center' },
botAvatarText: { fontSize: 10, fontWeight: '700' },
bubble: { maxWidth: '75%', borderRadius: RADIUS.xl, padding: SPACING.md, gap: 4 },
bubbleUser: { borderBottomRightRadius: RADIUS.sm },
bubbleBot: { borderWidth: 1, borderBottomLeftRadius: RADIUS.sm },
bubbleText: { fontSize: FONT_SIZE.md, lineHeight: 22 },
confidence: { fontSize: FONT_SIZE.xs },
handoffTag: { fontSize: FONT_SIZE.xs, color: COLORS.warning, fontWeight: '600' },
timestamp: { fontSize: 10 },
});

View File

@@ -0,0 +1,264 @@
import React, { useRef, useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
RefreshControl,
Animated,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery } from '@tanstack/react-query';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { InboxStackParamList } from '../../navigation/types';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { Spinner, EmptyState } from '../../components/ui';
import { inboxAPI } from '../../services/api';
import { useTranslation } from '../../i18n';
import { Conversation } from '../../types';
type Props = NativeStackScreenProps<InboxStackParamList, 'InboxList'>;
type StatusFilter = 'all' | 'open' | 'agent_handling' | 'resolved';
const STATUS_COLORS: Record<string, string> = {
open: COLORS.success,
agent_handling: COLORS.warning,
resolved: COLORS.info,
};
export function InboxScreen({ navigation }: Props) {
const { theme } = useTheme();
const { t } = useTranslation();
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'all', label: t.inbox.filter_all },
{ key: 'open', label: t.inbox.filter_open },
{ key: 'agent_handling', label: t.inbox.filter_handling },
{ key: 'resolved', label: t.inbox.filter_resolved },
];
const { data, isLoading, refetch } = useQuery({
queryKey: ['inbox', statusFilter],
queryFn: () =>
inboxAPI.listConversations({
limit: 50,
...(statusFilter !== 'all' ? { status: statusFilter } : {}),
}),
});
const conversations: Conversation[] = data?.conversations ?? data?.items ?? data ?? [];
const formatTime = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const diffH = (now.getTime() - d.getTime()) / 3600000;
if (diffH < 24) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (diffH < 48) return 'Yesterday';
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
const renderItem = ({ item }: { item: Conversation }) => (
<ConversationRow
item={item}
time={formatTime(item.created_at)}
onPress={() => navigation.navigate('Conversation', {
conversationId: item.id,
chatbotName: item.chatbot_name,
})}
theme={theme}
/>
);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<View style={[styles.header, { borderBottomColor: theme.border }]}>
<Text style={[styles.headerTitle, { color: theme.text }]}>{t.inbox.title}</Text>
{conversations.length > 0 && (
<View style={[styles.countBadge, { backgroundColor: COLORS.primaryUltraLight }]}>
<Text style={[styles.countBadgeText, { color: COLORS.primaryDark }]}>{conversations.length}</Text>
</View>
)}
</View>
{/* Status filter tabs */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={[styles.filterBar, { borderBottomColor: theme.border }]}
contentContainerStyle={styles.filterBarContent}>
{FILTERS.map(f => (
<TouchableOpacity
key={f.key}
style={[
styles.filterTab,
statusFilter === f.key && { borderBottomColor: COLORS.primary, borderBottomWidth: 2 },
]}
onPress={() => setStatusFilter(f.key)}>
<Text style={[
styles.filterTabText,
{ color: statusFilter === f.key ? COLORS.primary : theme.textSecondary },
statusFilter === f.key && { fontWeight: '700' },
]}>
{f.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{isLoading ? (
<Spinner centered label={t.common.loading} />
) : (
<FlatList
data={conversations}
keyExtractor={item => item.id}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refetch} tintColor={COLORS.primary} />
}
ListEmptyComponent={
<EmptyState
icon={<Text style={{ fontSize: 52 }}></Text>}
title={t.inbox.empty_title}
description={statusFilter === 'all' ? t.inbox.empty_desc : t.inbox.empty_filtered(statusFilter.replace('_', ' '))}
/>
}
/>
)}
</SafeAreaView>
);
}
function ConversationRow({
item,
time,
onPress,
theme,
}: {
item: Conversation;
time: string;
onPress: () => void;
theme: any;
}) {
const { t } = useTranslation();
const scale = useRef(new Animated.Value(1)).current;
const statusColor = STATUS_COLORS[(item as any).status] ?? theme.border;
const onPressIn = () => Animated.spring(scale, { toValue: 0.98, useNativeDriver: true, speed: 60 }).start();
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 30, bounciness: 4 }).start();
return (
<TouchableOpacity
onPress={onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
activeOpacity={1}>
<Animated.View
style={[
styles.item,
{ backgroundColor: theme.surface, borderBottomColor: theme.border, transform: [{ scale }] },
]}>
<View style={[styles.avatar, { backgroundColor: COLORS.primaryUltraLight }]}>
<Text style={[styles.avatarText, { color: COLORS.primaryDark }]}>
{(item.chatbot_name ?? 'C')[0].toUpperCase()}
</Text>
</View>
<View style={styles.itemContent}>
<View style={styles.itemHeader}>
<Text style={[styles.chatbotName, { color: theme.text }]} numberOfLines={1}>
{item.chatbot_name ?? 'Conversation'}
</Text>
<Text style={[styles.time, { color: theme.textMuted }]}>{time}</Text>
</View>
<View style={styles.itemFooter}>
<Text style={[styles.preview, { color: theme.textSecondary }]} numberOfLines={1}>
{item.last_message ?? t.inbox.no_messages}
</Text>
{(item as any).status && (item as any).status !== 'open' && (
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
)}
{(item.message_count ?? 0) > 0 && (
<View style={[styles.msgCount, { backgroundColor: COLORS.primary }]}>
<Text style={styles.msgCountText}>{item.message_count}</Text>
</View>
)}
</View>
</View>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
gap: SPACING.sm,
},
headerTitle: { ...TEXT.h3 },
countBadge: {
borderRadius: RADIUS.full,
paddingVertical: 3,
paddingHorizontal: SPACING.sm,
minWidth: 28,
alignItems: 'center',
},
countBadgeText: { fontSize: FONT_SIZE.xs, fontWeight: '700' },
filterBar: { borderBottomWidth: 1, flexGrow: 0 },
filterBarContent: { paddingHorizontal: SPACING.lg },
filterTab: {
paddingVertical: SPACING.sm,
paddingHorizontal: SPACING.md,
marginRight: SPACING.xs,
},
filterTabText: { fontSize: FONT_SIZE.sm, fontWeight: '500' },
item: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
gap: SPACING.md,
},
avatar: {
width: 50,
height: 50,
borderRadius: RADIUS.full,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
avatarText: { fontSize: FONT_SIZE.lg, fontWeight: '800' },
itemContent: { flex: 1 },
itemHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 },
chatbotName: { ...TEXT.bodyM, flex: 1, marginRight: SPACING.sm },
time: { fontSize: FONT_SIZE.xs },
itemFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: SPACING.xs },
preview: { ...TEXT.small, flex: 1 },
statusDot: { width: 8, height: 8, borderRadius: RADIUS.full, flexShrink: 0 },
msgCount: {
borderRadius: RADIUS.full,
minWidth: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 6,
},
msgCountText: { color: COLORS.white, fontSize: 11, fontWeight: '700' },
});

View File

@@ -0,0 +1,432 @@
import React, { useRef, useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
TouchableOpacity,
Animated,
Share,
Alert,
Modal,
TextInput,
ScrollView,
ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { Spinner, EmptyState } from '../../components/ui';
import { leadsAPI } from '../../services/api';
import { useTranslation } from '../../i18n';
import { Lead } from '../../types';
type LeadStatus = 'new' | 'contacted' | 'qualified' | 'closed' | 'lost';
const STATUS_COLORS: Record<LeadStatus, string> = {
new: COLORS.info,
contacted: COLORS.warning,
qualified: COLORS.purple,
closed: COLORS.success,
lost: COLORS.error,
};
function buildCSV(leads: Lead[]): string {
const header = 'Name,Email,Phone,Company,Chatbot,Status,Date';
const rows = leads.map(l => [
l.name ?? '',
l.email ?? '',
l.phone ?? '',
l.company ?? '',
l.chatbot_name ?? '',
(l as any).status ?? '',
l.created_at ? new Date(l.created_at).toLocaleDateString() : '',
].map(v => `"${v.replace(/"/g, '""')}"`).join(','));
return [header, ...rows].join('\n');
}
export function LeadsScreen() {
const { theme } = useTheme();
const { t } = useTranslation();
const qc = useQueryClient();
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
const STATUS_OPTIONS: { key: LeadStatus; label: string; color: string }[] = [
{ key: 'new', label: t.leads.status_new, color: COLORS.info },
{ key: 'contacted', label: t.leads.status_contacted, color: COLORS.warning },
{ key: 'qualified', label: t.leads.status_qualified, color: COLORS.purple },
{ key: 'closed', label: t.leads.status_closed, color: COLORS.success },
{ key: 'lost', label: t.leads.status_lost, color: COLORS.error },
];
const { data, isLoading, refetch } = useQuery({
queryKey: ['leads'],
queryFn: () => leadsAPI.list({ limit: 200 }),
});
const leads: Lead[] = data?.leads ?? data?.items ?? data ?? [];
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, any> }) =>
leadsAPI.update(id, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['leads'] });
setSelectedLead(null);
},
});
const handleExport = async () => {
if (leads.length === 0) {
Alert.alert(t.leads.title, t.leads.no_export);
return;
}
const csv = buildCSV(leads);
await Share.share({ message: csv, title: 'Leads export' });
};
const renderItem = ({ item }: { item: Lead }) => (
<LeadCard item={item} theme={theme} onPress={() => setSelectedLead(item)} />
);
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<View style={[styles.header, { borderBottomColor: theme.border }]}>
<View>
<Text style={[styles.headerTitle, { color: theme.text }]}>{t.leads.title}</Text>
<Text style={[styles.headerSub, { color: theme.textSecondary }]}>
{t.leads.subtitle(leads.length)}
</Text>
</View>
<TouchableOpacity
style={[styles.exportBtn, { backgroundColor: COLORS.primaryUltraLight }]}
onPress={handleExport}>
<Text style={[styles.exportBtnText, { color: COLORS.primaryDark }]}>{t.leads.export}</Text>
</TouchableOpacity>
</View>
{isLoading ? (
<Spinner centered label={t.common.loading} />
) : (
<FlatList
data={leads}
keyExtractor={item => item.id}
renderItem={renderItem}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refetch} tintColor={COLORS.primary} />
}
ListEmptyComponent={
<EmptyState
icon={<Text style={{ fontSize: 52 }}>👥</Text>}
title={t.leads.empty_title}
description={t.leads.empty_desc}
/>
}
/>
)}
{/* Lead detail / edit modal */}
{selectedLead && (
<LeadDetailModal
lead={selectedLead}
theme={theme}
saving={updateMutation.isPending}
statusOptions={STATUS_OPTIONS}
onClose={() => setSelectedLead(null)}
onSave={(status, notes) =>
updateMutation.mutate({ id: selectedLead.id, data: { status, notes } })
}
/>
)}
</SafeAreaView>
);
}
function LeadDetailModal({
lead,
theme,
saving,
statusOptions,
onClose,
onSave,
}: {
lead: Lead;
theme: any;
saving: boolean;
statusOptions: { key: LeadStatus; label: string; color: string }[];
onClose: () => void;
onSave: (status: string, notes: string) => void;
}) {
const { t } = useTranslation();
const [status, setStatus] = useState<LeadStatus>((lead as any).status ?? 'new');
const [notes, setNotes] = useState<string>((lead as any).notes ?? '');
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<SafeAreaView style={[modalStyles.safe, { backgroundColor: theme.bg }]}>
<View style={[modalStyles.header, { borderBottomColor: theme.border }]}>
<TouchableOpacity onPress={onClose}>
<Text style={{ color: COLORS.primary, fontSize: FONT_SIZE.md }}>{t.leads.cancel}</Text>
</TouchableOpacity>
<Text style={[modalStyles.title, { color: theme.text }]}>{t.leads.detail_title}</Text>
<TouchableOpacity onPress={() => onSave(status, notes)} disabled={saving}>
{saving
? <ActivityIndicator size="small" color={COLORS.primary} />
: <Text style={{ color: COLORS.primary, fontSize: FONT_SIZE.md, fontWeight: '700' }}>{t.leads.save}</Text>}
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={modalStyles.scroll}>
{/* Info */}
<View style={[modalStyles.section, { backgroundColor: theme.surface, borderColor: theme.border }]}>
<Text style={[modalStyles.sectionTitle, { color: theme.text }]}>{t.leads.contact_info}</Text>
{lead.name ? <InfoRow label={t.leads.field_name} value={lead.name} theme={theme} /> : null}
{lead.email ? <InfoRow label={t.leads.field_email} value={lead.email} theme={theme} /> : null}
{lead.phone ? <InfoRow label={t.leads.field_phone} value={lead.phone} theme={theme} /> : null}
{lead.company ? <InfoRow label={t.leads.field_company} value={lead.company} theme={theme} /> : null}
{lead.chatbot_name ? <InfoRow label={t.leads.field_chatbot} value={lead.chatbot_name} theme={theme} /> : null}
{lead.created_at ? (
<InfoRow label={t.leads.field_date} value={new Date(lead.created_at).toLocaleDateString()} theme={theme} />
) : null}
</View>
{/* Status */}
<View style={[modalStyles.section, { backgroundColor: theme.surface, borderColor: theme.border }]}>
<Text style={[modalStyles.sectionTitle, { color: theme.text }]}>{t.leads.status_section}</Text>
<View style={modalStyles.statusGrid}>
{statusOptions.map(opt => (
<TouchableOpacity
key={opt.key}
style={[
modalStyles.statusPill,
{ borderColor: status === opt.key ? opt.color : theme.border },
status === opt.key && { backgroundColor: opt.color + '18' },
]}
onPress={() => setStatus(opt.key)}>
<Text style={[modalStyles.statusLabel, { color: status === opt.key ? opt.color : theme.textSecondary }]}>
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Notes */}
<View style={[modalStyles.section, { backgroundColor: theme.surface, borderColor: theme.border }]}>
<Text style={[modalStyles.sectionTitle, { color: theme.text }]}>{t.leads.notes_section}</Text>
<TextInput
style={[modalStyles.notesInput, { backgroundColor: theme.inputBg, borderColor: theme.border, color: theme.text }]}
value={notes}
onChangeText={setNotes}
placeholder={t.leads.notes_placeholder}
placeholderTextColor={theme.placeholder}
multiline
numberOfLines={5}
textAlignVertical="top"
/>
</View>
</ScrollView>
</SafeAreaView>
</Modal>
);
}
function InfoRow({ label, value, theme }: { label: string; value: string; theme: any }) {
return (
<View style={infoStyles.row}>
<Text style={[infoStyles.label, { color: theme.textMuted }]}>{label}</Text>
<Text style={[infoStyles.value, { color: theme.text }]} selectable>{value}</Text>
</View>
);
}
function LeadCard({ item, theme, onPress }: { item: Lead; theme: any; onPress: () => void }) {
const { t } = useTranslation();
const scale = useRef(new Animated.Value(1)).current;
const onPressIn = () => Animated.spring(scale, { toValue: 0.98, useNativeDriver: true, speed: 60 }).start();
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 30, bounciness: 4 }).start();
const initials = (item.name ?? item.email ?? '?')[0].toUpperCase();
const colors = ['#6366f1', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444'];
const colorIdx = initials.charCodeAt(0) % colors.length;
const avatarColor = colors[colorIdx];
const itemStatus = (item as any).status as LeadStatus | undefined;
const statusLabels: Record<LeadStatus, string> = {
new: t.leads.status_new,
contacted: t.leads.status_contacted,
qualified: t.leads.status_qualified,
closed: t.leads.status_closed,
lost: t.leads.status_lost,
};
const statusColor = itemStatus ? STATUS_COLORS[itemStatus] : undefined;
const statusLabel = itemStatus ? statusLabels[itemStatus] : undefined;
return (
<TouchableOpacity onPress={onPress} onPressIn={onPressIn} onPressOut={onPressOut} activeOpacity={1}>
<Animated.View
style={[
styles.card,
{ backgroundColor: theme.surface, borderColor: theme.border, transform: [{ scale }] },
SHADOWS.sm,
]}>
<View style={styles.cardTop}>
<View style={[styles.avatar, { backgroundColor: avatarColor }]}>
<Text style={styles.avatarText}>{initials}</Text>
</View>
<View style={styles.info}>
{item.name ? (
<Text style={[styles.name, { color: theme.text }]}>{item.name}</Text>
) : null}
{item.email ? (
<Text style={[styles.email, { color: theme.textSecondary }]} selectable>{item.email}</Text>
) : null}
{item.company ? (
<Text style={[styles.company, { color: theme.textMuted }]}>{item.company}</Text>
) : null}
</View>
<View style={styles.rightCol}>
{item.created_at ? (
<Text style={[styles.date, { color: theme.textMuted }]}>
{new Date(item.created_at).toLocaleDateString([], { month: 'short', day: 'numeric' })}
</Text>
) : null}
{statusColor && statusLabel && (
<View style={[styles.statusBadge, { backgroundColor: statusColor + '22' }]}>
<Text style={[styles.statusText, { color: statusColor }]}>{statusLabel}</Text>
</View>
)}
</View>
</View>
{(item.phone || item.chatbot_name) && (
<View style={styles.chips}>
{item.phone ? (
<MetaChip icon="📞" value={item.phone} theme={theme} />
) : null}
{item.chatbot_name ? (
<MetaChip icon="🤖" value={item.chatbot_name} theme={theme} />
) : null}
</View>
)}
</Animated.View>
</TouchableOpacity>
);
}
function MetaChip({ icon, value, theme }: { icon: string; value: string; theme: any }) {
return (
<View style={[chipStyles.chip, { backgroundColor: theme.bgSecondary, borderColor: theme.border }]}>
<Text style={chipStyles.icon}>{icon}</Text>
<Text style={[chipStyles.value, { color: theme.textSecondary }]}>{value}</Text>
</View>
);
}
const chipStyles = StyleSheet.create({
chip: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: RADIUS.full,
borderWidth: 1,
paddingVertical: 4,
paddingHorizontal: SPACING.sm,
gap: 4,
},
icon: { fontSize: 12 },
value: { fontSize: FONT_SIZE.xs },
});
const infoStyles = StyleSheet.create({
row: { flexDirection: 'row', paddingVertical: SPACING.xs, gap: SPACING.md },
label: { width: 70, fontSize: FONT_SIZE.sm },
value: { flex: 1, fontSize: FONT_SIZE.sm, fontWeight: '500' },
});
const modalStyles = StyleSheet.create({
safe: { flex: 1 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: SPACING.lg,
borderBottomWidth: 1,
},
title: { fontSize: FONT_SIZE.md, fontWeight: '700' },
scroll: { padding: SPACING.lg, gap: SPACING.md, paddingBottom: SPACING.xxxl },
section: {
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.lg,
gap: SPACING.sm,
},
sectionTitle: { fontSize: FONT_SIZE.md, fontWeight: '700', marginBottom: SPACING.xs },
statusGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING.sm },
statusPill: {
borderWidth: 1.5,
borderRadius: RADIUS.full,
paddingVertical: SPACING.xs,
paddingHorizontal: SPACING.md,
},
statusLabel: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
notesInput: {
borderWidth: 1.5,
borderRadius: RADIUS.md,
padding: SPACING.md,
fontSize: FONT_SIZE.md,
minHeight: 110,
},
});
const styles = StyleSheet.create({
safe: { flex: 1 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: SPACING.lg,
paddingVertical: SPACING.md,
borderBottomWidth: 1,
},
headerTitle: { ...TEXT.h3 },
headerSub: { ...TEXT.small, marginTop: 2 },
exportBtn: {
borderRadius: RADIUS.full,
paddingVertical: 7,
paddingHorizontal: SPACING.md,
},
exportBtnText: { fontSize: FONT_SIZE.sm, fontWeight: '700' },
list: { padding: SPACING.lg, gap: SPACING.md, flexGrow: 1, paddingBottom: SPACING.xxxl },
card: {
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.lg,
gap: SPACING.md,
},
cardTop: { flexDirection: 'row', alignItems: 'flex-start', gap: SPACING.md },
avatar: {
width: 46,
height: 46,
borderRadius: RADIUS.full,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
avatarText: { color: COLORS.white, fontSize: FONT_SIZE.md, fontWeight: '800' },
info: { flex: 1 },
name: { ...TEXT.bodyM },
email: { ...TEXT.small, marginTop: 2 },
company: { fontSize: FONT_SIZE.xs, marginTop: 2 },
rightCol: { alignItems: 'flex-end', gap: 4 },
date: { fontSize: FONT_SIZE.xs },
statusBadge: { borderRadius: RADIUS.full, paddingVertical: 2, paddingHorizontal: SPACING.sm },
statusText: { fontSize: FONT_SIZE.xs, fontWeight: '700' },
chips: { flexDirection: 'row', gap: SPACING.sm, flexWrap: 'wrap' },
});

View File

@@ -0,0 +1,137 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery } from '@tanstack/react-query';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { MarketplaceStackParamList } from '../../navigation/types';
import { COLORS, FONT_SIZE, SPACING, RADIUS } from '../../theme';
import { useTheme } from '../../theme';
import { Card, Badge, Spinner, Button } from '../../components/ui';
import { marketplaceAPI } from '../../services/api';
type Props = NativeStackScreenProps<MarketplaceStackParamList, 'MarketplaceDetail'>;
export function ChatbotDetailScreen({ route, navigation }: Props) {
const { chatbotId } = route.params;
const { theme } = useTheme();
const { data: bot, isLoading } = useQuery({
queryKey: ['marketplace-bot', chatbotId],
queryFn: () => marketplaceAPI.get(chatbotId),
});
if (isLoading) return <Spinner centered />;
if (!bot) return null;
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<ScrollView contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false}>
{/* Hero card */}
<Card style={styles.hero}>
<View style={[styles.heroAvatar, { backgroundColor: bot.primary_color || COLORS.primary }]}>
<Text style={styles.heroAvatarText}>{bot.name[0]?.toUpperCase()}</Text>
</View>
<Text style={[styles.heroName, { color: theme.text }]}>{bot.name}</Text>
{bot.company_name ? (
<Text style={[styles.heroCompany, { color: theme.textSecondary }]}>by {bot.company_name}</Text>
) : null}
<View style={styles.heroStats}>
<StatItem icon="★" value={bot.average_rating?.toFixed(1) ?? ''} label="Rating" />
<StatItem icon="💬" value={String(bot.conversation_count ?? 0)} label="Chats" />
{bot.category ? <StatItem icon="📌" value={bot.category} label="Category" /> : null}
</View>
<Button
title="Start Chatting"
onPress={() => navigation.navigate('PublicChat', { chatbotId: bot.id, chatbotName: bot.name })}
fullWidth
size="lg"
style={styles.chatBtn}
/>
</Card>
{/* Description */}
{bot.description ? (
<Card style={styles.section}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>About</Text>
<Text style={[styles.sectionText, { color: theme.textSecondary }]}>{bot.description}</Text>
</Card>
) : null}
{/* Details */}
<Card style={styles.section}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>Details</Text>
{bot.industry ? <DetailRow label="Industry" value={bot.industry} theme={theme} /> : null}
{bot.category ? <DetailRow label="Category" value={bot.category} theme={theme} /> : null}
{(bot.languages ?? []).length > 0 ? (
<View style={styles.detailRow}>
<Text style={[styles.detailLabel, { color: theme.textSecondary }]}>Languages</Text>
<View style={styles.languagesRow}>
{bot.languages.map((lang: string) => (
<Badge key={lang} label={lang} variant="info" />
))}
</View>
</View>
) : null}
</Card>
</ScrollView>
</SafeAreaView>
);
}
function StatItem({ icon, value, label }: { icon: string; value: string; label: string }) {
const { theme } = useTheme();
return (
<View style={statStyles.item}>
<Text style={statStyles.icon}>{icon}</Text>
<Text style={[statStyles.value, { color: theme.text }]}>{value}</Text>
<Text style={[statStyles.label, { color: theme.textMuted }]}>{label}</Text>
</View>
);
}
function DetailRow({ label, value, theme }: { label: string; value: string; theme: any }) {
return (
<View style={styles.detailRow}>
<Text style={[styles.detailLabel, { color: theme.textSecondary }]}>{label}</Text>
<Text style={[styles.detailValue, { color: theme.text }]}>{value}</Text>
</View>
);
}
const statStyles = StyleSheet.create({
item: { alignItems: 'center', flex: 1, gap: 2 },
icon: { fontSize: 20 },
value: { fontSize: FONT_SIZE.md, fontWeight: '700' },
label: { fontSize: FONT_SIZE.xs },
});
const styles = StyleSheet.create({
safe: { flex: 1 },
scroll: { padding: SPACING.lg, gap: SPACING.lg },
hero: { alignItems: 'center', gap: SPACING.sm },
heroAvatar: { width: 72, height: 72, borderRadius: RADIUS.xl, alignItems: 'center', justifyContent: 'center' },
heroAvatarText: { color: '#fff', fontSize: 32, fontWeight: '700' },
heroName: { fontSize: FONT_SIZE.xxl, fontWeight: '700', textAlign: 'center' },
heroCompany: { fontSize: FONT_SIZE.sm },
heroStats: { flexDirection: 'row', width: '100%', paddingVertical: SPACING.md },
chatBtn: { marginTop: SPACING.sm },
section: { gap: SPACING.md },
sectionTitle: { fontSize: FONT_SIZE.lg, fontWeight: '600' },
sectionText: { fontSize: FONT_SIZE.md, lineHeight: 22 },
detailRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: SPACING.xs },
detailLabel: { fontSize: FONT_SIZE.sm },
detailValue: { fontSize: FONT_SIZE.sm, fontWeight: '500' },
languagesRow: { flexDirection: 'row', gap: SPACING.xs, flexWrap: 'wrap' },
});

View File

@@ -0,0 +1,225 @@
import React, { useState, useRef } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TextInput,
TouchableOpacity,
RefreshControl,
Animated,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery } from '@tanstack/react-query';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { MarketplaceStackParamList } from '../../navigation/types';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { Spinner, EmptyState } from '../../components/ui';
import { marketplaceAPI } from '../../services/api';
import { MarketplaceChatbot } from '../../types';
type Props = NativeStackScreenProps<MarketplaceStackParamList, 'MarketplaceList'>;
export function MarketplaceScreen({ navigation }: Props) {
const { theme, isDark } = useTheme();
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleSearchChange = (text: string) => {
setSearch(text);
if (searchTimer.current) clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => setDebouncedSearch(text), 400);
};
const { data, isLoading, refetch } = useQuery({
queryKey: ['marketplace', debouncedSearch],
queryFn: () => marketplaceAPI.list({ search: debouncedSearch || undefined, limit: 20 }),
});
const chatbots: MarketplaceChatbot[] = data?.chatbots ?? data?.items ?? data ?? [];
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<Text style={[styles.headerTitle, { color: theme.text }]}>Explore</Text>
<Text style={[styles.headerSub, { color: theme.textSecondary }]}>
Discover AI chatbots built by the community
</Text>
</View>
{/* Search */}
<View style={[styles.searchContainer, { backgroundColor: theme.inputBg, borderColor: theme.border }]}>
<Text style={styles.searchIcon}>🔍</Text>
<TextInput
style={[styles.searchInput, { color: theme.text }]}
value={search}
onChangeText={handleSearchChange}
placeholder="Search chatbots..."
placeholderTextColor={theme.placeholder}
autoCorrect={false}
/>
{search.length > 0 && (
<TouchableOpacity onPress={() => { setSearch(''); setDebouncedSearch(''); }}>
<View style={[styles.clearBtn, { backgroundColor: theme.bgSecondary }]}>
<Text style={{ fontSize: 10, color: theme.textMuted }}></Text>
</View>
</TouchableOpacity>
)}
</View>
{isLoading ? (
<Spinner centered label="Loading marketplace..." />
) : (
<FlatList
data={chatbots}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<BotCard item={item} onPress={() => navigation.navigate('MarketplaceDetail', { chatbotId: item.id })} />
)}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refetch} tintColor={COLORS.primary} />
}
ListEmptyComponent={
<EmptyState
icon={<Text style={{ fontSize: 52 }}>🧭</Text>}
title={debouncedSearch ? 'No results found' : 'Marketplace is empty'}
description={debouncedSearch ? 'Try a different search term.' : 'No chatbots published yet.'}
/>
}
/>
)}
</SafeAreaView>
);
}
function BotCard({ item, onPress }: { item: MarketplaceChatbot; onPress: () => void }) {
const { theme } = useTheme();
const scale = useRef(new Animated.Value(1)).current;
const accentColor = item.primary_color || COLORS.primary;
const onPressIn = () => Animated.spring(scale, { toValue: 0.97, useNativeDriver: true, speed: 60 }).start();
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 30, bounciness: 4 }).start();
return (
<TouchableOpacity
onPress={onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
activeOpacity={1}>
<Animated.View
style={[
styles.card,
{ backgroundColor: theme.surface, borderColor: theme.border, transform: [{ scale }] },
SHADOWS.sm,
]}>
{/* Top */}
<View style={styles.cardTop}>
<View style={[styles.botAvatar, { backgroundColor: accentColor }]}>
<Text style={styles.botAvatarText}>{item.name[0]?.toUpperCase()}</Text>
</View>
<View style={styles.botMeta}>
<Text style={[styles.botName, { color: theme.text }]} numberOfLines={1}>{item.name}</Text>
{item.company_name ? (
<Text style={[styles.companyName, { color: theme.textMuted }]}>by {item.company_name}</Text>
) : null}
</View>
<View style={styles.ratingPill}>
<Text style={styles.ratingStar}></Text>
<Text style={styles.ratingText}>{item.average_rating?.toFixed(1) ?? ''}</Text>
</View>
</View>
{/* Description */}
{item.description ? (
<Text style={[styles.desc, { color: theme.textSecondary }]} numberOfLines={2}>
{item.description}
</Text>
) : null}
{/* Footer chips */}
<View style={styles.chips}>
{item.category ? (
<View style={[styles.chip, { backgroundColor: COLORS.primaryUltraLight }]}>
<Text style={[styles.chipText, { color: COLORS.primaryDark }]}>{item.category}</Text>
</View>
) : null}
{(item.conversation_count ?? 0) > 0 ? (
<View style={[styles.chip, { backgroundColor: theme.bgSecondary }]}>
<Text style={[styles.chipText, { color: theme.textSecondary }]}>
💬 {item.conversation_count?.toLocaleString()} chats
</Text>
</View>
) : null}
{(item.languages ?? []).length > 0 ? (
<View style={[styles.chip, { backgroundColor: theme.bgSecondary }]}>
<Text style={[styles.chipText, { color: theme.textSecondary }]}>
🌐 {item.languages.slice(0, 2).join(', ')}
</Text>
</View>
) : null}
</View>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
header: { paddingHorizontal: SPACING.lg, paddingTop: SPACING.md, paddingBottom: SPACING.sm },
headerTitle: { ...TEXT.h2 },
headerSub: { ...TEXT.small, marginTop: 4 },
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: SPACING.lg,
marginBottom: SPACING.sm,
borderRadius: RADIUS.full,
borderWidth: 1.5,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.xs,
gap: SPACING.sm,
},
searchIcon: { fontSize: 15 },
searchInput: { flex: 1, fontSize: FONT_SIZE.md, paddingVertical: SPACING.xs },
clearBtn: { width: 22, height: 22, borderRadius: RADIUS.full, alignItems: 'center', justifyContent: 'center' },
list: { paddingHorizontal: SPACING.lg, paddingTop: SPACING.sm, paddingBottom: SPACING.xxxl, gap: SPACING.md },
card: {
borderRadius: RADIUS.xl,
padding: SPACING.lg,
borderWidth: 1,
gap: SPACING.md,
},
cardTop: { flexDirection: 'row', alignItems: 'center', gap: SPACING.md },
botAvatar: { width: 48, height: 48, borderRadius: RADIUS.lg, alignItems: 'center', justifyContent: 'center' },
botAvatarText: { color: COLORS.white, fontSize: FONT_SIZE.xl, fontWeight: '700' },
botMeta: { flex: 1 },
botName: { ...TEXT.h4 },
companyName: { ...TEXT.caption, marginTop: 2 },
ratingPill: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fef9c3',
borderRadius: RADIUS.full,
paddingVertical: 4,
paddingHorizontal: SPACING.sm,
gap: 3,
},
ratingStar: { fontSize: 12, color: '#d97706' },
ratingText: { fontSize: FONT_SIZE.sm, fontWeight: '700', color: '#92400e' },
desc: { ...TEXT.small, lineHeight: 20 },
chips: { flexDirection: 'row', gap: SPACING.sm, flexWrap: 'wrap' },
chip: { borderRadius: RADIUS.full, paddingVertical: 4, paddingHorizontal: SPACING.sm },
chipText: { ...TEXT.captionM },
});

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useQuery } from '@tanstack/react-query';
import { MarketplaceStackParamList } from '../../navigation/types';
import { ChatInterface } from '../../components/ChatInterface';
import { Spinner } from '../../components/ui';
import { useTheme } from '../../theme';
import { marketplaceAPI } from '../../services/api';
type Props = NativeStackScreenProps<MarketplaceStackParamList, 'PublicChat'>;
export function PublicChatScreen({ route }: Props) {
const { chatbotId } = route.params;
const { theme } = useTheme();
const { data: bot, isLoading } = useQuery({
queryKey: ['marketplace-bot', chatbotId],
queryFn: () => marketplaceAPI.get(chatbotId),
staleTime: 5 * 60 * 1000,
});
if (isLoading) return <Spinner centered />;
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<ChatInterface
chatbotId={chatbotId}
welcomeMessage={bot?.welcome_message}
primaryColor={bot?.primary_color}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
});

View File

@@ -0,0 +1,414 @@
import React, { useState, useRef } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Alert,
Animated,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { AccountStackParamList } from '../../navigation/types';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../../theme';
import { useTheme } from '../../theme';
import { PlanBadge, Button, Input, SecureInput } from '../../components/ui';
import { useToast } from '../../contexts/ToastContext';
import { authAPI, billingAPI } from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
import { useThemeStore, ThemeMode } from '../../stores/themeStore';
import { useLanguageStore, AppLanguage } from '../../stores/languageStore';
import { useTranslation } from '../../i18n';
type Props = NativeStackScreenProps<AccountStackParamList, 'AccountHome'>;
const PLAN_FEATURES: Record<string, string[]> = {
free: ['1 published chatbot', '3 docs/bot', '100 convos/mo', 'Llama 3.3 model'],
starter: ['1 published chatbot', '10 docs/bot', '1,500 convos/mo', 'Telegram integration', 'Inbox & Leads'],
business: ['3 published chatbots', '50 docs/bot', '5,000 convos/mo', 'All models', 'WhatsApp + Telegram'],
agency: ['Unlimited chatbots', 'Unlimited docs', '20,000 convos/mo', 'Code export'],
enterprise: ['Everything in Agency', 'Unlimited convos', 'Priority support'],
};
export function SettingsScreen({ navigation }: Props) {
const { theme } = useTheme();
const { t } = useTranslation();
const toast = useToast();
const qc = useQueryClient();
const user = useAuthStore(s => s.user);
const updateUser = useAuthStore(s => s.updateUser);
const logout = useAuthStore(s => s.logout);
const appLanguage = useLanguageStore(s => s.language);
const setAppLanguage = useLanguageStore(s => s.setLanguage);
const [companyName, setCompanyName] = useState(user?.company_name ?? '');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const LANGUAGE_OPTIONS: { key: AppLanguage; label: string; flag: string }[] = [
{ key: 'fr', label: 'Français', flag: '🇫🇷' },
{ key: 'en', label: 'English', flag: '🇬🇧' },
];
const { data: subscription } = useQuery({
queryKey: ['subscription'],
queryFn: billingAPI.subscription,
});
const themeMode = useThemeStore(s => s.mode);
const setThemeMode = useThemeStore(s => s.setMode);
const THEME_OPTIONS: { key: ThemeMode; label: string; icon: string }[] = [
{ key: 'system', label: t.settings.theme_system, icon: '⚙️' },
{ key: 'light', label: t.settings.theme_light, icon: '☀️' },
{ key: 'dark', label: t.settings.theme_dark, icon: '🌙' },
];
const handleSaveProfile = async () => {
if (!companyName.trim()) {
toast.error(t.settings.company_required);
return;
}
setSavingProfile(true);
try {
const payload: any = { company_name: companyName.trim(), language: appLanguage };
if (currentPassword && newPassword) {
payload.current_password = currentPassword;
payload.new_password = newPassword;
}
const updated = await authAPI.updateProfile(payload);
updateUser(updated.user ?? { company_name: companyName.trim() });
setCurrentPassword('');
setNewPassword('');
toast.success(t.settings.profile_updated);
} catch (err: any) {
toast.error(err?.response?.data?.detail ?? t.settings.update_failed);
} finally {
setSavingProfile(false);
}
};
const handleLogout = () => {
Alert.alert(t.settings.sign_out, t.settings.sign_out_confirm, [
{ text: t.common.cancel, style: 'cancel' },
{
text: t.settings.sign_out,
style: 'destructive',
onPress: async () => {
try { await authAPI.logout(); } catch {}
logout();
qc.clear();
},
},
]);
};
const handleDeleteAccount = () => {
Alert.alert(
t.settings.delete_account,
t.settings.delete_confirm,
[
{ text: t.common.cancel, style: 'cancel' },
{
text: t.settings.delete_account,
style: 'destructive',
onPress: async () => {
try {
await authAPI.deleteAccount();
logout();
qc.clear();
} catch (err: any) {
toast.error(err?.response?.data?.detail ?? 'Failed to delete account');
}
},
},
],
);
};
const currentPlan = subscription?.plan ?? user?.plan ?? 'free';
const planFeatures = PLAN_FEATURES[currentPlan] ?? [];
return (
<SafeAreaView style={[styles.safe, { backgroundColor: theme.bg }]} edges={['bottom']}>
<ScrollView contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false}>
{/* User hero card */}
<View style={[styles.heroCard, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.md]}>
<View style={[styles.userAvatar, SHADOWS.primary]}>
<Text style={styles.userAvatarText}>
{(user?.email ?? 'U')[0].toUpperCase()}
</Text>
</View>
<View style={styles.userInfo}>
<Text style={[styles.userName, { color: theme.text }]}>{user?.company_name}</Text>
<Text style={[styles.userEmail, { color: theme.textSecondary }]}>{user?.email}</Text>
<PlanBadge plan={user?.plan ?? 'free'} />
</View>
</View>
{/* Subscription */}
<View style={[styles.section, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>{t.settings.subscription}</Text>
<View style={styles.planRow}>
<PlanBadge plan={currentPlan} />
{subscription?.status && (
<View style={[styles.statusPill, { backgroundColor: theme.bgSecondary }]}>
<Text style={[styles.statusText, { color: theme.textSecondary }]}>{subscription.status}</Text>
</View>
)}
</View>
{subscription?.current_period_end && (
<Text style={[styles.renewDate, { color: theme.textMuted }]}>
Renews {new Date(subscription.current_period_end).toLocaleDateString()}
</Text>
)}
<View style={[styles.featuresList, { borderTopColor: theme.borderLight }]}>
{planFeatures.map(f => (
<View key={f} style={styles.featureItem}>
<Text style={[styles.featureCheck, { color: COLORS.success }]}></Text>
<Text style={[styles.featureText, { color: theme.textSecondary }]}>{f}</Text>
</View>
))}
</View>
</View>
{/* Quick nav */}
<View style={[styles.section, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>{t.settings.reports}</Text>
<MenuRow icon="📊" label={t.settings.analytics} onPress={() => navigation.navigate('Analytics')} theme={theme} />
<MenuRow icon="👥" label={t.settings.leads} onPress={() => navigation.navigate('Leads')} theme={theme} />
<MenuRow icon="📣" label={t.settings.campaigns} onPress={() => navigation.navigate('Campaigns')} theme={theme} />
<MenuRow icon="📅" label={t.settings.appointments} onPress={() => navigation.navigate('Appointments')} theme={theme} isLast />
</View>
{/* Appearance */}
<View style={[styles.section, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>{t.settings.appearance}</Text>
<View style={themeStyles.row}>
{THEME_OPTIONS.map(opt => (
<TouchableOpacity
key={opt.key}
style={[
themeStyles.pill,
{ borderColor: themeMode === opt.key ? COLORS.primary : theme.border },
themeMode === opt.key && { backgroundColor: COLORS.primaryUltraLight },
]}
onPress={() => setThemeMode(opt.key)}>
<Text style={themeStyles.pillIcon}>{opt.icon}</Text>
<Text style={[themeStyles.pillLabel, { color: themeMode === opt.key ? COLORS.primaryDark : theme.textSecondary }]}>
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Profile */}
<View style={[styles.section, { backgroundColor: theme.surface, borderColor: theme.border }, SHADOWS.sm]}>
<Text style={[styles.sectionTitle, { color: theme.text }]}>{t.settings.profile}</Text>
<Input
label={t.settings.company_label}
value={companyName}
onChangeText={setCompanyName}
placeholder={t.settings.company_placeholder}
/>
<View style={langStyles.field}>
<Text style={[langStyles.label, { color: theme.text }]}>{t.settings.language_label}</Text>
<View style={langStyles.row}>
{LANGUAGE_OPTIONS.map(opt => (
<TouchableOpacity
key={opt.key}
style={[
langStyles.pill,
{ borderColor: appLanguage === opt.key ? COLORS.primary : theme.border },
appLanguage === opt.key && { backgroundColor: COLORS.primaryUltraLight },
]}
onPress={() => setAppLanguage(opt.key)}>
<Text style={langStyles.flag}>{opt.flag}</Text>
<Text style={[langStyles.pillLabel, { color: appLanguage === opt.key ? COLORS.primaryDark : theme.textSecondary }]}>
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<View style={[styles.divider, { borderTopColor: theme.borderLight }]}>
<Text style={[styles.subTitle, { color: theme.textSecondary }]}>{t.settings.change_password}</Text>
</View>
<SecureInput
label={t.settings.current_password}
value={currentPassword}
onChangeText={setCurrentPassword}
placeholder={t.settings.current_password}
/>
<SecureInput
label={t.settings.new_password}
value={newPassword}
onChangeText={setNewPassword}
placeholder={t.settings.new_password}
/>
<Button
title={t.settings.save_profile}
onPress={handleSaveProfile}
loading={savingProfile}
size="md"
/>
</View>
{/* Account actions */}
<View style={[styles.section, styles.dangerSection, { backgroundColor: theme.surface, borderColor: '#fee2e2' }, SHADOWS.sm]}>
<Text style={[styles.sectionTitle, { color: COLORS.error }]}>{t.settings.account}</Text>
<Button title={t.settings.sign_out} variant="outline" onPress={handleLogout} fullWidth />
<Button title={t.settings.delete_account} variant="danger" onPress={handleDeleteAccount} fullWidth />
</View>
<Text style={[styles.version, { color: theme.textMuted }]}>Contexta v1.0.0</Text>
</ScrollView>
</SafeAreaView>
);
}
function MenuRow({
icon,
label,
onPress,
theme,
isLast,
}: {
icon: string;
label: string;
onPress: () => void;
theme: any;
isLast?: boolean;
}) {
const scale = useRef(new Animated.Value(1)).current;
const onPressIn = () => Animated.spring(scale, { toValue: 0.98, useNativeDriver: true, speed: 60 }).start();
const onPressOut = () => Animated.spring(scale, { toValue: 1, useNativeDriver: true, speed: 30, bounciness: 4 }).start();
return (
<TouchableOpacity onPress={onPress} onPressIn={onPressIn} onPressOut={onPressOut} activeOpacity={1}>
<Animated.View
style={[
menuStyles.row,
!isLast && { borderBottomWidth: 1, borderBottomColor: theme.borderLight },
{ transform: [{ scale }] },
]}>
<View style={[menuStyles.iconBg, { backgroundColor: theme.bgSecondary }]}>
<Text style={menuStyles.icon}>{icon}</Text>
</View>
<Text style={[menuStyles.label, { color: theme.text }]}>{label}</Text>
<Text style={[menuStyles.chevron, { color: theme.textMuted }]}></Text>
</Animated.View>
</TouchableOpacity>
);
}
const menuStyles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING.md,
gap: SPACING.md,
},
iconBg: { width: 36, height: 36, borderRadius: RADIUS.md, alignItems: 'center', justifyContent: 'center' },
icon: { fontSize: 18 },
label: { flex: 1, ...TEXT.body },
chevron: { fontSize: 20, fontWeight: '300' },
});
const styles = StyleSheet.create({
safe: { flex: 1 },
scroll: { padding: SPACING.lg, gap: SPACING.md, paddingBottom: SPACING.xxxl },
heroCard: {
borderRadius: RADIUS.xxl,
padding: SPACING.xl,
borderWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: SPACING.lg,
},
userAvatar: {
width: 64,
height: 64,
borderRadius: RADIUS.full,
backgroundColor: COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
userAvatarText: { color: COLORS.white, fontSize: FONT_SIZE.xxl, fontWeight: '800' },
userInfo: { flex: 1, gap: SPACING.xs },
userName: { ...TEXT.h4 },
userEmail: { ...TEXT.small },
section: {
borderRadius: RADIUS.xl,
borderWidth: 1,
padding: SPACING.lg,
gap: SPACING.md,
},
dangerSection: {},
sectionTitle: { ...TEXT.h4 },
planRow: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm },
statusPill: { borderRadius: RADIUS.full, paddingVertical: 3, paddingHorizontal: SPACING.sm },
statusText: { fontSize: FONT_SIZE.xs, fontWeight: '500', textTransform: 'capitalize' },
renewDate: { ...TEXT.small },
featuresList: { borderTopWidth: 1, paddingTop: SPACING.md, gap: SPACING.sm },
featureItem: { flexDirection: 'row', alignItems: 'flex-start', gap: SPACING.xs },
featureCheck: { fontSize: FONT_SIZE.sm, fontWeight: '700', lineHeight: 20 },
featureText: { ...TEXT.small, flex: 1, lineHeight: 20 },
divider: { borderTopWidth: 1, paddingTop: SPACING.sm },
subTitle: { ...TEXT.smallM },
version: { ...TEXT.caption, textAlign: 'center' },
});
const langStyles = StyleSheet.create({
field: { gap: SPACING.xs },
label: { fontSize: FONT_SIZE.sm, fontWeight: '500' },
row: { flexDirection: 'row', gap: SPACING.sm },
pill: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: SPACING.xs,
borderWidth: 1.5,
borderRadius: RADIUS.lg,
paddingVertical: SPACING.sm,
},
flag: { fontSize: 18 },
pillLabel: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
});
const themeStyles = StyleSheet.create({
row: { flexDirection: 'row', gap: SPACING.sm },
pill: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: SPACING.xs,
borderWidth: 1.5,
borderRadius: RADIUS.lg,
paddingVertical: SPACING.sm,
},
pillIcon: { fontSize: 16 },
pillLabel: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
});

264
src/services/api.ts Normal file
View File

@@ -0,0 +1,264 @@
import axios from 'axios';
import { useAuthStore } from '../stores/authStore';
// Change this to your backend URL
// Android emulator: http://10.0.2.2:8000
// iOS simulator: http://localhost:8000
// Physical device: http://<your-machine-ip>:8000
export const API_BASE_URL = "https://contexta-production-672d.up.railway.app"; //'http://192.168.1.72:8000';
const api = axios.create({
baseURL: `${API_BASE_URL}/api/v1`,
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
});
api.interceptors.request.use(config => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
useAuthStore.getState().logout();
}
return Promise.reject(error);
},
);
// ─── Auth ────────────────────────────────────────────────────────────────────
export const authAPI = {
login: (email: string, password: string) =>
api.post('/auth/login', { email, password }).then(r => r.data),
signup: (email: string, password: string, company_name: string) =>
api.post('/auth/signup', { email, password, company_name }).then(r => r.data),
me: () => api.get('/auth/me').then(r => r.data),
logout: () => api.post('/auth/logout').then(r => r.data),
forgotPassword: (email: string) =>
api.post('/auth/forgot-password', { email }).then(r => r.data),
resetPassword: (access_token: string, new_password: string) =>
api.post('/auth/reset-password', { access_token, new_password }).then(r => r.data),
updateProfile: (data: { company_name?: string; current_password?: string; new_password?: string }) =>
api.patch('/auth/profile', data).then(r => r.data),
deleteAccount: () => api.delete('/auth/account').then(r => r.data),
};
// ─── Chatbots ────────────────────────────────────────────────────────────────
export const chatbotsAPI = {
list: () => api.get('/chatbots').then(r => r.data),
get: (id: string) => api.get(`/chatbots/${id}`).then(r => r.data),
create: (data: Partial<import('../types').Chatbot>) =>
api.post('/chatbots', data).then(r => r.data),
update: (id: string, data: Partial<import('../types').Chatbot>) =>
api.put(`/chatbots/${id}`, data).then(r => r.data),
delete: (id: string) => api.delete(`/chatbots/${id}`).then(r => r.data),
publish: (id: string) => api.post(`/chatbots/${id}/publish`).then(r => r.data),
unpublish: (id: string) => api.post(`/chatbots/${id}/unpublish`).then(r => r.data),
getPublic: (id: string) => api.get(`/chatbots/${id}/public`).then(r => r.data),
getEmbed: (id: string) => api.get(`/chatbots/${id}/embed`).then(r => r.data),
};
// ─── Documents ───────────────────────────────────────────────────────────────
export const documentsAPI = {
list: (chatbotId: string) =>
api.get(`/chatbots/${chatbotId}/documents`).then(r => r.data),
upload: (chatbotId: string, formData: FormData) =>
api.post(`/chatbots/${chatbotId}/documents`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then(r => r.data),
delete: (chatbotId: string, docId: string) =>
api.delete(`/chatbots/${chatbotId}/documents/${docId}`).then(r => r.data),
retry: (chatbotId: string, docId: string) =>
api.post(`/chatbots/${chatbotId}/documents/${docId}/retry`).then(r => r.data),
};
// ─── URL Sources ──────────────────────────────────────────────────────────────
export const urlSourcesAPI = {
list: (chatbotId: string) =>
api.get(`/chatbots/${chatbotId}/url-sources`).then(r => r.data),
add: (chatbotId: string, url: string) =>
api.post(`/chatbots/${chatbotId}/url-sources`, { url }).then(r => r.data),
delete: (chatbotId: string, sourceId: string) =>
api.delete(`/chatbots/${chatbotId}/url-sources/${sourceId}`).then(r => r.data),
refresh: (chatbotId: string, sourceId: string) =>
api.post(`/chatbots/${chatbotId}/url-sources/${sourceId}/refresh`).then(r => r.data),
};
// ─── Chat ─────────────────────────────────────────────────────────────────────
export const chatAPI = {
sendMessage: (chatbotId: string, message: string, session_id?: string) =>
api.post(`/chat/${chatbotId}`, { message, session_id }).then(r => r.data),
getHistory: (chatbotId: string, sessionId: string) =>
api.get(`/chat/${chatbotId}/history/${sessionId}`).then(r => r.data),
sendFeedback: (chatbotId: string, message_id: string, feedback: 'positive' | 'negative') =>
api.post(`/chat/${chatbotId}/feedback`, { message_id, feedback }).then(r => r.data),
test: (chatbotId: string, questions: string[]) =>
api.post(`/chat/${chatbotId}/test`, { questions }).then(r => r.data),
};
// ─── Marketplace ─────────────────────────────────────────────────────────────
export const marketplaceAPI = {
list: (params?: { search?: string; category?: string; industry?: string; page?: number; limit?: number }) =>
api.get('/marketplace/chatbots', { params }).then(r => r.data),
get: (id: string) => api.get(`/marketplace/chatbots/${id}`).then(r => r.data),
categories: () => api.get('/marketplace/categories').then(r => r.data),
rate: (id: string, rating: number, comment?: string) =>
api.post(`/marketplace/chatbots/${id}/rate`, { rating, comment }).then(r => r.data),
};
// ─── Analytics ───────────────────────────────────────────────────────────────
export const analyticsAPI = {
overview: () => api.get('/analytics/overview').then(r => r.data),
chatbot: (id: string) => api.get(`/analytics/chatbot/${id}`).then(r => r.data),
gaps: (id: string) => api.get(`/analytics/chatbot/${id}/gaps`).then(r => r.data),
};
// ─── Leads ───────────────────────────────────────────────────────────────────
export const leadsAPI = {
list: (params?: { chatbot_id?: string; page?: number; limit?: number }) =>
api.get('/leads', { params }).then(r => r.data),
update: (id: string, data: Record<string, any>) =>
api.patch(`/leads/${id}`, data).then(r => r.data),
submit: (chatbotId: string, data: Record<string, any>) =>
api.post(`/chatbots/${chatbotId}/leads`, data).then(r => r.data),
};
// ─── Inbox ───────────────────────────────────────────────────────────────────
export const inboxAPI = {
listConversations: (params?: { chatbot_id?: string; page?: number; limit?: number }) =>
api.get('/inbox/conversations', { params }).then(r => r.data),
getConversation: (id: string) =>
api.get(`/inbox/conversations/${id}`).then(r => r.data),
updateStatus: (id: string, status: string) =>
api.patch(`/inbox/conversations/${id}`, { status }).then(r => r.data),
reply: (id: string, message: string) =>
api.post(`/inbox/conversations/${id}/reply`, { message }).then(r => r.data),
deleteConversation: (id: string) =>
api.delete(`/inbox/conversations/${id}`).then(r => r.data),
};
// ─── Billing ─────────────────────────────────────────────────────────────────
export const billingAPI = {
subscription: () => api.get('/billing/subscription').then(r => r.data),
portal: (return_url?: string) =>
api.post('/billing/portal', { return_url }).then(r => r.data),
checkout: (plan: string, success_url: string, cancel_url: string) =>
api.post('/billing/checkout', { plan, success_url, cancel_url }).then(r => r.data),
};
// ─── Models ──────────────────────────────────────────────────────────────────
export const modelsAPI = {
available: () => api.get('/models/available').then(r => r.data),
};
// ─── Channels ────────────────────────────────────────────────────────────────
export const channelsAPI = {
list: (chatbotId: string) =>
api.get('/channels', { params: { chatbot_id: chatbotId } }).then(r => r.data),
connectTelegram: (chatbot_id: string, bot_token: string) =>
api.post('/channels/telegram', { chatbot_id, bot_token }).then(r => r.data),
connectWhatsApp: (chatbot_id: string, wa_keyword?: string) =>
api.post('/channels/whatsapp', { chatbot_id, wa_keyword }).then(r => r.data),
disconnect: (connectionId: string) =>
api.delete(`/channels/${connectionId}`).then(r => r.data),
};
// ─── Campaigns ───────────────────────────────────────────────────────────────
export const campaignsAPI = {
list: (params?: { chatbot_id?: string; page?: number }) =>
api.get('/campaigns', { params }).then(r => r.data),
create: (data: { chatbot_id: string; title: string; message: string }) =>
api.post('/campaigns', data).then(r => r.data),
send: (id: string) =>
api.post(`/campaigns/${id}/send`).then(r => r.data),
delete: (id: string) =>
api.delete(`/campaigns/${id}`).then(r => r.data),
};
// ─── Appointments ─────────────────────────────────────────────────────────────
export const appointmentsAPI = {
list: (params?: { chatbot_id?: string; status?: string; page?: number }) =>
api.get('/appointments', { params }).then(r => r.data),
updateStatus: (id: string, status: string) =>
api.patch(`/appointments/${id}`, { status }).then(r => r.data),
getHours: (chatbotId: string) =>
api.get(`/appointments/chatbot/${chatbotId}/hours`).then(r => r.data),
saveHours: (chatbotId: string, hours: import('../types').BusinessHoursEntry[]) =>
api.put(`/appointments/chatbot/${chatbotId}/hours`, { hours }).then(r => r.data),
};
// ─── Upload ──────────────────────────────────────────────────────────────────
export const uploadAPI = {
logo: (formData: FormData) =>
api.post('/upload/logo', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then(r => r.data),
};
export default api;

38
src/stores/authStore.ts Normal file
View File

@@ -0,0 +1,38 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User } from '../types';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
setAuth: (user: User, token: string) => void;
updateUser: (partial: Partial<User>) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
set => ({
user: null,
token: null,
isAuthenticated: false,
setAuth: (user, token) =>
set({ user, token, isAuthenticated: true }),
updateUser: partial =>
set(state => ({
user: state.user ? { ...state.user, ...partial } : null,
})),
logout: () =>
set({ user: null, token: null, isAuthenticated: false }),
}),
{
name: 'contexta-auth',
storage: createJSONStorage(() => AsyncStorage),
},
),
);

View File

@@ -0,0 +1,23 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
export type AppLanguage = 'en' | 'fr';
interface LanguageState {
language: AppLanguage;
setLanguage: (lang: AppLanguage) => void;
}
export const useLanguageStore = create<LanguageState>()(
persist(
set => ({
language: 'fr',
setLanguage: lang => set({ language: lang }),
}),
{
name: 'contexta-language',
storage: createJSONStorage(() => AsyncStorage),
},
),
);

23
src/stores/themeStore.ts Normal file
View File

@@ -0,0 +1,23 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
export type ThemeMode = 'system' | 'light' | 'dark';
interface ThemeState {
mode: ThemeMode;
setMode: (mode: ThemeMode) => void;
}
export const useThemeStore = create<ThemeState>()(
persist(
set => ({
mode: 'system',
setMode: mode => set({ mode }),
}),
{
name: 'contexta-theme',
storage: createJSONStorage(() => AsyncStorage),
},
),
);

158
src/theme/index.ts Normal file
View File

@@ -0,0 +1,158 @@
import { useColorScheme, Platform } from 'react-native';
import { useThemeStore } from '../stores/themeStore';
// ─── Brand colors ─────────────────────────────────────────────────────────────
export const COLORS = {
primary: '#6366f1',
primaryDark: '#4f46e5',
primaryLight: '#818cf8',
primaryUltraLight:'#eef2ff',
primaryMid: '#c7d2fe',
success: '#10b981',
successBg:'#d1fae5',
warning: '#f59e0b',
warningBg:'#fef3c7',
error: '#ef4444',
errorBg: '#fee2e2',
info: '#3b82f6',
infoBg: '#dbeafe',
purple: '#8b5cf6',
purpleBg: '#ede9fe',
white: '#ffffff',
black: '#000000',
light: {
bg: '#f8fafc',
bgSecondary: '#f1f5f9',
surface: '#ffffff',
surfaceHover: '#f8fafc',
border: '#e2e8f0',
borderLight: '#f1f5f9',
text: '#0f172a',
textSecondary:'#475569',
textMuted: '#94a3b8',
inputBg: '#ffffff',
tabBar: '#ffffff',
tabBarBorder: '#f1f5f9',
placeholder: '#94a3b8',
icon: '#64748b',
overlay: 'rgba(15,23,42,0.4)',
},
dark: {
bg: '#0a0f1e',
bgSecondary: '#0f172a',
surface: '#151f32',
surfaceHover: '#1e293b',
border: '#1e293b',
borderLight: '#162033',
text: '#f1f5f9',
textSecondary:'#94a3b8',
textMuted: '#475569',
inputBg: '#151f32',
tabBar: '#0a0f1e',
tabBarBorder: '#151f32',
placeholder: '#475569',
icon: '#64748b',
overlay: 'rgba(0,0,0,0.6)',
},
};
// ─── Spacing ──────────────────────────────────────────────────────────────────
export const SPACING = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 24,
xxxl: 32,
huge: 48,
};
// ─── Border radius ────────────────────────────────────────────────────────────
export const RADIUS = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 20,
xxl: 24,
full: 9999,
};
// ─── Typography ───────────────────────────────────────────────────────────────
export const FONT_SIZE = {
xs: 11,
sm: 13,
md: 15,
lg: 17,
xl: 20,
xxl: 24,
xxxl: 30,
huge: 38,
};
export const FONT = {
regular: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
extrabold:'800' as const,
};
// Pre-built text style objects (use with spread)
export const TEXT = {
h1: { fontSize: FONT_SIZE.xxxl, fontWeight: FONT.bold, lineHeight: 38 },
h2: { fontSize: FONT_SIZE.xxl, fontWeight: FONT.bold, lineHeight: 32 },
h3: { fontSize: FONT_SIZE.xl, fontWeight: FONT.semibold, lineHeight: 28 },
h4: { fontSize: FONT_SIZE.lg, fontWeight: FONT.semibold, lineHeight: 24 },
body: { fontSize: FONT_SIZE.md, fontWeight: FONT.regular, lineHeight: 22 },
bodyM: { fontSize: FONT_SIZE.md, fontWeight: FONT.medium, lineHeight: 22 },
small: { fontSize: FONT_SIZE.sm, fontWeight: FONT.regular, lineHeight: 18 },
smallM: { fontSize: FONT_SIZE.sm, fontWeight: FONT.medium, lineHeight: 18 },
caption: { fontSize: FONT_SIZE.xs, fontWeight: FONT.regular, lineHeight: 16 },
captionM:{ fontSize: FONT_SIZE.xs, fontWeight: FONT.medium, lineHeight: 16 },
overline:{ fontSize: FONT_SIZE.xs, fontWeight: FONT.bold, letterSpacing: 1, lineHeight: 16 },
} as const;
// ─── Shadows ──────────────────────────────────────────────────────────────────
export const SHADOWS = {
none: {},
xs: Platform.select({
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 3 },
android: { elevation: 1 },
}) ?? {},
sm: Platform.select({
ios: { shadowColor: '#0f172a', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8 },
android: { elevation: 2 },
}) ?? {},
md: Platform.select({
ios: { shadowColor: '#0f172a', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.09, shadowRadius: 16 },
android: { elevation: 4 },
}) ?? {},
lg: Platform.select({
ios: { shadowColor: '#0f172a', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.12, shadowRadius: 24 },
android: { elevation: 8 },
}) ?? {},
xl: Platform.select({
ios: { shadowColor: '#0f172a', shadowOffset: { width: 0, height: 16 }, shadowOpacity: 0.18, shadowRadius: 40 },
android: { elevation: 16 },
}) ?? {},
// Tinted primary shadow
primary: Platform.select({
ios: { shadowColor: '#6366f1', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.3, shadowRadius: 16 },
android: { elevation: 6 },
}) ?? {},
};
// ─── Theme hook ───────────────────────────────────────────────────────────────
export function useTheme() {
const scheme = useColorScheme();
const mode = useThemeStore(s => s.mode);
const isDark = mode === 'system' ? scheme === 'dark' : mode === 'dark';
const theme = isDark ? COLORS.dark : COLORS.light;
return { isDark, colors: COLORS, theme, shadows: SHADOWS };
}

233
src/types/index.ts Normal file
View File

@@ -0,0 +1,233 @@
export interface User {
id: string;
email: string;
company_name: string;
plan: 'free' | 'starter' | 'business' | 'agency' | 'enterprise';
is_admin: boolean;
chatbot_count?: number;
conversations_used?: number;
}
export interface Subscription {
plan: string;
status: string;
current_period_end?: string;
chatbots_published: number;
conversations_used: number;
}
export interface Chatbot {
id: string;
company_id: string;
name: string;
description: string;
system_prompt: string;
model: string;
temperature: number;
max_tokens: number;
primary_color: string;
welcome_message: string;
logo_url?: string;
category?: string;
industry?: string;
languages: string[];
visibility: string;
is_published: boolean;
average_rating: number;
show_branding: boolean;
lead_capture_enabled: boolean;
lead_capture_fields: string[];
lead_capture_trigger: string;
handoff_enabled: boolean;
handoff_message: string;
handoff_email: string;
handoff_keywords: string[];
booking_enabled?: boolean;
document_count?: number;
conversation_count?: number;
created_at?: string;
}
export interface Document {
id: string;
chatbot_id: string;
file_name: string;
file_type: string;
file_size: number;
chunk_count: number;
status: 'pending' | 'processing' | 'completed' | 'failed';
error_message?: string;
created_at?: string;
}
export interface URLSource {
id: string;
chatbot_id: string;
url: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
page_title?: string;
chunk_count?: number;
error_message?: string;
}
export interface Message {
id: string;
conversation_id: string;
role: 'user' | 'assistant';
content: string;
sources?: MessageSource[];
model?: string;
confidence_score?: number;
is_handoff?: boolean;
created_at?: string;
}
export interface MessageSource {
document_name: string;
chunk_text: string;
score: number;
page_number?: number;
}
export interface Conversation {
id: string;
chatbot_id: string;
session_id: string;
language?: string;
message_count: number;
created_at?: string;
last_message?: string;
chatbot_name?: string;
}
export interface Lead {
id: string;
chatbot_id: string;
conversation_id?: string;
email?: string;
name?: string;
phone?: string;
company?: string;
created_at?: string;
chatbot_name?: string;
}
export interface MarketplaceChatbot {
id: string;
name: string;
description: string;
category?: string;
industry?: string;
languages: string[];
average_rating: number;
logo_url?: string;
primary_color: string;
conversation_count?: number;
company_name?: string;
}
export interface AnalyticsOverview {
total_conversations: number;
total_messages: number;
total_chatbots: number;
published_chatbots: number;
unique_sessions: number;
conversations_this_month: number;
avg_messages_per_conversation: number;
average_rating: number | null;
plan: string;
conversations_limit: number;
conversations_used: number;
chatbots: ChatbotAnalytics[];
}
export interface DailyConversation {
date: string;
count: number;
}
export interface TopQuery {
query: string;
count: number;
}
export interface ChatbotAnalytics {
chatbot_id: string;
chatbot_name: string;
conversations: number;
messages: number;
avg_confidence: number;
total_conversations: number;
unique_sessions: number;
total_messages: number;
average_rating: number | null;
total_ratings: number;
conversations_today: number;
conversations_this_week: number;
conversations_this_month: number;
daily_conversations: DailyConversation[];
top_queries: TopQuery[];
languages_used: Record<string, number>;
peak_hour: number | null;
unanswered_count: number;
unanswered_queries: TopQuery[];
feedback_positive: number;
feedback_negative: number;
}
export interface Campaign {
id: string;
chatbot_id: string;
title: string;
message: string;
status: 'draft' | 'sending' | 'sent' | 'failed';
recipients_count: number;
sent_count: number;
created_at?: string;
sent_at?: string;
}
export interface Appointment {
id: string;
chatbot_id: string;
customer_name: string;
customer_contact: string;
service?: string;
notes?: string;
slot_start: string;
slot_end: string;
status: 'pending' | 'confirmed' | 'cancelled' | 'completed';
created_at?: string;
}
export interface BusinessHoursEntry {
day_of_week: number;
is_open: boolean;
open_time: string;
close_time: string;
slot_duration_minutes: number;
}
export interface ChannelConnection {
id: string;
chatbot_id: string;
channel: 'telegram' | 'whatsapp';
bot_username?: string;
wa_keyword?: string;
is_active: boolean;
}
export interface AIModel {
id: string;
name: string;
provider: string;
available: boolean;
upgrade_required?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
}