mirror of
http://88.130.71.182:3000/BlitTech/contexta_fe.git
synced 2026-06-12 23:23:22 +00:00
fixed some little issues and added new pages
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,4 +23,5 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
.vercel
|
||||||
|
|||||||
100
package-lock.json
generated
100
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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">
|
||||||
<button
|
{feedbackGiven[msg.id] ? (
|
||||||
onClick={() => handleFeedback(msg.id, 'positive')}
|
<span className={cn(
|
||||||
disabled={feedbackSent.has(msg.id)}
|
'text-[11px] px-2 py-0.5 rounded-full font-medium',
|
||||||
className={cn(
|
feedbackGiven[msg.id] === 'positive'
|
||||||
'text-xs px-1.5 py-0.5 rounded-lg transition-all',
|
? 'bg-green-50 text-green-600 border border-green-100'
|
||||||
feedbackSent.has(msg.id)
|
: 'bg-red-50 text-red-500 border border-red-100'
|
||||||
? 'text-gray-200 cursor-default'
|
)}>
|
||||||
: 'text-gray-300 hover:text-green-600 hover:bg-green-50 active:scale-90'
|
{feedbackGiven[msg.id] === 'positive' ? '👍 Thanks!' : '👎 Got it'}
|
||||||
)}
|
</span>
|
||||||
title="Helpful"
|
) : (
|
||||||
>
|
<>
|
||||||
👍
|
<button
|
||||||
</button>
|
onClick={() => handleFeedback(msg.id, 'positive')}
|
||||||
<button
|
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"
|
||||||
onClick={() => handleFeedback(msg.id, 'negative')}
|
title="Helpful"
|
||||||
disabled={feedbackSent.has(msg.id)}
|
>
|
||||||
className={cn(
|
👍
|
||||||
'text-xs px-1.5 py-0.5 rounded-lg transition-all',
|
</button>
|
||||||
feedbackSent.has(msg.id)
|
<button
|
||||||
? 'text-gray-200 cursor-default'
|
onClick={() => handleFeedback(msg.id, 'negative')}
|
||||||
: 'text-gray-300 hover:text-red-500 hover:bg-red-50 active:scale-90'
|
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"
|
||||||
)}
|
title="Not helpful"
|
||||||
title="Not helpful"
|
>
|
||||||
>
|
👎
|
||||||
👎
|
</button>
|
||||||
</button>
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
311
src/components/OnboardingChecklist.tsx
Normal file
311
src/components/OnboardingChecklist.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,31 +63,35 @@ 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 */}
|
||||||
<button
|
<div className="md:hidden flex items-center gap-2">
|
||||||
className="md:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
<LangToggle size="sm" />
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
<button
|
||||||
aria-label="Toggle menu"
|
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
>
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
aria-label="Toggle menu"
|
||||||
</button>
|
>
|
||||||
|
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile menu */}
|
{/* Mobile menu */}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
18
src/i18n/i18n.ts
Normal 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
656
src/i18n/locales/en.json
Normal 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
656
src/i18n/locales/fr.json
Normal 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é."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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, // Mon–Fri 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>
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
<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">
|
const { t } = useTranslation()
|
||||||
{/* decorative circles */}
|
return (
|
||||||
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
|
<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="absolute bottom-10 -left-16 w-48 h-48 rounded-full bg-white/5" />
|
<div className="absolute -top-16 -right-16 w-64 h-64 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 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" />
|
||||||
|
|
||||||
{/* 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>
|
||||||
|
<span className="text-xl font-bold tracking-tight">Contexta</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h2 className="text-3xl font-bold leading-snug mb-3" style={{ whiteSpace: 'pre-line' }}>
|
||||||
|
{t('auth.branding_headline')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-primary-200 text-sm leading-relaxed mb-8">{t('auth.branding_subtext')}</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ icon: MessageSquare, key: 'auth.branding_feature_1' },
|
||||||
|
{ icon: FileText, key: 'auth.branding_feature_2' },
|
||||||
|
{ icon: Globe, key: 'auth.branding_feature_3' },
|
||||||
|
].map(({ key }) => (
|
||||||
|
<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">
|
||||||
|
<Check className="w-3.5 h-3.5 text-white" />
|
||||||
|
</span>
|
||||||
|
{t(key)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-bold tracking-tight">Contexta</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center content */}
|
<p className="relative z-10 text-xs text-primary-300">{t('auth.branding_footer')}</p>
|
||||||
<div className="relative z-10">
|
|
||||||
<h2 className="text-3xl font-bold leading-snug mb-3">
|
|
||||||
Build AI chatbots<br />that actually work.
|
|
||||||
</h2>
|
|
||||||
<p className="text-primary-200 text-sm leading-relaxed mb-8">
|
|
||||||
Upload your docs, train your bot, and publish it anywhere — in minutes.
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{[
|
|
||||||
{ icon: MessageSquare, text: 'Custom chatbots trained on your content' },
|
|
||||||
{ icon: FileText, text: 'PDF, DOCX, CSV, and URL sources' },
|
|
||||||
{ icon: Globe, text: 'Embed on any website or channel' },
|
|
||||||
].map(({ icon: Icon, text }) => (
|
|
||||||
<li key={text} 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">
|
|
||||||
<Check className="w-3.5 h-3.5 text-white" />
|
|
||||||
</span>
|
|
||||||
{text}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
{/* Footer quote */}
|
}
|
||||||
<p className="relative z-10 text-xs text-primary-300">
|
|
||||||
Trusted by businesses building smarter customer experiences.
|
|
||||||
</p>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,110 +295,187 @@ 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 }) => {
|
||||||
<div
|
const { t } = useTranslation();
|
||||||
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" }}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{/* Colored accent top bar — thicker and with gradient */}
|
|
||||||
<div
|
|
||||||
className="h-1.5 w-full"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}aa)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="p-5">
|
return (
|
||||||
{/* Header row */}
|
<div
|
||||||
<div className="flex items-start gap-3 mb-3">
|
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"
|
||||||
{chatbot.logo_url ? (
|
style={{ animationDelay: `${index * 60}ms`, animationFillMode: "both" }}
|
||||||
<img
|
onClick={onClick}
|
||||||
src={chatbot.logo_url}
|
>
|
||||||
alt={chatbot.name}
|
<div
|
||||||
className="w-12 h-12 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
className="h-1.5 w-full"
|
||||||
/>
|
style={{ background: `linear-gradient(90deg, ${chatbot.primary_color}, ${chatbot.primary_color}aa)` }}
|
||||||
) : (
|
/>
|
||||||
<div
|
|
||||||
className="w-12 h-12 rounded-xl flex items-center justify-center text-white flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
<div className="p-5">
|
||||||
style={{ background: chatbot.primary_color }}
|
<div className="flex items-start gap-3 mb-3">
|
||||||
>
|
{chatbot.logo_url ? (
|
||||||
<Bot className="w-6 h-6" />
|
<img
|
||||||
|
src={chatbot.logo_url}
|
||||||
|
alt={chatbot.name}
|
||||||
|
className="w-12 h-12 rounded-xl object-cover flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-xl flex items-center justify-center text-white flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform duration-200"
|
||||||
|
style={{ background: chatbot.primary_color }}
|
||||||
|
>
|
||||||
|
<Bot className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0 pt-0.5">
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm leading-tight truncate group-hover:text-primary-700 transition-colors">
|
||||||
|
{chatbot.name}
|
||||||
|
</h3>
|
||||||
|
{chatbot.company_name && (
|
||||||
|
<p className="text-xs text-gray-400 truncate mt-0.5">
|
||||||
|
{t("marketplace.by", { name: chatbot.company_name })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{chatbot.description ? (
|
||||||
|
<p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">{chatbot.description}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mb-4" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0 pt-0.5">
|
|
||||||
<h3 className="font-semibold text-gray-900 text-sm leading-tight truncate group-hover:text-primary-700 transition-colors">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{chatbot.name}
|
{chatbot.average_rating && chatbot.average_rating > 0 && (
|
||||||
</h3>
|
<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">
|
||||||
{chatbot.company_name && (
|
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
||||||
<p className="text-xs text-gray-400 truncate mt-0.5">
|
{chatbot.average_rating.toFixed(1)}
|
||||||
by {chatbot.company_name}
|
</span>
|
||||||
</p>
|
)}
|
||||||
|
<span className="flex items-center gap-1 bg-gray-50 text-gray-500 px-2.5 py-1 rounded-full text-xs border border-gray-100">
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{chatbot.total_conversations.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{chatbot.category && (
|
||||||
|
<span className="bg-primary-50 text-primary-700 px-2.5 py-1 rounded-full text-xs font-medium border border-primary-100 truncate max-w-[120px]">
|
||||||
|
{chatbot.category}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
<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">
|
||||||
{chatbot.description ? (
|
<div
|
||||||
<p className="text-xs text-gray-500 mb-4 line-clamp-2 leading-relaxed">
|
className="px-5 py-2 rounded-full text-white text-xs font-semibold shadow-lg pointer-events-none"
|
||||||
{chatbot.description}
|
style={{ background: chatbot.primary_color }}
|
||||||
</p>
|
>
|
||||||
) : (
|
{t("marketplace.chat_now")}
|
||||||
<div className="mb-4" />
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{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">
|
|
||||||
<Star className="w-3 h-3 fill-amber-400 text-amber-400" />
|
|
||||||
{chatbot.average_rating.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="flex items-center gap-1 bg-gray-50 text-gray-500 px-2.5 py-1 rounded-full text-xs border border-gray-100">
|
|
||||||
<MessageSquare className="w-3 h-3" />
|
|
||||||
{chatbot.total_conversations.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{chatbot.category && (
|
|
||||||
<span className="bg-primary-50 text-primary-700 px-2.5 py-1 rounded-full text-xs font-medium border border-primary-100 truncate max-w-[120px]">
|
|
||||||
{chatbot.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</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="px-5 py-2 rounded-full text-white text-xs font-semibold shadow-lg pointer-events-none"
|
|
||||||
style={{ background: chatbot.primary_color }}
|
|
||||||
>
|
|
||||||
Chat now →
|
|
||||||
</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}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -148,9 +86,9 @@ export const PricingPage: React.FC = () => {
|
|||||||
setLoading(planId)
|
setLoading(planId)
|
||||||
try {
|
try {
|
||||||
const { checkout_url } = await billingAPI.createCheckout(
|
const { checkout_url } = await billingAPI.createCheckout(
|
||||||
planId,
|
planId,
|
||||||
`${window.location.origin}/settings/billing?success=true`,
|
`${window.location.origin}/settings/billing?success=true`,
|
||||||
`${window.location.origin}/pricing`
|
`${window.location.origin}/pricing`
|
||||||
)
|
)
|
||||||
window.location.href = checkout_url
|
window.location.href = checkout_url
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
3
vercel.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user