fixed some little issues and added new pages

This commit is contained in:
belviskhoremk
2026-04-26 21:42:51 +00:00
parent 8722539c66
commit dd3e970bbd
34 changed files with 3031 additions and 1096 deletions

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ dist-ssr
*.sw? *.sw?
.env .env
.vercel

100
package-lock.json generated
View File

@@ -12,10 +12,12 @@
"@tsparticles/react": "^3.0.0", "@tsparticles/react": "^3.0.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^26.0.5",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-i18next": "^17.0.3",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tsparticles-slim": "^2.12.0", "tsparticles-slim": "^2.12.0",
@@ -287,6 +289,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -3123,6 +3134,47 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.0.5",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.5.tgz",
"integrity": "sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3958,6 +4010,33 @@
"react": ">= 16.8 || 18.0.0" "react": ">= 16.8 || 18.0.0"
} }
}, },
"node_modules/react-i18next": {
"version": "17.0.3",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.3.tgz",
"integrity": "sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.0.1",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -4866,7 +4945,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "peer": true,
"bin": { "bin": {
@@ -4949,6 +5028,16 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -5032,6 +5121,15 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -14,10 +14,12 @@
"@tsparticles/react": "^3.0.0", "@tsparticles/react": "^3.0.0",
"axios": "^1.13.5", "axios": "^1.13.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^26.0.5",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-i18next": "^17.0.3",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tsparticles-slim": "^2.12.0", "tsparticles-slim": "^2.12.0",

View File

@@ -28,9 +28,6 @@ export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children
navigate('/login') navigate('/login')
} }
const isActive = (item: typeof NAV_ITEMS[0]) =>
item.exact ? location.pathname === item.href : location.pathname.startsWith(item.href)
return ( return (
<div className="flex h-screen bg-gray-950 overflow-hidden"> <div className="flex h-screen bg-gray-950 overflow-hidden">
{/* Mobile backdrop */} {/* Mobile backdrop */}

View File

@@ -25,10 +25,10 @@ interface ChatInterfaceProps {
export const ChatInterface: React.FC<ChatInterfaceProps> = ({ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
chatbotId, chatbotName, welcomeMessage, primaryColor, logoUrl, chatbotId, chatbotName, welcomeMessage, primaryColor, logoUrl,
isPreview = false, sessionId: externalSessionId, isPreview = false, sessionId: externalSessionId,
showBranding = false, leadCaptureEnabled = false, showBranding = false, leadCaptureEnabled: _leadCaptureEnabled = false,
leadCaptureFields = ['email'], leadCaptureTrigger = 'after_first_message', leadCaptureFields = ['email'], leadCaptureTrigger: _leadCaptureTrigger = 'after_first_message',
handoffEnabled = false, handoffMessage: _handoffMessage, handoffEnabled: _handoffEnabled = false, handoffMessage: _handoffMessage,
chatbotIdForLeads, conversationId, chatbotIdForLeads: _chatbotIdForLeads, conversationId,
}) => { }) => {
const [messages, setMessages] = useState<ChatMessage[]>([ const [messages, setMessages] = useState<ChatMessage[]>([
{ id: '0', role: 'assistant', content: welcomeMessage } { id: '0', role: 'assistant', content: welcomeMessage }
@@ -39,14 +39,28 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
const [sessionId] = useState(() => { const [sessionId] = useState(() => {
if (externalSessionId) return externalSessionId if (externalSessionId) return externalSessionId
const storageKey = `chat-session-${chatbotId}` const storageKey = `chat-session-${chatbotId}`
const stored = sessionStorage.getItem(storageKey) const stored = localStorage.getItem(storageKey)
if (stored) return stored if (stored) return stored
const newId = crypto.randomUUID() const newId = crypto.randomUUID()
sessionStorage.setItem(storageKey, newId) localStorage.setItem(storageKey, newId)
return newId return newId
}) })
const [feedbackSent, setFeedbackSent] = useState<Set<string>>(new Set()) useEffect(() => {
if (isPreview || externalSessionId) return
chatAPI.history(chatbotId, sessionId).then((msgs: { id: string; role: string; content: string }[]) => {
if (msgs && msgs.length > 0) {
setMessages([
{ id: '0', role: 'assistant', content: welcomeMessage },
...msgs.map(m => ({ id: m.id, role: m.role as 'user' | 'assistant', content: m.content })),
])
}
}).catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const [feedbackGiven, setFeedbackGiven] = useState<Record<string, 'positive' | 'negative'>>({})
const [showLeadForm, setShowLeadForm] = useState(false) const [showLeadForm, setShowLeadForm] = useState(false)
const [leadSubmitted, setLeadSubmitted] = useState(false) const [leadSubmitted, setLeadSubmitted] = useState(false)
const [leadFormData, setLeadFormData] = useState({ email: '', name: '', phone: '', company: '' }) const [leadFormData, setLeadFormData] = useState({ email: '', name: '', phone: '', company: '' })
@@ -77,7 +91,7 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
const response = await chatAPI.send(chatbotId, { const response = await chatAPI.send(chatbotId, {
message: text, message: text,
session_id: sessionId, session_id: sessionId,
language: navigator.language.split('-')[0] || 'en', language: 'auto',
}) })
const assistantMsg: ChatMessage = { const assistantMsg: ChatMessage = {
@@ -117,10 +131,10 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
} }
const handleFeedback = async (msgId: string, feedback: 'positive' | 'negative') => { const handleFeedback = async (msgId: string, feedback: 'positive' | 'negative') => {
if (feedbackSent.has(msgId)) return if (feedbackGiven[msgId]) return
setFeedbackGiven(prev => ({ ...prev, [msgId]: feedback }))
try { try {
await chatAPI.feedback(chatbotId, msgId, feedback) await chatAPI.feedback(chatbotId, msgId, feedback)
setFeedbackSent(prev => new Set(prev).add(msgId))
} catch { } catch {
// silently fail // silently fail
} }
@@ -253,33 +267,34 @@ export const ChatInterface: React.FC<ChatInterfaceProps> = ({
</div> </div>
{msg.role === 'assistant' && msg.id !== '0' && ( {msg.role === 'assistant' && msg.id !== '0' && (
<div className="flex items-center gap-0.5 ml-1"> <div className="flex items-center gap-1 ml-1">
{feedbackGiven[msg.id] ? (
<span className={cn(
'text-[11px] px-2 py-0.5 rounded-full font-medium',
feedbackGiven[msg.id] === 'positive'
? 'bg-green-50 text-green-600 border border-green-100'
: 'bg-red-50 text-red-500 border border-red-100'
)}>
{feedbackGiven[msg.id] === 'positive' ? '👍 Thanks!' : '👎 Got it'}
</span>
) : (
<>
<button <button
onClick={() => handleFeedback(msg.id, 'positive')} onClick={() => handleFeedback(msg.id, 'positive')}
disabled={feedbackSent.has(msg.id)} className="text-xs px-1.5 py-0.5 rounded-lg text-gray-300 hover:text-green-600 hover:bg-green-50 active:scale-90 transition-all"
className={cn(
'text-xs px-1.5 py-0.5 rounded-lg transition-all',
feedbackSent.has(msg.id)
? 'text-gray-200 cursor-default'
: 'text-gray-300 hover:text-green-600 hover:bg-green-50 active:scale-90'
)}
title="Helpful" title="Helpful"
> >
👍 👍
</button> </button>
<button <button
onClick={() => handleFeedback(msg.id, 'negative')} onClick={() => handleFeedback(msg.id, 'negative')}
disabled={feedbackSent.has(msg.id)} className="text-xs px-1.5 py-0.5 rounded-lg text-gray-300 hover:text-red-500 hover:bg-red-50 active:scale-90 transition-all"
className={cn(
'text-xs px-1.5 py-0.5 rounded-lg transition-all',
feedbackSent.has(msg.id)
? 'text-gray-200 cursor-default'
: 'text-gray-300 hover:text-red-500 hover:bg-red-50 active:scale-90'
)}
title="Not helpful" title="Not helpful"
> >
👎 👎
</button> </button>
</>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -4,29 +4,30 @@ import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { authAPI } from '@/services/api' import { authAPI } from '@/services/api'
import { getPlanColor } from '@/lib/utils' import { getPlanColor } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { import {
LayoutDashboard, ShoppingBag, Settings, LayoutDashboard, ShoppingBag, Settings,
LogOut, Menu, Sparkles, BarChart3, Mail, Users, LogOut, Menu, Sparkles, BarChart3, Mail, Users,
Shield, X, CalendarDays, Megaphone, Shield, X, CalendarDays, Megaphone,
} from 'lucide-react' } from 'lucide-react'
import { Divider } from './ui'
const NAV_ITEMS = [
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ label: 'Inbox', href: '/inbox', icon: Mail },
{ label: 'Leads', href: '/leads', icon: Users },
{ label: 'Appointments', href: '/appointments', icon: CalendarDays },
{ label: 'Campaigns', href: '/campaigns', icon: Megaphone },
{ label: 'Analytics', href: '/analytics', icon: BarChart3 },
{ label: 'Marketplace', href: '/marketplace', icon: ShoppingBag },
{ label: 'Settings', href: '/settings', icon: Settings },
]
export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
const { t } = useTranslation()
const NAV_ITEMS = [
{ label: t('nav.dashboard'), href: '/dashboard', icon: LayoutDashboard },
{ label: t('nav.inbox'), href: '/inbox', icon: Mail },
{ label: t('nav.leads'), href: '/leads', icon: Users },
{ label: t('nav.appointments'), href: '/appointments', icon: CalendarDays },
{ label: t('nav.campaigns'), href: '/campaigns', icon: Megaphone },
{ label: t('nav.analytics'), href: '/analytics', icon: BarChart3 },
{ label: t('nav.marketplace'), href: '/marketplace', icon: ShoppingBag },
{ label: t('nav.settings'), href: '/settings', icon: Settings },
]
const handleLogout = async () => { const handleLogout = async () => {
try { await authAPI.logout() } catch { /* ignore */ } try { await authAPI.logout() } catch { /* ignore */ }
@@ -107,7 +108,7 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
className="flex items-center gap-2.5 px-3 py-2 rounded-xl text-sm font-medium text-red-600 hover:bg-red-50 transition-colors" className="flex items-center gap-2.5 px-3 py-2 rounded-xl text-sm font-medium text-red-600 hover:bg-red-50 transition-colors"
> >
<Shield className="w-4 h-4" /> <Shield className="w-4 h-4" />
Admin Panel {t('nav.admin_panel')}
</Link> </Link>
)} )}
<div className="flex items-center gap-2.5 px-3 py-2"> <div className="flex items-center gap-2.5 px-3 py-2">
@@ -126,7 +127,7 @@ export const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children })
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all duration-150" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all duration-150"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
Sign out {t('nav.sign_out')}
</button> </button>
</div> </div>
</aside> </aside>

View File

@@ -0,0 +1,311 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import {
CheckCircle2, ChevronDown, ChevronUp, X,
Sparkles, Bot, BookOpen, Eye, Globe, Share2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { Chatbot } from '@/types'
interface Props {
userId: string
userName?: string
chatbots: Chatbot[]
}
interface StoredState {
dismissed?: boolean
collapsed?: boolean
manuallyDone?: string[]
}
const storageKey = (userId: string) => `onboarding_v1_${userId}`
const load = (userId: string): StoredState => {
try {
return JSON.parse(localStorage.getItem(storageKey(userId)) || '{}')
} catch {
return {}
}
}
const save = (userId: string, updates: Partial<StoredState>) => {
const current = load(userId)
localStorage.setItem(storageKey(userId), JSON.stringify({ ...current, ...updates }))
}
export const OnboardingChecklist: React.FC<Props> = ({ userId, userName, chatbots }) => {
const navigate = useNavigate()
const { t } = useTranslation()
const [dismissed, setDismissed] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const [manuallyDone, setManuallyDone] = useState<Set<string>>(new Set())
const [celebrating, setCelebrating] = useState(false)
const [prevAllDone, setPrevAllDone] = useState(false)
useEffect(() => {
const stored = load(userId)
if (stored.dismissed) setDismissed(true)
if (stored.collapsed) setCollapsed(true)
if (stored.manuallyDone) setManuallyDone(new Set(stored.manuallyDone))
}, [userId])
const markDone = useCallback((stepId: string) => {
setManuallyDone(prev => {
const next = new Set(prev).add(stepId)
save(userId, { manuallyDone: [...next] })
return next
})
}, [userId])
const firstChatbot = chatbots[0]
const steps = [
{
id: 'create_chatbot',
icon: Bot,
title: t('onboarding.step_create_title'),
description: t('onboarding.step_create_desc'),
done: chatbots.length > 0,
cta: t('onboarding.step_create_cta'),
action: () => navigate('/chatbots/new'),
},
{
id: 'add_knowledge',
icon: BookOpen,
title: t('onboarding.step_knowledge_title'),
description: t('onboarding.step_knowledge_desc'),
done: chatbots.some(c => c.document_count > 0) || manuallyDone.has('add_knowledge'),
cta: t('onboarding.step_knowledge_cta'),
action: firstChatbot
? () => navigate(`/chatbots/${firstChatbot.id}/edit`)
: () => navigate('/chatbots/new'),
},
{
id: 'test_chatbot',
icon: Eye,
title: t('onboarding.step_test_title'),
description: t('onboarding.step_test_desc'),
done: manuallyDone.has('test_chatbot'),
cta: t('onboarding.step_test_cta'),
action: firstChatbot
? () => { markDone('test_chatbot'); navigate(`/chatbots/${firstChatbot.id}/preview`) }
: undefined,
},
{
id: 'publish_chatbot',
icon: Globe,
title: t('onboarding.step_publish_title'),
description: t('onboarding.step_publish_desc'),
done: chatbots.some(c => c.is_published) || manuallyDone.has('publish_chatbot'),
cta: t('onboarding.step_publish_cta'),
action: firstChatbot
? () => navigate(`/chatbots/${firstChatbot.id}/edit`)
: undefined,
},
{
id: 'share_chatbot',
icon: Share2,
title: t('onboarding.step_share_title'),
description: t('onboarding.step_share_desc'),
done: manuallyDone.has('share_chatbot'),
cta: t('onboarding.step_share_cta'),
action: firstChatbot
? () => { markDone('share_chatbot'); navigate(`/chatbots/${firstChatbot.id}/edit`) }
: undefined,
},
]
const completedCount = steps.filter(s => s.done).length
const allDone = completedCount === steps.length
const progress = (completedCount / steps.length) * 100
const nextStep = steps.find(s => !s.done)
// Trigger celebration once when all steps are completed
useEffect(() => {
if (allDone && !prevAllDone && !dismissed) {
setCelebrating(true)
const t = setTimeout(() => {
setCelebrating(false)
setDismissed(true)
save(userId, { dismissed: true })
}, 4000)
return () => clearTimeout(t)
}
setPrevAllDone(allDone)
}, [allDone, prevAllDone, dismissed, userId])
const dismiss = () => {
setDismissed(true)
save(userId, { dismissed: true })
}
const toggleCollapsed = () => {
const next = !collapsed
setCollapsed(next)
save(userId, { collapsed: next })
}
if (dismissed) return null
return (
<div
className={cn(
'fixed bottom-6 right-6 z-50 w-[320px] rounded-2xl overflow-hidden',
'shadow-[0_8px_40px_rgba(0,0,0,0.14)] border border-gray-100',
'animate-fade-in-up bg-white',
)}
>
{/* Header */}
<button
onClick={toggleCollapsed}
className="w-full flex items-center justify-between px-4 py-3 select-none"
style={{ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)' }}
>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-white/80 shrink-0" />
<span className="text-sm font-semibold text-white">
{t('onboarding.title')}
</span>
<span className="text-[11px] text-white/75 bg-white/20 px-1.5 py-0.5 rounded-full font-medium tabular-nums">
{completedCount}/{steps.length}
</span>
</div>
<div className="flex items-center gap-1.5">
{collapsed
? <ChevronUp className="w-4 h-4 text-white/70" />
: <ChevronDown className="w-4 h-4 text-white/70" />}
<span
role="button"
tabIndex={0}
onClick={e => { e.stopPropagation(); dismiss() }}
onKeyDown={e => e.key === 'Enter' && dismiss()}
className="rounded-md p-0.5 hover:bg-white/20 transition-colors text-white/70 hover:text-white cursor-pointer"
aria-label="Dismiss"
>
<X className="w-3.5 h-3.5" />
</span>
</div>
</button>
{/* Progress bar */}
<div className="h-1 bg-gray-100">
<div
className="h-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all duration-700 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{/* Body */}
{!collapsed && (
<>
{celebrating ? (
<div className="px-5 py-6 text-center space-y-2 bg-emerald-50">
<div className="text-3xl">🎉</div>
<p className="text-sm font-semibold text-emerald-800">
{t('onboarding.all_done_title')}
</p>
<p className="text-xs text-emerald-600 leading-relaxed">
{t('onboarding.all_done_desc')}
</p>
</div>
) : (
<>
{userName && completedCount === 0 && (
<div className="px-4 pt-3 pb-1">
<p className="text-xs text-gray-500">
{t('onboarding.welcome', { name: userName })}
</p>
</div>
)}
<div className="divide-y divide-gray-50 max-h-[400px] overflow-y-auto">
{steps.map((step, idx) => {
const isNext = step.id === nextStep?.id
const Icon = step.icon
return (
<div
key={step.id}
className={cn(
'flex gap-3 px-4 py-3 transition-colors',
isNext && 'bg-indigo-50/60',
step.done && 'opacity-50',
)}
>
{/* Step icon */}
<div className="shrink-0 mt-0.5">
{step.done ? (
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
) : (
<div className={cn(
'w-5 h-5 rounded-full border-2 flex items-center justify-center',
isNext ? 'border-indigo-400' : 'border-gray-200',
)}>
<span className={cn(
'text-[9px] font-bold tabular-nums',
isNext ? 'text-indigo-500' : 'text-gray-300',
)}>
{idx + 1}
</span>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<Icon className={cn(
'w-3.5 h-3.5 shrink-0',
step.done ? 'text-gray-300' : isNext ? 'text-indigo-500' : 'text-gray-400',
)} />
<p className={cn(
'text-sm font-medium leading-tight',
step.done
? 'text-gray-400 line-through'
: isNext ? 'text-indigo-700' : 'text-gray-700',
)}>
{step.title}
</p>
</div>
{!step.done && (
<p className="text-xs text-gray-400 mt-0.5 leading-relaxed">
{step.description}
</p>
)}
{isNext && step.action && (
<button
onClick={step.action}
className="mt-1.5 text-xs font-semibold text-indigo-600 hover:text-indigo-800 transition-colors"
>
{step.cta}
</button>
)}
</div>
</div>
)
})}
</div>
{/* Footer */}
<div className="px-4 py-2.5 border-t border-gray-50 flex items-center justify-between">
<span className="text-[11px] text-gray-400">
{t('onboarding.est_time')}
</span>
<button
onClick={dismiss}
className="text-[11px] text-gray-400 hover:text-gray-600 transition-colors underline underline-offset-2"
>
{t('onboarding.dismiss')}
</button>
</div>
</>
)}
</>
)}
</div>
)
}

View File

@@ -1,22 +1,40 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Sparkles, Menu, X } from 'lucide-react' import { Sparkles, Menu, X } from 'lucide-react'
import i18n from '@/i18n/i18n'
/**
* R-07 FIX: PublicLayout provides navigation for unauthenticated users
* on public pages (Marketplace, Pricing, ChatbotDetail).
* Previously these pages had NO navigation header, making it impossible
* for unauthenticated users to navigate between public pages.
*/
export const PublicLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const PublicLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation()
const location = useLocation() const location = useLocation()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const currentLang = i18n.language?.startsWith('fr') ? 'fr' : 'en'
const isActive = (path: string) => location.pathname.startsWith(path) const isActive = (path: string) => location.pathname.startsWith(path)
const setLang = (lang: string) => {
i18n.changeLanguage(lang)
}
const LangToggle = ({ size = 'md' }: { size?: 'sm' | 'md' }) => (
<div className="flex items-center border border-gray-200 rounded-lg overflow-hidden text-xs font-semibold">
<button
onClick={() => setLang('fr')}
className={`${size === 'sm' ? 'px-2 py-1' : 'px-2.5 py-1.5'} transition-colors ${currentLang === 'fr' ? 'bg-primary-600 text-white' : 'text-gray-500 hover:bg-gray-50'}`}
>
FR
</button>
<button
onClick={() => setLang('en')}
className={`${size === 'sm' ? 'px-2 py-1' : 'px-2.5 py-1.5'} transition-colors ${currentLang === 'en' ? 'bg-primary-600 text-white' : 'text-gray-500 hover:bg-gray-50'}`}
>
EN
</button>
</div>
)
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Navigation Header */}
<nav className="bg-white border-b border-gray-200 sticky top-0 z-50"> <nav className="bg-white border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6"> <div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="flex items-center justify-between h-14"> <div className="flex items-center justify-between h-14">
@@ -37,7 +55,7 @@ export const PublicLayout: React.FC<{ children: React.ReactNode }> = ({ children
isActive('/marketplace') ? 'text-primary-600' : 'text-gray-600 hover:text-gray-900' isActive('/marketplace') ? 'text-primary-600' : 'text-gray-600 hover:text-gray-900'
}`} }`}
> >
Marketplace {t('nav.marketplace')}
</Link> </Link>
<Link <Link
to="/pricing" to="/pricing"
@@ -45,32 +63,36 @@ export const PublicLayout: React.FC<{ children: React.ReactNode }> = ({ children
isActive('/pricing') ? 'text-primary-600' : 'text-gray-600 hover:text-gray-900' isActive('/pricing') ? 'text-primary-600' : 'text-gray-600 hover:text-gray-900'
}`} }`}
> >
Pricing {t('nav.pricing')}
</Link> </Link>
</div> </div>
{/* Auth buttons (desktop) */} {/* Desktop right side */}
<div className="hidden md:flex items-center gap-3"> <div className="hidden md:flex items-center gap-3">
<LangToggle />
<Link to="/login" className="text-sm text-gray-600 hover:text-gray-900 font-medium px-3 py-1.5 transition-colors"> <Link to="/login" className="text-sm text-gray-600 hover:text-gray-900 font-medium px-3 py-1.5 transition-colors">
Sign in {t('nav.signin')}
</Link> </Link>
<Link <Link
to="/signup" to="/signup"
className="bg-primary-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-primary-700 font-semibold transition-all shadow-sm" className="bg-primary-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-primary-700 font-semibold transition-all shadow-sm"
> >
Get started free {t('nav.get_started')}
</Link> </Link>
</div> </div>
{/* Mobile hamburger */} {/* Mobile controls */}
<div className="md:hidden flex items-center gap-2">
<LangToggle size="sm" />
<button <button
className="md:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors" className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu" aria-label="Toggle menu"
> >
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />} {mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button> </button>
</div> </div>
</div>
{/* Mobile menu */} {/* Mobile menu */}
{mobileMenuOpen && ( {mobileMenuOpen && (
@@ -80,14 +102,14 @@ export const PublicLayout: React.FC<{ children: React.ReactNode }> = ({ children
className="block px-3 py-2 text-sm text-gray-700 font-medium rounded-lg hover:bg-gray-50" className="block px-3 py-2 text-sm text-gray-700 font-medium rounded-lg hover:bg-gray-50"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
Marketplace {t('nav.marketplace')}
</Link> </Link>
<Link <Link
to="/pricing" to="/pricing"
className="block px-3 py-2 text-sm text-gray-700 font-medium rounded-lg hover:bg-gray-50" className="block px-3 py-2 text-sm text-gray-700 font-medium rounded-lg hover:bg-gray-50"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
Pricing {t('nav.pricing')}
</Link> </Link>
<hr className="border-gray-100 my-2" /> <hr className="border-gray-100 my-2" />
<Link <Link
@@ -95,21 +117,20 @@ export const PublicLayout: React.FC<{ children: React.ReactNode }> = ({ children
className="block px-3 py-2 text-sm text-gray-700 font-medium rounded-lg hover:bg-gray-50" className="block px-3 py-2 text-sm text-gray-700 font-medium rounded-lg hover:bg-gray-50"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
Sign in {t('nav.signin')}
</Link> </Link>
<Link <Link
to="/signup" to="/signup"
className="block mx-3 bg-primary-600 text-white text-sm px-4 py-2.5 rounded-lg font-semibold text-center" className="block mx-3 bg-primary-600 text-white text-sm px-4 py-2.5 rounded-lg font-semibold text-center"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
> >
Get started free {t('nav.get_started')}
</Link> </Link>
</div> </div>
)} )}
</div> </div>
</nav> </nav>
{/* Page content */}
<main>{children}</main> <main>{children}</main>
</div> </div>
) )

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react' import React, { useEffect } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { X } from 'lucide-react' import { X } from 'lucide-react'

18
src/i18n/i18n.ts Normal file
View File

@@ -0,0 +1,18 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from './locales/en.json'
import fr from './locales/fr.json'
i18n
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
fr: { translation: fr },
},
lng: 'fr',
fallbackLng: 'fr',
interpolation: { escapeValue: false },
})
export default i18n

656
src/i18n/locales/en.json Normal file
View File

@@ -0,0 +1,656 @@
{
"onboarding": {
"title": "Getting started",
"welcome": "Welcome! Let's get your first chatbot live in 7 minutes.",
"est_time": "~7 min to finish",
"dismiss": "Dismiss",
"all_done_title": "You're all set!",
"all_done_desc": "Your chatbot is live. Share it with the world.",
"step_create_title": "Create your first chatbot",
"step_create_desc": "Give it a name, personality, and brand color.",
"step_create_cta": "Create chatbot",
"step_knowledge_title": "Train it with your content",
"step_knowledge_desc": "Upload documents or add website URLs so it can answer questions.",
"step_knowledge_cta": "Add content",
"step_test_title": "Test your chatbot",
"step_test_desc": "Chat with it and make sure the answers are accurate.",
"step_test_cta": "Test now",
"step_publish_title": "Publish your chatbot",
"step_publish_desc": "Make it live and accessible to visitors.",
"step_publish_cta": "Publish",
"step_share_title": "Share or embed it",
"step_share_desc": "Add the chat widget to your website or share the link.",
"step_share_cta": "Get embed code"
},
"common": {
"cancel": "Cancel",
"delete": "Delete",
"save": "Save",
"save_changes": "Save Changes",
"close": "Close",
"confirm": "Confirm",
"filter": "Filter",
"export_csv": "Export CSV",
"all_chatbots": "All chatbots",
"all_statuses": "All statuses",
"published": "Published",
"draft": "Draft",
"preview": "Preview",
"publish": "Publish",
"unpublish": "Unpublish",
"edit_settings": "Edit Settings",
"analytics": "Analytics",
"no_data": "No data yet",
"loading": "Loading...",
"back": "Back",
"no_changes": "No changes to save"
},
"nav": {
"dashboard": "Dashboard",
"inbox": "Inbox",
"leads": "Leads",
"appointments": "Appointments",
"campaigns": "Campaigns",
"analytics": "Analytics",
"marketplace": "Marketplace",
"settings": "Settings",
"admin_panel": "Admin Panel",
"sign_out": "Sign out",
"pricing": "Pricing",
"features": "Features",
"signin": "Sign in",
"get_started": "Get started free"
},
"dashboard": {
"title": "Dashboard",
"subtitle_empty": "Manage your AI chatbots",
"chatbot_count_one": "{{count}} chatbot",
"chatbot_count_other": "{{count}} chatbots",
"new_chatbot": "New Chatbot",
"no_chatbots_title": "No chatbots yet",
"no_chatbots_desc": "Create your first AI chatbot powered by your documents. Free to build and test.",
"create_first": "Create your first chatbot",
"delete_chatbot": "Delete Chatbot",
"delete_confirm": "All documents, conversation history, and settings will be permanently removed. This cannot be undone.",
"publish_to_marketplace": "Publish to Marketplace",
"unpublish_chatbot": "Unpublish Chatbot",
"publish_confirm": "Your chatbot will be publicly visible on the marketplace.",
"unpublish_confirm": "Your chatbot will be removed from the marketplace.",
"chatbot_deleted": "Chatbot deleted",
"chatbot_published": "Chatbot published to marketplace!",
"chatbot_unpublished": "Chatbot unpublished"
},
"inbox": {
"title": "Inbox",
"conversation_count_one": "{{count}} conversation",
"conversation_count_other": "{{count}} conversations",
"filter_all": "All",
"filter_open": "Open",
"filter_agent": "Agent",
"filter_resolved": "Resolved",
"no_conversations": "No conversations",
"try_different_filter": "Try a different filter",
"no_messages": "(No messages)",
"select_conversation": "Select a conversation",
"select_conversation_desc": "Choose one from the list to view the full exchange",
"take_over": "Take Over",
"resolve": "Resolve",
"reopen": "Reopen",
"you_agent": "You (agent)",
"handoff_requested": "Handoff requested",
"low_confidence": "Low confidence",
"conversation_resolved": "Conversation resolved —",
"reopen_link": "reopen",
"to_reply": "to reply",
"type_reply": "Type a reply as agent...",
"delete_conversation": "Delete this conversation?",
"failed_to_delete": "Failed to delete conversation",
"status_open": "Open",
"status_agent": "Agent",
"status_resolved": "Resolved",
"upgrade_title": "Conversation Inbox",
"upgrade_desc": "Upgrade to Starter to read all your chatbot conversations in one place."
},
"leads": {
"title": "Leads",
"subtitle": "Contacts collected by your chatbots",
"total_leads": "Total leads",
"this_month": "This month",
"filter_by_chatbot": "Filter by chatbot",
"clear_status_filter": "Clear status filter",
"col_contact": "Contact",
"col_phone": "Phone",
"col_company": "Company",
"col_status": "Status",
"col_notes": "Notes",
"col_date": "Date",
"add_note": "Add note",
"notes_modal_title": "Notes — {{name}}",
"notes_placeholder": "Add notes about this lead...",
"no_leads_title": "No leads yet",
"no_leads_with_status": "No leads with status \"{{status}}\"",
"no_leads_desc": "Enable lead capture on your chatbots to start collecting contact information.",
"no_leads_status_desc": "Try a different filter or clear the current one.",
"export_failed": "Export failed",
"status_new": "New",
"status_contacted": "Contacted",
"status_qualified": "Qualified",
"status_closed": "Closed",
"status_lost": "Lost",
"upgrade_title": "Lead Capture",
"upgrade_desc": "Upgrade to Starter to capture and manage leads from your chatbots."
},
"appointments": {
"title": "Appointments",
"subtitle": "Bookings made through your chatbots",
"stat_today": "Today",
"stat_upcoming": "Upcoming",
"stat_confirmed": "Confirmed",
"stat_pending": "Pending",
"filter": "Filter",
"hours_label": "Hours:",
"configure_chatbot_hours": "Configure chatbot...",
"enable_booking_title": "Enable booking on a chatbot",
"enable_booking_desc": "Go to a chatbot's Deploy tab and enable \"Appointment Booking\" to start accepting bookings.",
"configure_chatbot": "Configure chatbot →",
"no_appointments_title": "No appointments yet",
"no_appointments_desc": "Once customers book through your chatbot, appointments will appear here.",
"today_label": "Today",
"to": "to",
"confirm_btn": "Confirm",
"decline_btn": "Decline",
"mark_complete": "Mark Complete",
"cancel_btn": "Cancel",
"restore_btn": "Restore",
"hours_title": "Business Hours",
"hours_desc": "Configure when customers can book appointments.",
"hours_back": "← Back",
"hours_closed": "Closed",
"save_hours": "Save Hours",
"hours_saved": "✓ Saved!",
"status_pending": "Pending",
"status_confirmed": "Confirmed",
"status_cancelled": "Cancelled",
"status_completed": "Completed",
"days_mon": "Mon",
"days_tue": "Tue",
"days_wed": "Wed",
"days_thu": "Thu",
"days_fri": "Fri",
"days_sat": "Sat",
"days_sun": "Sun",
"upgrade_title": "Appointment Booking",
"upgrade_desc": "Upgrade to Starter to enable appointment booking for your chatbots."
},
"campaigns": {
"title": "Campaigns",
"subtitle": "Broadcast messages to Telegram subscribers",
"new_campaign": "New Campaign",
"stat_campaigns": "Campaigns",
"stat_sent": "Sent",
"stat_delivered": "Messages delivered",
"chatbot_label": "Chatbot",
"chatbot_hint": "Will broadcast to all Telegram subscribers of this chatbot.",
"campaign_name": "Campaign name",
"campaign_name_placeholder": "e.g. Summer promotion, New menu announcement...",
"message_label": "Message",
"message_placeholder": "Write your broadcast message here...",
"characters": "{{count}}/4000 characters",
"create_campaign": "Create Campaign",
"send_campaign": "Send Campaign",
"delete_record": "Delete record",
"send_modal_title": "Send this campaign?",
"send_modal_desc_one": "\"{{title}}\" will be sent to {{count}} subscriber via Telegram.",
"send_modal_desc_other": "\"{{title}}\" will be sent to {{count}} subscribers via Telegram.",
"send_modal_warning": "This action cannot be undone. The message will be delivered immediately.",
"send_now": "Send Now",
"delete_campaign": "Delete this campaign?",
"delete_campaign_record": "Delete this campaign record?",
"subscriber_one": "{{count}} subscriber",
"subscriber_other": "{{count}} subscribers",
"delivered": "delivered",
"no_campaigns_title": "No campaigns yet",
"no_campaigns_desc": "Create a campaign to broadcast a message to all your Telegram subscribers at once.",
"no_chatbots_needed": "You need at least one chatbot to create a campaign.",
"status_draft": "Draft",
"status_sending": "Sending...",
"status_sent": "Sent",
"status_failed": "Failed",
"upgrade_title": "Telegram Campaigns",
"upgrade_desc": "Upgrade to Starter to broadcast messages to your Telegram subscribers."
},
"analytics": {
"title": "Analytics",
"subtitle": "Track how your chatbots are performing",
"stat_conversations": "Conversations",
"stat_unique_users": "Unique users",
"stat_messages": "Messages",
"stat_avg_rating": "Avg rating",
"stat_this_month": "{{count}} this month",
"stat_across_all": "Across all chatbots",
"stat_total_exchanged": "Total exchanged",
"stat_across_rated": "Across rated chatbots",
"stat_no_ratings": "No ratings yet",
"monthly_conversations": "Monthly conversations",
"your_chatbots": "Your chatbots",
"published": "published",
"today": "Today",
"this_week": "This week",
"this_month": "This month",
"avg_msgs": "Avg msgs/convo",
"last_30_days": "Last 30 days",
"top_questions": "Top questions",
"languages": "Languages",
"knowledge_gaps": "Knowledge gaps — {{count}} unanswered",
"add_content": "+ Add content →",
"gaps_desc": "Customers asked these questions but your bot couldn't answer well. Add documents or URL sources covering these topics.",
"more_gaps_one": "+{{count}} more gap",
"more_gaps_other": "+{{count}} more gaps",
"feedback": "Feedback",
"helpful_pct": "{{pct}}% helpful",
"peak_hour": "Peak: {{from}}:00 {{to}}:00",
"conversations_today": "{{count}} today",
"no_chatbots_title": "No chatbots yet",
"no_chatbots_desc": "Create your first chatbot to start seeing analytics.",
"create_chatbot": "Create chatbot",
"unable_to_load": "Unable to load analytics",
"try_refreshing": "Please try refreshing the page.",
"upgrade_title": "Analytics Dashboard",
"upgrade_desc": "Unlock analytics to see how your chatbots are performing — conversations, user engagement, top questions, and more.",
"upgrade_button": "Upgrade to Starter — $3/mo",
"upgrade_note": "Available on Starter and Pro plans",
"plan_badge": "{{plan}} plan"
},
"settings": {
"title": "Settings",
"light_mode": "Light mode",
"dark_mode": "Dark mode",
"tab_profile": "Profile",
"tab_billing": "Billing",
"profile_info": "Profile Information",
"email": "Email",
"email_hint": "Email cannot be changed",
"company_name": "Company Name",
"company_placeholder": "Your company name",
"plan_label": "Plan",
"manage_plan": "Manage plan",
"change_password": "Change Password",
"current_password": "Current Password",
"current_password_placeholder": "Enter current password",
"new_password": "New Password",
"new_password_placeholder": "Min 8 characters",
"new_password_hint": "Leave blank to keep current password",
"danger_zone": "Danger Zone",
"danger_desc": "Permanently delete your account, all chatbots, documents, and data. This cannot be undone.",
"delete_account_btn": "Delete Account",
"delete_account_title": "Delete Account",
"delete_account_desc": "This will permanently delete your account and all associated data including chatbots, documents, conversations, and leads.",
"delete_account_desc_bold": "This action cannot be undone.",
"type_delete": "Type DELETE to confirm:",
"profile_updated": "Profile updated successfully",
"update_failed": "Failed to update profile",
"language_label": "Language",
"language_updated": "Language updated",
"lang_en": "English",
"lang_fr": "Français",
"current_plan": "Current Plan",
"status_label": "Status:",
"status_active": "Active",
"renewal_date": "Renewal Date",
"upgrade_plan": "✨ Upgrade Plan",
"manage_billing": "Manage Billing",
"plan_features": "Plan Features",
"chatbots_published": "Chatbots published",
"conversations_per_month": "Conversations / month",
"code_export": "Code export",
"chatbot_suffix": "chatbot(s)",
"conversations_suffix": "conversations",
"billing_footer_paid": "💳 Simplified subscription management",
"billing_footer_free": "🚀 Unlock more features by upgrading your plan"
},
"builder": {
"loading": "Loading chatbot…",
"choose_template": "Choose a template",
"choose_template_sub": "Start from a template or build from scratch",
"scratch": "Start from scratch",
"create_chatbot": "Create Chatbot",
"untitled": "Untitled Chatbot",
"published": "Published",
"draft": "Draft",
"create": "Create",
"save": "Save",
"tab_settings": "Settings",
"tab_documents": "Documents",
"tab_preview": "Preview",
"tab_testing": "Testing",
"tab_deploy": "Deploy",
"save_first_testing": "Save your chatbot first to run tests.",
"testing_title": "Bot Testing",
"testing_desc": "Run questions against your chatbot and inspect answers, confidence scores, and source documents.",
"testing_placeholder": "e.g. What are your opening hours?",
"testing_add": "Add question",
"testing_run": "Run tests",
"testing_running": "Running…",
"testing_results": "{{count}} result(s)",
"testing_sources": "Sources used",
"testing_model": "Model",
"testing_error": "Test failed. Make sure your chatbot has a knowledge base.",
"refresh_url": "Re-scrape this URL",
"created": "Chatbot created!",
"create_failed": "Failed to create",
"saved": "Settings saved!",
"save_failed": "Save failed",
"name_required": "Chatbot name is required",
"save_first_docs": "Save your chatbot first to upload documents.",
"save_first_preview": "Save your chatbot first to preview it.",
"save_first_deploy": "Save your chatbot first to access deployment options.",
"save_first_hint": "Fill in the Settings tab and click Save to continue.",
"section_basic": "Basic Info",
"section_basic_desc": "Name, description, and greeting message for your chatbot",
"chatbot_name": "Chatbot Name",
"chatbot_name_placeholder": "e.g. Customer Support Bot",
"description": "Description",
"description_placeholder": "What does this chatbot do?",
"welcome_message": "Welcome Message",
"welcome_hint": "The first message visitors will see when opening the chat",
"system_prompt": "System Prompt",
"system_prompt_placeholder": "You are a helpful assistant for...",
"system_prompt_hint": "Custom instructions for the AI's behavior and personality (optional)",
"section_appearance": "Appearance",
"section_appearance_desc": "Logo and brand color shown in the chat widget",
"logo_label": "Chatbot Logo",
"logo_hint": "Upload your company logo. It will appear in the chat header.",
"brand_color": "Brand Color",
"color_preview": "Preview of how the chat button will look",
"section_advanced": "Advanced Settings",
"section_advanced_desc": "AI model, temperature, response length",
"ai_model": "AI Model",
"models_loading": "Loading available models...",
"models_empty": "No models available on your current plan.",
"models_upgrade": "Upgrade",
"models_upgrade_suffix": "to access AI models.",
"response_params": "Response Parameters",
"temperature": "Temperature",
"temp_precise": "Precise",
"temp_creative": "Creative",
"max_tokens": "Max Tokens",
"max_tokens_hint": "Max response length",
"section_classification": "Classification",
"section_classification_desc": "Helps users discover your chatbot in the marketplace",
"select_category": "Select category",
"select_industry": "Select industry",
"logo_uploaded": "Logo uploaded",
"logo_remove": "Remove logo",
"logo_drop": "Drop your logo here",
"logo_click": "Click or drag to upload a logo",
"logo_formats": "PNG, JPG, SVG, or WebP · Max 2MB",
"logo_processing": "Processing...",
"logo_error_type": "Please upload a PNG, JPG, GIF, SVG, or WebP image.",
"logo_error_size": "Image must be under 2MB.",
"logo_error_upload": "Upload failed. Please try again.",
"section_upload": "Upload Documents",
"section_upload_desc": "PDF, DOCX, CSV, XLSX, TXT, MD — used to train your chatbot's knowledge base",
"drop_files": "Drop files here",
"click_upload": "Click or drag files to upload",
"uploading": "Uploading...",
"upload_success": "Documents uploaded successfully!",
"docs_empty": "No documents yet",
"docs_empty_hint": "Upload files above to build your chatbot's knowledge base.",
"doc_count_one": "{{count}} document",
"doc_count_other": "{{count}} documents",
"chunks": "{{n}} chunks",
"section_urls": "URL Sources",
"section_urls_desc": "Add web pages to your chatbot's knowledge base",
"add_url": "Add URL",
"url_failed": "Failed to add URL",
"section_chat_link": "Public Chat Link",
"section_chat_link_desc": "Share a direct link to your chatbot with anyone",
"copy": "Copy",
"copied": "Copied",
"publish_for_link": "Publish your chatbot in the Deploy settings to get a public chat link.",
"section_embed": "Embed Code",
"section_embed_desc": "Add a chat widget to any website with one line of code",
"publish_for_embed": "Publish your chatbot first to get the embed code.",
"section_lead": "Lead Capture",
"section_lead_desc": "Collect visitor information before or during the conversation",
"lead_enable": "Enable lead capture",
"lead_enable_sub": "Ask visitors for their contact info",
"collect_fields": "Collect fields",
"required": "required",
"when_show": "When to show form",
"after_first": "After first message",
"before_first": "Before first message",
"section_handoff": "Human Handoff",
"section_handoff_desc": "Let visitors request to speak with a human agent",
"handoff_enable": "Enable human handoff",
"handoff_enable_sub": "Triggered when user says \"human\", \"agent\", etc.",
"handoff_message_label": "Handoff message",
"handoff_webhook_note": "Configure the n8n webhook URL in your backend to receive notifications.",
"section_branding": "Branding",
"section_branding_desc": "Control the Contexta attribution in your chat widget",
"show_branding": "Show \"Powered by Contexta\"",
"show_branding_sub": "Remove branding by upgrading to Pro plan or above",
"section_booking": "Appointment Booking",
"section_booking_desc": "Let customers book appointments directly through your chatbot",
"booking_enable": "Enable appointment booking",
"booking_enable_sub": "When enabled, the chatbot will guide users to your booking page and mention it in conversations.",
"booking_url_label": "Booking page URL:",
"booking_url_hint": "Share this link on your website or social media. Set your available hours in the",
"booking_url_hint_link": "Appointments page",
"section_channels": "Messaging Channels",
"section_channels_desc": "Connect your chatbot to Telegram",
"telegram_connected": "Connected",
"telegram_share": "Share this bot link with your customers — they open it and start chatting.",
"telegram_owner_notice": "To receive handoff alerts, open your bot and send it",
"telegram_owner_notice2": "It will register you as the owner and notify you here whenever a visitor needs human help.",
"telegram_disconnect": "Disconnect",
"telegram_how_title": "How to create a Telegram bot (2 minutes):",
"telegram_step1": "Open Telegram and search for @BotFather",
"telegram_step2": "Send /newbot",
"telegram_step3": "Choose a name and username for your bot",
"telegram_step4": "BotFather will send you a token — copy it",
"telegram_step5": "Paste the token below and click Connect",
"telegram_share_hint": "Once connected, share your bot link (e.g. t.me/YourBotName) with customers.",
"telegram_placeholder": "Bot token from @BotFather",
"telegram_connect": "Connect",
"telegram_connect_failed": "Failed to connect. Check your token.",
"embed_hint_html": "Paste before the closing </body> tag in your HTML file.",
"embed_hint_react": "Add to your index.html (in the public/ folder) before the closing </body> tag.",
"embed_hint_nextjs": "Use the built-in Script component inside your root layout so it loads on every page.",
"embed_hint_wordpress": "Go to Appearance → Theme File Editor → footer.php and paste before </body>. Or use \"Insert Headers and Footers\" plugin.",
"embed_hint_webflow": "Go to Site Settings → Custom Code → Footer Code and paste the script there. Republish your site.",
"embed_hint_shopify": "Go to Online Store → Themes → Edit code → layout/theme.liquid and paste before </body>.",
"model_default": "(default)"
},
"marketplace": {
"title": "AI Chatbot Marketplace",
"subtitle": "Discover and interact with AI-powered chatbots built by businesses — ready to answer your questions instantly.",
"search_placeholder": "Search chatbots by name or description...",
"filters": "Filters",
"category": "Category",
"all": "All",
"industry": "Industry",
"all_industries": "All Industries",
"clear_all_filters": "Clear all filters",
"clear_filters": "Clear filters",
"no_chatbots_title": "No chatbots found",
"no_chatbots_filtered": "Try adjusting your filters or search query.",
"no_chatbots_empty": "Be the first to publish your AI chatbot to the marketplace!",
"create_chatbot": "Create Chatbot",
"available_one": "{{count}} chatbot available",
"available_other": "{{count}} chatbots available",
"by": "by {{name}}",
"chat_now": "Chat now →",
"conversations": "{{count}} conversations",
"back_to_marketplace": "Back to Marketplace",
"not_found_title": "Chatbot not found",
"not_found_desc": "This chatbot may have been unpublished or removed.",
"submit_rating": "Submit",
"your_rating": "Your rating",
"login_to_rate": "Sign in to rate this chatbot",
"ratings": "ratings"
},
"auth": {
"login_title": "Welcome back",
"login_subtitle": "Sign in to your Contexta account",
"email": "Email",
"password": "Password",
"sign_in": "Sign in",
"no_account": "No account?",
"sign_up_free": "Sign up free",
"forgot_password": "Forgot password?",
"login_failed": "Login failed. Please check your credentials.",
"signup_title": "Create your account",
"signup_subtitle": "Start building AI chatbots — free forever",
"company_name": "Company Name",
"create_free_account": "Create free account",
"terms_text": "By signing up you agree to our",
"terms_of_service": "Terms of Service",
"and": "and",
"privacy_policy": "Privacy Policy",
"already_account": "Already have an account?",
"already_confirmed": "Already confirmed?",
"check_inbox_title": "Check your inbox",
"check_inbox_desc": "A confirmation link was sent to",
"password_min_8": "Password must be at least 8 characters",
"signup_failed": "Signup failed. Please try again.",
"forgot_title": "Reset your password",
"forgot_subtitle": "We'll send a reset link to your email address.",
"email_address": "Email address",
"send_reset_link": "Send reset link",
"back_to_signin": "Back to sign in",
"forgot_sent_title": "Check your inbox",
"forgot_sent_desc_one": "If {{email}} is registered,",
"forgot_sent_desc_two": "a password reset link has been sent.",
"forgot_error": "Something went wrong. Please try again.",
"reset_title": "Set new password",
"reset_subtitle": "Choose a strong password for your account.",
"new_password": "New Password",
"confirm_password": "Confirm Password",
"confirm_placeholder": "Repeat password",
"set_new_password": "Set new password",
"link_expired_title": "Link expired",
"request_new_link": "Request a new reset link",
"passwords_dont_match": "Passwords do not match",
"failed_to_reset": "Failed to reset password. The link may have expired.",
"branding_headline": "Your AI chatbot,\nready in minutes.",
"branding_subtext": "Train on your documents, manage every conversation, capture leads and book appointments — all in one place.",
"branding_feature_1": "PDF, DOCX, CSV, URLs — any source",
"branding_feature_2": "Live inbox, lead capture & appointment booking",
"branding_feature_3": "Web embed, Telegram & marketplace listing",
"branding_footer": "Trusted by businesses building smarter customer experiences."
},
"pricing": {
"badge": "Pricing",
"title": "Simple, transparent pricing",
"subtitle": "Start free and go live for $12/month. Built for individuals, small businesses, agencies, and enterprises alike.",
"monthly": "Monthly",
"yearly": "Yearly",
"per_month": "/mo",
"save_yr": "Save ${{amount}}/yr",
"custom_price": "Custom",
"current_plan_badge": "Current Plan",
"most_popular": "Most Popular",
"cta_current": "Current Plan",
"cta_free": "Get Started Free",
"cta_contact": "Contact Sales",
"cta_downgrade": "Downgrade",
"cta_upgrade": "Upgrade Now",
"faq_title": "Frequently Asked Questions",
"faq_subtitle": "Everything you need to know about Contexta's plans.",
"plan_free": "Free",
"plan_free_desc": "Build, test and launch your first chatbot — no card needed",
"plan_starter": "Starter",
"plan_starter_desc": "For solo operators: live chat, leads, booking, and campaigns",
"plan_business": "Business",
"plan_business_desc": "For growing businesses: premium AI, unlimited booking, full analytics",
"plan_agency": "Agency",
"plan_agency_desc": "For agencies: unlimited everything, white-label ready",
"plan_enterprise": "Enterprise",
"plan_enterprise_desc": "For large organizations with custom needs and SLAs",
"feat_free": [
"1 published chatbot",
"100 conversations/month",
"3 documents per chatbot",
"Public chat link + website embed",
"Llama 3.3 70B model",
"Read-only inbox (no agent replies)",
"View-only leads (no editing)",
"Analytics dashboard",
"Appointments & campaigns",
"Messaging channels",
"Remove \"Powered by Contexta\""
],
"feat_starter": [
"Everything in Free",
"3 published chatbots",
"1,500 conversations/month",
"10 documents per chatbot",
"4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)",
"Live chat inbox + agent replies",
"Full lead CRM (status + notes)",
"Appointment booking (1 chatbot)",
"Telegram campaigns (3/mo · 500 recipients)",
"Analytics dashboard",
"Knowledge gap suggestions",
"Premium models (GPT-4o, Claude, Gemini)",
"Remove \"Powered by Contexta\""
],
"feat_business": [
"Everything in Starter",
"10 published chatbots",
"5,000 conversations/month",
"50 documents per chatbot",
"GPT-4o, Claude Haiku 4.5, Gemini 2.5",
"Appointment booking (all chatbots)",
"Unlimited campaigns · 5,000 recipients",
"Knowledge gap suggestions",
"Remove \"Powered by Contexta\"",
"Unlimited URL sources"
],
"feat_agency": [
"Everything in Business",
"Unlimited published chatbots",
"20,000 conversations/month",
"Unlimited documents",
"Unlimited campaign recipients",
"Code export (FastAPI + React)",
"Dedicated support"
],
"feat_enterprise": [
"Everything in Agency",
"Unlimited conversations",
"White-label platform",
"SSO (SAML)",
"SLA guarantees",
"Dedicated account manager",
"24/7 phone support"
],
"faq": [
{
"q": "Can I use the free tier forever?",
"a": "Yes! Build and test unlimited chatbots for free. Your chatbots will remain in preview mode until you subscribe."
},
{
"q": "What is code export?",
"a": "Agency plan users can export their chatbot as a complete, production-ready package including a FastAPI backend and React TypeScript widget — giving you full control to self-host."
},
{
"q": "Do I need my own API keys?",
"a": "No — API keys are handled by Contexta. If you export the code on the Agency plan, you'll need your own keys for self-hosted deployment."
},
{
"q": "Can I cancel anytime?",
"a": "Yes, cancel anytime. Your chatbots will revert to the free tier at the end of your billing period."
},
{
"q": "What happens if I hit my conversation limit?",
"a": "Your chatbot will show a friendly message to try again later. Upgrade your plan for more conversations."
},
{
"q": "I'm a small business. Which plan is right for me?",
"a": "Start with Starter at $12/month — 1 published chatbot, 1,500 conversations, analytics, lead capture and Telegram. Perfect for restaurants, shops, salons, and more. Upgrade to Business for premium AI models and more capacity."
}
]
}
}

656
src/i18n/locales/fr.json Normal file
View File

@@ -0,0 +1,656 @@
{
"onboarding": {
"title": "Premiers pas",
"welcome": "Bienvenue ! Mettez votre premier chatbot en ligne en 7 minutes.",
"est_time": "~7 min pour terminer",
"dismiss": "Masquer",
"all_done_title": "Vous êtes prêt !",
"all_done_desc": "Votre chatbot est en ligne. Partagez-le avec le monde.",
"step_create_title": "Créez votre premier chatbot",
"step_create_desc": "Donnez-lui un nom, une personnalité et une couleur.",
"step_create_cta": "Créer le chatbot",
"step_knowledge_title": "Entraînez-le avec votre contenu",
"step_knowledge_desc": "Importez des documents ou ajoutez des URLs pour qu'il puisse répondre aux questions.",
"step_knowledge_cta": "Ajouter du contenu",
"step_test_title": "Testez votre chatbot",
"step_test_desc": "Discutez avec lui et vérifiez que les réponses sont correctes.",
"step_test_cta": "Tester maintenant",
"step_publish_title": "Publiez votre chatbot",
"step_publish_desc": "Rendez-le accessible aux visiteurs.",
"step_publish_cta": "Publier",
"step_share_title": "Partagez ou intégrez-le",
"step_share_desc": "Ajoutez le widget sur votre site ou partagez le lien.",
"step_share_cta": "Obtenir le code"
},
"common": {
"cancel": "Annuler",
"delete": "Supprimer",
"save": "Enregistrer",
"save_changes": "Enregistrer les modifications",
"close": "Fermer",
"confirm": "Confirmer",
"filter": "Filtrer",
"export_csv": "Exporter en CSV",
"all_chatbots": "Tous les chatbots",
"all_statuses": "Tous les statuts",
"published": "Publié",
"draft": "Brouillon",
"preview": "Aperçu",
"publish": "Publier",
"unpublish": "Dépublier",
"edit_settings": "Modifier les paramètres",
"analytics": "Analytiques",
"no_data": "Aucune donnée",
"loading": "Chargement...",
"back": "Retour",
"no_changes": "Aucune modification à enregistrer"
},
"nav": {
"dashboard": "Tableau de bord",
"inbox": "Boîte de réception",
"leads": "Prospects",
"appointments": "Rendez-vous",
"campaigns": "Campagnes",
"analytics": "Analytiques",
"marketplace": "Marketplace",
"settings": "Paramètres",
"admin_panel": "Panel d'administration",
"sign_out": "Se déconnecter",
"pricing": "Tarifs",
"features": "Fonctionnalités",
"signin": "Se connecter",
"get_started": "Commencer gratuitement"
},
"dashboard": {
"title": "Tableau de bord",
"subtitle_empty": "Gérez vos chatbots IA",
"chatbot_count_one": "{{count}} chatbot",
"chatbot_count_other": "{{count}} chatbots",
"new_chatbot": "Nouveau chatbot",
"no_chatbots_title": "Aucun chatbot pour l'instant",
"no_chatbots_desc": "Créez votre premier chatbot IA alimenté par vos documents. Gratuit pour construire et tester.",
"create_first": "Créer votre premier chatbot",
"delete_chatbot": "Supprimer le chatbot",
"delete_confirm": "Tous les documents, l'historique des conversations et les paramètres seront définitivement supprimés. Cette action est irréversible.",
"publish_to_marketplace": "Publier sur la Marketplace",
"unpublish_chatbot": "Dépublier le chatbot",
"publish_confirm": "Votre chatbot sera visible publiquement sur la marketplace.",
"unpublish_confirm": "Votre chatbot sera retiré de la marketplace.",
"chatbot_deleted": "Chatbot supprimé",
"chatbot_published": "Chatbot publié sur la marketplace !",
"chatbot_unpublished": "Chatbot dépublié"
},
"inbox": {
"title": "Boîte de réception",
"conversation_count_one": "{{count}} conversation",
"conversation_count_other": "{{count}} conversations",
"filter_all": "Tous",
"filter_open": "Ouvert",
"filter_agent": "Agent",
"filter_resolved": "Résolu",
"no_conversations": "Aucune conversation",
"try_different_filter": "Essayez un autre filtre",
"no_messages": "(Aucun message)",
"select_conversation": "Sélectionnez une conversation",
"select_conversation_desc": "Choisissez-en une dans la liste pour voir l'échange complet",
"take_over": "Prendre en charge",
"resolve": "Résoudre",
"reopen": "Rouvrir",
"you_agent": "Vous (agent)",
"handoff_requested": "Transfert demandé",
"low_confidence": "Faible confiance",
"conversation_resolved": "Conversation résolue —",
"reopen_link": "rouvrir",
"to_reply": "pour répondre",
"type_reply": "Écrivez une réponse en tant qu'agent...",
"delete_conversation": "Supprimer cette conversation ?",
"failed_to_delete": "Échec de la suppression de la conversation",
"status_open": "Ouvert",
"status_agent": "Agent",
"status_resolved": "Résolu",
"upgrade_title": "Boîte de réception",
"upgrade_desc": "Passez à Starter pour lire toutes vos conversations de chatbot au même endroit."
},
"leads": {
"title": "Prospects",
"subtitle": "Contacts collectés par vos chatbots",
"total_leads": "Total des prospects",
"this_month": "Ce mois-ci",
"filter_by_chatbot": "Filtrer par chatbot",
"clear_status_filter": "Effacer le filtre de statut",
"col_contact": "Contact",
"col_phone": "Téléphone",
"col_company": "Entreprise",
"col_status": "Statut",
"col_notes": "Notes",
"col_date": "Date",
"add_note": "Ajouter une note",
"notes_modal_title": "Notes — {{name}}",
"notes_placeholder": "Ajoutez des notes sur ce prospect...",
"no_leads_title": "Aucun prospect pour l'instant",
"no_leads_with_status": "Aucun prospect avec le statut « {{status}} »",
"no_leads_desc": "Activez la capture de prospects sur vos chatbots pour commencer à collecter des contacts.",
"no_leads_status_desc": "Essayez un autre filtre ou effacez celui en cours.",
"export_failed": "Échec de l'exportation",
"status_new": "Nouveau",
"status_contacted": "Contacté",
"status_qualified": "Qualifié",
"status_closed": "Fermé",
"status_lost": "Perdu",
"upgrade_title": "Capture de prospects",
"upgrade_desc": "Passez à Starter pour capturer et gérer les prospects de vos chatbots."
},
"appointments": {
"title": "Rendez-vous",
"subtitle": "Réservations effectuées via vos chatbots",
"stat_today": "Aujourd'hui",
"stat_upcoming": "À venir",
"stat_confirmed": "Confirmé",
"stat_pending": "En attente",
"filter": "Filtrer",
"hours_label": "Horaires :",
"configure_chatbot_hours": "Configurer un chatbot...",
"enable_booking_title": "Activer la réservation sur un chatbot",
"enable_booking_desc": "Allez dans l'onglet Déploiement d'un chatbot et activez « Prise de rendez-vous » pour commencer à accepter des réservations.",
"configure_chatbot": "Configurer le chatbot →",
"no_appointments_title": "Aucun rendez-vous pour l'instant",
"no_appointments_desc": "Une fois que des clients réservent via votre chatbot, les rendez-vous apparaîtront ici.",
"today_label": "Aujourd'hui",
"to": "à",
"confirm_btn": "Confirmer",
"decline_btn": "Refuser",
"mark_complete": "Marquer terminé",
"cancel_btn": "Annuler",
"restore_btn": "Restaurer",
"hours_title": "Horaires d'ouverture",
"hours_desc": "Configurez quand les clients peuvent prendre rendez-vous.",
"hours_back": "← Retour",
"hours_closed": "Fermé",
"save_hours": "Enregistrer les horaires",
"hours_saved": "✓ Enregistré !",
"status_pending": "En attente",
"status_confirmed": "Confirmé",
"status_cancelled": "Annulé",
"status_completed": "Terminé",
"days_mon": "Lun",
"days_tue": "Mar",
"days_wed": "Mer",
"days_thu": "Jeu",
"days_fri": "Ven",
"days_sat": "Sam",
"days_sun": "Dim",
"upgrade_title": "Prise de rendez-vous",
"upgrade_desc": "Passez à Starter pour activer la prise de rendez-vous pour vos chatbots."
},
"campaigns": {
"title": "Campagnes",
"subtitle": "Diffusez des messages à vos abonnés Telegram",
"new_campaign": "Nouvelle campagne",
"stat_campaigns": "Campagnes",
"stat_sent": "Envoyé",
"stat_delivered": "Messages délivrés",
"chatbot_label": "Chatbot",
"chatbot_hint": "Sera diffusé à tous les abonnés Telegram de ce chatbot.",
"campaign_name": "Nom de la campagne",
"campaign_name_placeholder": "ex. Promotion estivale, Annonce du nouveau menu...",
"message_label": "Message",
"message_placeholder": "Rédigez votre message de diffusion ici...",
"characters": "{{count}}/4000 caractères",
"create_campaign": "Créer la campagne",
"send_campaign": "Envoyer la campagne",
"delete_record": "Supprimer l'enregistrement",
"send_modal_title": "Envoyer cette campagne ?",
"send_modal_desc_one": "« {{title}} » sera envoyé à {{count}} abonné via Telegram.",
"send_modal_desc_other": "« {{title}} » sera envoyé à {{count}} abonnés via Telegram.",
"send_modal_warning": "Cette action est irréversible. Le message sera délivré immédiatement.",
"send_now": "Envoyer maintenant",
"delete_campaign": "Supprimer cette campagne ?",
"delete_campaign_record": "Supprimer cet enregistrement de campagne ?",
"subscriber_one": "{{count}} abonné",
"subscriber_other": "{{count}} abonnés",
"delivered": "délivrés",
"no_campaigns_title": "Aucune campagne pour l'instant",
"no_campaigns_desc": "Créez une campagne pour diffuser un message à tous vos abonnés Telegram en une fois.",
"no_chatbots_needed": "Vous avez besoin d'au moins un chatbot pour créer une campagne.",
"status_draft": "Brouillon",
"status_sending": "Envoi en cours...",
"status_sent": "Envoyé",
"status_failed": "Échec",
"upgrade_title": "Campagnes Telegram",
"upgrade_desc": "Passez à Starter pour diffuser des messages à vos abonnés Telegram."
},
"analytics": {
"title": "Analytiques",
"subtitle": "Suivez les performances de vos chatbots",
"stat_conversations": "Conversations",
"stat_unique_users": "Utilisateurs uniques",
"stat_messages": "Messages",
"stat_avg_rating": "Note moyenne",
"stat_this_month": "{{count}} ce mois-ci",
"stat_across_all": "Tous les chatbots",
"stat_total_exchanged": "Total échangé",
"stat_across_rated": "Chatbots notés",
"stat_no_ratings": "Aucune note",
"monthly_conversations": "Conversations mensuelles",
"your_chatbots": "Vos chatbots",
"published": "publié",
"today": "Aujourd'hui",
"this_week": "Cette semaine",
"this_month": "Ce mois-ci",
"avg_msgs": "Msgs moy./convo",
"last_30_days": "30 derniers jours",
"top_questions": "Questions fréquentes",
"languages": "Langues",
"knowledge_gaps": "Lacunes — {{count}} sans réponse",
"add_content": "+ Ajouter du contenu →",
"gaps_desc": "Des clients ont posé ces questions mais votre bot n'a pas pu y répondre correctement. Ajoutez des documents ou des URL couvrant ces sujets.",
"more_gaps_one": "+{{count}} lacune supplémentaire",
"more_gaps_other": "+{{count}} lacunes supplémentaires",
"feedback": "Retours",
"helpful_pct": "{{pct}}% utile",
"peak_hour": "Pic : {{from}}h {{to}}h",
"conversations_today": "{{count}} aujourd'hui",
"no_chatbots_title": "Aucun chatbot pour l'instant",
"no_chatbots_desc": "Créez votre premier chatbot pour commencer à voir les analytiques.",
"create_chatbot": "Créer un chatbot",
"unable_to_load": "Impossible de charger les analytiques",
"try_refreshing": "Veuillez actualiser la page.",
"upgrade_title": "Tableau de bord analytique",
"upgrade_desc": "Débloquez les analytiques pour voir les performances de vos chatbots — conversations, engagement, questions fréquentes, et plus.",
"upgrade_button": "Passer à Starter — 3$/mois",
"upgrade_note": "Disponible sur les plans Starter et Pro",
"plan_badge": "Plan {{plan}}"
},
"settings": {
"title": "Paramètres",
"light_mode": "Mode clair",
"dark_mode": "Mode sombre",
"tab_profile": "Profil",
"tab_billing": "Facturation",
"profile_info": "Informations du profil",
"email": "E-mail",
"email_hint": "L'e-mail ne peut pas être modifié",
"company_name": "Nom de l'entreprise",
"company_placeholder": "Nom de votre entreprise",
"plan_label": "Plan",
"manage_plan": "Gérer le plan",
"change_password": "Modifier le mot de passe",
"current_password": "Mot de passe actuel",
"current_password_placeholder": "Entrez le mot de passe actuel",
"new_password": "Nouveau mot de passe",
"new_password_placeholder": "Min. 8 caractères",
"new_password_hint": "Laissez vide pour conserver le mot de passe actuel",
"danger_zone": "Zone dangereuse",
"danger_desc": "Supprimez définitivement votre compte, tous vos chatbots, documents et données. Cette action est irréversible.",
"delete_account_btn": "Supprimer le compte",
"delete_account_title": "Supprimer le compte",
"delete_account_desc": "Cela supprimera définitivement votre compte et toutes les données associées, y compris les chatbots, documents, conversations et prospects.",
"delete_account_desc_bold": "Cette action est irréversible.",
"type_delete": "Tapez SUPPRIMER pour confirmer :",
"profile_updated": "Profil mis à jour avec succès",
"update_failed": "Échec de la mise à jour du profil",
"language_label": "Langue",
"language_updated": "Langue mise à jour",
"lang_en": "English",
"lang_fr": "Français",
"current_plan": "Plan actuel",
"status_label": "Statut :",
"status_active": "Actif",
"renewal_date": "Date de renouvellement",
"upgrade_plan": "✨ Améliorer le plan",
"manage_billing": "Gérer la facturation",
"plan_features": "Fonctionnalités du plan",
"chatbots_published": "Chatbots publiés",
"conversations_per_month": "Conversations / mois",
"code_export": "Export de code",
"chatbot_suffix": "chatbot(s)",
"conversations_suffix": "conversations",
"billing_footer_paid": "💳 Gestion simplifiée de l'abonnement",
"billing_footer_free": "🚀 Débloquez plus de fonctionnalités en améliorant votre plan"
},
"builder": {
"loading": "Chargement du chatbot…",
"choose_template": "Choisir un modèle",
"choose_template_sub": "Démarrez à partir d'un modèle ou créez de zéro",
"scratch": "Créer de zéro",
"create_chatbot": "Créer le chatbot",
"untitled": "Chatbot sans titre",
"published": "Publié",
"draft": "Brouillon",
"create": "Créer",
"save": "Enregistrer",
"tab_settings": "Paramètres",
"tab_documents": "Documents",
"tab_preview": "Aperçu",
"tab_testing": "Tests",
"tab_deploy": "Déploiement",
"save_first_testing": "Enregistrez d'abord votre chatbot pour lancer des tests.",
"testing_title": "Tests du bot",
"testing_desc": "Posez des questions à votre chatbot et inspectez les réponses, scores de confiance et documents sources.",
"testing_placeholder": "ex. Quels sont vos horaires d'ouverture ?",
"testing_add": "Ajouter une question",
"testing_run": "Lancer les tests",
"testing_running": "En cours…",
"testing_results": "{{count}} résultat(s)",
"testing_sources": "Sources utilisées",
"testing_model": "Modèle",
"testing_error": "Test échoué. Vérifiez que votre chatbot a une base de connaissances.",
"refresh_url": "Re-scraper cette URL",
"created": "Chatbot créé !",
"create_failed": "Échec de la création",
"saved": "Paramètres enregistrés !",
"save_failed": "Échec de l'enregistrement",
"name_required": "Le nom du chatbot est requis",
"save_first_docs": "Enregistrez votre chatbot avant d'importer des documents.",
"save_first_preview": "Enregistrez votre chatbot avant de le prévisualiser.",
"save_first_deploy": "Enregistrez votre chatbot pour accéder aux options de déploiement.",
"save_first_hint": "Remplissez l'onglet Paramètres et cliquez sur Enregistrer pour continuer.",
"section_basic": "Informations de base",
"section_basic_desc": "Nom, description et message d'accueil de votre chatbot",
"chatbot_name": "Nom du chatbot",
"chatbot_name_placeholder": "ex. Bot de support client",
"description": "Description",
"description_placeholder": "Que fait ce chatbot ?",
"welcome_message": "Message d'accueil",
"welcome_hint": "Le premier message que les visiteurs verront en ouvrant le chat",
"system_prompt": "Invite système",
"system_prompt_placeholder": "Vous êtes un assistant utile pour...",
"system_prompt_hint": "Instructions personnalisées pour le comportement et la personnalité de l'IA (facultatif)",
"section_appearance": "Apparence",
"section_appearance_desc": "Logo et couleur de marque affichés dans le widget de chat",
"logo_label": "Logo du chatbot",
"logo_hint": "Importez le logo de votre entreprise. Il apparaîtra dans l'en-tête du chat.",
"brand_color": "Couleur de marque",
"color_preview": "Aperçu du bouton de chat",
"section_advanced": "Paramètres avancés",
"section_advanced_desc": "Modèle IA, température, longueur des réponses",
"ai_model": "Modèle IA",
"models_loading": "Chargement des modèles disponibles...",
"models_empty": "Aucun modèle disponible sur votre plan actuel.",
"models_upgrade": "Améliorer",
"models_upgrade_suffix": "pour accéder aux modèles IA.",
"response_params": "Paramètres de réponse",
"temperature": "Température",
"temp_precise": "Précis",
"temp_creative": "Créatif",
"max_tokens": "Tokens max",
"max_tokens_hint": "Longueur maximale de la réponse",
"section_classification": "Classification",
"section_classification_desc": "Aide les utilisateurs à découvrir votre chatbot sur la marketplace",
"select_category": "Sélectionner une catégorie",
"select_industry": "Sélectionner un secteur",
"logo_uploaded": "Logo importé",
"logo_remove": "Supprimer le logo",
"logo_drop": "Déposez votre logo ici",
"logo_click": "Cliquez ou faites glisser pour importer un logo",
"logo_formats": "PNG, JPG, SVG ou WebP · Max 2 Mo",
"logo_processing": "Traitement en cours...",
"logo_error_type": "Veuillez importer une image PNG, JPG, GIF, SVG ou WebP.",
"logo_error_size": "L'image doit faire moins de 2 Mo.",
"logo_error_upload": "Échec de l'importation. Veuillez réessayer.",
"section_upload": "Importer des documents",
"section_upload_desc": "PDF, DOCX, CSV, XLSX, TXT, MD — utilisés pour entraîner la base de connaissances de votre chatbot",
"drop_files": "Déposez les fichiers ici",
"click_upload": "Cliquez ou faites glisser des fichiers pour les importer",
"uploading": "Importation en cours...",
"upload_success": "Documents importés avec succès !",
"docs_empty": "Aucun document pour l'instant",
"docs_empty_hint": "Importez des fichiers ci-dessus pour construire la base de connaissances de votre chatbot.",
"doc_count_one": "{{count}} document",
"doc_count_other": "{{count}} documents",
"chunks": "{{n}} fragments",
"section_urls": "Sources URL",
"section_urls_desc": "Ajoutez des pages web à la base de connaissances de votre chatbot",
"add_url": "Ajouter une URL",
"url_failed": "Échec de l'ajout de l'URL",
"section_chat_link": "Lien de chat public",
"section_chat_link_desc": "Partagez un lien direct vers votre chatbot avec n'importe qui",
"copy": "Copier",
"copied": "Copié",
"publish_for_link": "Publiez votre chatbot dans les paramètres de déploiement pour obtenir un lien de chat public.",
"section_embed": "Code d'intégration",
"section_embed_desc": "Ajoutez un widget de chat à n'importe quel site web en une ligne de code",
"publish_for_embed": "Publiez votre chatbot pour obtenir le code d'intégration.",
"section_lead": "Capture de prospects",
"section_lead_desc": "Collectez les informations des visiteurs avant ou pendant la conversation",
"lead_enable": "Activer la capture de prospects",
"lead_enable_sub": "Demander aux visiteurs leurs coordonnées",
"collect_fields": "Champs à collecter",
"required": "requis",
"when_show": "Quand afficher le formulaire",
"after_first": "Après le premier message",
"before_first": "Avant le premier message",
"section_handoff": "Transfert à un humain",
"section_handoff_desc": "Permettre aux visiteurs de demander à parler à un agent humain",
"handoff_enable": "Activer le transfert humain",
"handoff_enable_sub": "Déclenché quand l'utilisateur dit « humain », « agent », etc.",
"handoff_message_label": "Message de transfert",
"handoff_webhook_note": "Configurez l'URL webhook n8n dans votre backend pour recevoir les notifications.",
"section_branding": "Marque",
"section_branding_desc": "Contrôlez l'attribution Contexta dans votre widget de chat",
"show_branding": "Afficher « Propulsé par Contexta »",
"show_branding_sub": "Supprimez la marque en passant au plan Pro ou supérieur",
"section_booking": "Prise de rendez-vous",
"section_booking_desc": "Permettez aux clients de réserver des rendez-vous directement via votre chatbot",
"booking_enable": "Activer la prise de rendez-vous",
"booking_enable_sub": "Lorsqu'activé, le chatbot guidera les utilisateurs vers votre page de réservation et en parlera dans les conversations.",
"booking_url_label": "URL de la page de réservation :",
"booking_url_hint": "Partagez ce lien sur votre site web ou vos réseaux sociaux. Définissez vos horaires disponibles dans la",
"booking_url_hint_link": "page Rendez-vous",
"section_channels": "Canaux de messagerie",
"section_channels_desc": "Connectez votre chatbot à Telegram",
"telegram_connected": "Connecté",
"telegram_share": "Partagez ce lien de bot avec vos clients — ils l'ouvrent et commencent à discuter.",
"telegram_owner_notice": "Pour recevoir les alertes de transfert, ouvrez votre bot et envoyez-lui",
"telegram_owner_notice2": "Cela vous enregistrera en tant que propriétaire et vous notifiera ici quand un visiteur a besoin d'aide humaine.",
"telegram_disconnect": "Déconnecter",
"telegram_how_title": "Comment créer un bot Telegram (2 minutes) :",
"telegram_step1": "Ouvrez Telegram et recherchez @BotFather",
"telegram_step2": "Envoyez /newbot",
"telegram_step3": "Choisissez un nom et un nom d'utilisateur pour votre bot",
"telegram_step4": "BotFather vous enverra un token — copiez-le",
"telegram_step5": "Collez le token ci-dessous et cliquez sur Connecter",
"telegram_share_hint": "Une fois connecté, partagez le lien de votre bot (ex. t.me/VotreBotName) avec vos clients.",
"telegram_placeholder": "Token du bot depuis @BotFather",
"telegram_connect": "Connecter",
"telegram_connect_failed": "Échec de la connexion. Vérifiez votre token.",
"embed_hint_html": "Collez avant la balise fermante </body> dans votre fichier HTML.",
"embed_hint_react": "Ajoutez à votre index.html (dans le dossier public/) avant la balise fermante </body>.",
"embed_hint_nextjs": "Utilisez le composant Script intégré dans votre layout racine pour qu'il se charge sur chaque page.",
"embed_hint_wordpress": "Allez dans Apparence → Éditeur de fichiers → footer.php et collez avant </body>. Ou utilisez le plugin « Insert Headers and Footers ».",
"embed_hint_webflow": "Allez dans Paramètres du site → Code personnalisé → Code de pied de page et collez le script. Republiez votre site.",
"embed_hint_shopify": "Allez dans Boutique en ligne → Thèmes → Modifier le code → layout/theme.liquid et collez avant </body>.",
"model_default": "(par défaut)"
},
"marketplace": {
"title": "Marketplace de chatbots IA",
"subtitle": "Découvrez et interagissez avec des chatbots IA créés par des entreprises — prêts à répondre à vos questions instantanément.",
"search_placeholder": "Rechercher des chatbots par nom ou description...",
"filters": "Filtres",
"category": "Catégorie",
"all": "Tous",
"industry": "Secteur",
"all_industries": "Tous les secteurs",
"clear_all_filters": "Effacer tous les filtres",
"clear_filters": "Effacer les filtres",
"no_chatbots_title": "Aucun chatbot trouvé",
"no_chatbots_filtered": "Essayez d'ajuster vos filtres ou votre recherche.",
"no_chatbots_empty": "Soyez le premier à publier votre chatbot IA sur la marketplace !",
"create_chatbot": "Créer un chatbot",
"available_one": "{{count}} chatbot disponible",
"available_other": "{{count}} chatbots disponibles",
"by": "par {{name}}",
"chat_now": "Démarrer →",
"conversations": "{{count}} conversations",
"back_to_marketplace": "Retour à la marketplace",
"not_found_title": "Chatbot introuvable",
"not_found_desc": "Ce chatbot a peut-être été dépublié ou supprimé.",
"submit_rating": "Soumettre",
"your_rating": "Votre note",
"login_to_rate": "Connectez-vous pour noter ce chatbot",
"ratings": "avis"
},
"auth": {
"login_title": "Bon retour",
"login_subtitle": "Connectez-vous à votre compte Contexta",
"email": "E-mail",
"password": "Mot de passe",
"sign_in": "Se connecter",
"no_account": "Pas de compte ?",
"sign_up_free": "S'inscrire gratuitement",
"forgot_password": "Mot de passe oublié ?",
"login_failed": "Échec de la connexion. Vérifiez vos identifiants.",
"signup_title": "Créez votre compte",
"signup_subtitle": "Commencez à créer des chatbots IA — gratuit pour toujours",
"company_name": "Nom de l'entreprise",
"create_free_account": "Créer un compte gratuit",
"terms_text": "En vous inscrivant, vous acceptez nos",
"terms_of_service": "Conditions d'utilisation",
"and": "et notre",
"privacy_policy": "Politique de confidentialité",
"already_account": "Déjà un compte ?",
"already_confirmed": "Déjà confirmé ?",
"check_inbox_title": "Vérifiez votre boîte mail",
"check_inbox_desc": "Un lien de confirmation a été envoyé à",
"password_min_8": "Le mot de passe doit comporter au moins 8 caractères",
"signup_failed": "Échec de l'inscription. Veuillez réessayer.",
"forgot_title": "Réinitialisez votre mot de passe",
"forgot_subtitle": "Nous enverrons un lien de réinitialisation à votre adresse e-mail.",
"email_address": "Adresse e-mail",
"send_reset_link": "Envoyer le lien de réinitialisation",
"back_to_signin": "Retour à la connexion",
"forgot_sent_title": "Vérifiez votre boîte mail",
"forgot_sent_desc_one": "Si {{email}} est enregistré,",
"forgot_sent_desc_two": "un lien de réinitialisation a été envoyé.",
"forgot_error": "Une erreur s'est produite. Veuillez réessayer.",
"reset_title": "Définir un nouveau mot de passe",
"reset_subtitle": "Choisissez un mot de passe fort pour votre compte.",
"new_password": "Nouveau mot de passe",
"confirm_password": "Confirmer le mot de passe",
"confirm_placeholder": "Répétez le mot de passe",
"set_new_password": "Définir le nouveau mot de passe",
"link_expired_title": "Lien expiré",
"request_new_link": "Demander un nouveau lien de réinitialisation",
"passwords_dont_match": "Les mots de passe ne correspondent pas",
"failed_to_reset": "Échec de la réinitialisation du mot de passe. Le lien a peut-être expiré.",
"branding_headline": "Votre chatbot IA,\nprêt en quelques minutes.",
"branding_subtext": "Formez-le sur vos documents, gérez chaque conversation, capturez des prospects et acceptez des rendez-vous — tout en un.",
"branding_feature_1": "PDF, DOCX, CSV, URL — toutes vos sources",
"branding_feature_2": "Boîte de réception, prospects & rendez-vous",
"branding_feature_3": "Site web, Telegram & marketplace",
"branding_footer": "La confiance des entreprises qui modernisent leur relation client."
},
"pricing": {
"badge": "Tarifs",
"title": "Des tarifs simples et transparents",
"subtitle": "Démarrez gratuitement et publiez votre chatbot dès 12 $/mois. Conçu pour les particuliers, les PME, les agences et les grandes entreprises.",
"monthly": "Mensuel",
"yearly": "Annuel",
"per_month": "/mois",
"save_yr": "Économisez {{amount}} $/an",
"custom_price": "Sur mesure",
"current_plan_badge": "Plan actuel",
"most_popular": "Le plus populaire",
"cta_current": "Plan actuel",
"cta_free": "Commencer gratuitement",
"cta_contact": "Contacter les ventes",
"cta_downgrade": "Rétrograder",
"cta_upgrade": "Améliorer",
"faq_title": "Questions fréquentes",
"faq_subtitle": "Tout ce que vous devez savoir sur les plans Contexta.",
"plan_free": "Gratuit",
"plan_free_desc": "Créez, testez et publiez votre premier chatbot — sans carte bancaire",
"plan_starter": "Starter",
"plan_starter_desc": "Pour les indépendants : chat en direct, prospects, réservation et campagnes",
"plan_business": "Business",
"plan_business_desc": "Pour les entreprises en croissance : IA premium, réservation illimitée, analytics complet",
"plan_agency": "Agency",
"plan_agency_desc": "Pour les agences : tout illimité, prêt pour le white-label",
"plan_enterprise": "Enterprise",
"plan_enterprise_desc": "Pour les grandes organisations avec des besoins personnalisés et des SLA",
"feat_free": [
"1 chatbot publié",
"100 conversations/mois",
"3 documents par chatbot",
"Lien de chat public + intégration site web",
"Modèle Llama 3.3 70B",
"Boîte de réception en lecture seule (sans réponses)",
"Prospects en lecture seule (sans modification)",
"Tableau de bord analytics",
"Rendez-vous et campagnes",
"Canaux de messagerie",
"Supprimer « Propulsé par Contexta »"
],
"feat_starter": [
"Tout le plan Gratuit",
"3 chatbots publiés",
"1 500 conversations/mois",
"10 documents par chatbot",
"4 modèles Fireworks IA (Qwen3, DeepSeek, Kimi, Llama)",
"Boîte de réception + réponses agent",
"CRM prospects complet (statut + notes)",
"Prise de rendez-vous (1 chatbot)",
"Campagnes Telegram (3/mois · 500 destinataires)",
"Tableau de bord analytics",
"Suggestions de lacunes de connaissances",
"Modèles premium (GPT-4o, Claude, Gemini)",
"Supprimer « Propulsé par Contexta »"
],
"feat_business": [
"Tout le plan Starter",
"10 chatbots publiés",
"5 000 conversations/mois",
"50 documents par chatbot",
"GPT-4o, Claude Haiku 4.5, Gemini 2.5",
"Prise de rendez-vous (tous les chatbots)",
"Campagnes illimitées · 5 000 destinataires",
"Suggestions de lacunes de connaissances",
"Supprimer « Propulsé par Contexta »",
"Sources URL illimitées"
],
"feat_agency": [
"Tout le plan Business",
"Chatbots publiés illimités",
"20 000 conversations/mois",
"Documents illimités",
"Destinataires de campagnes illimités",
"Export de code (FastAPI + React)",
"Support dédié"
],
"feat_enterprise": [
"Tout le plan Agency",
"Conversations illimitées",
"Plateforme white-label",
"SSO (SAML)",
"Garanties SLA",
"Responsable de compte dédié",
"Support téléphonique 24h/24 7j/7"
],
"faq": [
{
"q": "Puis-je utiliser le plan gratuit indéfiniment ?",
"a": "Oui ! Créez et testez des chatbots gratuitement. Vos chatbots resteront en mode prévisualisation jusqu'à votre abonnement."
},
{
"q": "Qu'est-ce que l'export de code ?",
"a": "Les utilisateurs du plan Agency peuvent exporter leur chatbot sous forme de package complet prêt pour la production, incluant un backend FastAPI et un widget React TypeScript — pour un hébergement autonome total."
},
{
"q": "Ai-je besoin de mes propres clés API ?",
"a": "Non — les clés API sont gérées par Contexta. Si vous exportez le code avec le plan Agency, vous aurez besoin de vos propres clés pour un déploiement auto-hébergé."
},
{
"q": "Puis-je annuler à tout moment ?",
"a": "Oui, annulez à tout moment. Vos chatbots reviendront au plan gratuit à la fin de votre période de facturation."
},
{
"q": "Que se passe-t-il si j'atteins ma limite de conversations ?",
"a": "Votre chatbot affichera un message convivial pour réessayer plus tard. Améliorez votre plan pour plus de conversations."
},
{
"q": "Je suis une petite entreprise. Quel plan me convient ?",
"a": "Commencez avec Starter à 12 $/mois — 1 chatbot publié, 1 500 conversations, analytics, capture de prospects et Telegram. Parfait pour les restaurants, boutiques, salons et plus. Passez à Business pour des modèles IA premium et plus de capacité."
}
]
}
}

View File

@@ -5,10 +5,18 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App' import { App } from './App'
import { ToastProvider } from '@/contexts/ToastContext' import { ToastProvider } from '@/contexts/ToastContext'
import { initTheme } from '@/store/themeStore' import { initTheme } from '@/store/themeStore'
import { useAuthStore } from '@/store/authStore'
import i18n from '@/i18n/i18n'
import './index.css' import './index.css'
initTheme() initTheme()
// Restore persisted language on page reload
const persistedUser = useAuthStore.getState().user
if (persistedUser?.language) {
i18n.changeLanguage(persistedUser.language)
}
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { analyticsAPI } from '@/services/api' import { analyticsAPI } from '@/services/api'
import { Card, Button, Badge } from '@/components/ui' import { Card, Button, Badge } from '@/components/ui'
import { import {
@@ -11,12 +12,6 @@ import {
import { SkeletonStatCard } from '@/components/Skeletons' import { SkeletonStatCard } from '@/components/Skeletons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// ═══════════════════════════════════════════════════════════════════════════════
// ANALYTICS PAGE — Available for Starter and Pro plans
// Shows: conversations, unique users, ratings, top queries, daily trends
// Does NOT show: LLM costs, token usage costs, API spending
// ═══════════════════════════════════════════════════════════════════════════════
interface DailyConversation { interface DailyConversation {
date: string date: string
count: number count: number
@@ -65,19 +60,19 @@ interface OverviewData {
// ─── Mini bar chart component ───────────────────────────────────────────────── // ─── Mini bar chart component ─────────────────────────────────────────────────
const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => { const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
const { t } = useTranslation()
const [tooltip, setTooltip] = useState<{ date: string; count: number; idx: number } | null>(null) const [tooltip, setTooltip] = useState<{ date: string; count: number; idx: number } | null>(null)
if (!data.length) { if (!data.length) {
return ( return (
<div className="flex items-center justify-center h-16 text-xs text-gray-400 italic"> <div className="flex items-center justify-center h-16 text-xs text-gray-400 italic">
No data yet {t('common.no_data')}
</div> </div>
) )
} }
const max = Math.max(...data.map(d => d.count), 1) const max = Math.max(...data.map(d => d.count), 1)
// Fill last 30 days
const today = new Date() const today = new Date()
const days: { date: string; count: number }[] = [] const days: { date: string; count: number }[] = []
for (let i = 29; i >= 0; i--) { for (let i = 29; i >= 0; i--) {
@@ -90,7 +85,6 @@ const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
return ( return (
<div className="relative"> <div className="relative">
{/* Grid lines */}
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none" style={{ height: '64px' }}> <div className="absolute inset-0 flex flex-col justify-between pointer-events-none" style={{ height: '64px' }}>
{[0, 1, 2, 3].map(i => ( {[0, 1, 2, 3].map(i => (
<div key={i} className="w-full border-t border-gray-100/80" /> <div key={i} className="w-full border-t border-gray-100/80" />
@@ -117,7 +111,6 @@ const MiniBarChart: React.FC<{ data: DailyConversation[] }> = ({ data }) => {
))} ))}
</div> </div>
{/* Tooltip */}
{tooltip && ( {tooltip && (
<div <div
className="absolute bottom-full mb-2 bg-gray-900 text-white text-xs rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none whitespace-nowrap z-20" className="absolute bottom-full mb-2 bg-gray-900 text-white text-xs rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none whitespace-nowrap z-20"
@@ -171,6 +164,7 @@ const StatCard: React.FC<{
// ─── Usage bar ──────────────────────────────────────────────────────────────── // ─── Usage bar ────────────────────────────────────────────────────────────────
const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) => { const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) => {
const { t } = useTranslation()
const pct = limit > 0 ? Math.min((used / limit) * 100, 100) : 0 const pct = limit > 0 ? Math.min((used / limit) * 100, 100) : 0
const isHigh = pct > 80 const isHigh = pct > 80
const isFull = pct >= 100 const isFull = pct >= 100
@@ -178,7 +172,7 @@ const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) =>
return ( return (
<div> <div>
<div className="flex items-center justify-between text-xs mb-2"> <div className="flex items-center justify-between text-xs mb-2">
<span className="text-gray-600 font-medium">Monthly conversations</span> <span className="text-gray-600 font-medium">{t('analytics.monthly_conversations')}</span>
<span className={cn( <span className={cn(
'font-semibold', 'font-semibold',
isFull ? 'text-red-600' : isHigh ? 'text-amber-600' : 'text-gray-600' isFull ? 'text-red-600' : isHigh ? 'text-amber-600' : 'text-gray-600'
@@ -198,7 +192,7 @@ const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) =>
<div className="flex justify-between mt-1.5"> <div className="flex justify-between mt-1.5">
<span className="text-[10px] text-gray-400">0</span> <span className="text-[10px] text-gray-400">0</span>
<span className={cn('text-[10px] font-medium', isFull ? 'text-red-500' : isHigh ? 'text-amber-500' : 'text-gray-400')}> <span className={cn('text-[10px] font-medium', isFull ? 'text-red-500' : isHigh ? 'text-amber-500' : 'text-gray-400')}>
{Math.round(pct)}% used {Math.round(pct)}%
</span> </span>
<span className="text-[10px] text-gray-400">{limit.toLocaleString()}</span> <span className="text-[10px] text-gray-400">{limit.toLocaleString()}</span>
</div> </div>
@@ -208,6 +202,8 @@ const UsageBar: React.FC<{ used: number; limit: number }> = ({ used, limit }) =>
// ─── Chatbot detail row ─────────────────────────────────────────────────────── // ─── Chatbot detail row ───────────────────────────────────────────────────────
const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => { const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
const { t } = useTranslation()
const navigate = useNavigate()
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const feedbackTotal = chatbot.feedback_positive + chatbot.feedback_negative const feedbackTotal = chatbot.feedback_positive + chatbot.feedback_negative
@@ -228,7 +224,7 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
<div> <div>
<h3 className="font-semibold text-gray-900">{chatbot.chatbot_name}</h3> <h3 className="font-semibold text-gray-900">{chatbot.chatbot_name}</h3>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
{chatbot.total_conversations.toLocaleString()} conversations · {chatbot.unique_sessions.toLocaleString()} users {chatbot.total_conversations.toLocaleString()} · {chatbot.unique_sessions.toLocaleString()}
</p> </p>
</div> </div>
</div> </div>
@@ -240,7 +236,7 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
</div> </div>
)} )}
<div className="text-xs text-gray-500 bg-gray-100 px-2.5 py-1 rounded-lg font-medium"> <div className="text-xs text-gray-500 bg-gray-100 px-2.5 py-1 rounded-lg font-medium">
{chatbot.conversations_today} today {t('analytics.conversations_today', { count: chatbot.conversations_today })}
</div> </div>
<div className="p-1 rounded-lg text-gray-400"> <div className="p-1 rounded-lg text-gray-400">
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} {expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
@@ -254,10 +250,10 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
{/* Stats row */} {/* Stats row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[ {[
{ label: 'Today', value: chatbot.conversations_today }, { label: t('analytics.today'), value: chatbot.conversations_today },
{ label: 'This week', value: chatbot.conversations_this_week }, { label: t('analytics.this_week'), value: chatbot.conversations_this_week },
{ label: 'This month', value: chatbot.conversations_this_month }, { label: t('analytics.this_month'), value: chatbot.conversations_this_month },
{ label: 'Avg msgs/convo', value: chatbot.average_messages_per_conversation }, { label: t('analytics.avg_msgs'), value: chatbot.average_messages_per_conversation },
].map(({ label, value }) => ( ].map(({ label, value }) => (
<div key={label} className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm text-center"> <div key={label} className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm text-center">
<p className="text-xs text-gray-500 mb-1">{label}</p> <p className="text-xs text-gray-500 mb-1">{label}</p>
@@ -268,7 +264,7 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
{/* Daily chart */} {/* Daily chart */}
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm"> <div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
<p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Last 30 days</p> <p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">{t('analytics.last_30_days')}</p>
<MiniBarChart data={chatbot.daily_conversations} /> <MiniBarChart data={chatbot.daily_conversations} />
</div> </div>
@@ -277,7 +273,7 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
{chatbot.top_queries.length > 0 && ( {chatbot.top_queries.length > 0 && (
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm"> <div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
<p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Top questions</p> <p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">{t('analytics.top_questions')}</p>
<div className="space-y-2"> <div className="space-y-2">
{chatbot.top_queries.slice(0, 5).map((q, i) => ( {chatbot.top_queries.slice(0, 5).map((q, i) => (
<div key={i} className="flex items-start gap-2"> <div key={i} className="flex items-start gap-2">
@@ -296,7 +292,7 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
{Object.keys(chatbot.languages_used).length > 0 && ( {Object.keys(chatbot.languages_used).length > 0 && (
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm"> <div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm">
<p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">Languages</p> <p className="text-xs font-semibold text-gray-600 mb-3 uppercase tracking-wide">{t('analytics.languages')}</p>
<div className="space-y-2"> <div className="space-y-2">
{Object.entries(chatbot.languages_used) {Object.entries(chatbot.languages_used)
.sort(([, a], [, b]) => b - a) .sort(([, a], [, b]) => b - a)
@@ -327,26 +323,24 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
)} )}
</div> </div>
{/* Knowledge Gaps — Phase 3: actionable suggestions */} {/* Knowledge Gaps */}
{chatbot.unanswered_queries && chatbot.unanswered_queries.length > 0 && ( {chatbot.unanswered_queries && chatbot.unanswered_queries.length > 0 && (
<div className="bg-amber-50 rounded-xl p-4 border border-amber-100 space-y-3"> <div className="bg-amber-50 rounded-xl p-4 border border-amber-100 space-y-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0" /> <AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0" />
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide"> <p className="text-xs font-semibold text-amber-700 uppercase tracking-wide">
Knowledge gaps {chatbot.unanswered_count} unanswered {t('analytics.knowledge_gaps', { count: chatbot.unanswered_count })}
</p> </p>
</div> </div>
<button <button
onClick={() => navigate(`/chatbots/${chatbot.chatbot_id}/edit`)} onClick={() => navigate(`/chatbots/${chatbot.chatbot_id}/edit`)}
className="text-xs font-semibold text-amber-700 hover:text-amber-900 bg-amber-100 hover:bg-amber-200 px-3 py-1.5 rounded-lg transition-colors border border-amber-200 flex-shrink-0" className="text-xs font-semibold text-amber-700 hover:text-amber-900 bg-amber-100 hover:bg-amber-200 px-3 py-1.5 rounded-lg transition-colors border border-amber-200 flex-shrink-0"
> >
+ Add content {t('analytics.add_content')}
</button> </button>
</div> </div>
<p className="text-xs text-amber-600"> <p className="text-xs text-amber-600">{t('analytics.gaps_desc')}</p>
Customers asked these questions but your bot couldn't answer well. Add documents or URL sources covering these topics.
</p>
<div className="space-y-2"> <div className="space-y-2">
{chatbot.unanswered_queries.slice(0, 6).map((q, i) => ( {chatbot.unanswered_queries.slice(0, 6).map((q, i) => (
<div <div
@@ -356,7 +350,7 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
<span className="text-xs text-amber-800 truncate flex-1">"{q.query}"</span> <span className="text-xs text-amber-800 truncate flex-1">"{q.query}"</span>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<span className="bg-amber-200 text-amber-700 text-[10px] px-2 py-0.5 rounded-full font-bold"> <span className="bg-amber-200 text-amber-700 text-[10px] px-2 py-0.5 rounded-full font-bold">
{q.count}× asked {q.count}×
</span> </span>
</div> </div>
</div> </div>
@@ -364,7 +358,7 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
</div> </div>
{chatbot.unanswered_queries.length > 6 && ( {chatbot.unanswered_queries.length > 6 && (
<p className="text-xs text-amber-500 text-center"> <p className="text-xs text-amber-500 text-center">
+{chatbot.unanswered_queries.length - 6} more gaps {t('analytics.more_gaps', { count: chatbot.unanswered_queries.length - 6 })}
</p> </p>
)} )}
</div> </div>
@@ -374,7 +368,7 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
{feedbackTotal > 0 && ( {feedbackTotal > 0 && (
<div className="flex items-center gap-3 bg-white rounded-xl px-4 py-3 border border-gray-100 shadow-sm"> <div className="flex items-center gap-3 bg-white rounded-xl px-4 py-3 border border-gray-100 shadow-sm">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Feedback</span> <span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{t('analytics.feedback')}</span>
<div className="flex items-center gap-1 text-emerald-600"> <div className="flex items-center gap-1 text-emerald-600">
<ThumbsUp className="w-3.5 h-3.5" /> <ThumbsUp className="w-3.5 h-3.5" />
<span className="text-sm font-bold">{chatbot.feedback_positive}</span> <span className="text-sm font-bold">{chatbot.feedback_positive}</span>
@@ -385,7 +379,7 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
</div> </div>
{helpfulPct !== null && ( {helpfulPct !== null && (
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full"> <span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
{helpfulPct}% helpful {t('analytics.helpful_pct', { pct: helpfulPct })}
</span> </span>
)} )}
</div> </div>
@@ -394,7 +388,7 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
{chatbot.peak_hour !== null && ( {chatbot.peak_hour !== null && (
<div className="flex items-center gap-2 bg-white rounded-xl px-4 py-3 border border-gray-100 shadow-sm text-xs text-gray-600"> <div className="flex items-center gap-2 bg-white rounded-xl px-4 py-3 border border-gray-100 shadow-sm text-xs text-gray-600">
<Clock className="w-3.5 h-3.5 text-gray-400" /> <Clock className="w-3.5 h-3.5 text-gray-400" />
<span>Peak: <span className="font-semibold text-gray-900">{chatbot.peak_hour}:00 {chatbot.peak_hour + 1}:00</span></span> <span>{t('analytics.peak_hour', { from: chatbot.peak_hour, to: chatbot.peak_hour + 1 })}</span>
</div> </div>
)} )}
</div> </div>
@@ -409,16 +403,16 @@ const ChatbotRow: React.FC<{ chatbot: ChatbotAnalytics }> = ({ chatbot }) => {
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
export const AnalyticsPage: React.FC = () => { export const AnalyticsPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const { data, isLoading, error } = useQuery<OverviewData>({ const { data, isLoading, error } = useQuery<OverviewData>({
queryKey: ['analytics-overview'], queryKey: ['analytics-overview'],
queryFn: analyticsAPI.overview, queryFn: analyticsAPI.overview,
staleTime: 60_000, // 1 min cache staleTime: 60_000,
retry: false, retry: false,
}) })
// Handle plan gate (402 response)
if (error && (error as { response?: { status?: number } })?.response?.status === 402) { if (error && (error as { response?: { status?: number } })?.response?.status === 402) {
return ( return (
<div className="p-6 max-w-2xl mx-auto"> <div className="p-6 max-w-2xl mx-auto">
@@ -426,14 +420,10 @@ export const AnalyticsPage: React.FC = () => {
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5"> <div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Lock className="w-7 h-7 text-primary-600" /> <Lock className="w-7 h-7 text-primary-600" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Analytics Dashboard</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">{t('analytics.upgrade_title')}</h2>
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto leading-relaxed"> <p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto leading-relaxed">{t('analytics.upgrade_desc')}</p>
Unlock analytics to see how your chatbots are performing — conversations, user engagement, top questions, and more. <Button onClick={() => navigate('/pricing')}>{t('analytics.upgrade_button')}</Button>
</p> <p className="text-xs text-gray-400 mt-3">{t('analytics.upgrade_note')}</p>
<Button onClick={() => navigate('/pricing')}>
Upgrade to Starter — $3/mo
</Button>
<p className="text-xs text-gray-400 mt-3">Available on Starter and Pro plans</p>
</Card> </Card>
</div> </div>
) )
@@ -442,7 +432,6 @@ export const AnalyticsPage: React.FC = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="p-4 sm:p-6 max-w-8xl mx-auto space-y-6"> <div className="p-4 sm:p-6 max-w-8xl mx-auto space-y-6">
{/* Header skeleton */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-2"> <div className="space-y-2">
<div className="h-7 w-40 bg-gray-200 rounded animate-pulse" /> <div className="h-7 w-40 bg-gray-200 rounded animate-pulse" />
@@ -450,13 +439,10 @@ export const AnalyticsPage: React.FC = () => {
</div> </div>
<div className="h-6 w-20 bg-gray-100 rounded-full animate-pulse" /> <div className="h-6 w-20 bg-gray-100 rounded-full animate-pulse" />
</div> </div>
{/* Usage bar skeleton */}
<div className="h-16 bg-gray-100 rounded-xl animate-pulse" /> <div className="h-16 bg-gray-100 rounded-xl animate-pulse" />
{/* Stat cards skeleton */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[0, 1, 2, 3].map(i => <SkeletonStatCard key={i} />)} {[0, 1, 2, 3].map(i => <SkeletonStatCard key={i} />)}
</div> </div>
{/* Chatbot rows skeleton */}
<div className="space-y-3"> <div className="space-y-3">
{[0, 1].map(i => ( {[0, 1].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse"> <div key={i} className="bg-white rounded-xl border border-gray-200 p-5 animate-pulse">
@@ -481,8 +467,8 @@ export const AnalyticsPage: React.FC = () => {
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-4"> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-4">
<BarChart3 className="w-6 h-6 text-gray-400" /> <BarChart3 className="w-6 h-6 text-gray-400" />
</div> </div>
<p className="text-gray-600 font-medium">Unable to load analytics</p> <p className="text-gray-600 font-medium">{t('analytics.unable_to_load')}</p>
<p className="text-sm text-gray-400 mt-1">Please try refreshing the page.</p> <p className="text-sm text-gray-400 mt-1">{t('analytics.try_refreshing')}</p>
</Card> </Card>
</div> </div>
) )
@@ -497,11 +483,11 @@ export const AnalyticsPage: React.FC = () => {
<BarChart3 className="w-5 h-5 text-primary-600" /> <BarChart3 className="w-5 h-5 text-primary-600" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1> <h1 className="text-2xl font-bold text-gray-900">{t('analytics.title')}</h1>
<p className="text-sm text-gray-500 mt-0.5">Track how your chatbots are performing</p> <p className="text-sm text-gray-500 mt-0.5">{t('analytics.subtitle')}</p>
</div> </div>
</div> </div>
<Badge className="text-xs capitalize">{data.plan} plan</Badge> <Badge className="text-xs capitalize">{t('analytics.plan_badge', { plan: data.plan })}</Badge>
</div> </div>
{/* ── Usage bar ── */} {/* ── Usage bar ── */}
@@ -512,31 +498,31 @@ export const AnalyticsPage: React.FC = () => {
{/* ── Overview stat cards ── */} {/* ── Overview stat cards ── */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
label="Conversations" label={t('analytics.stat_conversations')}
value={data.total_conversations} value={data.total_conversations}
icon={<MessageSquare className="w-4 h-4" />} icon={<MessageSquare className="w-4 h-4" />}
subtitle={`${data.conversations_this_month} this month`} subtitle={t('analytics.stat_this_month', { count: data.conversations_this_month })}
color="primary" color="primary"
/> />
<StatCard <StatCard
label="Unique users" label={t('analytics.stat_unique_users')}
value={data.unique_sessions} value={data.unique_sessions}
icon={<Users className="w-4 h-4" />} icon={<Users className="w-4 h-4" />}
subtitle="Across all chatbots" subtitle={t('analytics.stat_across_all')}
color="sky" color="sky"
/> />
<StatCard <StatCard
label="Messages" label={t('analytics.stat_messages')}
value={data.total_messages} value={data.total_messages}
icon={<BarChart3 className="w-4 h-4" />} icon={<BarChart3 className="w-4 h-4" />}
subtitle="Total exchanged" subtitle={t('analytics.stat_total_exchanged')}
color="violet" color="violet"
/> />
<StatCard <StatCard
label="Avg rating" label={t('analytics.stat_avg_rating')}
value={data.average_rating ? data.average_rating.toFixed(1) : '—'} value={data.average_rating ? data.average_rating.toFixed(1) : '—'}
icon={<Star className="w-4 h-4" />} icon={<Star className="w-4 h-4" />}
subtitle={data.average_rating ? 'Across rated chatbots' : 'No ratings yet'} subtitle={data.average_rating ? t('analytics.stat_across_rated') : t('analytics.stat_no_ratings')}
color="amber" color="amber"
/> />
</div> </div>
@@ -544,13 +530,13 @@ export const AnalyticsPage: React.FC = () => {
{/* ── Chatbot breakdown header ── */} {/* ── Chatbot breakdown header ── */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">
Your chatbots {t('analytics.your_chatbots')}
<span className="ml-2 text-sm font-medium text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full"> <span className="ml-2 text-sm font-medium text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
{data.total_chatbots} {data.total_chatbots}
</span> </span>
</h2> </h2>
<p className="text-xs text-gray-500 bg-emerald-50 text-emerald-600 px-2.5 py-1 rounded-full font-medium"> <p className="text-xs text-gray-500 bg-emerald-50 text-emerald-600 px-2.5 py-1 rounded-full font-medium">
{data.published_chatbots} published {data.published_chatbots} {t('analytics.published')}
</p> </p>
</div> </div>
@@ -560,10 +546,10 @@ export const AnalyticsPage: React.FC = () => {
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5"> <div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
<Bot className="w-7 h-7 text-gray-300" /> <Bot className="w-7 h-7 text-gray-300" />
</div> </div>
<p className="text-sm font-medium text-gray-600 mb-1">No chatbots yet</p> <p className="text-sm font-medium text-gray-600 mb-1">{t('analytics.no_chatbots_title')}</p>
<p className="text-xs text-gray-400 mb-5">Create your first chatbot to start seeing analytics.</p> <p className="text-xs text-gray-400 mb-5">{t('analytics.no_chatbots_desc')}</p>
<Button size="sm" onClick={() => navigate('/chatbots/new')}> <Button size="sm" onClick={() => navigate('/chatbots/new')}>
Create chatbot {t('analytics.create_chatbot')}
</Button> </Button>
</Card> </Card>
) : ( ) : (

View File

@@ -1,27 +1,19 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { appointmentsAPI, chatbotsAPI } from '@/services/api' import { appointmentsAPI, chatbotsAPI } from '@/services/api'
import { Card, Button, Spinner } from '@/components/ui' import { Card, Button, Spinner } from '@/components/ui'
import { import {
Calendar, Clock, User, Phone, Filter, Lock, CheckCircle2, Calendar, Clock, Phone, Filter, Lock, CheckCircle2,
XCircle, RotateCcw, ChevronDown, Settings, CalendarDays, XCircle, RotateCcw, Settings, CalendarDays,
} from 'lucide-react' } from 'lucide-react'
import type { Appointment, Chatbot, BusinessHoursEntry } from '@/types' import type { Appointment, Chatbot, BusinessHoursEntry } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = { const DEFAULT_HOURS: BusinessHoursEntry[] = Array.from({ length: 7 }, (_, i) => ({
pending: { label: 'Pending', color: 'bg-yellow-100 text-yellow-700', icon: Clock },
confirmed: { label: 'Confirmed', color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
cancelled: { label: 'Cancelled', color: 'bg-red-100 text-red-600', icon: XCircle },
completed: { label: 'Completed', color: 'bg-gray-100 text-gray-600', icon: CheckCircle2 },
}
const DAY_LABELS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const DEFAULT_HOURS: BusinessHoursEntry[] = DAY_LABELS.map((_, i) => ({
day_of_week: i, day_of_week: i,
is_open: i < 5, // MonFri open by default is_open: i < 5,
open_time: '09:00', open_time: '09:00',
close_time: '17:00', close_time: '17:00',
slot_duration_minutes: 60, slot_duration_minutes: 60,
@@ -33,15 +25,17 @@ const HoursSettings: React.FC<{ chatbotId: string; onClose: () => void }> = ({ c
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [hours, setHours] = useState<BusinessHoursEntry[]>(DEFAULT_HOURS) const [hours, setHours] = useState<BusinessHoursEntry[]>(DEFAULT_HOURS)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const { t } = useTranslation()
const DAY_KEYS = ['days_mon', 'days_tue', 'days_wed', 'days_thu', 'days_fri', 'days_sat', 'days_sun'] as const
const { isLoading } = useQuery<BusinessHoursEntry[]>({ const { isLoading } = useQuery<BusinessHoursEntry[]>({
queryKey: ['business-hours', chatbotId], queryKey: ['business-hours', chatbotId],
queryFn: () => appointmentsAPI.getHours(chatbotId), queryFn: () => appointmentsAPI.getHours(chatbotId),
onSuccess: (data) => { onSuccess: (data: BusinessHoursEntry[]) => {
if (data && data.length > 0) { if (data && data.length > 0) {
// Merge fetched data with defaults
const merged = DEFAULT_HOURS.map(d => { const merged = DEFAULT_HOURS.map(d => {
const found = data.find(h => h.day_of_week === d.day_of_week) const found = data.find((h: BusinessHoursEntry) => h.day_of_week === d.day_of_week)
return found ? { ...d, ...found } : d return found ? { ...d, ...found } : d
}) })
setHours(merged) setHours(merged)
@@ -67,10 +61,10 @@ const HoursSettings: React.FC<{ chatbotId: string; onClose: () => void }> = ({ c
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900">Business Hours</h3> <h3 className="font-semibold text-gray-900">{t('appointments.hours_title')}</h3>
<button onClick={onClose} className="text-xs text-gray-500 hover:text-gray-700"> Back</button> <button onClick={onClose} className="text-xs text-gray-500 hover:text-gray-700">{t('appointments.hours_back')}</button>
</div> </div>
<p className="text-xs text-gray-500">Configure when customers can book appointments.</p> <p className="text-xs text-gray-500">{t('appointments.hours_desc')}</p>
<div className="space-y-2"> <div className="space-y-2">
{hours.map((h, i) => ( {hours.map((h, i) => (
@@ -84,7 +78,7 @@ const HoursSettings: React.FC<{ chatbotId: string; onClose: () => void }> = ({ c
className="w-3.5 h-3.5 accent-primary-600" className="w-3.5 h-3.5 accent-primary-600"
/> />
<span className={cn('text-xs font-medium', h.is_open ? 'text-gray-900' : 'text-gray-400')}> <span className={cn('text-xs font-medium', h.is_open ? 'text-gray-900' : 'text-gray-400')}>
{DAY_LABELS[i].slice(0, 3)} {t(`appointments.${DAY_KEYS[i]}`)}
</span> </span>
</label> </label>
</div> </div>
@@ -96,7 +90,7 @@ const HoursSettings: React.FC<{ chatbotId: string; onClose: () => void }> = ({ c
onChange={e => update(i, 'open_time', e.target.value)} onChange={e => update(i, 'open_time', e.target.value)}
className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-primary-400" className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-primary-400"
/> />
<span className="text-xs text-gray-400">to</span> <span className="text-xs text-gray-400">{t('appointments.to')}</span>
<input <input
type="time" type="time"
value={h.close_time} value={h.close_time}
@@ -116,7 +110,7 @@ const HoursSettings: React.FC<{ chatbotId: string; onClose: () => void }> = ({ c
</select> </select>
</> </>
) : ( ) : (
<span className="text-xs text-gray-400 italic">Closed</span> <span className="text-xs text-gray-400 italic">{t('appointments.hours_closed')}</span>
)} )}
</div> </div>
))} ))}
@@ -128,7 +122,7 @@ const HoursSettings: React.FC<{ chatbotId: string; onClose: () => void }> = ({ c
className="w-full gap-2" className="w-full gap-2"
size="sm" size="sm"
> >
{save.isPending ? <Spinner className="w-4 h-4 text-white" /> : saved ? '✓ Saved!' : 'Save Hours'} {save.isPending ? <Spinner className="w-4 h-4 text-white" /> : saved ? t('appointments.hours_saved') : t('appointments.save_hours')}
</Button> </Button>
</div> </div>
) )
@@ -142,6 +136,14 @@ export const AppointmentsPage: React.FC = () => {
const [chatbotFilter, setChatbotFilter] = useState('') const [chatbotFilter, setChatbotFilter] = useState('')
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = useState('')
const [settingsChatbotId, setSettingsChatbotId] = useState<string | null>(null) const [settingsChatbotId, setSettingsChatbotId] = useState<string | null>(null)
const { t } = useTranslation()
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = {
pending: { label: t('appointments.status_pending'), color: 'bg-yellow-100 text-yellow-700', icon: Clock },
confirmed: { label: t('appointments.status_confirmed'), color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
cancelled: { label: t('appointments.status_cancelled'), color: 'bg-red-100 text-red-600', icon: XCircle },
completed: { label: t('appointments.status_completed'), color: 'bg-gray-100 text-gray-600', icon: CheckCircle2 },
}
const { data: chatbots = [] } = useQuery<Chatbot[]>({ const { data: chatbots = [] } = useQuery<Chatbot[]>({
queryKey: ['chatbots'], queryKey: ['chatbots'],
@@ -172,9 +174,9 @@ export const AppointmentsPage: React.FC = () => {
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5"> <div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Lock className="w-7 h-7 text-primary-600" /> <Lock className="w-7 h-7 text-primary-600" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Appointment Booking</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">{t('appointments.upgrade_title')}</h2>
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto"> <p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
Upgrade to Starter to enable appointment booking for your chatbots. {t('appointments.upgrade_desc')}
</p> </p>
</Card> </Card>
</div> </div>
@@ -200,8 +202,8 @@ export const AppointmentsPage: React.FC = () => {
<CalendarDays className="w-5 h-5 text-primary-600" /> <CalendarDays className="w-5 h-5 text-primary-600" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Appointments</h1> <h1 className="text-2xl font-bold text-gray-900">{t('appointments.title')}</h1>
<p className="text-sm text-gray-500 mt-0.5">Bookings made through your chatbots</p> <p className="text-sm text-gray-500 mt-0.5">{t('appointments.subtitle')}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -224,17 +226,15 @@ export const AppointmentsPage: React.FC = () => {
<Calendar className="w-5 h-5 text-amber-600" /> <Calendar className="w-5 h-5 text-amber-600" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold text-gray-900 mb-1">Enable booking on a chatbot</h3> <h3 className="font-semibold text-gray-900 mb-1">{t('appointments.enable_booking_title')}</h3>
<p className="text-sm text-gray-500 mb-3"> <p className="text-sm text-gray-500 mb-3">{t('appointments.enable_booking_desc')}</p>
Go to a chatbot's Deploy tab and enable "Appointment Booking" to start accepting bookings.
</p>
{chatbots.length > 0 && ( {chatbots.length > 0 && (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={() => navigate(`/chatbots/${chatbots[0].id}/edit`)} onClick={() => navigate(`/chatbots/${chatbots[0].id}/edit`)}
> >
Configure chatbot {t('appointments.configure_chatbot')}
</Button> </Button>
)} )}
</div> </div>
@@ -246,10 +246,10 @@ export const AppointmentsPage: React.FC = () => {
{appointments.length > 0 && ( {appointments.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[ {[
{ label: 'Today', count: today.length, color: 'text-blue-600', bg: 'bg-blue-50' }, { label: t('appointments.stat_today'), count: today.length, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: 'Upcoming', count: upcoming.length, color: 'text-primary-600', bg: 'bg-primary-50' }, { label: t('appointments.stat_upcoming'), count: upcoming.length, color: 'text-primary-600', bg: 'bg-primary-50' },
{ label: 'Confirmed', count: appointments.filter(a => a.status === 'confirmed').length, color: 'text-green-600', bg: 'bg-green-50' }, { label: t('appointments.stat_confirmed'), count: appointments.filter(a => a.status === 'confirmed').length, color: 'text-green-600', bg: 'bg-green-50' },
{ label: 'Pending', count: appointments.filter(a => a.status === 'pending').length, color: 'text-yellow-600', bg: 'bg-yellow-50' }, { label: t('appointments.stat_pending'), count: appointments.filter(a => a.status === 'pending').length, color: 'text-yellow-600', bg: 'bg-yellow-50' },
].map(stat => ( ].map(stat => (
<Card key={stat.label} className="p-4 flex items-center gap-3"> <Card key={stat.label} className="p-4 flex items-center gap-3">
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', stat.bg)}> <div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', stat.bg)}>
@@ -269,14 +269,14 @@ export const AppointmentsPage: React.FC = () => {
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 flex-wrap"> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm font-medium text-gray-600 flex-shrink-0"> <div className="flex items-center gap-2 text-sm font-medium text-gray-600 flex-shrink-0">
<Filter className="w-4 h-4 text-gray-400" /> <Filter className="w-4 h-4 text-gray-400" />
Filter {t('appointments.filter')}
</div> </div>
<select <select
value={chatbotFilter} value={chatbotFilter}
onChange={e => setChatbotFilter(e.target.value)} onChange={e => setChatbotFilter(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer" className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer"
> >
<option value="">All chatbots</option> <option value="">{t('common.all_chatbots')}</option>
{bookingEnabledChatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} {bookingEnabledChatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select> </select>
<select <select
@@ -284,20 +284,20 @@ export const AppointmentsPage: React.FC = () => {
onChange={e => setStatusFilter(e.target.value)} onChange={e => setStatusFilter(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer" className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer"
> >
<option value="">All statuses</option> <option value="">{t('common.all_statuses')}</option>
{Object.entries(STATUS_CONFIG).map(([v, c]) => <option key={v} value={v}>{c.label}</option>)} {Object.entries(STATUS_CONFIG).map(([v, c]) => <option key={v} value={v}>{c.label}</option>)}
</select> </select>
{/* Per-chatbot hours settings */} {/* Per-chatbot hours settings */}
{bookingEnabledChatbots.length > 0 && ( {bookingEnabledChatbots.length > 0 && (
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<span className="text-xs text-gray-500">Hours:</span> <span className="text-xs text-gray-500">{t('appointments.hours_label')}</span>
<select <select
value={settingsChatbotId || ''} value={settingsChatbotId || ''}
onChange={e => setSettingsChatbotId(e.target.value || null)} onChange={e => setSettingsChatbotId(e.target.value || null)}
className="border border-gray-200 rounded-lg px-2 py-1.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-primary-400 appearance-none cursor-pointer" className="border border-gray-200 rounded-lg px-2 py-1.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-primary-400 appearance-none cursor-pointer"
> >
<option value="">Configure chatbot...</option> <option value="">{t('appointments.configure_chatbot_hours')}</option>
{bookingEnabledChatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} {bookingEnabledChatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select> </select>
<Settings className="w-3.5 h-3.5 text-gray-400" /> <Settings className="w-3.5 h-3.5 text-gray-400" />
@@ -314,9 +314,9 @@ export const AppointmentsPage: React.FC = () => {
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5"> <div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
<Calendar className="w-7 h-7 text-gray-300" /> <Calendar className="w-7 h-7 text-gray-300" />
</div> </div>
<h3 className="font-semibold text-gray-700 mb-2">No appointments yet</h3> <h3 className="font-semibold text-gray-700 mb-2">{t('appointments.no_appointments_title')}</h3>
<p className="text-sm text-gray-500 max-w-sm mx-auto"> <p className="text-sm text-gray-500 max-w-sm mx-auto">
Once customers book through your chatbot, appointments will appear here. {t('appointments.no_appointments_desc')}
</p> </p>
</Card> </Card>
) : ( ) : (
@@ -337,7 +337,7 @@ export const AppointmentsPage: React.FC = () => {
{slotDate.toLocaleDateString(undefined, { month: 'short' })} {slotDate.toLocaleDateString(undefined, { month: 'short' })}
</p> </p>
<p className="text-2xl font-bold text-primary-700 leading-none">{slotDate.getDate()}</p> <p className="text-2xl font-bold text-primary-700 leading-none">{slotDate.getDate()}</p>
{isToday && <p className="text-[10px] text-primary-500 font-medium mt-0.5">Today</p>} {isToday && <p className="text-[10px] text-primary-500 font-medium mt-0.5">{t('appointments.today_label')}</p>}
</div> </div>
{/* Details */} {/* Details */}
@@ -376,14 +376,14 @@ export const AppointmentsPage: React.FC = () => {
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 rounded-lg transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 rounded-lg transition-colors disabled:opacity-50"
> >
<CheckCircle2 className="w-3.5 h-3.5" /> Confirm <CheckCircle2 className="w-3.5 h-3.5" /> {t('appointments.confirm_btn')}
</button> </button>
<button <button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'cancelled' })} onClick={() => updateStatus.mutate({ id: appt.id, status: 'cancelled' })}
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50"
> >
<XCircle className="w-3.5 h-3.5" /> Decline <XCircle className="w-3.5 h-3.5" /> {t('appointments.decline_btn')}
</button> </button>
</div> </div>
)} )}
@@ -394,14 +394,14 @@ export const AppointmentsPage: React.FC = () => {
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 border border-gray-200 rounded-lg transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 border border-gray-200 rounded-lg transition-colors disabled:opacity-50"
> >
<CheckCircle2 className="w-3.5 h-3.5" /> Mark Complete <CheckCircle2 className="w-3.5 h-3.5" /> {t('appointments.mark_complete')}
</button> </button>
<button <button
onClick={() => updateStatus.mutate({ id: appt.id, status: 'cancelled' })} onClick={() => updateStatus.mutate({ id: appt.id, status: 'cancelled' })}
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50"
> >
<XCircle className="w-3.5 h-3.5" /> Cancel <XCircle className="w-3.5 h-3.5" /> {t('appointments.cancel_btn')}
</button> </button>
</div> </div>
)} )}
@@ -411,7 +411,7 @@ export const AppointmentsPage: React.FC = () => {
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 mt-3 text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 border border-gray-200 rounded-lg transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-3 py-1.5 mt-3 text-xs font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 border border-gray-200 rounded-lg transition-colors disabled:opacity-50"
> >
<RotateCcw className="w-3.5 h-3.5" /> Restore <RotateCcw className="w-3.5 h-3.5" /> {t('appointments.restore_btn')}
</button> </button>
)} )}
</div> </div>

View File

@@ -1,56 +1,52 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { authAPI } from '@/services/api' import { authAPI } from '@/services/api'
import { Button, Input } from '@/components/ui' import { Button } from '@/components/ui'
import { Sparkles, Eye, EyeOff, Mail, Lock, Building2, Check, X, MessageSquare, FileText, Globe } from 'lucide-react' import { Sparkles, Eye, EyeOff, Mail, Lock, Building2, Check, X, MessageSquare, FileText, Globe } from 'lucide-react'
// ─── Shared branding panel ──────────────────────────────────────────────────── // ─── Shared branding panel ────────────────────────────────────────────────────
const BrandingPanel: React.FC = () => ( const BrandingPanel: React.FC = () => {
const { t } = useTranslation()
return (
<div className="hidden lg:flex flex-col justify-between h-full p-10 bg-gradient-to-br from-primary-600 via-primary-700 to-purple-800 text-white relative overflow-hidden"> <div className="hidden lg:flex flex-col justify-between h-full p-10 bg-gradient-to-br from-primary-600 via-primary-700 to-purple-800 text-white relative overflow-hidden">
{/* decorative circles */}
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" /> <div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
<div className="absolute bottom-10 -left-16 w-48 h-48 rounded-full bg-white/5" /> <div className="absolute bottom-10 -left-16 w-48 h-48 rounded-full bg-white/5" />
<div className="absolute top-1/2 right-4 w-32 h-32 rounded-full bg-white/5" /> <div className="absolute top-1/2 right-4 w-32 h-32 rounded-full bg-white/5" />
{/* Logo */} <Link to="/" className="relative z-10 flex items-center gap-3 w-fit">
<div className="relative z-10 flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center backdrop-blur-sm"> <div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center backdrop-blur-sm">
<Sparkles className="w-5 h-5 text-white" /> <Sparkles className="w-5 h-5 text-white" />
</div> </div>
<span className="text-xl font-bold tracking-tight">Contexta</span> <span className="text-xl font-bold tracking-tight">Contexta</span>
</div> </Link>
{/* Center content */}
<div className="relative z-10"> <div className="relative z-10">
<h2 className="text-3xl font-bold leading-snug mb-3"> <h2 className="text-3xl font-bold leading-snug mb-3" style={{ whiteSpace: 'pre-line' }}>
Build AI chatbots<br />that actually work. {t('auth.branding_headline')}
</h2> </h2>
<p className="text-primary-200 text-sm leading-relaxed mb-8"> <p className="text-primary-200 text-sm leading-relaxed mb-8">{t('auth.branding_subtext')}</p>
Upload your docs, train your bot, and publish it anywhere in minutes.
</p>
<ul className="space-y-3"> <ul className="space-y-3">
{[ {[
{ icon: MessageSquare, text: 'Custom chatbots trained on your content' }, { icon: MessageSquare, key: 'auth.branding_feature_1' },
{ icon: FileText, text: 'PDF, DOCX, CSV, and URL sources' }, { icon: FileText, key: 'auth.branding_feature_2' },
{ icon: Globe, text: 'Embed on any website or channel' }, { icon: Globe, key: 'auth.branding_feature_3' },
].map(({ icon: Icon, text }) => ( ].map(({ key }) => (
<li key={text} className="flex items-center gap-3 text-sm text-primary-100"> <li key={key} className="flex items-center gap-3 text-sm text-primary-100">
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0"> <span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0">
<Check className="w-3.5 h-3.5 text-white" /> <Check className="w-3.5 h-3.5 text-white" />
</span> </span>
{text} {t(key)}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
{/* Footer quote */} <p className="relative z-10 text-xs text-primary-300">{t('auth.branding_footer')}</p>
<p className="relative z-10 text-xs text-primary-300">
Trusted by businesses building smarter customer experiences.
</p>
</div> </div>
) )
}
// ─── Shared page wrapper ────────────────────────────────────────────────────── // ─── Shared page wrapper ──────────────────────────────────────────────────────
const AuthLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => ( const AuthLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
@@ -115,6 +111,7 @@ const IconInput: React.FC<{
// ─── LoginPage ───────────────────────────────────────────────────────────────── // ─── LoginPage ─────────────────────────────────────────────────────────────────
export const LoginPage: React.FC = () => { export const LoginPage: React.FC = () => {
const { t } = useTranslation()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPass, setShowPass] = useState(false) const [showPass, setShowPass] = useState(false)
@@ -133,7 +130,7 @@ export const LoginPage: React.FC = () => {
navigate('/dashboard') navigate('/dashboard')
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } } const e = err as { response?: { data?: { detail?: string } } }
setError(e.response?.data?.detail || 'Login failed. Please check your credentials.') setError(e.response?.data?.detail || t('auth.login_failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -141,22 +138,21 @@ export const LoginPage: React.FC = () => {
return ( return (
<AuthLayout> <AuthLayout>
{/* Mobile logo */} <Link to="/" className="flex lg:hidden items-center gap-2 mb-8 w-fit">
<div className="flex lg:hidden items-center gap-2 mb-8">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center"> <div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white" /> <Sparkles className="w-4 h-4 text-white" />
</div> </div>
<span className="font-bold text-gray-900">Contexta</span> <span className="font-bold text-gray-900">Contexta</span>
</div> </Link>
<div className="mb-7"> <div className="mb-7">
<h1 className="text-2xl font-bold text-gray-900">Welcome back</h1> <h1 className="text-2xl font-bold text-gray-900">{t('auth.login_title')}</h1>
<p className="text-gray-500 mt-1 text-sm">Sign in to your Contexta account</p> <p className="text-gray-500 mt-1 text-sm">{t('auth.login_subtitle')}</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<IconInput <IconInput
label="Email" label={t('auth.email')}
icon={<Mail className="w-4 h-4" />} icon={<Mail className="w-4 h-4" />}
type="email" type="email"
value={email} value={email}
@@ -166,7 +162,7 @@ export const LoginPage: React.FC = () => {
/> />
<IconInput <IconInput
label="Password" label={t('auth.password')}
icon={<Lock className="w-4 h-4" />} icon={<Lock className="w-4 h-4" />}
type={showPass ? 'text' : 'password'} type={showPass ? 'text' : 'password'}
value={password} value={password}
@@ -192,25 +188,25 @@ export const LoginPage: React.FC = () => {
className="w-full mt-2 bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200" className="w-full mt-2 bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200"
size="lg" size="lg"
> >
Sign in {t('auth.sign_in')}
</Button> </Button>
</form> </form>
<div className="mt-4 flex items-center justify-between text-sm"> <div className="mt-4 flex items-center justify-between text-sm">
<span className="text-gray-500"> <span className="text-gray-500">
No account?{' '} {t('auth.no_account')}{' '}
<Link <Link
to="/signup" to="/signup"
className="text-primary-600 font-medium hover:text-primary-700 transition-colors underline-offset-2 hover:underline" className="text-primary-600 font-medium hover:text-primary-700 transition-colors underline-offset-2 hover:underline"
> >
Sign up free {t('auth.sign_up_free')}
</Link> </Link>
</span> </span>
<Link <Link
to="/forgot-password" to="/forgot-password"
className="text-gray-400 hover:text-primary-600 transition-colors text-xs" className="text-gray-400 hover:text-primary-600 transition-colors text-xs"
> >
Forgot password? {t('auth.forgot_password')}
</Link> </Link>
</div> </div>
</AuthLayout> </AuthLayout>
@@ -219,6 +215,7 @@ export const LoginPage: React.FC = () => {
// ─── SignupPage ──────────────────────────────────────────────────────────────── // ─── SignupPage ────────────────────────────────────────────────────────────────
export const SignupPage: React.FC = () => { export const SignupPage: React.FC = () => {
const { t } = useTranslation()
const [form, setForm] = useState({ email: '', password: '', company_name: '' }) const [form, setForm] = useState({ email: '', password: '', company_name: '' })
const [showPass, setShowPass] = useState(false) const [showPass, setShowPass] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -231,23 +228,21 @@ export const SignupPage: React.FC = () => {
e.preventDefault() e.preventDefault()
setError('') setError('')
if (form.password.length < 8) { if (form.password.length < 8) {
setError('Password must be at least 8 characters') setError(t('auth.password_min_8'))
return return
} }
setLoading(true) setLoading(true)
try { try {
const data = await authAPI.signup(form) const data = await authAPI.signup(form)
if (data.access_token) { if (data.access_token) {
// Email confirmation not required — go straight to dashboard
setAuth(data.user, data.access_token) setAuth(data.user, data.access_token)
navigate('/dashboard') navigate('/dashboard')
} else { } else {
// Supabase requires email confirmation
setEmailSent(true) setEmailSent(true)
} }
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } } const e = err as { response?: { data?: { detail?: string } } }
setError(e.response?.data?.detail || 'Signup failed. Please try again.') setError(e.response?.data?.detail || t('auth.signup_failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -260,14 +255,12 @@ export const SignupPage: React.FC = () => {
<div className="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-5"> <div className="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-5">
<Check className="w-8 h-8 text-green-600" /> <Check className="w-8 h-8 text-green-600" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Check your inbox</h1> <h1 className="text-2xl font-bold text-gray-900 mb-2">{t('auth.check_inbox_title')}</h1>
<p className="text-gray-500 text-sm mb-1"> <p className="text-gray-500 text-sm mb-1">{t('auth.check_inbox_desc')}</p>
A confirmation link was sent to
</p>
<p className="text-gray-900 font-medium text-sm mb-6">{form.email}</p> <p className="text-gray-900 font-medium text-sm mb-6">{form.email}</p>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Already confirmed?{' '} {t('auth.already_confirmed')}{' '}
<Link to="/login" className="text-primary-600 font-medium hover:underline">Sign in</Link> <Link to="/login" className="text-primary-600 font-medium hover:underline">{t('auth.sign_in')}</Link>
</p> </p>
</div> </div>
</div> </div>
@@ -276,22 +269,21 @@ export const SignupPage: React.FC = () => {
return ( return (
<AuthLayout> <AuthLayout>
{/* Mobile logo */} <Link to="/" className="flex lg:hidden items-center gap-2 mb-8 w-fit">
<div className="flex lg:hidden items-center gap-2 mb-8">
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center"> <div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white" /> <Sparkles className="w-4 h-4 text-white" />
</div> </div>
<span className="font-bold text-gray-900">Contexta</span> <span className="font-bold text-gray-900">Contexta</span>
</div> </Link>
<div className="mb-7"> <div className="mb-7">
<h1 className="text-2xl font-bold text-gray-900">Create your account</h1> <h1 className="text-2xl font-bold text-gray-900">{t('auth.signup_title')}</h1>
<p className="text-gray-500 mt-1 text-sm">Start building AI chatbots free forever</p> <p className="text-gray-500 mt-1 text-sm">{t('auth.signup_subtitle')}</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<IconInput <IconInput
label="Company Name" label={t('auth.company_name')}
icon={<Building2 className="w-4 h-4" />} icon={<Building2 className="w-4 h-4" />}
type="text" type="text"
value={form.company_name} value={form.company_name}
@@ -301,7 +293,7 @@ export const SignupPage: React.FC = () => {
/> />
<IconInput <IconInput
label="Email" label={t('auth.email')}
icon={<Mail className="w-4 h-4" />} icon={<Mail className="w-4 h-4" />}
type="email" type="email"
value={form.email} value={form.email}
@@ -311,7 +303,7 @@ export const SignupPage: React.FC = () => {
/> />
<IconInput <IconInput
label="Password" label={t('auth.password')}
icon={<Lock className="w-4 h-4" />} icon={<Lock className="w-4 h-4" />}
type={showPass ? 'text' : 'password'} type={showPass ? 'text' : 'password'}
value={form.password} value={form.password}
@@ -337,24 +329,24 @@ export const SignupPage: React.FC = () => {
className="w-full mt-2 bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200" className="w-full mt-2 bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200"
size="lg" size="lg"
> >
Create free account {t('auth.create_free_account')}
</Button> </Button>
<p className="text-xs text-center text-gray-400 leading-relaxed"> <p className="text-xs text-center text-gray-400 leading-relaxed">
By signing up you agree to our{' '} {t('auth.terms_text')}{' '}
<span className="text-gray-500 underline cursor-pointer">Terms of Service</span>{' '} <span className="text-gray-500 underline cursor-pointer">{t('auth.terms_of_service')}</span>{' '}
and{' '} {t('auth.and')}{' '}
<span className="text-gray-500 underline cursor-pointer">Privacy Policy</span> <span className="text-gray-500 underline cursor-pointer">{t('auth.privacy_policy')}</span>
</p> </p>
</form> </form>
<div className="mt-5 text-center text-sm text-gray-500"> <div className="mt-5 text-center text-sm text-gray-500">
Already have an account?{' '} {t('auth.already_account')}{' '}
<Link <Link
to="/login" to="/login"
className="text-primary-600 font-medium hover:text-primary-700 transition-colors underline-offset-2 hover:underline" className="text-primary-600 font-medium hover:text-primary-700 transition-colors underline-offset-2 hover:underline"
> >
Sign in {t('auth.sign_in')}
</Link> </Link>
</div> </div>
</AuthLayout> </AuthLayout>

View File

@@ -1,30 +1,23 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { campaignsAPI, chatbotsAPI } from '@/services/api' import { campaignsAPI, chatbotsAPI } from '@/services/api'
import { Card, Button, Spinner } from '@/components/ui' import { Card, Button, Spinner } from '@/components/ui'
import { import {
Megaphone, Send, Trash2, Lock, Users, CheckCircle2, Clock, Megaphone, Send, Trash2, Lock, Users, CheckCircle2, Clock,
AlertCircle, Plus, X, ChevronDown, AlertCircle, Plus, X,
} from 'lucide-react' } from 'lucide-react'
import type { Campaign, Chatbot } from '@/types' import type { Campaign, Chatbot } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ElementType }> = { function useStatusConfig() {
draft: { label: 'Draft', color: 'bg-gray-100 text-gray-600', icon: Clock }, const { t } = useTranslation()
sending: { label: 'Sending...', color: 'bg-blue-100 text-blue-700', icon: Clock }, return {
sent: { label: 'Sent', color: 'bg-green-100 text-green-700', icon: CheckCircle2 }, draft: { label: t('campaigns.status_draft'), color: 'bg-gray-100 text-gray-600', icon: Clock },
failed: { label: 'Failed', color: 'bg-red-100 text-red-600', icon: AlertCircle }, sending: { label: t('campaigns.status_sending'), color: 'bg-blue-100 text-blue-700', icon: Clock },
} sent: { label: t('campaigns.status_sent'), color: 'bg-green-100 text-green-700', icon: CheckCircle2 },
failed: { label: t('campaigns.status_failed'), color: 'bg-red-100 text-red-600', icon: AlertCircle },
function timeAgo(dateStr?: string): string { } as Record<string, { label: string; color: string; icon: React.ElementType }>
if (!dateStr) return ''
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return new Date(dateStr).toLocaleDateString()
} }
// ── New Campaign Form ───────────────────────────────────────────────────────── // ── New Campaign Form ─────────────────────────────────────────────────────────
@@ -35,12 +28,11 @@ const NewCampaignForm: React.FC<{
onCreate: (data: { chatbot_id: string; title: string; message: string }) => void onCreate: (data: { chatbot_id: string; title: string; message: string }) => void
creating: boolean creating: boolean
}> = ({ chatbots, onClose, onCreate, creating }) => { }> = ({ chatbots, onClose, onCreate, creating }) => {
const { t } = useTranslation()
const [chatbotId, setChatbotId] = useState(chatbots[0]?.id || '') const [chatbotId, setChatbotId] = useState(chatbots[0]?.id || '')
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const telegramChatbots = chatbots // All chatbots can have Telegram connected
const canSubmit = chatbotId && title.trim() && message.trim() const canSubmit = chatbotId && title.trim() && message.trim()
return ( return (
@@ -48,7 +40,7 @@ const NewCampaignForm: React.FC<{
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900 text-sm flex items-center gap-2"> <h3 className="font-semibold text-gray-900 text-sm flex items-center gap-2">
<Megaphone className="w-4 h-4 text-primary-600" /> <Megaphone className="w-4 h-4 text-primary-600" />
New Campaign {t('campaigns.new_campaign')}
</h3> </h3>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 text-gray-400"> <button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 text-gray-400">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -57,44 +49,42 @@ const NewCampaignForm: React.FC<{
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-xs font-medium text-gray-700 block mb-1">Chatbot</label> <label className="text-xs font-medium text-gray-700 block mb-1">{t('campaigns.chatbot_label')}</label>
<select <select
value={chatbotId} value={chatbotId}
onChange={e => setChatbotId(e.target.value)} onChange={e => setChatbotId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 appearance-none" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 appearance-none"
> >
{telegramChatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} {chatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select> </select>
<p className="text-[11px] text-gray-400 mt-1"> <p className="text-[11px] text-gray-400 mt-1">{t('campaigns.chatbot_hint')}</p>
Will broadcast to all Telegram subscribers of this chatbot.
</p>
</div> </div>
<div> <div>
<label className="text-xs font-medium text-gray-700 block mb-1">Campaign name</label> <label className="text-xs font-medium text-gray-700 block mb-1">{t('campaigns.campaign_name')}</label>
<input <input
type="text" type="text"
value={title} value={title}
onChange={e => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
placeholder="e.g. Summer promotion, New menu announcement..." placeholder={t('campaigns.campaign_name_placeholder')}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/> />
</div> </div>
<div> <div>
<label className="text-xs font-medium text-gray-700 block mb-1">Message</label> <label className="text-xs font-medium text-gray-700 block mb-1">{t('campaigns.message_label')}</label>
<textarea <textarea
value={message} value={message}
onChange={e => setMessage(e.target.value)} onChange={e => setMessage(e.target.value)}
placeholder="Write your broadcast message here..." placeholder={t('campaigns.message_placeholder')}
rows={4} rows={4}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/> />
<p className="text-[11px] text-gray-400 mt-1">{message.length}/4000 characters</p> <p className="text-[11px] text-gray-400 mt-1">{t('campaigns.characters', { count: message.length })}</p>
</div> </div>
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button variant="secondary" size="sm" onClick={onClose} className="flex-1">Cancel</Button> <Button variant="secondary" size="sm" onClick={onClose} className="flex-1">{t('common.cancel')}</Button>
<Button <Button
size="sm" size="sm"
onClick={() => onCreate({ chatbot_id: chatbotId, title: title.trim(), message: message.trim() })} onClick={() => onCreate({ chatbot_id: chatbotId, title: title.trim(), message: message.trim() })}
@@ -102,7 +92,7 @@ const NewCampaignForm: React.FC<{
className="flex-1 gap-2" className="flex-1 gap-2"
> >
{creating ? <Spinner className="w-4 h-4 text-white" /> : <Plus className="w-4 h-4" />} {creating ? <Spinner className="w-4 h-4 text-white" /> : <Plus className="w-4 h-4" />}
Create Campaign {t('campaigns.create_campaign')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -113,11 +103,14 @@ const NewCampaignForm: React.FC<{
// ── Main Page ───────────────────────────────────────────────────────────────── // ── Main Page ─────────────────────────────────────────────────────────────────
export const CampaignsPage: React.FC = () => { export const CampaignsPage: React.FC = () => {
const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [chatbotFilter, setChatbotFilter] = useState('') const [chatbotFilter, setChatbotFilter] = useState('')
const [confirmSendId, setConfirmSendId] = useState<string | null>(null) const [confirmSendId, setConfirmSendId] = useState<string | null>(null)
const STATUS_CONFIG = useStatusConfig()
const { data: chatbots = [] } = useQuery<Chatbot[]>({ const { data: chatbots = [] } = useQuery<Chatbot[]>({
queryKey: ['chatbots'], queryKey: ['chatbots'],
queryFn: chatbotsAPI.list, queryFn: chatbotsAPI.list,
@@ -127,7 +120,7 @@ export const CampaignsPage: React.FC = () => {
queryKey: ['campaigns', chatbotFilter], queryKey: ['campaigns', chatbotFilter],
queryFn: () => campaignsAPI.list(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined), queryFn: () => campaignsAPI.list(chatbotFilter ? { chatbot_id: chatbotFilter } : undefined),
retry: false, retry: false,
refetchInterval: 5000, // Poll while a campaign may be sending refetchInterval: 5000,
}) })
const createCampaign = useMutation({ const createCampaign = useMutation({
@@ -160,10 +153,8 @@ export const CampaignsPage: React.FC = () => {
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5"> <div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Lock className="w-7 h-7 text-primary-600" /> <Lock className="w-7 h-7 text-primary-600" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Telegram Campaigns</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">{t('campaigns.upgrade_title')}</h2>
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto"> <p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">{t('campaigns.upgrade_desc')}</p>
Upgrade to Starter to broadcast messages to your Telegram subscribers.
</p>
</Card> </Card>
</div> </div>
) )
@@ -182,18 +173,16 @@ export const CampaignsPage: React.FC = () => {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6"> <div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-6">
<h3 className="font-bold text-gray-900 mb-2">Send this campaign?</h3> <h3 className="font-bold text-gray-900 mb-2">{t('campaigns.send_modal_title')}</h3>
<p className="text-sm text-gray-600 mb-1"> <p className="text-sm text-gray-600 mb-1">
<strong>"{c.title}"</strong> will be sent to{' '} {t('campaigns.send_modal_desc', { title: c.title, count: c.recipients_count })}
<strong>{c.recipients_count} subscriber{c.recipients_count !== 1 ? 's' : ''}</strong>{' '}
via Telegram.
</p> </p>
<p className="text-xs text-amber-600 bg-amber-50 rounded-lg p-2.5 mt-3"> <p className="text-xs text-amber-600 bg-amber-50 rounded-lg p-2.5 mt-3">
This action cannot be undone. The message will be delivered immediately. {t('campaigns.send_modal_warning')}
</p> </p>
<div className="flex gap-2 mt-5"> <div className="flex gap-2 mt-5">
<Button variant="secondary" size="sm" className="flex-1" onClick={() => setConfirmSendId(null)}> <Button variant="secondary" size="sm" className="flex-1" onClick={() => setConfirmSendId(null)}>
Cancel {t('common.cancel')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -202,7 +191,7 @@ export const CampaignsPage: React.FC = () => {
disabled={sendCampaign.isPending} disabled={sendCampaign.isPending}
> >
{sendCampaign.isPending ? <Spinner className="w-4 h-4 text-white" /> : <Send className="w-4 h-4" />} {sendCampaign.isPending ? <Spinner className="w-4 h-4 text-white" /> : <Send className="w-4 h-4" />}
Send Now {t('campaigns.send_now')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -217,14 +206,14 @@ export const CampaignsPage: React.FC = () => {
<Megaphone className="w-5 h-5 text-primary-600" /> <Megaphone className="w-5 h-5 text-primary-600" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Campaigns</h1> <h1 className="text-2xl font-bold text-gray-900">{t('campaigns.title')}</h1>
<p className="text-sm text-gray-500 mt-0.5">Broadcast messages to Telegram subscribers</p> <p className="text-sm text-gray-500 mt-0.5">{t('campaigns.subtitle')}</p>
</div> </div>
</div> </div>
{!showForm && ( {!showForm && (
<Button size="sm" onClick={() => setShowForm(true)} className="self-start sm:self-auto gap-2"> <Button size="sm" onClick={() => setShowForm(true)} className="self-start sm:self-auto gap-2">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
New Campaign {t('campaigns.new_campaign')}
</Button> </Button>
)} )}
</div> </div>
@@ -233,9 +222,9 @@ export const CampaignsPage: React.FC = () => {
{campaigns.length > 0 && ( {campaigns.length > 0 && (
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{[ {[
{ label: 'Campaigns', value: campaigns.length }, { label: t('campaigns.stat_campaigns'), value: campaigns.length },
{ label: 'Sent', value: campaigns.filter(c => c.status === 'sent').length }, { label: t('campaigns.stat_sent'), value: campaigns.filter(c => c.status === 'sent').length },
{ label: 'Messages delivered', value: sentTotal.toLocaleString() }, { label: t('campaigns.stat_delivered'), value: sentTotal.toLocaleString() },
].map(s => ( ].map(s => (
<Card key={s.label} className="p-4 text-center"> <Card key={s.label} className="p-4 text-center">
<p className="text-2xl font-bold text-gray-900">{s.value}</p> <p className="text-2xl font-bold text-gray-900">{s.value}</p>
@@ -257,8 +246,10 @@ export const CampaignsPage: React.FC = () => {
{showForm && chatbots.length === 0 && ( {showForm && chatbots.length === 0 && (
<Card className="p-6 text-center"> <Card className="p-6 text-center">
<p className="text-sm text-gray-500">You need at least one chatbot to create a campaign.</p> <p className="text-sm text-gray-500">{t('campaigns.no_chatbots_needed')}</p>
<button onClick={() => setShowForm(false)} className="text-xs text-primary-600 mt-2 hover:underline">Close</button> <button onClick={() => setShowForm(false)} className="text-xs text-primary-600 mt-2 hover:underline">
{t('common.close')}
</button>
</Card> </Card>
)} )}
@@ -270,7 +261,7 @@ export const CampaignsPage: React.FC = () => {
onChange={e => setChatbotFilter(e.target.value)} onChange={e => setChatbotFilter(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer" className="border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 appearance-none cursor-pointer"
> >
<option value="">All chatbots</option> <option value="">{t('common.all_chatbots')}</option>
{chatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)} {chatbots.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select> </select>
</div> </div>
@@ -284,14 +275,12 @@ export const CampaignsPage: React.FC = () => {
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5"> <div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
<Megaphone className="w-7 h-7 text-gray-300" /> <Megaphone className="w-7 h-7 text-gray-300" />
</div> </div>
<h3 className="font-semibold text-gray-700 mb-2">No campaigns yet</h3> <h3 className="font-semibold text-gray-700 mb-2">{t('campaigns.no_campaigns_title')}</h3>
<p className="text-sm text-gray-500 max-w-sm mx-auto"> <p className="text-sm text-gray-500 max-w-sm mx-auto">{t('campaigns.no_campaigns_desc')}</p>
Create a campaign to broadcast a message to all your Telegram subscribers at once.
</p>
{!showForm && ( {!showForm && (
<Button size="sm" className="mt-4 gap-2" onClick={() => setShowForm(true)}> <Button size="sm" className="mt-4 gap-2" onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
New Campaign {t('campaigns.new_campaign')}
</Button> </Button>
)} )}
</Card> </Card>
@@ -301,6 +290,7 @@ export const CampaignsPage: React.FC = () => {
const sc = STATUS_CONFIG[campaign.status] || STATUS_CONFIG.draft const sc = STATUS_CONFIG[campaign.status] || STATUS_CONFIG.draft
const Icon = sc.icon const Icon = sc.icon
const chatbotName = chatbotMap[campaign.chatbot_id] || 'Unknown chatbot' const chatbotName = chatbotMap[campaign.chatbot_id] || 'Unknown chatbot'
const sentAt = campaign.sent_at ? new Date(campaign.sent_at).toLocaleDateString() : ''
return ( return (
<Card key={campaign.id} className="p-5"> <Card key={campaign.id} className="p-5">
@@ -315,7 +305,7 @@ export const CampaignsPage: React.FC = () => {
</div> </div>
<p className="text-xs text-gray-500 mb-2"> <p className="text-xs text-gray-500 mb-2">
{chatbotName} · {timeAgo(campaign.created_at)} {chatbotName} · {campaign.created_at ? new Date(campaign.created_at).toLocaleDateString() : ''}
</p> </p>
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg px-3 py-2 mb-3 line-clamp-2"> <p className="text-sm text-gray-700 bg-gray-50 rounded-lg px-3 py-2 mb-3 line-clamp-2">
@@ -325,12 +315,12 @@ export const CampaignsPage: React.FC = () => {
<div className="flex items-center gap-4 text-xs text-gray-500"> <div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Users className="w-3 h-3" /> <Users className="w-3 h-3" />
{campaign.recipients_count} subscriber{campaign.recipients_count !== 1 ? 's' : ''} {t('campaigns.subscriber', { count: campaign.recipients_count })}
</span> </span>
{campaign.status === 'sent' && ( {campaign.status === 'sent' && (
<span className="flex items-center gap-1 text-green-600"> <span className="flex items-center gap-1 text-green-600">
<CheckCircle2 className="w-3 h-3" /> <CheckCircle2 className="w-3 h-3" />
{campaign.sent_count} delivered · {timeAgo(campaign.sent_at)} {campaign.sent_count} {t('campaigns.delivered')} · {sentAt}
</span> </span>
)} )}
</div> </div>
@@ -343,32 +333,32 @@ export const CampaignsPage: React.FC = () => {
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
> >
<Send className="w-3.5 h-3.5" /> <Send className="w-3.5 h-3.5" />
Send Campaign {t('campaigns.send_campaign')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
if (confirm('Delete this campaign?')) if (confirm(t('campaigns.delete_campaign')))
deleteCampaign.mutate(campaign.id) deleteCampaign.mutate(campaign.id)
}} }}
disabled={deleteCampaign.isPending} disabled={deleteCampaign.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors disabled:opacity-50"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
Delete {t('common.delete')}
</button> </button>
</div> </div>
)} )}
{campaign.status === 'sent' && ( {campaign.status === 'sent' && (
<button <button
onClick={() => { onClick={() => {
if (confirm('Delete this campaign record?')) if (confirm(t('campaigns.delete_campaign_record')))
deleteCampaign.mutate(campaign.id) deleteCampaign.mutate(campaign.id)
}} }}
disabled={deleteCampaign.isPending} disabled={deleteCampaign.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 mt-3 text-xs font-medium text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 mt-3 text-xs font-medium text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
Delete record {t('campaigns.delete_record')}
</button> </button>
)} )}
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,13 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { chatbotsAPI } from '@/services/api' import { chatbotsAPI } from '@/services/api'
import { Button, StatusDot, EmptyState, Modal } from '@/components/ui' import { Button, StatusDot, EmptyState, Modal } from '@/components/ui'
import { SkeletonCard } from '@/components/Skeletons' import { SkeletonCard } from '@/components/Skeletons'
import { useToast } from '@/contexts/ToastContext' import { useToast } from '@/contexts/ToastContext'
import { useAuthStore } from '@/store/authStore'
import { OnboardingChecklist } from '@/components/OnboardingChecklist'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { Chatbot } from '@/types' import type { Chatbot } from '@/types'
import { import {
@@ -15,9 +18,11 @@ import {
export const DashboardPage: React.FC = () => { export const DashboardPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const user = useAuthStore(s => s.user)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
const [confirmAction, setConfirmAction] = useState<{ type: 'publish' | 'unpublish'; id: string } | null>(null) const [confirmAction, setConfirmAction] = useState<{ type: 'publish' | 'unpublish'; id: string } | null>(null)
const { success: showToast } = useToast() const { success: showToast } = useToast()
const { t } = useTranslation()
const { data: chatbots = [], isLoading } = useQuery({ const { data: chatbots = [], isLoading } = useQuery({
queryKey: ['chatbots'], queryKey: ['chatbots'],
@@ -29,7 +34,7 @@ export const DashboardPage: React.FC = () => {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chatbots'] }) queryClient.invalidateQueries({ queryKey: ['chatbots'] })
setDeleteId(null) setDeleteId(null)
showToast('Chatbot deleted') showToast(t('dashboard.chatbot_deleted'))
}, },
}) })
@@ -37,7 +42,7 @@ export const DashboardPage: React.FC = () => {
mutationFn: chatbotsAPI.publish, mutationFn: chatbotsAPI.publish,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chatbots'] }) queryClient.invalidateQueries({ queryKey: ['chatbots'] })
showToast('Chatbot published to marketplace!') showToast(t('dashboard.chatbot_published'))
}, },
}) })
@@ -45,7 +50,7 @@ export const DashboardPage: React.FC = () => {
mutationFn: chatbotsAPI.unpublish, mutationFn: chatbotsAPI.unpublish,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chatbots'] }) queryClient.invalidateQueries({ queryKey: ['chatbots'] })
showToast('Chatbot unpublished') showToast(t('dashboard.chatbot_unpublished'))
}, },
}) })
@@ -61,16 +66,16 @@ export const DashboardPage: React.FC = () => {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-3">
<div> <div>
<h1 className="text-xl sm:text-4xl font-bold text-gray-900 tracking-tight">Dashboard</h1> <h1 className="text-xl sm:text-4xl font-bold text-gray-900 tracking-tight">{t('dashboard.title')}</h1>
<p className="text-sm text-gray-500 mt-0.5"> <p className="text-sm text-gray-500 mt-0.5">
{chatbots.length > 0 {chatbots.length > 0
? `${chatbots.length} chatbot${chatbots.length !== 1 ? 's' : ''}` ? t('dashboard.chatbot_count', { count: chatbots.length })
: 'Manage your AI chatbots'} : t('dashboard.subtitle_empty')}
</p> </p>
</div> </div>
<Button onClick={() => navigate('/chatbots/new')} size="md"> <Button onClick={() => navigate('/chatbots/new')} size="md">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
New Chatbot {t('dashboard.new_chatbot')}
</Button> </Button>
</div> </div>
@@ -82,12 +87,12 @@ export const DashboardPage: React.FC = () => {
) : chatbots.length === 0 ? ( ) : chatbots.length === 0 ? (
<EmptyState <EmptyState
icon={<Bot className="w-8 h-8" />} icon={<Bot className="w-8 h-8" />}
title="No chatbots yet" title={t('dashboard.no_chatbots_title')}
description="Create your first AI chatbot powered by your documents. Free to build and test." description={t('dashboard.no_chatbots_desc')}
action={ action={
<Button onClick={() => navigate('/chatbots/new')} size="lg"> <Button onClick={() => navigate('/chatbots/new')} size="lg">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Create your first chatbot {t('dashboard.create_first')}
</Button> </Button>
} }
/> />
@@ -121,26 +126,26 @@ export const DashboardPage: React.FC = () => {
<Plus className="w-5 h-5 text-gray-400 group-hover:text-primary-500 transition-colors duration-200" /> <Plus className="w-5 h-5 text-gray-400 group-hover:text-primary-500 transition-colors duration-200" />
</div> </div>
<p className="text-sm font-medium text-gray-400 group-hover:text-primary-600 transition-colors duration-200"> <p className="text-sm font-medium text-gray-400 group-hover:text-primary-600 transition-colors duration-200">
New Chatbot {t('dashboard.new_chatbot')}
</p> </p>
</button> </button>
</div> </div>
)} )}
{/* Delete modal */} {/* Delete modal */}
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="Delete Chatbot" size="sm"> <Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title={t('dashboard.delete_chatbot')} size="sm">
<p className="text-sm text-gray-500 mb-5 leading-relaxed"> <p className="text-sm text-gray-500 mb-5 leading-relaxed">
All documents, conversation history, and settings will be permanently removed. This cannot be undone. {t('dashboard.delete_confirm')}
</p> </p>
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1">Cancel</Button> <Button variant="outline" onClick={() => setDeleteId(null)} className="flex-1">{t('common.cancel')}</Button>
<Button <Button
variant="danger" variant="danger"
onClick={() => deleteId && deleteMutation.mutate(deleteId)} onClick={() => deleteId && deleteMutation.mutate(deleteId)}
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
className="flex-1" className="flex-1"
> >
Delete {t('common.delete')}
</Button> </Button>
</div> </div>
</Modal> </Modal>
@@ -149,25 +154,33 @@ export const DashboardPage: React.FC = () => {
<Modal <Modal
isOpen={!!confirmAction} isOpen={!!confirmAction}
onClose={() => setConfirmAction(null)} onClose={() => setConfirmAction(null)}
title={confirmAction?.type === 'publish' ? 'Publish to Marketplace' : 'Unpublish Chatbot'} title={confirmAction?.type === 'publish' ? t('dashboard.publish_to_marketplace') : t('dashboard.unpublish_chatbot')}
size="sm" size="sm"
> >
<p className="text-sm text-gray-500 mb-5 leading-relaxed"> <p className="text-sm text-gray-500 mb-5 leading-relaxed">
{confirmAction?.type === 'publish' {confirmAction?.type === 'publish'
? 'Your chatbot will be publicly visible on the marketplace.' ? t('dashboard.publish_confirm')
: 'Your chatbot will be removed from the marketplace.'} : t('dashboard.unpublish_confirm')}
</p> </p>
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<Button variant="outline" onClick={() => setConfirmAction(null)} className="flex-1">Cancel</Button> <Button variant="outline" onClick={() => setConfirmAction(null)} className="flex-1">{t('common.cancel')}</Button>
<Button <Button
onClick={handleConfirmAction} onClick={handleConfirmAction}
loading={publishMutation.isPending || unpublishMutation.isPending} loading={publishMutation.isPending || unpublishMutation.isPending}
className="flex-1" className="flex-1"
> >
{confirmAction?.type === 'publish' ? 'Publish' : 'Unpublish'} {confirmAction?.type === 'publish' ? t('common.publish') : t('common.unpublish')}
</Button> </Button>
</div> </div>
</Modal> </Modal>
{user && !isLoading && (
<OnboardingChecklist
userId={user.id}
userName={user.full_name || user.company_name}
chatbots={chatbots}
/>
)}
</div> </div>
) )
} }
@@ -184,6 +197,7 @@ const ChatbotCard: React.FC<{
onAnalytics: () => void onAnalytics: () => void
}> = ({ chatbot, index, onEdit, onPreview, onPublish, onUnpublish, onDelete, onAnalytics }) => { }> = ({ chatbot, index, onEdit, onPreview, onPublish, onUnpublish, onDelete, onAnalytics }) => {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const { t } = useTranslation()
return ( return (
<div <div
@@ -219,7 +233,7 @@ const ChatbotCard: React.FC<{
'text-xs font-medium', 'text-xs font-medium',
chatbot.is_published ? 'text-green-600' : 'text-gray-400' chatbot.is_published ? 'text-green-600' : 'text-gray-400'
)}> )}>
{chatbot.is_published ? 'Published' : 'Draft'} {chatbot.is_published ? t('common.published') : t('common.draft')}
</span> </span>
</div> </div>
</div> </div>
@@ -238,9 +252,9 @@ const ChatbotCard: React.FC<{
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} /> <div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 mt-1 w-44 bg-white border border-gray-100 rounded-xl shadow-xl z-20 overflow-hidden py-1 animate-scale-in"> <div className="absolute right-0 mt-1 w-44 bg-white border border-gray-100 rounded-xl shadow-xl z-20 overflow-hidden py-1 animate-scale-in">
{[ {[
{ label: 'Edit Settings', icon: Settings, action: onEdit }, { label: t('common.edit_settings'), icon: Settings, action: onEdit },
{ label: 'Preview', icon: Eye, action: onPreview }, { label: t('common.preview'), icon: Eye, action: onPreview },
{ label: 'Analytics', icon: BarChart2, action: onAnalytics }, { label: t('common.analytics'), icon: BarChart2, action: onAnalytics },
].map(({ label, icon: Icon, action }) => ( ].map(({ label, icon: Icon, action }) => (
<button <button
key={label} key={label}
@@ -257,14 +271,14 @@ const ChatbotCard: React.FC<{
onClick={() => { onUnpublish(); setMenuOpen(false) }} onClick={() => { onUnpublish(); setMenuOpen(false) }}
className="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-amber-50 text-amber-600 text-left text-sm transition-colors" className="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-amber-50 text-amber-600 text-left text-sm transition-colors"
> >
<Lock className="w-3.5 h-3.5" /> Unpublish <Lock className="w-3.5 h-3.5" /> {t('common.unpublish')}
</button> </button>
) : ( ) : (
<button <button
onClick={() => { onPublish(); setMenuOpen(false) }} onClick={() => { onPublish(); setMenuOpen(false) }}
className="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-green-50 text-green-600 text-left text-sm transition-colors" className="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-green-50 text-green-600 text-left text-sm transition-colors"
> >
<Globe className="w-3.5 h-3.5" /> Publish <Globe className="w-3.5 h-3.5" /> {t('common.publish')}
</button> </button>
)} )}
<div className="h-px bg-gray-50 my-1" /> <div className="h-px bg-gray-50 my-1" />
@@ -272,7 +286,7 @@ const ChatbotCard: React.FC<{
onClick={() => { onDelete(); setMenuOpen(false) }} onClick={() => { onDelete(); setMenuOpen(false) }}
className="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-red-50 text-red-500 text-left text-sm transition-colors" className="flex items-center gap-2.5 w-full px-3 py-2 hover:bg-red-50 text-red-500 text-left text-sm transition-colors"
> >
<Trash2 className="w-3.5 h-3.5" /> Delete <Trash2 className="w-3.5 h-3.5" /> {t('common.delete')}
</button> </button>
</div> </div>
</> </>
@@ -306,7 +320,7 @@ const ChatbotCard: React.FC<{
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onPreview} className="flex-1 text-xs"> <Button variant="outline" size="sm" onClick={onPreview} className="flex-1 text-xs">
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
Preview {t('common.preview')}
</Button> </Button>
{chatbot.is_published ? ( {chatbot.is_published ? (
<Button <Button
@@ -316,12 +330,12 @@ const ChatbotCard: React.FC<{
className="flex-1 text-xs text-amber-600 border-amber-200 hover:bg-amber-50" className="flex-1 text-xs text-amber-600 border-amber-200 hover:bg-amber-50"
> >
<Lock className="w-3 h-3" /> <Lock className="w-3 h-3" />
Unpublish {t('common.unpublish')}
</Button> </Button>
) : ( ) : (
<Button size="sm" onClick={onPublish} className="flex-1 text-xs"> <Button size="sm" onClick={onPublish} className="flex-1 text-xs">
<Globe className="w-3 h-3" /> <Globe className="w-3 h-3" />
Publish {t('common.publish')}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -1,10 +1,12 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { authAPI } from '@/services/api' import { authAPI } from '@/services/api'
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
import { Sparkles, ArrowLeft, Mail, X, Check } from 'lucide-react' import { Sparkles, ArrowLeft, Mail, X, Check } from 'lucide-react'
export const ForgotPasswordPage: React.FC = () => { export const ForgotPasswordPage: React.FC = () => {
const { t } = useTranslation()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
@@ -18,7 +20,7 @@ export const ForgotPasswordPage: React.FC = () => {
await authAPI.forgotPassword(email) await authAPI.forgotPassword(email)
setSent(true) setSent(true)
} catch { } catch {
setError('Something went wrong. Please try again.') setError(t('auth.forgot_error'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -27,13 +29,12 @@ export const ForgotPasswordPage: React.FC = () => {
return ( return (
<div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md animate-scale-in"> <div className="w-full max-w-md animate-scale-in">
{/* Logo */} <Link to="/" className="flex items-center justify-center gap-2 mb-8">
<div className="flex items-center justify-center gap-2 mb-8">
<div className="w-9 h-9 bg-primary-600 rounded-xl flex items-center justify-center shadow-sm"> <div className="w-9 h-9 bg-primary-600 rounded-xl flex items-center justify-center shadow-sm">
<Sparkles className="w-5 h-5 text-white" /> <Sparkles className="w-5 h-5 text-white" />
</div> </div>
<span className="font-bold text-gray-900 text-lg">Contexta</span> <span className="font-bold text-gray-900 text-lg">Contexta</span>
</div> </Link>
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8"> <div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
{sent ? ( {sent ? (
@@ -41,34 +42,27 @@ export const ForgotPasswordPage: React.FC = () => {
<div className="w-14 h-14 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-5"> <div className="w-14 h-14 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-5">
<Check className="w-7 h-7 text-green-600" /> <Check className="w-7 h-7 text-green-600" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Check your inbox</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">{t('auth.forgot_sent_title')}</h2>
<p className="text-sm text-gray-500 mb-1"> <p className="text-sm text-gray-500 mb-1">{t('auth.forgot_sent_desc_one', { email })}</p>
If <strong className="text-gray-700">{email}</strong> is registered, <p className="text-sm text-gray-500 mb-6">{t('auth.forgot_sent_desc_two')}</p>
</p>
<p className="text-sm text-gray-500 mb-6">
a password reset link has been sent.
</p>
<Link <Link
to="/login" to="/login"
className="inline-flex items-center gap-1.5 text-sm text-primary-600 font-medium hover:text-primary-700 transition-colors" className="inline-flex items-center gap-1.5 text-sm text-primary-600 font-medium hover:text-primary-700 transition-colors"
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
Back to sign in {t('auth.back_to_signin')}
</Link> </Link>
</div> </div>
) : ( ) : (
<> <>
<div className="mb-7"> <div className="mb-7">
<h1 className="text-2xl font-bold text-gray-900">Reset your password</h1> <h1 className="text-2xl font-bold text-gray-900">{t('auth.forgot_title')}</h1>
<p className="text-gray-500 mt-1 text-sm"> <p className="text-gray-500 mt-1 text-sm">{t('auth.forgot_subtitle')}</p>
We'll send a reset link to your email address.
</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Email input with icon */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm text-left font-medium text-gray-700">Email address</label> <label className="text-sm text-left font-medium text-gray-700">{t('auth.email_address')}</label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
<Mail className="w-4 h-4" /> <Mail className="w-4 h-4" />
@@ -101,7 +95,7 @@ export const ForgotPasswordPage: React.FC = () => {
className="w-full bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200" className="w-full bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200"
size="lg" size="lg"
> >
Send reset link {t('auth.send_reset_link')}
</Button> </Button>
</form> </form>
@@ -111,7 +105,7 @@ export const ForgotPasswordPage: React.FC = () => {
className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors" className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
Back to sign in {t('auth.back_to_signin')}
</Link> </Link>
</div> </div>
</> </>

View File

@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from 'react' import React, { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { inboxAPI } from '@/services/api' import { inboxAPI } from '@/services/api'
import { Card, Spinner } from '@/components/ui' import { Card, Spinner } from '@/components/ui'
import { import {
@@ -51,19 +52,6 @@ const AvatarInitial: React.FC<{ name: string; size?: 'sm' | 'md' }> = ({ name, s
) )
} }
const STATUS_CONFIG: Record<ConversationStatus, { label: string; color: string }> = {
open: { label: 'Open', color: 'bg-blue-100 text-blue-700' },
agent_handling: { label: 'Agent', color: 'bg-orange-100 text-orange-700' },
resolved: { label: 'Resolved', color: 'bg-green-100 text-green-700' },
}
const STATUS_TABS: { key: ConversationStatus | 'all'; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'open', label: 'Open' },
{ key: 'agent_handling', label: 'Agent' },
{ key: 'resolved', label: 'Resolved' },
]
export const InboxPage: React.FC = () => { export const InboxPage: React.FC = () => {
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [statusFilter, setStatusFilter] = useState<ConversationStatus | 'all'>('all') const [statusFilter, setStatusFilter] = useState<ConversationStatus | 'all'>('all')
@@ -71,12 +59,26 @@ export const InboxPage: React.FC = () => {
const [replyText, setReplyText] = useState('') const [replyText, setReplyText] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { t } = useTranslation()
const STATUS_CONFIG: Record<ConversationStatus, { label: string; color: string }> = {
open: { label: t('inbox.status_open'), color: 'bg-blue-100 text-blue-700' },
agent_handling: { label: t('inbox.status_agent'), color: 'bg-orange-100 text-orange-700' },
resolved: { label: t('inbox.status_resolved'), color: 'bg-green-100 text-green-700' },
}
const STATUS_TABS: { key: ConversationStatus | 'all'; label: string }[] = [
{ key: 'all', label: t('inbox.filter_all') },
{ key: 'open', label: t('inbox.filter_open') },
{ key: 'agent_handling', label: t('inbox.filter_agent') },
{ key: 'resolved', label: t('inbox.filter_resolved') },
]
const { data: conversations = [], isLoading, error } = useQuery<InboxConversation[]>({ const { data: conversations = [], isLoading, error } = useQuery<InboxConversation[]>({
queryKey: ['inbox-conversations', statusFilter], queryKey: ['inbox-conversations', statusFilter],
queryFn: () => inboxAPI.conversations(statusFilter !== 'all' ? { status: statusFilter } : undefined), queryFn: () => inboxAPI.conversations(statusFilter !== 'all' ? { status: statusFilter } : undefined),
retry: false, retry: false,
refetchInterval: 15000, // poll every 15s refetchInterval: 15000,
}) })
const { data: detail, isLoading: detailLoading } = useQuery<ConversationDetail>({ const { data: detail, isLoading: detailLoading } = useQuery<ConversationDetail>({
@@ -111,14 +113,14 @@ export const InboxPage: React.FC = () => {
const handleDelete = async (e: React.MouseEvent, convId: string) => { const handleDelete = async (e: React.MouseEvent, convId: string) => {
e.stopPropagation() e.stopPropagation()
if (!confirm('Delete this conversation?')) return if (!confirm(t('inbox.delete_conversation'))) return
setDeletingId(convId) setDeletingId(convId)
try { try {
await inboxAPI.deleteConversation(convId) await inboxAPI.deleteConversation(convId)
if (selectedId === convId) setSelectedId(null) if (selectedId === convId) setSelectedId(null)
queryClient.invalidateQueries({ queryKey: ['inbox-conversations'] }) queryClient.invalidateQueries({ queryKey: ['inbox-conversations'] })
} catch { } catch {
alert('Failed to delete conversation') alert(t('inbox.failed_to_delete'))
} finally { } finally {
setDeletingId(null) setDeletingId(null)
} }
@@ -139,9 +141,9 @@ export const InboxPage: React.FC = () => {
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5"> <div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Mail className="w-7 h-7 text-primary-600" /> <Mail className="w-7 h-7 text-primary-600" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Conversation Inbox</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">{t('inbox.upgrade_title')}</h2>
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto"> <p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
Upgrade to Starter to read all your chatbot conversations in one place. {t('inbox.upgrade_desc')}
</p> </p>
</Card> </Card>
</div> </div>
@@ -164,24 +166,24 @@ export const InboxPage: React.FC = () => {
<Inbox className="w-4 h-4 text-primary-600" /> <Inbox className="w-4 h-4 text-primary-600" />
</div> </div>
<div> <div>
<h1 className="text-sm font-bold text-gray-900">Inbox</h1> <h1 className="text-sm font-bold text-gray-900">{t('inbox.title')}</h1>
<p className="text-xs text-gray-500">{conversations.length} conversation{conversations.length !== 1 ? 's' : ''}</p> <p className="text-xs text-gray-500">{t('inbox.conversation_count', { count: conversations.length })}</p>
</div> </div>
</div> </div>
{/* Status tabs */} {/* Status tabs */}
<div className="flex gap-1 bg-gray-100 rounded-lg p-0.5"> <div className="flex gap-1 bg-gray-100 rounded-lg p-0.5">
{STATUS_TABS.map(t => ( {STATUS_TABS.map(tab => (
<button <button
key={t.key} key={tab.key}
onClick={() => setStatusFilter(t.key)} onClick={() => setStatusFilter(tab.key)}
className={cn( className={cn(
'flex-1 text-xs font-medium py-1.5 rounded-md transition-all', 'flex-1 text-xs font-medium py-1.5 rounded-md transition-all',
statusFilter === t.key statusFilter === tab.key
? 'bg-white text-gray-900 shadow-sm' ? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700' : 'text-gray-500 hover:text-gray-700'
)} )}
> >
{t.label} {tab.label}
</button> </button>
))} ))}
</div> </div>
@@ -196,8 +198,8 @@ export const InboxPage: React.FC = () => {
<div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-4"> <div className="w-14 h-14 rounded-2xl bg-gray-50 flex items-center justify-center mb-4">
<MessageSquare className="w-6 h-6 text-gray-300" /> <MessageSquare className="w-6 h-6 text-gray-300" />
</div> </div>
<p className="text-sm font-medium text-gray-600 mb-1">No conversations</p> <p className="text-sm font-medium text-gray-600 mb-1">{t('inbox.no_conversations')}</p>
<p className="text-xs text-gray-400">Try a different filter</p> <p className="text-xs text-gray-400">{t('inbox.try_different_filter')}</p>
</div> </div>
) : ( ) : (
conversations.map((conv) => { conversations.map((conv) => {
@@ -223,7 +225,7 @@ export const InboxPage: React.FC = () => {
<span className="text-[10px] text-gray-400 flex-shrink-0">{timeAgo(conv.created_at)}</span> <span className="text-[10px] text-gray-400 flex-shrink-0">{timeAgo(conv.created_at)}</span>
</div> </div>
<p className="text-sm text-gray-600 truncate leading-snug"> <p className="text-sm text-gray-600 truncate leading-snug">
{conv.first_message || '(No messages)'} {conv.first_message || t('inbox.no_messages')}
</p> </p>
<div className="flex items-center gap-1.5 mt-1.5"> <div className="flex items-center gap-1.5 mt-1.5">
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium', sc.color)}> <span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-medium', sc.color)}>
@@ -241,7 +243,7 @@ export const InboxPage: React.FC = () => {
onClick={(e) => handleDelete(e, conv.id)} onClick={(e) => handleDelete(e, conv.id)}
disabled={deletingId === conv.id} disabled={deletingId === conv.id}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-gray-300 hover:text-red-500 hover:bg-red-50 opacity-0 group-hover:opacity-100 transition-all" className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-gray-300 hover:text-red-500 hover:bg-red-50 opacity-0 group-hover:opacity-100 transition-all"
title="Delete" title={t('common.delete')}
> >
{deletingId === conv.id ? <Spinner className="w-3.5 h-3.5 text-red-400" /> : <Trash2 className="w-3.5 h-3.5" />} {deletingId === conv.id ? <Spinner className="w-3.5 h-3.5 text-red-400" /> : <Trash2 className="w-3.5 h-3.5" />}
</button> </button>
@@ -263,8 +265,8 @@ export const InboxPage: React.FC = () => {
<div className="w-16 h-16 rounded-2xl bg-white border border-gray-200 shadow-sm flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 rounded-2xl bg-white border border-gray-200 shadow-sm flex items-center justify-center mx-auto mb-4">
<Mail className="w-7 h-7 text-gray-300" /> <Mail className="w-7 h-7 text-gray-300" />
</div> </div>
<p className="text-sm font-medium text-gray-500 mb-1">Select a conversation</p> <p className="text-sm font-medium text-gray-500 mb-1">{t('inbox.select_conversation')}</p>
<p className="text-xs text-gray-400">Choose one from the list to view the full exchange</p> <p className="text-xs text-gray-400">{t('inbox.select_conversation_desc')}</p>
</div> </div>
</div> </div>
) : detailLoading ? ( ) : detailLoading ? (
@@ -294,10 +296,9 @@ export const InboxPage: React.FC = () => {
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'agent_handling' })} onClick={() => updateStatus.mutate({ id: selectedId!, status: 'agent_handling' })}
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-orange-700 bg-orange-50 hover:bg-orange-100 border border-orange-200 rounded-lg transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-orange-700 bg-orange-50 hover:bg-orange-100 border border-orange-200 rounded-lg transition-colors disabled:opacity-50"
title="Take over this conversation"
> >
<UserCheck className="w-3.5 h-3.5" /> <UserCheck className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Take Over</span> <span className="hidden sm:inline">{t('inbox.take_over')}</span>
</button> </button>
)} )}
{selectedConv?.status !== 'resolved' && ( {selectedConv?.status !== 'resolved' && (
@@ -305,10 +306,9 @@ export const InboxPage: React.FC = () => {
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'resolved' })} onClick={() => updateStatus.mutate({ id: selectedId!, status: 'resolved' })}
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 rounded-lg transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 rounded-lg transition-colors disabled:opacity-50"
title="Mark as resolved"
> >
<CheckCircle2 className="w-3.5 h-3.5" /> <CheckCircle2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Resolve</span> <span className="hidden sm:inline">{t('inbox.resolve')}</span>
</button> </button>
)} )}
{selectedConv?.status !== 'open' && ( {selectedConv?.status !== 'open' && (
@@ -316,10 +316,9 @@ export const InboxPage: React.FC = () => {
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'open' })} onClick={() => updateStatus.mutate({ id: selectedId!, status: 'open' })}
disabled={updateStatus.isPending} disabled={updateStatus.isPending}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded-lg transition-colors disabled:opacity-50" className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 border border-blue-200 rounded-lg transition-colors disabled:opacity-50"
title="Reopen"
> >
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Reopen</span> <span className="hidden sm:inline">{t('inbox.reopen')}</span>
</button> </button>
)} )}
</div> </div>
@@ -356,7 +355,7 @@ export const InboxPage: React.FC = () => {
: 'bg-white border border-gray-200 text-gray-800 rounded-2xl rounded-bl-sm' : 'bg-white border border-gray-200 text-gray-800 rounded-2xl rounded-bl-sm'
)}> )}>
{msg.role === 'agent' && ( {msg.role === 'agent' && (
<p className="text-[10px] font-semibold text-orange-600 mb-1 uppercase tracking-wide">You (agent)</p> <p className="text-[10px] font-semibold text-orange-600 mb-1 uppercase tracking-wide">{t('inbox.you_agent')}</p>
)} )}
<p className="whitespace-pre-wrap">{msg.content}</p> <p className="whitespace-pre-wrap">{msg.content}</p>
{(msg.is_handoff || (msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2)) && ( {(msg.is_handoff || (msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2)) && (
@@ -366,12 +365,12 @@ export const InboxPage: React.FC = () => {
)}> )}>
{msg.is_handoff && ( {msg.is_handoff && (
<span className="text-[10px] bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-medium"> <span className="text-[10px] bg-orange-100 text-orange-700 px-2 py-0.5 rounded-full font-medium">
Handoff requested {t('inbox.handoff_requested')}
</span> </span>
)} )}
{msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2 && ( {msg.confidence_score !== undefined && msg.confidence_score !== null && msg.confidence_score < 0.2 && (
<span className="text-[10px] flex items-center gap-0.5 text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full"> <span className="text-[10px] flex items-center gap-0.5 text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
<AlertTriangle className="w-2.5 h-2.5" /> Low confidence <AlertTriangle className="w-2.5 h-2.5" /> {t('inbox.low_confidence')}
</span> </span>
)} )}
</div> </div>
@@ -392,14 +391,14 @@ export const InboxPage: React.FC = () => {
<div className="p-3 bg-white border-t border-gray-200"> <div className="p-3 bg-white border-t border-gray-200">
{selectedConv?.status === 'resolved' ? ( {selectedConv?.status === 'resolved' ? (
<p className="text-xs text-center text-gray-400 py-1"> <p className="text-xs text-center text-gray-400 py-1">
Conversation resolved {' '} {t('inbox.conversation_resolved')}{' '}
<button <button
onClick={() => updateStatus.mutate({ id: selectedId!, status: 'open' })} onClick={() => updateStatus.mutate({ id: selectedId!, status: 'open' })}
className="text-primary-600 hover:underline font-medium" className="text-primary-600 hover:underline font-medium"
> >
reopen {t('inbox.reopen_link')}
</button> </button>
{' '}to reply {' '}{t('inbox.to_reply')}
</p> </p>
) : ( ) : (
<div className="flex gap-2"> <div className="flex gap-2">
@@ -408,14 +407,13 @@ export const InboxPage: React.FC = () => {
value={replyText} value={replyText}
onChange={e => setReplyText(e.target.value)} onChange={e => setReplyText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSendReply()} onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSendReply()}
placeholder="Type a reply as agent..." placeholder={t('inbox.type_reply')}
className="flex-1 border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors" className="flex-1 border border-gray-200 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/> />
<button <button
onClick={handleSendReply} onClick={handleSendReply}
disabled={!replyText.trim() || sendReply.isPending} disabled={!replyText.trim() || sendReply.isPending}
className="p-2.5 bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-40 transition-colors flex-shrink-0" className="p-2.5 bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-40 transition-colors flex-shrink-0"
title="Send reply"
> >
{sendReply.isPending ? <Spinner className="w-4 h-4 text-white" /> : <Send className="w-4 h-4" />} {sendReply.isPending ? <Spinner className="w-4 h-4 text-white" /> : <Send className="w-4 h-4" />}
</button> </button>

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import i18n from '@/i18n/i18n'
import { import {
Sparkles, Bot, Shield, Zap, ArrowRight, Sparkles, Bot, Zap, ArrowRight,
Check, MessageSquare, Upload, Play, ChevronRight, Star, Check, MessageSquare, Upload, Play, ChevronRight, Star,
Cpu, Menu, X, Cpu, Menu, X,
Users, CalendarDays, Megaphone, TrendingUp, Inbox, Users, CalendarDays, Megaphone, TrendingUp, Inbox,
@@ -466,7 +467,9 @@ const TestimonialCard: React.FC<{
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
export const LandingPage: React.FC = () => { export const LandingPage: React.FC = () => {
const [lang, setLang] = useState<Lang>('fr') const [lang, setLang] = useState<Lang>(
(i18n.language?.startsWith('fr') ? 'fr' : 'en') as Lang
)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [scrolled, setScrolled] = useState(false) const [scrolled, setScrolled] = useState(false)
@@ -523,13 +526,13 @@ export const LandingPage: React.FC = () => {
{/* Language toggle */} {/* Language toggle */}
<div className="flex items-center border border-gray-200 rounded-lg overflow-hidden text-xs font-semibold"> <div className="flex items-center border border-gray-200 rounded-lg overflow-hidden text-xs font-semibold">
<button <button
onClick={() => setLang('fr')} onClick={() => { setLang('fr'); i18n.changeLanguage('fr') }}
className={`px-2.5 py-1.5 transition-colors ${lang === 'fr' ? 'bg-primary-600 text-white' : 'text-gray-500 hover:bg-gray-50'}`} className={`px-2.5 py-1.5 transition-colors ${lang === 'fr' ? 'bg-primary-600 text-white' : 'text-gray-500 hover:bg-gray-50'}`}
> >
FR FR
</button> </button>
<button <button
onClick={() => setLang('en')} onClick={() => { setLang('en'); i18n.changeLanguage('en') }}
className={`px-2.5 py-1.5 transition-colors ${lang === 'en' ? 'bg-primary-600 text-white' : 'text-gray-500 hover:bg-gray-50'}`} className={`px-2.5 py-1.5 transition-colors ${lang === 'en' ? 'bg-primary-600 text-white' : 'text-gray-500 hover:bg-gray-50'}`}
> >
EN EN
@@ -548,8 +551,8 @@ export const LandingPage: React.FC = () => {
<div className="md:hidden flex items-center gap-2"> <div className="md:hidden flex items-center gap-2">
{/* Mobile language toggle */} {/* Mobile language toggle */}
<div className="flex items-center border border-gray-200 rounded-lg overflow-hidden text-xs font-semibold"> <div className="flex items-center border border-gray-200 rounded-lg overflow-hidden text-xs font-semibold">
<button onClick={() => setLang('fr')} className={`px-2 py-1 transition-colors ${lang === 'fr' ? 'bg-primary-600 text-white' : 'text-gray-500'}`}>FR</button> <button onClick={() => { setLang('fr'); i18n.changeLanguage('fr') }} className={`px-2 py-1 transition-colors ${lang === 'fr' ? 'bg-primary-600 text-white' : 'text-gray-500'}`}>FR</button>
<button onClick={() => setLang('en')} className={`px-2 py-1 transition-colors ${lang === 'en' ? 'bg-primary-600 text-white' : 'text-gray-500'}`}>EN</button> <button onClick={() => { setLang('en'); i18n.changeLanguage('en') }} className={`px-2 py-1 transition-colors ${lang === 'en' ? 'bg-primary-600 text-white' : 'text-gray-500'}`}>EN</button>
</div> </div>
<button <button
className="p-2 rounded-lg hover:bg-gray-100 transition-colors" className="p-2 rounded-lg hover:bg-gray-100 transition-colors"

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { leadsAPI, chatbotsAPI } from '@/services/api' import { leadsAPI, chatbotsAPI } from '@/services/api'
import { Card, Button } from '@/components/ui' import { Card, Button } from '@/components/ui'
import { Users, Download, Mail, Lock, TrendingUp, Filter, UserCheck, StickyNote, X, Check } from 'lucide-react' import { Users, Download, Mail, Lock, TrendingUp, Filter, UserCheck, StickyNote, X, Check } from 'lucide-react'
@@ -7,16 +8,16 @@ import type { Lead, LeadStatus, Chatbot } from '@/types'
import { SkeletonTable } from '@/components/Skeletons' import { SkeletonTable } from '@/components/Skeletons'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const STATUS_OPTIONS: { value: LeadStatus; label: string; color: string }[] = [ const useStatusOptions = () => {
{ value: 'new', label: 'New', color: 'bg-blue-100 text-blue-700' }, const { t } = useTranslation()
{ value: 'contacted', label: 'Contacted', color: 'bg-yellow-100 text-yellow-700' }, return [
{ value: 'qualified', label: 'Qualified', color: 'bg-purple-100 text-purple-700' }, { value: 'new' as LeadStatus, label: t('leads.status_new'), color: 'bg-blue-100 text-blue-700' },
{ value: 'closed', label: 'Closed', color: 'bg-green-100 text-green-700' }, { value: 'contacted' as LeadStatus, label: t('leads.status_contacted'), color: 'bg-yellow-100 text-yellow-700' },
{ value: 'lost', label: 'Lost', color: 'bg-gray-100 text-gray-500' }, { value: 'qualified' as LeadStatus, label: t('leads.status_qualified'), color: 'bg-purple-100 text-purple-700' },
] { value: 'closed' as LeadStatus, label: t('leads.status_closed'), color: 'bg-green-100 text-green-700' },
{ value: 'lost' as LeadStatus, label: t('leads.status_lost'), color: 'bg-gray-100 text-gray-500' },
const statusConfig = (status: LeadStatus) => ]
STATUS_OPTIONS.find(s => s.value === status) || STATUS_OPTIONS[0] }
interface NotesModalProps { interface NotesModalProps {
lead: Lead lead: Lead
@@ -27,12 +28,13 @@ interface NotesModalProps {
const NotesModal: React.FC<NotesModalProps> = ({ lead, onClose, onSave, saving }) => { const NotesModal: React.FC<NotesModalProps> = ({ lead, onClose, onSave, saving }) => {
const [text, setText] = useState(lead.notes || '') const [text, setText] = useState(lead.notes || '')
const { t } = useTranslation()
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md"> <div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100"> <div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 className="font-semibold text-gray-900 text-sm"> <h3 className="font-semibold text-gray-900 text-sm">
Notes {lead.name || lead.email || 'Lead'} {t('leads.notes_modal_title', { name: lead.name || lead.email || 'Lead' })}
</h3> </h3>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400"> <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -42,16 +44,16 @@ const NotesModal: React.FC<NotesModalProps> = ({ lead, onClose, onSave, saving }
<textarea <textarea
value={text} value={text}
onChange={e => setText(e.target.value)} onChange={e => setText(e.target.value)}
placeholder="Add notes about this lead..." placeholder={t('leads.notes_placeholder')}
rows={5} rows={5}
className="w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors" className="w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-400 transition-colors"
/> />
</div> </div>
<div className="flex gap-2 px-5 pb-4"> <div className="flex gap-2 px-5 pb-4">
<Button variant="secondary" size="sm" onClick={onClose} className="flex-1">Cancel</Button> <Button variant="secondary" size="sm" onClick={onClose} className="flex-1">{t('common.cancel')}</Button>
<Button size="sm" onClick={() => onSave(text)} disabled={saving} className="flex-1 gap-1.5"> <Button size="sm" onClick={() => onSave(text)} disabled={saving} className="flex-1 gap-1.5">
<Check className="w-3.5 h-3.5" /> <Check className="w-3.5 h-3.5" />
Save {t('common.save')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -64,6 +66,11 @@ export const LeadsPage: React.FC = () => {
const [statusFilter, setStatusFilter] = useState<LeadStatus | ''>('') const [statusFilter, setStatusFilter] = useState<LeadStatus | ''>('')
const [notesLead, setNotesLead] = useState<Lead | null>(null) const [notesLead, setNotesLead] = useState<Lead | null>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { t } = useTranslation()
const STATUS_OPTIONS = useStatusOptions()
const statusConfig = (status: LeadStatus) =>
STATUS_OPTIONS.find(s => s.value === status) || STATUS_OPTIONS[0]
const { data: chatbots = [] } = useQuery<Chatbot[]>({ const { data: chatbots = [] } = useQuery<Chatbot[]>({
queryKey: ['chatbots'], queryKey: ['chatbots'],
@@ -95,7 +102,7 @@ export const LeadsPage: React.FC = () => {
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} catch { alert('Export failed') } } catch { alert(t('leads.export_failed')) }
} }
if (isPlanError) { if (isPlanError) {
@@ -105,9 +112,9 @@ export const LeadsPage: React.FC = () => {
<div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5"> <div className="w-16 h-16 rounded-2xl bg-primary-50 flex items-center justify-center mx-auto mb-5">
<Lock className="w-7 h-7 text-primary-600" /> <Lock className="w-7 h-7 text-primary-600" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Lead Capture</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">{t('leads.upgrade_title')}</h2>
<p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto"> <p className="text-gray-500 text-sm mb-6 max-w-sm mx-auto">
Upgrade to Starter to capture and manage leads from your chatbots. {t('leads.upgrade_desc')}
</p> </p>
</Card> </Card>
</div> </div>
@@ -121,10 +128,7 @@ export const LeadsPage: React.FC = () => {
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
}) })
// Apply client-side status filter
const filtered = statusFilter ? leads.filter(l => l.status === statusFilter) : leads const filtered = statusFilter ? leads.filter(l => l.status === statusFilter) : leads
// CRM stats
const byStatus = (s: LeadStatus) => leads.filter(l => l.status === s).length const byStatus = (s: LeadStatus) => leads.filter(l => l.status === s).length
const LeadAvatar: React.FC<{ name?: string | null; email?: string | null }> = ({ name, email }) => { const LeadAvatar: React.FC<{ name?: string | null; email?: string | null }> = ({ name, email }) => {
@@ -159,13 +163,13 @@ export const LeadsPage: React.FC = () => {
<Users className="w-5 h-5 text-primary-600" /> <Users className="w-5 h-5 text-primary-600" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Leads</h1> <h1 className="text-2xl font-bold text-gray-900">{t('leads.title')}</h1>
<p className="text-sm text-gray-500 mt-0.5">Contacts collected by your chatbots</p> <p className="text-sm text-gray-500 mt-0.5">{t('leads.subtitle')}</p>
</div> </div>
</div> </div>
<Button onClick={handleExport} variant="secondary" size="sm" className="self-start sm:self-auto gap-2"> <Button onClick={handleExport} variant="secondary" size="sm" className="self-start sm:self-auto gap-2">
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
Export CSV {t('common.export_csv')}
</Button> </Button>
</div> </div>
@@ -200,7 +204,7 @@ export const LeadsPage: React.FC = () => {
<UserCheck className="w-5 h-5 text-primary-600" /> <UserCheck className="w-5 h-5 text-primary-600" />
</div> </div>
<div> <div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Total leads</p> <p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{t('leads.total_leads')}</p>
<p className="text-2xl font-bold text-gray-900">{leads.length.toLocaleString()}</p> <p className="text-2xl font-bold text-gray-900">{leads.length.toLocaleString()}</p>
</div> </div>
</Card> </Card>
@@ -209,7 +213,7 @@ export const LeadsPage: React.FC = () => {
<TrendingUp className="w-5 h-5 text-emerald-600" /> <TrendingUp className="w-5 h-5 text-emerald-600" />
</div> </div>
<div> <div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">This month</p> <p className="text-xs font-medium text-gray-500 uppercase tracking-wide">{t('leads.this_month')}</p>
<p className="text-2xl font-bold text-gray-900">{thisMonthLeads.length.toLocaleString()}</p> <p className="text-2xl font-bold text-gray-900">{thisMonthLeads.length.toLocaleString()}</p>
</div> </div>
</Card> </Card>
@@ -221,14 +225,14 @@ export const LeadsPage: React.FC = () => {
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3"> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<div className="flex items-center gap-2 text-sm font-medium text-gray-600 flex-shrink-0"> <div className="flex items-center gap-2 text-sm font-medium text-gray-600 flex-shrink-0">
<Filter className="w-4 h-4 text-gray-400" /> <Filter className="w-4 h-4 text-gray-400" />
Filter by chatbot {t('leads.filter_by_chatbot')}
</div> </div>
<select <select
value={chatbotFilter} value={chatbotFilter}
onChange={e => setChatbotFilter(e.target.value)} onChange={e => setChatbotFilter(e.target.value)}
className="w-full sm:max-w-xs border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-400 transition-all appearance-none cursor-pointer" className="w-full sm:max-w-xs border border-gray-200 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-400 transition-all appearance-none cursor-pointer"
> >
<option value="">All chatbots</option> <option value="">{t('common.all_chatbots')}</option>
{chatbots.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)} {chatbots.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select> </select>
{statusFilter && ( {statusFilter && (
@@ -236,7 +240,7 @@ export const LeadsPage: React.FC = () => {
onClick={() => setStatusFilter('')} onClick={() => setStatusFilter('')}
className="text-xs text-primary-600 hover:underline flex items-center gap-1" className="text-xs text-primary-600 hover:underline flex items-center gap-1"
> >
<X className="w-3 h-3" /> Clear status filter <X className="w-3 h-3" /> {t('leads.clear_status_filter')}
</button> </button>
)} )}
</div> </div>
@@ -250,11 +254,13 @@ export const LeadsPage: React.FC = () => {
<div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5"> <div className="w-16 h-16 rounded-2xl bg-gray-50 flex items-center justify-center mx-auto mb-5">
<Mail className="w-7 h-7 text-gray-300" /> <Mail className="w-7 h-7 text-gray-300" />
</div> </div>
<h3 className="font-semibold text-gray-700 mb-2">No leads {statusFilter ? `with status "${statusFilter}"` : 'yet'}</h3> <h3 className="font-semibold text-gray-700 mb-2">
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
{statusFilter {statusFilter
? 'Try a different filter or clear the current one.' ? t('leads.no_leads_with_status', { status: statusFilter })
: 'Enable lead capture on your chatbots to start collecting contact information.'} : t('leads.no_leads_title')}
</h3>
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
{statusFilter ? t('leads.no_leads_status_desc') : t('leads.no_leads_desc')}
</p> </p>
</Card> </Card>
) : ( ) : (
@@ -265,12 +271,12 @@ export const LeadsPage: React.FC = () => {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-gray-50/80 border-b border-gray-200"> <tr className="bg-gray-50/80 border-b border-gray-200">
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Contact</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{t('leads.col_contact')}</th>
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Phone</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{t('leads.col_phone')}</th>
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Company</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{t('leads.col_company')}</th>
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{t('leads.col_status')}</th>
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Notes</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{t('leads.col_notes')}</th>
<th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Date</th> <th className="px-5 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{t('leads.col_date')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
@@ -308,11 +314,11 @@ export const LeadsPage: React.FC = () => {
<button <button
onClick={() => setNotesLead(lead)} onClick={() => setNotesLead(lead)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-primary-600 transition-colors group" className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-primary-600 transition-colors group"
title={lead.notes || 'Add notes'} title={lead.notes || t('leads.add_note')}
> >
<StickyNote className="w-3.5 h-3.5 group-hover:text-primary-500" /> <StickyNote className="w-3.5 h-3.5 group-hover:text-primary-500" />
<span className="max-w-[100px] truncate"> <span className="max-w-[100px] truncate">
{lead.notes || <span className="text-gray-300">Add note</span>} {lead.notes || <span className="text-gray-300">{t('leads.add_note')}</span>}
</span> </span>
</button> </button>
</td> </td>
@@ -356,7 +362,7 @@ export const LeadsPage: React.FC = () => {
className="text-xs text-gray-400 hover:text-primary-600 flex items-center gap-1 mt-1 transition-colors" className="text-xs text-gray-400 hover:text-primary-600 flex items-center gap-1 mt-1 transition-colors"
> >
<StickyNote className="w-3 h-3" /> <StickyNote className="w-3 h-3" />
{lead.notes || 'Add note'} {lead.notes || t('leads.add_note')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,14 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { marketplaceAPI } from "@/services/api"; import { marketplaceAPI } from "@/services/api";
import { useAuthStore } from "@/store/authStore";
import { Spinner, EmptyState, Button, Card } from "@/components/ui"; import { Spinner, EmptyState, Button, Card } from "@/components/ui";
import { SkeletonCard } from "@/components/Skeletons"; import { SkeletonCard } from "@/components/Skeletons";
import { ChatInterface } from "@/components/ChatInterface"; import { ChatInterface } from "@/components/ChatInterface";
import { CATEGORIES, INDUSTRIES } from "@/lib/utils"; import { CATEGORIES, INDUSTRIES } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { import {
Search, Search,
Bot, Bot,
@@ -23,6 +26,7 @@ import type { ChatbotPublic } from "@/types";
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
export const MarketplacePage: React.FC = () => { export const MarketplacePage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState("");
@@ -31,8 +35,7 @@ export const MarketplacePage: React.FC = () => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
// Debounce search const searchTimeout = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const searchTimeout = React.useRef<ReturnType<typeof setTimeout>>();
const handleSearch = (value: string) => { const handleSearch = (value: string) => {
setSearch(value); setSearch(value);
clearTimeout(searchTimeout.current); clearTimeout(searchTimeout.current);
@@ -68,12 +71,11 @@ export const MarketplacePage: React.FC = () => {
<Bot className="w-5 h-5 text-white" /> <Bot className="w-5 h-5 text-white" />
</div> </div>
<h1 className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-gray-900 via-primary-800 to-primary-600 bg-clip-text text-transparent"> <h1 className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-gray-900 via-primary-800 to-primary-600 bg-clip-text text-transparent">
AI Chatbot Marketplace {t("marketplace.title")}
</h1> </h1>
</div> </div>
<p className="text-gray-500 text-sm sm:text-base max-w-xl"> <p className="text-gray-500 text-sm sm:text-base max-w-xl">
Discover and interact with AI-powered chatbots built by businesses {t("marketplace.subtitle")}
ready to answer your questions instantly.
</p> </p>
</div> </div>
</div> </div>
@@ -90,7 +92,7 @@ export const MarketplacePage: React.FC = () => {
type="text" type="text"
value={search} value={search}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
placeholder="Search chatbots by name or description..." placeholder={t("marketplace.search_placeholder")}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-400 bg-white shadow-sm transition-all placeholder-gray-400" className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-400 bg-white shadow-sm transition-all placeholder-gray-400"
/> />
{search && ( {search && (
@@ -98,18 +100,8 @@ export const MarketplacePage: React.FC = () => {
onClick={() => handleSearch("")} onClick={() => handleSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
> >
<svg <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-4 h-4" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
)} )}
@@ -123,7 +115,7 @@ export const MarketplacePage: React.FC = () => {
}`} }`}
> >
<SlidersHorizontal className="w-4 h-4" /> <SlidersHorizontal className="w-4 h-4" />
<span className="hidden sm:inline">Filters</span> <span className="hidden sm:inline">{t("marketplace.filters")}</span>
{hasActiveFilters && ( {hasActiveFilters && (
<span className="w-5 h-5 rounded-full bg-primary-600 text-white text-xs flex items-center justify-center"> <span className="w-5 h-5 rounded-full bg-primary-600 text-white text-xs flex items-center justify-center">
{(category ? 1 : 0) + (industry ? 1 : 0)} {(category ? 1 : 0) + (industry ? 1 : 0)}
@@ -135,32 +127,25 @@ export const MarketplacePage: React.FC = () => {
{/* Expandable filter section */} {/* Expandable filter section */}
{showFilters && ( {showFilters && (
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm space-y-4 animate-fade-in-down"> <div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm space-y-4 animate-fade-in-down">
{/* Category filter — pill buttons since list is manageable */}
<div> <div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5"> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5">
Category {t("marketplace.category")}
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
onClick={() => { onClick={() => { setCategory(""); setPage(1); }}
setCategory("");
setPage(1);
}}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${ className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${
category === "" category === ""
? "bg-primary-600 text-white border-primary-600 shadow-sm" ? "bg-primary-600 text-white border-primary-600 shadow-sm"
: "bg-white text-gray-600 border-gray-200 hover:border-primary-300 hover:text-primary-600" : "bg-white text-gray-600 border-gray-200 hover:border-primary-300 hover:text-primary-600"
}`} }`}
> >
All {t("marketplace.all")}
</button> </button>
{CATEGORIES.map((c) => ( {CATEGORIES.map((c) => (
<button <button
key={c} key={c}
onClick={() => { onClick={() => { setCategory(category === c ? "" : c); setPage(1); }}
setCategory(category === c ? "" : c);
setPage(1);
}}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${ className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${
category === c category === c
? "bg-primary-600 text-white border-primary-600 shadow-sm" ? "bg-primary-600 text-white border-primary-600 shadow-sm"
@@ -173,38 +158,28 @@ export const MarketplacePage: React.FC = () => {
</div> </div>
</div> </div>
{/* Industry filter — select dropdown since list is long */}
<div> <div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5"> <p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2.5">
Industry {t("marketplace.industry")}
</p> </p>
<select <select
value={industry} value={industry}
onChange={(e) => { onChange={(e) => { setIndustry(e.target.value); setPage(1); }}
setIndustry(e.target.value);
setPage(1);
}}
className="w-full sm:w-72 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white transition-all text-gray-700" className="w-full sm:w-72 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white transition-all text-gray-700"
> >
<option value="">All Industries</option> <option value="">{t("marketplace.all_industries")}</option>
{INDUSTRIES.map((i) => ( {INDUSTRIES.map((i) => (
<option key={i} value={i}> <option key={i} value={i}>{i}</option>
{i}
</option>
))} ))}
</select> </select>
</div> </div>
{hasActiveFilters && ( {hasActiveFilters && (
<button <button
onClick={() => { onClick={() => { setCategory(""); setIndustry(""); setPage(1); }}
setCategory("");
setIndustry("");
setPage(1);
}}
className="text-xs text-red-500 hover:text-red-700 transition-colors font-medium" className="text-xs text-red-500 hover:text-red-700 transition-colors font-medium"
> >
Clear all filters {t("marketplace.clear_all_filters")}
</button> </button>
)} )}
</div> </div>
@@ -214,34 +189,28 @@ export const MarketplacePage: React.FC = () => {
{/* Results */} {/* Results */}
{isLoading ? ( {isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => <SkeletonCard key={i} />)}
<SkeletonCard key={i} />
))}
</div> </div>
) : !data?.chatbots?.length ? ( ) : !data?.chatbots?.length ? (
<EmptyState <EmptyState
icon={<Bot className="w-8 h-8" />} icon={<Bot className="w-8 h-8" />}
title="No chatbots found" title={t("marketplace.no_chatbots_title")}
description={ description={
hasActiveFilters || debouncedSearch hasActiveFilters || debouncedSearch
? "Try adjusting your filters or search query." ? t("marketplace.no_chatbots_filtered")
: "Be the first to publish your AI chatbot to the marketplace!" : t("marketplace.no_chatbots_empty")
} }
action={ action={
hasActiveFilters || debouncedSearch ? ( hasActiveFilters || debouncedSearch ? (
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => { setCategory(""); setIndustry(""); handleSearch(""); }}
setCategory("");
setIndustry("");
handleSearch("");
}}
> >
Clear filters {t("marketplace.clear_filters")}
</Button> </Button>
) : ( ) : (
<Button onClick={() => navigate("/chatbots/new")}> <Button onClick={() => navigate("/chatbots/new")}>
Create Chatbot {t("marketplace.create_chatbot")}
</Button> </Button>
) )
} }
@@ -250,18 +219,14 @@ export const MarketplacePage: React.FC = () => {
<> <>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide"> <p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
{data.total} chatbot{data.total !== 1 ? "s" : ""} available {t("marketplace.available", { count: data.total })}
</p> </p>
{hasActiveFilters && ( {hasActiveFilters && (
<button <button
onClick={() => { onClick={() => { setCategory(""); setIndustry(""); setPage(1); }}
setCategory("");
setIndustry("");
setPage(1);
}}
className="text-xs text-primary-600 hover:text-primary-800 transition-colors" className="text-xs text-primary-600 hover:text-primary-800 transition-colors"
> >
Clear filters {t("marketplace.clear_filters")}
</button> </button>
)} )}
</div> </div>
@@ -289,24 +254,15 @@ export const MarketplacePage: React.FC = () => {
</button> </button>
{Array.from({ length: totalPages }, (_, i) => i + 1) {Array.from({ length: totalPages }, (_, i) => i + 1)
.filter( .filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
(p) =>
p === 1 || p === totalPages || Math.abs(p - page) <= 1,
)
.reduce<(number | "ellipsis")[]>((acc, p, idx, arr) => { .reduce<(number | "ellipsis")[]>((acc, p, idx, arr) => {
if (idx > 0 && p - (arr[idx - 1] as number) > 1) if (idx > 0 && p - (arr[idx - 1] as number) > 1) acc.push("ellipsis");
acc.push("ellipsis");
acc.push(p); acc.push(p);
return acc; return acc;
}, []) }, [])
.map((p, idx) => .map((p, idx) =>
p === "ellipsis" ? ( p === "ellipsis" ? (
<span <span key={`ellipsis-${idx}`} className="w-9 h-9 flex items-center justify-center text-gray-400 text-sm"></span>
key={`ellipsis-${idx}`}
className="w-9 h-9 flex items-center justify-center text-gray-400 text-sm"
>
</span>
) : ( ) : (
<button <button
key={p} key={p}
@@ -339,29 +295,28 @@ export const MarketplacePage: React.FC = () => {
}; };
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// MARKETPLACE CARD — shows logo when available // MARKETPLACE CARD
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
const ChatbotMarketplaceCard: React.FC<{ const ChatbotMarketplaceCard: React.FC<{
chatbot: ChatbotPublic; chatbot: ChatbotPublic;
onClick: () => void; onClick: () => void;
index: number; index: number;
}> = ({ chatbot, onClick, index }) => ( }> = ({ chatbot, onClick, index }) => {
const { t } = useTranslation();
return (
<div <div
className="group relative bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg hover:border-gray-300 hover:-translate-y-1 transition-all duration-200 cursor-pointer overflow-hidden" className="group relative bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg hover:border-gray-300 hover:-translate-y-1 transition-all duration-200 cursor-pointer overflow-hidden"
style={{ animationDelay: `${index * 60}ms`, animationFillMode: "both" }} style={{ animationDelay: `${index * 60}ms`, animationFillMode: "both" }}
onClick={onClick} onClick={onClick}
> >
{/* Colored accent top bar — thicker and with gradient */}
<div <div
className="h-1.5 w-full" className="h-1.5 w-full"
style={{ style={{ background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}aa)` }}
background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}aa)`,
}}
/> />
<div className="p-5"> <div className="p-5">
{/* Header row */}
<div className="flex items-start gap-3 mb-3"> <div className="flex items-start gap-3 mb-3">
{chatbot.logo_url ? ( {chatbot.logo_url ? (
<img <img
@@ -383,22 +338,18 @@ const ChatbotMarketplaceCard: React.FC<{
</h3> </h3>
{chatbot.company_name && ( {chatbot.company_name && (
<p className="text-xs text-gray-400 truncate mt-0.5"> <p className="text-xs text-gray-400 truncate mt-0.5">
by {chatbot.company_name} {t("marketplace.by", { name: chatbot.company_name })}
</p> </p>
)} )}
</div> </div>
</div> </div>
{/* Description */}
{chatbot.description ? ( {chatbot.description ? (
<p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed"> <p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">{chatbot.description}</p>
{chatbot.description}
</p>
) : ( ) : (
<div className="mb-4" /> <div className="mb-4" />
)} )}
{/* Stats row */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{chatbot.average_rating && chatbot.average_rating > 0 && ( {chatbot.average_rating && chatbot.average_rating > 0 && (
<span className="flex items-center gap-1 bg-amber-50 text-amber-700 px-2.5 py-1 rounded-full text-xs font-medium border border-amber-100"> <span className="flex items-center gap-1 bg-amber-50 text-amber-700 px-2.5 py-1 rounded-full text-xs font-medium border border-amber-100">
@@ -418,31 +369,113 @@ const ChatbotMarketplaceCard: React.FC<{
</div> </div>
</div> </div>
{/* Hover overlay: "Chat now" CTA */}
<div className="absolute inset-0 flex items-end justify-end mr-5 pb-5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"> <div className="absolute inset-0 flex items-end justify-end mr-5 pb-5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
<div <div
className="px-5 py-2 rounded-full text-white text-xs font-semibold shadow-lg pointer-events-none" className="px-5 py-2 rounded-full text-white text-xs font-semibold shadow-lg pointer-events-none"
style={{ background: chatbot.primary_color }} style={{ background: chatbot.primary_color }}
> >
Chat now {t("marketplace.chat_now")}
</div> </div>
</div> </div>
</div> </div>
); );
};
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// CHATBOT DETAIL PAGE — shows logo in header + passes to ChatInterface // STAR RATING WIDGET
// ═══════════════════════════════════════════════════════════════════════════════
const ratedKey = (chatbotId: string) => `rated_${chatbotId}`
const StarRatingWidget: React.FC<{ chatbot: ChatbotPublic }> = ({ chatbot }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const isAuthenticated = useAuthStore(s => s.isAuthenticated);
const [hovered, setHovered] = useState(0);
const [selected, setSelected] = useState(0);
const [submitted, setSubmitted] = useState(() => {
const stored = localStorage.getItem(ratedKey(chatbot.id));
return stored ? parseInt(stored, 10) : 0;
});
const rateMutation = useMutation({
mutationFn: (rating: number) => marketplaceAPI.rate(chatbot.id, { rating }),
onSuccess: (data) => {
localStorage.setItem(ratedKey(chatbot.id), String(selected));
setSubmitted(selected);
queryClient.setQueryData(["marketplace-chatbot", chatbot.id], (old: ChatbotPublic) => ({
...old,
average_rating: data.average_rating,
rating_count: data.rating_count,
}));
},
});
const displayRating = hovered || selected;
if (submitted > 0) {
return (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map(i => (
<Star
key={i}
className={cn("w-4 h-4", i <= submitted ? "fill-amber-400 text-amber-400" : "text-gray-200")}
/>
))}
</div>
<span className="text-xs text-gray-400">{t("marketplace.your_rating")}</span>
</div>
);
}
if (!isAuthenticated) {
return (
<p className="text-xs text-gray-400 italic">{t("marketplace.login_to_rate")}</p>
);
}
return (
<div className="flex items-center gap-3">
<div className="flex gap-0.5" onMouseLeave={() => setHovered(0)}>
{[1, 2, 3, 4, 5].map(i => (
<button
key={i}
onMouseEnter={() => setHovered(i)}
onClick={() => setSelected(i)}
className="p-0.5 transition-transform hover:scale-110 active:scale-95"
>
<Star className={cn(
"w-5 h-5 transition-colors",
i <= displayRating ? "fill-amber-400 text-amber-400" : "text-gray-200 hover:text-amber-200",
)} />
</button>
))}
</div>
{selected > 0 && (
<Button
size="sm"
onClick={() => rateMutation.mutate(selected)}
loading={rateMutation.isPending}
className="text-xs py-1 px-3 h-auto"
>
{t("marketplace.submit_rating")}
</Button>
)}
</div>
);
};
// ═══════════════════════════════════════════════════════════════════════════════
// CHATBOT DETAIL PAGE
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
export const ChatbotDetailPage: React.FC = () => { export const ChatbotDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { const { data: chatbot, isLoading, error } = useQuery({
data: chatbot,
isLoading,
error,
} = useQuery({
queryKey: ["marketplace-chatbot", id], queryKey: ["marketplace-chatbot", id],
queryFn: () => marketplaceAPI.get(id!), queryFn: () => marketplaceAPI.get(id!),
enabled: !!id, enabled: !!id,
@@ -461,12 +494,12 @@ export const ChatbotDetailPage: React.FC = () => {
<div className="p-6 max-w-2xl mx-auto text-center"> <div className="p-6 max-w-2xl mx-auto text-center">
<EmptyState <EmptyState
icon={<Bot className="w-8 h-8" />} icon={<Bot className="w-8 h-8" />}
title="Chatbot not found" title={t("marketplace.not_found_title")}
description="This chatbot may have been unpublished or removed." description={t("marketplace.not_found_desc")}
action={ action={
<Button onClick={() => navigate("/marketplace")} variant="outline"> <Button onClick={() => navigate("/marketplace")} variant="outline">
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Back to Marketplace {t("marketplace.back_to_marketplace")}
</Button> </Button>
} }
/> />
@@ -476,23 +509,18 @@ export const ChatbotDetailPage: React.FC = () => {
return ( return (
<Card className="p-4 sm:p-6 max-w-5xl mx-auto animate-fade-in"> <Card className="p-4 sm:p-6 max-w-5xl mx-auto animate-fade-in">
{/* Back link */}
<button <button
onClick={() => navigate("/marketplace")} onClick={() => navigate("/marketplace")}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-primary-600 mb-6 transition-colors group" className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-primary-600 mb-6 transition-colors group"
> >
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" /> <ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
Back to Marketplace {t("marketplace.back_to_marketplace")}
</button> </button>
{/* Chatbot info card */}
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden mb-5"> <div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden mb-5">
{/* Accent bar */}
<div <div
className="h-1.5 w-full" className="h-1.5 w-full"
style={{ style={{ background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}88)` }}
background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}88)`,
}}
/> />
<div className="p-5 sm:p-6"> <div className="p-5 sm:p-6">
<div className="flex items-center gap-4 mb-3"> <div className="flex items-center gap-4 mb-3">
@@ -511,12 +539,10 @@ export const ChatbotDetailPage: React.FC = () => {
</div> </div>
)} )}
<div> <div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900"> <h1 className="text-xl sm:text-2xl font-bold text-gray-900">{chatbot.name}</h1>
{chatbot.name}
</h1>
{chatbot.company_name && ( {chatbot.company_name && (
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
by {chatbot.company_name} {t("marketplace.by", { name: chatbot.company_name })}
</p> </p>
)} )}
<div className="flex items-center gap-2 mt-1.5 flex-wrap"> <div className="flex items-center gap-2 mt-1.5 flex-wrap">
@@ -528,7 +554,7 @@ export const ChatbotDetailPage: React.FC = () => {
)} )}
<span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100"> <span className="flex items-center gap-1 text-xs text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100">
<MessageSquare className="w-3 h-3" /> <MessageSquare className="w-3 h-3" />
{chatbot.total_conversations.toLocaleString()} conversations {t("marketplace.conversations", { count: chatbot.total_conversations.toLocaleString() })}
</span> </span>
{chatbot.category && ( {chatbot.category && (
<span className="text-xs text-primary-700 bg-primary-50 px-2 py-0.5 rounded-full font-medium border border-primary-100"> <span className="text-xs text-primary-700 bg-primary-50 px-2 py-0.5 rounded-full font-medium border border-primary-100">
@@ -540,14 +566,22 @@ export const ChatbotDetailPage: React.FC = () => {
</div> </div>
{chatbot.description && ( {chatbot.description && (
<p className="text-gray-500 text-sm leading-relaxed"> <p className="text-gray-500 text-sm leading-relaxed mb-3">{chatbot.description}</p>
{chatbot.description} )}
</p> <div className="flex items-center gap-3 flex-wrap">
<StarRatingWidget chatbot={chatbot} />
{chatbot.average_rating && chatbot.average_rating > 0 && (
<span className="text-xs text-gray-400">
{chatbot.average_rating.toFixed(1)} / 5
{(chatbot as ChatbotPublic & { rating_count?: number }).rating_count
? ` · ${(chatbot as ChatbotPublic & { rating_count?: number }).rating_count} ${t("marketplace.ratings")}`
: ""}
</span>
)} )}
</div> </div>
</div> </div>
</div>
{/* Chat */}
<div className="h-[calc(100vh-340px)] min-h-[400px] max-h-[680px] rounded-2xl overflow-hidden border border-gray-200 shadow-sm"> <div className="h-[calc(100vh-340px)] min-h-[400px] max-h-[680px] rounded-2xl overflow-hidden border border-gray-200 shadow-sm">
<ChatInterface <ChatInterface
chatbotId={chatbot.id} chatbotId={chatbot.id}

View File

@@ -1,125 +1,63 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { billingAPI } from '@/services/api' import { billingAPI } from '@/services/api'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
import { Check, Minus, Zap, Building2, Rocket, Star } from 'lucide-react' import { Check, Minus, Zap, Building2, Rocket, Star } from 'lucide-react'
const PLANS = [ const PLAN_META = [
{ {
id: 'free', id: 'free',
name: 'Free',
price: 0,
yearlyPrice: 0,
description: 'Build, test and launch your first chatbot — no card needed',
icon: Star, icon: Star,
iconColor: 'text-gray-500', iconColor: 'text-gray-500',
iconBg: 'bg-gray-100', iconBg: 'bg-gray-100',
features: [ price: 0,
{ text: '1 published chatbot', included: true }, yearlyPrice: 0,
{ text: '100 conversations/month', included: true }, includedFlags: [true, true, true, true, true, true, true, false, false, false, false],
{ text: '3 documents per chatbot', included: true },
{ text: 'Public chat link + website embed', included: true },
{ text: 'Llama 3.3 70B model', included: true },
{ text: 'Read-only inbox (no agent replies)', included: true },
{ text: 'View-only leads (no editing)', included: true },
{ text: 'Analytics dashboard', included: false },
{ text: 'Appointments & campaigns', included: false },
{ text: 'Messaging channels', included: false },
{ text: 'Remove "Powered by Contexta"', included: false },
],
}, },
{ {
id: 'starter', id: 'starter',
name: 'Starter',
price: 19,
yearlyPrice: 15,
description: 'For solo operators: live chat, leads, booking, and campaigns',
icon: Rocket, icon: Rocket,
iconColor: 'text-blue-600', iconColor: 'text-blue-600',
iconBg: 'bg-blue-50', iconBg: 'bg-blue-50',
features: [ price: 19,
{ text: 'Everything in Free', included: true }, yearlyPrice: 15,
{ text: '3 published chatbots', included: true }, includedFlags: [true, true, true, true, true, true, true, true, true, true, false, false, false],
{ text: '1,500 conversations/month', included: true },
{ text: '10 documents per chatbot', included: true },
{ text: '4 Fireworks AI models (Qwen3, DeepSeek, Kimi, Llama)', included: true },
{ text: 'Live chat inbox + agent replies', included: true },
{ text: 'Full lead CRM (status + notes)', included: true },
{ text: 'Appointment booking (1 chatbot)', included: true },
{ text: 'Telegram campaigns (3/mo · 500 recipients)', included: true },
{ text: 'Analytics dashboard', included: true },
{ text: 'Knowledge gap suggestions', included: false },
{ text: 'Premium models (GPT-4o, Claude, Gemini)', included: false },
{ text: 'Remove "Powered by Contexta"', included: false },
],
}, },
{ {
id: 'business', id: 'business',
name: 'Business',
price: 49,
yearlyPrice: 39,
description: 'For growing businesses: premium AI, unlimited booking, full analytics',
icon: Zap, icon: Zap,
iconColor: 'text-primary-600', iconColor: 'text-primary-600',
iconBg: 'bg-primary-50', iconBg: 'bg-primary-50',
price: 49,
yearlyPrice: 39,
highlighted: true, highlighted: true,
badge: 'Most Popular', includedFlags: [true, true, true, true, true, true, true, true, true, true],
features: [
{ text: 'Everything in Starter', included: true },
{ text: '10 published chatbots', included: true },
{ text: '5,000 conversations/month', included: true },
{ text: '50 documents per chatbot', included: true },
{ text: 'GPT-4o, Claude Haiku 4.5, Gemini 2.5', included: true },
{ text: 'Appointment booking (all chatbots)', included: true },
{ text: 'Unlimited campaigns · 5,000 recipients', included: true },
{ text: 'Knowledge gap suggestions', included: true },
{ text: 'Remove "Powered by Contexta"', included: true },
{ text: 'Unlimited URL sources', included: true },
],
}, },
{ {
id: 'agency', id: 'agency',
name: 'Agency',
price: 99,
yearlyPrice: 79,
description: 'For agencies: unlimited everything, white-label ready',
icon: Building2, icon: Building2,
iconColor: 'text-purple-600', iconColor: 'text-purple-600',
iconBg: 'bg-purple-50', iconBg: 'bg-purple-50',
features: [ price: 99,
{ text: 'Everything in Business', included: true }, yearlyPrice: 79,
{ text: 'Unlimited published chatbots', included: true }, includedFlags: [true, true, true, true, true, true, true],
{ text: '20,000 conversations/month', included: true },
{ text: 'Unlimited documents', included: true },
{ text: 'Unlimited campaign recipients', included: true },
{ text: 'Code export (FastAPI + React)', included: true },
{ text: 'Dedicated support', included: true },
],
}, },
{ {
id: 'enterprise', id: 'enterprise',
name: 'Enterprise',
price: null,
yearlyPrice: null,
description: 'For large organizations with custom needs and SLAs',
icon: Building2, icon: Building2,
iconColor: 'text-gray-700', iconColor: 'text-gray-700',
iconBg: 'bg-gray-100', iconBg: 'bg-gray-100',
features: [ price: null,
{ text: 'Everything in Agency', included: true }, yearlyPrice: null,
{ text: 'Unlimited conversations', included: true }, includedFlags: [true, true, true, true, true, true, true],
{ text: 'White-label platform', included: true },
{ text: 'SSO (SAML)', included: true },
{ text: 'SLA guarantees', included: true },
{ text: 'Dedicated account manager', included: true },
{ text: '24/7 phone support', included: true },
],
}, },
] ]
export const PricingPage: React.FC = () => { export const PricingPage: React.FC = () => {
const { t } = useTranslation()
const { user } = useAuthStore() const { user } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const [loading, setLoading] = useState<string | null>(null) const [loading, setLoading] = useState<string | null>(null)
@@ -162,27 +100,29 @@ export const PricingPage: React.FC = () => {
} }
const getCtaText = (planId: string): string => { const getCtaText = (planId: string): string => {
if (!user) return planId === 'enterprise' ? 'Contact Sales' : 'Get Started Free' if (!user) return planId === 'enterprise' ? t('pricing.cta_contact') : t('pricing.cta_free')
if (planId === currentPlan) return 'Current Plan' if (planId === currentPlan) return t('pricing.cta_current')
if (planId === 'enterprise') return 'Contact Sales' if (planId === 'enterprise') return t('pricing.cta_contact')
if (planId === 'free') return 'Downgrade' if (planId === 'free') return t('pricing.cta_downgrade')
return 'Upgrade Now' return t('pricing.cta_upgrade')
} }
const isCurrentPlan = (planId: string) => user && planId === currentPlan const isCurrentPlan = (planId: string) => user && planId === currentPlan
const faq = t('pricing.faq', { returnObjects: true }) as { q: string; a: string }[]
return ( return (
<div className="p-6 max-w-6xl mx-auto"> <div className="p-6 max-w-6xl mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-10 animate-fade-in-up"> <div className="text-center mb-10 animate-fade-in-up">
<span className="inline-block px-3 py-1 text-xs font-semibold bg-primary-50 text-primary-600 rounded-full mb-4 border border-primary-100"> <span className="inline-block px-3 py-1 text-xs font-semibold bg-primary-50 text-primary-600 rounded-full mb-4 border border-primary-100">
Pricing {t('pricing.badge')}
</span> </span>
<h1 className="text-4xl font-bold mb-3"> <h1 className="text-4xl font-bold mb-3">
<span className="text-gradient">Simple, transparent pricing</span> <span className="text-gradient">{t('pricing.title')}</span>
</h1> </h1>
<p className="text-gray-500 max-w-xl mx-auto text-sm leading-relaxed"> <p className="text-gray-500 max-w-xl mx-auto text-sm leading-relaxed">
Start free and go live for $12/month. Built for individuals, small businesses, agencies, and enterprises alike. {t('pricing.subtitle')}
</p> </p>
{/* Billing toggle */} {/* Billing toggle */}
@@ -193,7 +133,7 @@ export const PricingPage: React.FC = () => {
!yearly ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700' !yearly ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`} }`}
> >
Monthly {t('pricing.monthly')}
</button> </button>
<button <button
onClick={() => setYearly(true)} onClick={() => setYearly(true)}
@@ -201,7 +141,7 @@ export const PricingPage: React.FC = () => {
yearly ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700' yearly ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`} }`}
> >
Yearly {t('pricing.yearly')}
<span className="text-xs font-semibold text-green-600 bg-green-50 px-1.5 py-0.5 rounded-md"> <span className="text-xs font-semibold text-green-600 bg-green-50 px-1.5 py-0.5 rounded-md">
-20% -20%
</span> </span>
@@ -211,16 +151,19 @@ export const PricingPage: React.FC = () => {
{/* Plan cards */} {/* Plan cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4">
{PLANS.map((plan, i) => { {PLAN_META.map((meta, i) => {
const PlanIcon = plan.icon const PlanIcon = meta.icon
const displayPrice = yearly ? plan.yearlyPrice : plan.price const displayPrice = yearly ? meta.yearlyPrice : meta.price
const isCurrent = isCurrentPlan(plan.id) const isCurrent = isCurrentPlan(meta.id)
const features = (t(`pricing.feat_${meta.id}`, { returnObjects: true }) as string[]).map(
(text, idx) => ({ text, included: meta.includedFlags[idx] ?? false })
)
return ( return (
<div <div
key={plan.id} key={meta.id}
className={`relative rounded-2xl border flex flex-col transition-all duration-200 animate-fade-in-up ${ className={`relative rounded-2xl border flex flex-col transition-all duration-200 animate-fade-in-up ${
plan.highlighted meta.highlighted
? 'border-primary-300 bg-gradient-to-b from-primary-50 via-white to-white shadow-lg shadow-primary-100/60 scale-[1.02]' ? 'border-primary-300 bg-gradient-to-b from-primary-50 via-white to-white shadow-lg shadow-primary-100/60 scale-[1.02]'
: isCurrent : isCurrent
? 'border-green-200 bg-green-50/30 shadow-sm' ? 'border-green-200 bg-green-50/30 shadow-sm'
@@ -232,14 +175,14 @@ export const PricingPage: React.FC = () => {
{isCurrent && ( {isCurrent && (
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10"> <div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
<span className="bg-green-600 text-white text-xs font-semibold px-3 py-1 rounded-full shadow-sm"> <span className="bg-green-600 text-white text-xs font-semibold px-3 py-1 rounded-full shadow-sm">
Current Plan {t('pricing.current_plan_badge')}
</span> </span>
</div> </div>
)} )}
{plan.badge && !isCurrent && ( {meta.highlighted && !isCurrent && (
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10"> <div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
<span className="bg-gradient-to-r from-primary-600 to-purple-600 text-white text-xs font-semibold px-3 py-1 rounded-full shadow-sm shadow-primary-200"> <span className="bg-gradient-to-r from-primary-600 to-purple-600 text-white text-xs font-semibold px-3 py-1 rounded-full shadow-sm shadow-primary-200">
{plan.badge} {t('pricing.most_popular')}
</span> </span>
</div> </div>
)} )}
@@ -247,26 +190,28 @@ export const PricingPage: React.FC = () => {
<div className="p-5 flex flex-col flex-1"> <div className="p-5 flex flex-col flex-1">
{/* Plan header */} {/* Plan header */}
<div className="mb-5"> <div className="mb-5">
<div className={`w-9 h-9 rounded-xl ${plan.iconBg} flex items-center justify-center mb-3`}> <div className={`w-9 h-9 rounded-xl ${meta.iconBg} flex items-center justify-center mb-3`}>
<PlanIcon className={`w-4.5 h-4.5 ${plan.iconColor}`} /> <PlanIcon className={`w-4.5 h-4.5 ${meta.iconColor}`} />
</div> </div>
<h2 className="text-lg font-bold text-gray-900">{plan.name}</h2> <h2 className="text-lg font-bold text-gray-900">{t(`pricing.plan_${meta.id}`)}</h2>
<p className="text-xs text-gray-500 mt-1 leading-relaxed min-h-[32px]">{plan.description}</p> <p className="text-xs text-gray-500 mt-1 leading-relaxed min-h-[32px]">
{t(`pricing.plan_${meta.id}_desc`)}
</p>
<div className="mt-4"> <div className="mt-4">
{displayPrice !== null ? ( {displayPrice !== null ? (
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-3xl font-extrabold text-gray-900">${displayPrice}</span> <span className="text-3xl font-extrabold text-gray-900">${displayPrice}</span>
{(displayPrice as number) > 0 && ( {(displayPrice as number) > 0 && (
<span className="text-gray-400 text-xs">/mo</span> <span className="text-gray-400 text-xs">{t('pricing.per_month')}</span>
)} )}
</div> </div>
) : ( ) : (
<div className="text-2xl font-bold text-gray-900">Custom</div> <div className="text-2xl font-bold text-gray-900">{t('pricing.custom_price')}</div>
)} )}
{yearly && plan.price !== null && (plan.price as number) > 0 && ( {yearly && meta.price !== null && (meta.price as number) > 0 && (
<p className="text-xs text-green-600 mt-0.5 font-medium"> <p className="text-xs text-green-600 mt-0.5 font-medium">
Save ${(((plan.price as number) - (plan.yearlyPrice as number)) * 12).toFixed(0)}/yr {t('pricing.save_yr', { amount: (((meta.price as number) - (meta.yearlyPrice as number)) * 12).toFixed(0) })}
</p> </p>
)} )}
</div> </div>
@@ -274,7 +219,7 @@ export const PricingPage: React.FC = () => {
{/* Features */} {/* Features */}
<ul className="space-y-2.5 mb-6 flex-1"> <ul className="space-y-2.5 mb-6 flex-1">
{plan.features.map((feature) => ( {features.map((feature) => (
<li key={feature.text} className="flex items-start gap-2 text-xs"> <li key={feature.text} className="flex items-start gap-2 text-xs">
{feature.included ? ( {feature.included ? (
<span className="w-4 h-4 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 mt-0.5"> <span className="w-4 h-4 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 mt-0.5">
@@ -294,18 +239,18 @@ export const PricingPage: React.FC = () => {
{/* CTA */} {/* CTA */}
<Button <Button
onClick={() => handleSubscribe(plan.id)} onClick={() => handleSubscribe(meta.id)}
loading={loading === plan.id} loading={loading === meta.id}
disabled={isCurrent || loading === plan.id} disabled={isCurrent || loading === meta.id}
variant={plan.highlighted ? 'primary' : isCurrent ? 'secondary' : 'outline'} variant={meta.highlighted ? 'primary' : isCurrent ? 'secondary' : 'outline'}
className={`w-full transition-all duration-200 ${ className={`w-full transition-all duration-200 ${
plan.highlighted meta.highlighted
? 'bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 shadow-sm hover:shadow-md' ? 'bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 shadow-sm hover:shadow-md'
: '' : ''
}`} }`}
size="md" size="md"
> >
{getCtaText(plan.id)} {getCtaText(meta.id)}
</Button> </Button>
</div> </div>
</div> </div>
@@ -316,36 +261,11 @@ export const PricingPage: React.FC = () => {
{/* FAQ */} {/* FAQ */}
<div className="mt-16 max-w-2xl mx-auto animate-fade-in-up"> <div className="mt-16 max-w-2xl mx-auto animate-fade-in-up">
<h2 className="text-2xl font-bold text-gray-900 text-center mb-2"> <h2 className="text-2xl font-bold text-gray-900 text-center mb-2">
Frequently Asked Questions {t('pricing.faq_title')}
</h2> </h2>
<p className="text-gray-500 text-sm text-center mb-8">Everything you need to know about Contexta's plans.</p> <p className="text-gray-500 text-sm text-center mb-8">{t('pricing.faq_subtitle')}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[ {faq.map(({ q, a }) => (
{
q: 'Can I use the free tier forever?',
a: 'Yes! Build and test unlimited chatbots for free. Your chatbots will remain in preview mode until you subscribe.'
},
{
q: 'What is code export?',
a: 'Agency plan users can export their chatbot as a complete, production-ready package including a FastAPI backend and React TypeScript widget giving you full control to self-host.'
},
{
q: 'Do I need my own API keys?',
a: 'No API keys are handled by Contexta. If you export the code on the Agency plan, you\'ll need your own keys for self-hosted deployment.'
},
{
q: 'Can I cancel anytime?',
a: 'Yes, cancel anytime. Your chatbots will revert to the free tier at the end of your billing period.'
},
{
q: 'What happens if I hit my conversation limit?',
a: 'Your chatbot will show a friendly message to try again later. Upgrade your plan for more conversations.'
},
{
q: 'I\'m a small business. Which plan is right for me?',
a: 'Start with Starter at $12/month — 1 published chatbot, 1,500 conversations, analytics, lead capture and Telegram. Perfect for restaurants, shops, salons, and more. Upgrade to Business for premium AI models and more capacity.'
},
].map(({ q, a }) => (
<div <div
key={q} key={q}
className="bg-white border border-gray-100 rounded-xl p-5 hover:border-gray-200 hover:shadow-sm transition-all duration-200" className="bg-white border border-gray-100 rounded-xl p-5 hover:border-gray-200 hover:shadow-sm transition-all duration-200"

View File

@@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { authAPI } from '@/services/api' import { authAPI } from '@/services/api'
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
import { Sparkles, Eye, EyeOff, Lock, X, ArrowLeft } from 'lucide-react' import { Sparkles, Eye, EyeOff, Lock, X, ArrowLeft } from 'lucide-react'
export const ResetPasswordPage: React.FC = () => { export const ResetPasswordPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('') const [confirm, setConfirm] = useState('')
@@ -13,7 +15,6 @@ export const ResetPasswordPage: React.FC = () => {
const [error, setError] = useState('') const [error, setError] = useState('')
const [accessToken, setAccessToken] = useState<string | null>(null) const [accessToken, setAccessToken] = useState<string | null>(null)
// Parse recovery token from URL hash: #access_token=xxx&type=recovery
useEffect(() => { useEffect(() => {
const hash = window.location.hash.substring(1) const hash = window.location.hash.substring(1)
const params = new URLSearchParams(hash) const params = new URLSearchParams(hash)
@@ -22,23 +23,23 @@ export const ResetPasswordPage: React.FC = () => {
if (token && type === 'recovery') { if (token && type === 'recovery') {
setAccessToken(token) setAccessToken(token)
} else { } else {
setError('Invalid or expired reset link. Please request a new one.') setError(t('auth.failed_to_reset'))
} }
}, []) }, [t])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
if (password.length < 8) { if (password.length < 8) {
setError('Password must be at least 8 characters') setError(t('auth.password_min_8'))
return return
} }
if (password !== confirm) { if (password !== confirm) {
setError('Passwords do not match') setError(t('auth.passwords_dont_match'))
return return
} }
if (!accessToken) { if (!accessToken) {
setError('Invalid reset token. Please request a new password reset.') setError(t('auth.failed_to_reset'))
return return
} }
setLoading(true) setLoading(true)
@@ -47,7 +48,7 @@ export const ResetPasswordPage: React.FC = () => {
navigate('/login?reset=success') navigate('/login?reset=success')
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } } const e = err as { response?: { data?: { detail?: string } } }
setError(e.response?.data?.detail || 'Failed to reset password. The link may have expired.') setError(e.response?.data?.detail || t('auth.failed_to_reset'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -56,13 +57,12 @@ export const ResetPasswordPage: React.FC = () => {
return ( return (
<div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-dots bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md animate-scale-in"> <div className="w-full max-w-md animate-scale-in">
{/* Logo */} <Link to="/" className="flex items-center justify-center gap-2 mb-8">
<div className="flex items-center justify-center gap-2 mb-8">
<div className="w-9 h-9 bg-primary-600 rounded-xl flex items-center justify-center shadow-sm"> <div className="w-9 h-9 bg-primary-600 rounded-xl flex items-center justify-center shadow-sm">
<Sparkles className="w-5 h-5 text-white" /> <Sparkles className="w-5 h-5 text-white" />
</div> </div>
<span className="font-bold text-gray-900 text-lg">Contexta</span> <span className="font-bold text-gray-900 text-lg">Contexta</span>
</div> </Link>
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8"> <div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-8">
{!accessToken && error ? ( {!accessToken && error ? (
@@ -70,28 +70,25 @@ export const ResetPasswordPage: React.FC = () => {
<div className="w-14 h-14 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-5"> <div className="w-14 h-14 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-5">
<X className="w-7 h-7 text-red-500" /> <X className="w-7 h-7 text-red-500" />
</div> </div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Link expired</h2> <h2 className="text-xl font-bold text-gray-900 mb-2">{t('auth.link_expired_title')}</h2>
<p className="text-sm text-gray-500 mb-6">{error}</p> <p className="text-sm text-gray-500 mb-6">{error}</p>
<Link <Link
to="/forgot-password" to="/forgot-password"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors" className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
> >
Request a new reset link {t('auth.request_new_link')}
</Link> </Link>
</div> </div>
) : ( ) : (
<> <>
<div className="mb-7"> <div className="mb-7">
<h1 className="text-2xl font-bold text-gray-900">Set new password</h1> <h1 className="text-2xl font-bold text-gray-900">{t('auth.reset_title')}</h1>
<p className="text-gray-500 mt-1 text-sm"> <p className="text-gray-500 mt-1 text-sm">{t('auth.reset_subtitle')}</p>
Choose a strong password for your account.
</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* New password */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">New Password</label> <label className="text-sm font-medium text-gray-700">{t('auth.new_password')}</label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
<Lock className="w-4 h-4" /> <Lock className="w-4 h-4" />
@@ -116,9 +113,8 @@ export const ResetPasswordPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Confirm password */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Confirm Password</label> <label className="text-sm font-medium text-gray-700">{t('auth.confirm_password')}</label>
<div className="relative"> <div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"> <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
<Lock className="w-4 h-4" /> <Lock className="w-4 h-4" />
@@ -127,7 +123,7 @@ export const ResetPasswordPage: React.FC = () => {
type={showPass ? 'text' : 'password'} type={showPass ? 'text' : 'password'}
value={confirm} value={confirm}
onChange={e => setConfirm(e.target.value)} onChange={e => setConfirm(e.target.value)}
placeholder="Repeat password" placeholder={t('auth.confirm_placeholder')}
required required
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg text-sm bg-white className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg text-sm bg-white
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
@@ -151,7 +147,7 @@ export const ResetPasswordPage: React.FC = () => {
className="w-full bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200" className="w-full bg-primary-600 hover:bg-primary-700 shadow-sm hover:shadow-md transition-all duration-200"
size="lg" size="lg"
> >
Set new password {t('auth.set_new_password')}
</Button> </Button>
</form> </form>
@@ -161,7 +157,7 @@ export const ResetPasswordPage: React.FC = () => {
className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors" className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
Back to sign in {t('auth.back_to_signin')}
</Link> </Link>
</div> </div>
</> </>

View File

@@ -1,6 +1,7 @@
import React, { useState, useCallback } from "react"; import React, { useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate, useLocation, Link } from "react-router-dom"; import { useNavigate, useLocation, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { billingAPI, authAPI } from "@/services/api"; import { billingAPI, authAPI } from "@/services/api";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
import { Button, Card, Input } from "@/components/ui"; import { Button, Card, Input } from "@/components/ui";
@@ -17,6 +18,7 @@ import {
} from "lucide-react"; } from "lucide-react";
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { success: showToast, error: showError } = useToast(); const { success: showToast, error: showError } = useToast();
@@ -38,23 +40,23 @@ export const SettingsPage: React.FC = () => {
return ( return (
<div className="p-6 max-w-8xl mx-auto"> <div className="p-6 max-w-8xl mx-auto">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-4xl font-bold text-gray-900">Settings</h1> <h1 className="text-4xl font-bold text-gray-900">{t("settings.title")}</h1>
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:bg-gray-100 text-sm text-gray-600 transition-colors" className="flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-200 hover:bg-gray-100 text-sm text-gray-600 transition-colors"
aria-label="Toggle dark mode" aria-label="Toggle dark mode"
> >
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} {isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{isDark ? "Light mode" : "Dark mode"} {isDark ? t("settings.light_mode") : t("settings.dark_mode")}
</button> </button>
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit"> <div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-xl w-fit">
{[ {[
{ id: "profile" as const, label: "Profile", icon: User }, { id: "profile" as const, labelKey: "settings.tab_profile", icon: User },
{ id: "billing" as const, label: "Billing", icon: CreditCard }, { id: "billing" as const, labelKey: "settings.tab_billing", icon: CreditCard },
].map(({ id, label, icon: Icon }) => ( ].map(({ id, labelKey, icon: Icon }) => (
<button <button
key={id} key={id}
onClick={() => handleTabChange(id)} onClick={() => handleTabChange(id)}
@@ -65,7 +67,7 @@ export const SettingsPage: React.FC = () => {
}`} }`}
> >
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
{label} {t(labelKey)}
</button> </button>
))} ))}
</div> </div>
@@ -84,11 +86,13 @@ const ProfileSettings: React.FC<{
onToast: (msg: string) => void; onToast: (msg: string) => void;
onError: (msg: string) => void; onError: (msg: string) => void;
}> = ({ onToast, onError }) => { }> = ({ onToast, onError }) => {
const { user, setAuth, token, logout } = useAuthStore(); const { t } = useTranslation();
const { user, setAuth, updateUser, token, logout } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
const [companyName, setCompanyName] = useState(user?.company_name || ""); const [companyName, setCompanyName] = useState(user?.company_name || "");
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [language, setLanguage] = useState(user?.language || "fr");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(""); const [deleteConfirm, setDeleteConfirm] = useState("");
@@ -101,6 +105,7 @@ const ProfileSettings: React.FC<{
company_name?: string; company_name?: string;
current_password?: string; current_password?: string;
new_password?: string; new_password?: string;
language?: string;
} = {}; } = {};
if (companyName !== user?.company_name) if (companyName !== user?.company_name)
payload.company_name = companyName; payload.company_name = companyName;
@@ -108,23 +113,30 @@ const ProfileSettings: React.FC<{
payload.current_password = currentPassword; payload.current_password = currentPassword;
payload.new_password = newPassword; payload.new_password = newPassword;
} }
if (language !== user?.language)
payload.language = language;
if (Object.keys(payload).length === 0) { if (Object.keys(payload).length === 0) {
onToast("No changes to save"); onToast(t("common.no_changes"));
return; return;
} }
const updated = await authAPI.updateProfile(payload); const updated = await authAPI.updateProfile(payload);
setAuth(updated, token || ""); setAuth(updated, token || "");
setCurrentPassword(""); setCurrentPassword("");
setNewPassword(""); setNewPassword("");
onToast("Profile updated successfully"); onToast(t("settings.profile_updated"));
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } }; const e = err as { response?: { data?: { detail?: string } } };
onError(e.response?.data?.detail || "Failed to update profile"); onError(e.response?.data?.detail || t("settings.update_failed"));
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleLanguageChange = (lang: string) => {
setLanguage(lang);
updateUser({ language: lang });
};
const handleDeleteAccount = async () => { const handleDeleteAccount = async () => {
if (deleteConfirm !== "DELETE") return; if (deleteConfirm !== "DELETE") return;
setDeleting(true); setDeleting(true);
@@ -134,7 +146,7 @@ const ProfileSettings: React.FC<{
navigate("/"); navigate("/");
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } }; const e = err as { response?: { data?: { detail?: string } } };
onError(e.response?.data?.detail || "Failed to delete account"); onError(e.response?.data?.detail || t("settings.update_failed"));
setDeleting(false); setDeleting(false);
} }
}; };
@@ -142,22 +154,38 @@ const ProfileSettings: React.FC<{
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card className="p-6 space-y-4"> <Card className="p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Profile Information</h2> <h2 className="font-semibold text-gray-900">{t("settings.profile_info")}</h2>
<Input <Input
label="Email" label={t("settings.email")}
value={user?.email || ""} value={user?.email || ""}
disabled disabled
hint="Email cannot be changed" hint={t("settings.email_hint")}
/> />
<Input <Input
label="Company Name" label={t("settings.company_name")}
value={companyName} value={companyName}
onChange={(e) => setCompanyName(e.target.value)} onChange={(e) => setCompanyName(e.target.value)}
placeholder="Your company name" placeholder={t("settings.company_placeholder")}
/> />
{/* Language selector */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1.5"> <label className="block text-sm font-medium text-gray-700 mb-1.5">
Plan {t("settings.language_label")}
</label>
<select
value={language}
onChange={(e) => handleLanguageChange(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-400 appearance-none cursor-pointer w-48"
>
<option value="en">{t("settings.lang_en")}</option>
<option value="fr">{t("settings.lang_fr")}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
{t("settings.plan_label")}
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
@@ -169,28 +197,28 @@ const ProfileSettings: React.FC<{
to="/pricing" to="/pricing"
className="text-sm text-primary-600 hover:underline" className="text-sm text-primary-600 hover:underline"
> >
Manage plan {t("settings.manage_plan")}
</Link> </Link>
</div> </div>
</div> </div>
</Card> </Card>
<Card className="p-6 space-y-4"> <Card className="p-6 space-y-4">
<h2 className="font-semibold text-gray-900">Change Password</h2> <h2 className="font-semibold text-gray-900">{t("settings.change_password")}</h2>
<Input <Input
label="Current Password" label={t("settings.current_password")}
type="password" type="password"
value={currentPassword} value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)} onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password" placeholder={t("settings.current_password_placeholder")}
/> />
<Input <Input
label="New Password" label={t("settings.new_password")}
type="password" type="password"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min 8 characters" placeholder={t("settings.new_password_placeholder")}
hint="Leave blank to keep current password" hint={t("settings.new_password_hint")}
/> />
</Card> </Card>
@@ -200,7 +228,7 @@ const ProfileSettings: React.FC<{
loading={saving} loading={saving}
className="w-1/2 h-11 my-5" className="w-1/2 h-11 my-5"
> >
Save Changes {t("common.save_changes")}
</Button> </Button>
</div> </div>
@@ -208,18 +236,15 @@ const ProfileSettings: React.FC<{
<Card className="p-6 border-red-200 bg-red-50/30 text-center"> <Card className="p-6 border-red-200 bg-red-50/30 text-center">
<h2 className="font-semibold text-lg text-red-800 mb-2 flex items-center justify-center gap-1.5"> <h2 className="font-semibold text-lg text-red-800 mb-2 flex items-center justify-center gap-1.5">
<AlertTriangle className="w-4 h-4" /> <AlertTriangle className="w-4 h-4" />
Danger Zone {t("settings.danger_zone")}
</h2> </h2>
<p className="text-sm text-red-700 mb-4"> <p className="text-sm text-red-700 mb-4">{t("settings.danger_desc")}</p>
Permanently delete your account, all chatbots, documents, and data.
This cannot be undone.
</p>
<Button <Button
variant="outline" variant="outline"
className="border-red-300 text-red-700 hover:bg-red-50" className="border-red-300 text-red-700 hover:bg-red-50"
onClick={() => setShowDeleteModal(true)} onClick={() => setShowDeleteModal(true)}
> >
Delete Account {t("settings.delete_account_btn")}
</Button> </Button>
</Card> </Card>
@@ -228,18 +253,17 @@ const ProfileSettings: React.FC<{
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl"> <div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
<h3 className="text-lg font-bold text-gray-900 mb-2"> <h3 className="text-lg font-bold text-gray-900 mb-2">
Delete Account {t("settings.delete_account_title")}
</h3> </h3>
<p className="text-sm text-gray-500 mb-4"> <p className="text-sm text-gray-500 mb-4">
This will permanently delete your account and all associated data {t("settings.delete_account_desc")}
including chatbots, documents, conversations, and leads.
<strong className="text-red-600"> <strong className="text-red-600">
{" "} {" "}
This action cannot be undone. {t("settings.delete_account_desc_bold")}
</strong> </strong>
</p> </p>
<p className="text-sm text-gray-700 mb-2"> <p className="text-sm text-gray-700 mb-2">
Type <strong>DELETE</strong> to confirm: {t("settings.type_delete")}
</p> </p>
<Input <Input
value={deleteConfirm} value={deleteConfirm}
@@ -255,7 +279,7 @@ const ProfileSettings: React.FC<{
setDeleteConfirm(""); setDeleteConfirm("");
}} }}
> >
Cancel {t("common.cancel")}
</Button> </Button>
<Button <Button
className="flex-1 bg-red-600 hover:bg-red-700" className="flex-1 bg-red-600 hover:bg-red-700"
@@ -263,7 +287,7 @@ const ProfileSettings: React.FC<{
loading={deleting} loading={deleting}
onClick={handleDeleteAccount} onClick={handleDeleteAccount}
> >
Delete Account {t("settings.delete_account_btn")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -277,6 +301,7 @@ const BillingSettings: React.FC<{
onToast: (msg: string) => void; onToast: (msg: string) => void;
onError: (msg: string) => void; onError: (msg: string) => void;
}> = ({ onError }) => { }> = ({ onError }) => {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -292,7 +317,7 @@ const BillingSettings: React.FC<{
window.location.href = url; window.location.href = url;
} catch (err) { } catch (err) {
const e = err as { response?: { data?: { detail?: string } } }; const e = err as { response?: { data?: { detail?: string } } };
onError(e.response?.data?.detail || "Failed to open billing portal"); onError(e.response?.data?.detail || t("settings.update_failed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -335,7 +360,6 @@ const BillingSettings: React.FC<{
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Current Plan Card - Version with hover effect */}
<Card className="group relative overflow-hidden border-0 shadow-lg transition-all duration-300 hover:shadow-2xl"> <Card className="group relative overflow-hidden border-0 shadow-lg transition-all duration-300 hover:shadow-2xl">
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/5 to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div> <div className="absolute inset-0 bg-gradient-to-r from-blue-600/5 to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
<div className="p-6 relative"> <div className="p-6 relative">
@@ -343,17 +367,11 @@ const BillingSettings: React.FC<{
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-r from-blue-500 to-purple-500 flex items-center justify-center"> <div className="w-8 h-8 rounded-full bg-gradient-to-r from-blue-500 to-purple-500 flex items-center justify-center">
<svg <svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
className="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 14h-2v-2h2v2zm0-4h-2V7h2v5z" /> <path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 14h-2v-2h2v2zm0-4h-2V7h2v5z" />
</svg> </svg>
</div> </div>
<h2 className="text-xl font-bold text-gray-900"> <h2 className="text-xl font-bold text-gray-900">{t("settings.current_plan")}</h2>
Current Plan
</h2>
</div> </div>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
@@ -364,7 +382,7 @@ const BillingSettings: React.FC<{
{plan} {plan}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Status:</span> <span className="text-xs text-gray-500">{t("settings.status_label")}</span>
<span <span
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-full ${ className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-full ${
subscription?.status === "active" subscription?.status === "active"
@@ -376,8 +394,8 @@ const BillingSettings: React.FC<{
className={`w-1.5 h-1.5 rounded-full ${subscription?.status === "active" ? "bg-green-500" : "bg-red-500"}`} className={`w-1.5 h-1.5 rounded-full ${subscription?.status === "active" ? "bg-green-500" : "bg-red-500"}`}
></span> ></span>
{subscription?.status === "active" {subscription?.status === "active"
? "Active" ? t("settings.status_active")
: subscription?.status || "Active"} : subscription?.status || t("settings.status_active")}
</span> </span>
</div> </div>
</div> </div>
@@ -385,9 +403,7 @@ const BillingSettings: React.FC<{
{isPaid && subscription?.current_period_end && ( {isPaid && subscription?.current_period_end && (
<div className="text-right bg-gray-50 rounded-lg px-4 py-2"> <div className="text-right bg-gray-50 rounded-lg px-4 py-2">
<p className="text-xs text-gray-500 uppercase tracking-wide"> <p className="text-xs text-gray-500 uppercase tracking-wide">{t("settings.renewal_date")}</p>
Renewal Date
</p>
<p className="text-sm font-bold text-gray-900"> <p className="text-sm font-bold text-gray-900">
{formatDate(subscription.current_period_end)} {formatDate(subscription.current_period_end)}
</p> </p>
@@ -402,7 +418,7 @@ const BillingSettings: React.FC<{
className="w-full bg-gradient-to-r from-primary-600 to-indigo-600 hover:from-primary-700 hover:to-indigo-700 text-white shadow-md hover:shadow-lg transition-all duration-300 rounded-lg py-5 text-base font-semibold group" className="w-full bg-gradient-to-r from-primary-600 to-indigo-600 hover:from-primary-700 hover:to-indigo-700 text-white shadow-md hover:shadow-lg transition-all duration-300 rounded-lg py-5 text-base font-semibold group"
> >
<span className="flex items-center justify-center gap-2"> <span className="flex items-center justify-center gap-2">
Upgrade Plan {t("settings.upgrade_plan")}
<svg <svg
className="w-4 h-4 transform group-hover:translate-x-1 transition-transform" className="w-4 h-4 transform group-hover:translate-x-1 transition-transform"
fill="none" fill="none"
@@ -426,24 +442,18 @@ const BillingSettings: React.FC<{
className="flex-1 border-gray-300 hover:border-gray-400 hover:bg-gray-50 rounded-lg py-5 text-base font-semibold transition-all duration-300" className="flex-1 border-gray-300 hover:border-gray-400 hover:bg-gray-50 rounded-lg py-5 text-base font-semibold transition-all duration-300"
> >
<ExternalLink className="w-4 h-4 mr-2" /> <ExternalLink className="w-4 h-4 mr-2" />
Manage Billing {t("settings.manage_billing")}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
</Card> </Card>
{/* Plan Features Card - Enhanced Version */}
<Card className="border-0 shadow-lg overflow-hidden"> <Card className="border-0 shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-gray-50 to-white p-6"> <div className="bg-gradient-to-r from-gray-50 to-white p-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center shadow-md"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center shadow-md">
<svg <svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -452,23 +462,23 @@ const BillingSettings: React.FC<{
/> />
</svg> </svg>
</div> </div>
<h3 className="text-lg font-bold text-gray-900">Plan Features</h3> <h3 className="text-lg font-bold text-gray-900">{t("settings.plan_features")}</h3>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{[ {[
{ {
label: "Chatbots published", label: t("settings.chatbots_published"),
value: features.published, value: features.published,
suffix: "chatbot(s)", suffix: t("settings.chatbot_suffix"),
}, },
{ {
label: "Conversations / month", label: t("settings.conversations_per_month"),
value: features.conversations, value: features.conversations,
suffix: "conversations", suffix: t("settings.conversations_suffix"),
}, },
{ {
label: "Code export", label: t("settings.code_export"),
value: features.codeExport, value: features.codeExport,
highlight: features.codeExport, highlight: features.codeExport,
}, },
@@ -481,7 +491,7 @@ const BillingSettings: React.FC<{
{label} {label}
</span> </span>
<span <span
className={`text-sm font-semibold ${highlight === false ? "text-gray-400" : "text-gray-900"}`} className={`text-sm font-semibold ${!highlight ? "text-gray-400" : "text-gray-900"}`}
> >
{value}{" "} {value}{" "}
{suffix && ( {suffix && (
@@ -496,9 +506,7 @@ const BillingSettings: React.FC<{
<div className="mt-6 pt-4 border-t border-gray-200 text-center"> <div className="mt-6 pt-4 border-t border-gray-200 text-center">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{isPaid {isPaid ? t("settings.billing_footer_paid") : t("settings.billing_footer_free")}
? "💳 Simplified subscription management"
: "🚀 Unlock more features by upgrading your plan"}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -86,7 +86,7 @@ export const AdminSystemPage: React.FC = () => {
<div> <div>
<p className="text-gray-300 text-sm font-medium">Overall Status</p> <p className="text-gray-300 text-sm font-medium">Overall Status</p>
<p className="text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
{[health.db, health.qdrant, ...Object.values(health.llm_providers).map(ok => ok ? 'healthy' : 'down')].every(s => s === 'healthy' || s === true) {[health.db, health.qdrant, ...Object.values(health.llm_providers).map(ok => ok ? 'healthy' : 'down')].every(s => s === 'healthy')
? 'All systems operational' ? 'All systems operational'
: 'Some services degraded'} : 'Some services degraded'}
</p> </p>

View File

@@ -111,7 +111,7 @@ export const AdminUsersPage: React.FC = () => {
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-200">{user.email}</span> <span className="text-gray-200">{user.email}</span>
{user.is_admin && <Shield className="w-3 h-3 text-red-400" title="Admin" />} {user.is_admin && <span title="Admin"><Shield className="w-3 h-3 text-red-400" /></span>}
</div> </div>
{user.company_name && ( {user.company_name && (
<span className="text-gray-500 text-xs">{user.company_name}</span> <span className="text-gray-500 text-xs">{user.company_name}</span>

View File

@@ -52,7 +52,7 @@ export const authAPI = {
resetPassword: (access_token: string, new_password: string) => resetPassword: (access_token: string, new_password: string) =>
api.post('/auth/reset-password', { access_token, new_password }).then(r => r.data), api.post('/auth/reset-password', { access_token, new_password }).then(r => r.data),
updateProfile: (data: { company_name?: string; current_password?: string; new_password?: string }) => updateProfile: (data: { company_name?: string; current_password?: string; new_password?: string; language?: string }) =>
api.patch('/auth/profile', data).then(r => r.data), api.patch('/auth/profile', data).then(r => r.data),
deleteAccount: () => api.delete('/auth/account').then(r => r.data), deleteAccount: () => api.delete('/auth/account').then(r => r.data),
@@ -116,6 +116,9 @@ export const chatAPI = {
feedback: (chatbotId: string, messageId: string, feedback: 'positive' | 'negative') => feedback: (chatbotId: string, messageId: string, feedback: 'positive' | 'negative') =>
api.post(`/chat/${chatbotId}/feedback`, { message_id: messageId, feedback }).then(r => r.data), api.post(`/chat/${chatbotId}/feedback`, { message_id: messageId, feedback }).then(r => r.data),
test: (chatbotId: string, questions: string[]) =>
api.post(`/chat/${chatbotId}/test`, { questions }).then(r => r.data),
} }
// ─── Marketplace ────────────────────────────────────────────────────────────── // ─── Marketplace ──────────────────────────────────────────────────────────────
@@ -191,6 +194,9 @@ export const urlSourcesAPI = {
delete: (chatbotId: string, sourceId: string) => delete: (chatbotId: string, sourceId: string) =>
api.delete(`/chatbots/${chatbotId}/url-sources/${sourceId}`).then(r => r.data), api.delete(`/chatbots/${chatbotId}/url-sources/${sourceId}`).then(r => r.data),
refresh: (chatbotId: string, sourceId: string) =>
api.post(`/chatbots/${chatbotId}/url-sources/${sourceId}/refresh`).then(r => r.data),
} }
// ─── Leads ──────────────────────────────────────────────────────────────────── // ─── Leads ────────────────────────────────────────────────────────────────────

View File

@@ -1,6 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import type { User } from '@/types' import type { User } from '@/types'
import i18n from '@/i18n/i18n'
interface AuthState { interface AuthState {
user: User | null user: User | null
@@ -23,16 +24,19 @@ export const useAuthStore = create<AuthState>()(
// The API interceptor now reads from Zustand store directly. // The API interceptor now reads from Zustand store directly.
setAuth: (user, token) => { setAuth: (user, token) => {
set({ user, token, isAuthenticated: true }) set({ user, token, isAuthenticated: true })
if (user.language) i18n.changeLanguage(user.language)
}, },
logout: () => { logout: () => {
set({ user: null, token: null, isAuthenticated: false }) set({ user: null, token: null, isAuthenticated: false })
}, },
updateUser: (updates) => updateUser: (updates) => {
if (updates.language) i18n.changeLanguage(updates.language)
set((state) => ({ set((state) => ({
user: state.user ? { ...state.user, ...updates } : null, user: state.user ? { ...state.user, ...updates } : null,
})), }))
},
}), }),
{ {
name: 'contexta-auth', name: 'contexta-auth',

View File

@@ -7,6 +7,7 @@ export interface User {
plan: 'free' | 'starter' | 'business' | 'agency' | 'enterprise' plan: 'free' | 'starter' | 'business' | 'agency' | 'enterprise'
created_at?: string created_at?: string
is_admin?: boolean is_admin?: boolean
language?: string
} }
export interface AuthResponse { export interface AuthResponse {

3
vercel.json Normal file
View File

@@ -0,0 +1,3 @@
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}

View File

@@ -12,7 +12,7 @@ export default defineConfig({
}, },
server: { server: {
port: 5173, port: 5173,
allowedHosts: ['gripple-delena-triserial.ngrok-free.dev', '127.0.0.1', 'localhost', 'contexta-production-672d.up.railway.app'], allowedHosts: ['gripple-delena-triserial.ngrok-free.dev', '127.0.0.1', 'localhost', 'contexta-production-672d.up.railway.app', "0.0.0.0"],
host: true host: true
} }
}) })