Initial commit

This commit is contained in:
belviskhoremk
2026-05-08 13:01:47 +00:00
parent 864bbd389e
commit 9e663bdc8b
64 changed files with 20910 additions and 74 deletions

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' },
});