mirror of
http://88.130.71.182:3000/BlitTech/contexta_mb.git
synced 2026-06-13 08:51:57 +00:00
Initial commit
This commit is contained in:
131
src/screens/GuestScreen.tsx
Normal file
131
src/screens/GuestScreen.tsx
Normal 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 },
|
||||
});
|
||||
254
src/screens/analytics/AnalyticsScreen.tsx
Normal file
254
src/screens/analytics/AnalyticsScreen.tsx
Normal 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' },
|
||||
});
|
||||
608
src/screens/appointments/AppointmentsScreen.tsx
Normal file
608
src/screens/appointments/AppointmentsScreen.tsx
Normal 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' },
|
||||
});
|
||||
145
src/screens/auth/ForgotPasswordScreen.tsx
Normal file
145
src/screens/auth/ForgotPasswordScreen.tsx
Normal 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' },
|
||||
});
|
||||
169
src/screens/auth/LoginScreen.tsx
Normal file
169
src/screens/auth/LoginScreen.tsx
Normal 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 },
|
||||
});
|
||||
183
src/screens/auth/SignupScreen.tsx
Normal file
183
src/screens/auth/SignupScreen.tsx
Normal 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 },
|
||||
});
|
||||
498
src/screens/campaigns/CampaignsScreen.tsx
Normal file
498
src/screens/campaigns/CampaignsScreen.tsx
Normal 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' },
|
||||
});
|
||||
38
src/screens/chatbots/ChatPreviewScreen.tsx
Normal file
38
src/screens/chatbots/ChatPreviewScreen.tsx
Normal 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 },
|
||||
});
|
||||
602
src/screens/chatbots/ChatbotBuilderScreen.tsx
Normal file
602
src/screens/chatbots/ChatbotBuilderScreen.tsx
Normal 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' },
|
||||
});
|
||||
234
src/screens/chatbots/tabs/DeployTab.tsx
Normal file
234
src/screens/chatbots/tabs/DeployTab.tsx
Normal 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' },
|
||||
});
|
||||
|
||||
286
src/screens/chatbots/tabs/DocumentsTab.tsx
Normal file
286
src/screens/chatbots/tabs/DocumentsTab.tsx
Normal 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 },
|
||||
});
|
||||
237
src/screens/chatbots/tabs/TestingTab.tsx
Normal file
237
src/screens/chatbots/tabs/TestingTab.tsx
Normal 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 },
|
||||
});
|
||||
406
src/screens/dashboard/DashboardScreen.tsx
Normal file
406
src/screens/dashboard/DashboardScreen.tsx
Normal 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' },
|
||||
});
|
||||
157
src/screens/inbox/ConversationScreen.tsx
Normal file
157
src/screens/inbox/ConversationScreen.tsx
Normal 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 },
|
||||
});
|
||||
264
src/screens/inbox/InboxScreen.tsx
Normal file
264
src/screens/inbox/InboxScreen.tsx
Normal 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' },
|
||||
});
|
||||
432
src/screens/leads/LeadsScreen.tsx
Normal file
432
src/screens/leads/LeadsScreen.tsx
Normal 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' },
|
||||
});
|
||||
137
src/screens/marketplace/ChatbotDetailScreen.tsx
Normal file
137
src/screens/marketplace/ChatbotDetailScreen.tsx
Normal 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' },
|
||||
});
|
||||
225
src/screens/marketplace/MarketplaceScreen.tsx
Normal file
225
src/screens/marketplace/MarketplaceScreen.tsx
Normal 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 },
|
||||
});
|
||||
39
src/screens/marketplace/PublicChatScreen.tsx
Normal file
39
src/screens/marketplace/PublicChatScreen.tsx
Normal 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 },
|
||||
});
|
||||
414
src/screens/settings/SettingsScreen.tsx
Normal file
414
src/screens/settings/SettingsScreen.tsx
Normal 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' },
|
||||
});
|
||||
Reference in New Issue
Block a user