diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 51ccc79..f898258 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,4 +26,12 @@ jobs: run: npm ci - name: Build project - run: npm run build \ No newline at end of file + run: npm run build + env: + # Bypass compile-time validation of app env. Vercel validates at runtime. + SKIP_ENV_VALIDATION: "true" + # Dummy values for vendor SDKs that read signing keys at module import + # time (e.g. @upstash/qstash's verifySignatureAppRouter). CI only needs + # these to satisfy the collect-page-data step; real values live in Vercel. + QSTASH_CURRENT_SIGNING_KEY: "build-placeholder" + QSTASH_NEXT_SIGNING_KEY: "build-placeholder" \ No newline at end of file diff --git a/app/[locale]/about/page.tsx b/app/[locale]/about/page.tsx new file mode 100644 index 0000000..605f3c1 --- /dev/null +++ b/app/[locale]/about/page.tsx @@ -0,0 +1,171 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { ExternalLink } from "lucide-react" +import { useTranslations } from 'next-intl' + +export default function AboutPage() { + const t = useTranslations('About') + + return ( +
+ {/* Hero Section */} +
+
+ Pakistan Parliament +
+
+
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+
+ + {/* Journey Section */} +
+ + + {t('originalVision')} + + +

+ {t('originalVisionDesc')} +

+
+
+ + + + {t('pivot')} + + +

+ {t('pivotDesc')} +

+
+
+ + + + {t('today')} + + +

+ {t('todayDesc')} +

+
+
+
+ + {/* Featured Article */} + + + {t('mediaTitle')} + + + Code for Pakistan Logo +
+

{t('mediaArticleTitle')}

+

+ {t('mediaArticleDesc')} +

+ +
+
+
+ + {/* Key Features */} +
+

{t('featuresTitle')}

+
+ + + {t('features.representativesTitle')} + + +

+ {t('features.representativesDesc')} +

+
+
+ + + + {t('features.locationTitle')} + + +

+ {t('features.locationDesc')} +

+
+
+ + + + {t('features.chatTitle')} + + +

+ {t('features.chatDesc')} +

+
+
+ + + + {t('features.availableTitle')} + + +

+ {t('features.availableDesc')} +

+
+
+ + + + {t('features.searchTitle')} + + +

+ {t('features.searchDesc')} +

+
+
+ + + + {t('features.languageTitle')} + + +

+ {t('features.languageDesc')} +

+
+
+
+
+
+ ) +} diff --git a/app/[locale]/admin/dashboard/page.tsx b/app/[locale]/admin/dashboard/page.tsx new file mode 100644 index 0000000..5253151 --- /dev/null +++ b/app/[locale]/admin/dashboard/page.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { formatDistanceToNow } from 'date-fns'; + +type UploadStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +interface DocumentUpload { + id: string; + status: UploadStatus; + originalFileName: string; + fileSize: number; + uploadProgress: number; + processingProgress: number; + error: string | null; + createdAt: string; + updatedAt: string; +} + +export default function DashboardPage() { + const [uploads, setUploads] = useState([]); + const [loading, setLoading] = useState(false); + const [isAuthorized, setIsAuthorized] = useState(false); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const checkPassword = async (inputPassword: string) => { + try { + const response = await fetch('/api/admin/check', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ password: inputPassword }), + }); + + if (response.ok) { + setIsAuthorized(true); + sessionStorage.setItem('adminAuthorized', 'true'); + fetchUploads(); + } else { + setError('Invalid password'); + } + } catch (error) { + setError('Something went wrong'); + } + }; + + const fetchUploads = async () => { + try { + setLoading(true); + const response = await fetch('/api/admin/uploads'); + const data = await response.json(); + setUploads(data); + } catch (error) { + console.error('Failed to fetch uploads:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (sessionStorage.getItem('adminAuthorized') === 'true') { + setIsAuthorized(true); + fetchUploads(); + } + }, []); + + if (!isAuthorized) { + return ( +
+

Admin Access

+
+
+ + setPassword(e.target.value)} + className="w-full rounded-md border border-input bg-background px-3 py-2" + /> +
+ + {error &&

{error}

} +
+
+ ); + } + + const getStatusBadge = (status: UploadStatus) => { + const variants = { + pending: 'secondary', + processing: 'default', + completed: 'success', + failed: 'destructive', + }; + + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }; + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + return ( +
+
+

Upload Dashboard

+ +
+ +
+ + + + File Name + Status + Size + Upload Progress + Processing Progress + Created + Error + + + + {loading ? ( + [...Array(5)].map((_, index) => ( + + + + + + + + + + )) + ) : ( + uploads.map((upload) => ( + + {upload.originalFileName} + {getStatusBadge(upload.status)} + {formatFileSize(upload.fileSize)} + + + + + + + + {formatDistanceToNow(new Date(upload.createdAt), { addSuffix: true })} + + + {upload.error || '-'} + + + )) + )} + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/[locale]/admin/upload/page.tsx b/app/[locale]/admin/upload/page.tsx new file mode 100644 index 0000000..08a4b6b --- /dev/null +++ b/app/[locale]/admin/upload/page.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Progress } from "@/components/ui/progress"; +import { useRouter } from 'next/navigation'; + +export default function UploadPage() { + const router = useRouter(); + const [isAuthorized, setIsAuthorized] = useState(false); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(''); + const [documentType, setDocumentType] = useState(''); + const [uploadProgress, setUploadProgress] = useState(0); + + const checkPassword = async (inputPassword: string) => { + try { + const response = await fetch('/api/admin/check', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ password: inputPassword }), + }); + + if (response.ok) { + setIsAuthorized(true); + sessionStorage.setItem('adminAuthorized', 'true'); + } else { + setError('Invalid password'); + } + } catch (error) { + setError('Something went wrong'); + } + }; + + useEffect(() => { + if (sessionStorage.getItem('adminAuthorized') === 'true') { + setIsAuthorized(true); + } + }, []); + + if (!isAuthorized) { + return ( +
+

Admin Access

+
+
+ + setPassword(e.target.value)} + /> +
+ + {error &&

{error}

} +
+
+ ); + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setMessage(''); + setUploadProgress(0); + + const formData = new FormData(e.currentTarget); + const file = formData.get('file') as File; + + try { + // Create upload record + const uploadResponse = await fetch('/api/admin/uploads', { + method: 'POST', + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error('Failed to create upload record'); + } + + const upload = await uploadResponse.json(); + setMessage('Upload started. You can monitor progress in the dashboard.'); + + // Redirect to dashboard after a short delay + setTimeout(() => { + router.push('/admin/dashboard'); + }, 2000); + + } catch (error) { + setMessage('Error: ' + (error as Error).message); + setLoading(false); + } + }; + + return ( +
+
+

Upload Document

+ +
+ +
+
+ + +
+ +
+ + +
+ + {documentType === 'parliamentary_bulletin' && ( +
+ + +
+ )} + + {documentType === 'bill' && ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + )} + +
+ + +
+ + {uploadProgress > 0 && ( +
+ + +
+ )} + + + + {message && ( +

+ {message} +

+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx new file mode 100644 index 0000000..0b55d87 --- /dev/null +++ b/app/[locale]/auth/callback/page.tsx @@ -0,0 +1,104 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { useToast } from '@/hooks/use-toast' +import { trackPehchanLogin } from '@/lib/analytics' + +export default function AuthCallback() { + const router = useRouter() + const { toast } = useToast() + const [isProcessing, setIsProcessing] = useState(true) + + useEffect(() => { + const handleCallback = async () => { + try { + const params = new URLSearchParams(window.location.search) + console.log('Callback URL params:', Object.fromEntries(params.entries())) + + // Check for direct token response first (implicit flow) + const accessToken = params.get('access_token') + const idToken = params.get('id_token') + const error = params.get('error') + + console.log('Received params:', { + hasAccessToken: !!accessToken, + hasIdToken: !!idToken, + hasError: !!error + }) + + if (error) { + throw new Error(error) + } + + // Handle implicit flow (direct token response) + if (accessToken) { + localStorage.setItem('access_token', accessToken) + if (idToken) localStorage.setItem('id_token', idToken) + + // Dispatch custom event for same-tab updates + window.dispatchEvent(new Event('localStorageChange')) + + // Use our proxy endpoint instead + const userResponse = await fetch('/api/auth/userinfo', { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + + if (!userResponse.ok) { + throw new Error('Failed to fetch user info') + } + + const userInfo = await userResponse.json() + console.log('User info:', userInfo) + localStorage.setItem('user_info', JSON.stringify(userInfo)) + localStorage.setItem('pehchan_id', userInfo.profile.cnic) + + toast({ + title: "Login successful", + description: "Welcome to Numainda" + }) + + trackPehchanLogin(true) + router.push('/chat') + return + } + + // If we get here and don't have tokens, something went wrong + throw new Error( + `Authentication failed. ` + + `Received parameters: ${JSON.stringify(Object.fromEntries(params.entries()))}` + ) + + } catch (error) { + console.error('Auth callback error:', error) + trackPehchanLogin(false) + toast({ + variant: "destructive", + title: "Authentication Failed", + description: error instanceof Error ? error.message : "An error occurred during login" + }) + router.push('/chat') + } finally { + sessionStorage.removeItem('auth_state') + setIsProcessing(false) + } + } + + handleCallback() + }, [router, toast]) + + if (isProcessing) { + return ( +
+
+
+

Processing your login...

+
+
+ ) + } + + return null +} \ No newline at end of file diff --git a/app/[locale]/bills/[id]/loading.tsx b/app/[locale]/bills/[id]/loading.tsx new file mode 100644 index 0000000..b1e5741 --- /dev/null +++ b/app/[locale]/bills/[id]/loading.tsx @@ -0,0 +1,38 @@ +import { Skeleton } from "@/components/ui/skeleton" + +export default function BillDetailLoading() { + return ( +
+
+ {/* Title */} + + + {/* Status + Passage Date grid */} +
+
+ + +
+
+ + +
+
+ + {/* Summary section */} +
+ +
+ + + + + + + +
+
+
+
+ ) +} diff --git a/app/[locale]/bills/[id]/page.tsx b/app/[locale]/bills/[id]/page.tsx new file mode 100644 index 0000000..b12da4e --- /dev/null +++ b/app/[locale]/bills/[id]/page.tsx @@ -0,0 +1,68 @@ +import { db } from '@/lib/db'; +import { bills } from '@/lib/db/schema/bills'; +import { eq } from 'drizzle-orm'; +import { notFound } from 'next/navigation'; +import ReactMarkdown from 'react-markdown' +import { BillViewTracker } from '@/components/bill-view-tracker' +import { getTranslations } from 'next-intl/server'; + +export default async function BillPage({ params }: { params: { id: string } }) { + const t = await getTranslations('Bills'); + const [bill] = await db + .select() + .from(bills) + .where(eq(bills.id, params.id)); + + if (!bill) { + notFound(); + } + + const getStatusLabel = (status: string) => { + switch (status) { + case 'passed': return t('statusPassed'); + case 'rejected': return t('statusRejected'); + default: return t('statusPending'); + } + }; + + return ( +
+ +
+

{bill.title}

+
+ {/*
+

Bill Number

+

{bill.billNumber}

+
+
+

Session Number

+

{bill.sessionNumber}

+
*/} +
+

{t('status')}

+

+ {getStatusLabel(bill.status)} +

+
+ {bill.passageDate && ( +
+

{t('passageDate')}

+

+ {new Date(bill.passageDate).toLocaleDateString()} +

+
+ )} +
+
+

{t('summary')}

+
{bill.summary}
+
+
+
+ ); +} diff --git a/app/[locale]/bills/page.tsx b/app/[locale]/bills/page.tsx new file mode 100644 index 0000000..3529fdb --- /dev/null +++ b/app/[locale]/bills/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { Search } from 'lucide-react'; +import { Skeleton } from "@/components/ui/skeleton"; +import { Input } from "@/components/ui/input"; +import { Pagination } from "@/components/ui/pagination"; +import { useTranslations } from 'next-intl'; + +// Define the type for a bill +interface Bill { + id: string; + title: string; + status: string; + createdAt: string; + billNumber?: string; +} + +interface BillsResponse { + data: Bill[]; + pagination: { + page: number; + limit: number; + totalCount: number; + totalPages: number; + }; +} + +export default function BillsPage() { + const t = useTranslations('Bills'); + const [allBills, setAllBills] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [pagination, setPagination] = useState({ + page: 1, + limit: 10, + totalCount: 0, + totalPages: 0, + }); + + useEffect(() => { + const fetchBills = async () => { + try { + setLoading(true); + setError(null); + const params = new URLSearchParams({ + page: currentPage.toString(), + limit: '10', + ...(searchQuery && { search: searchQuery }), + }); + const response = await fetch(`/api/bills?${params}`); + if (!response.ok) { + throw new Error('Failed to fetch bills'); + } + const data: BillsResponse = await response.json(); + setAllBills(data.data); + setPagination(data.pagination); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + fetchBills(); + }, [currentPage, searchQuery]); + + const getStatusClassName = (status: string) => { + switch (status) { + case 'passed': return 'bg-green-100 text-green-800'; + case 'rejected': return 'bg-red-100 text-red-800'; + default: return 'bg-yellow-100 text-yellow-800'; + } + }; + + const getStatusLabel = (status: string) => { + switch (status) { + case 'passed': return t('statusPassed'); + case 'rejected': return t('statusRejected'); + default: return t('statusPending'); + } + }; + + const handleSearch = (value: string) => { + setSearchQuery(value); + setCurrentPage(1); // Reset to first page on new search + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+

{t('title')}

+ + {/* Search Bar */} +
+ + handleSearch(e.target.value)} + className="pl-10" + /> +
+ + {loading && ( +
+ {[...Array(3)].map((_, index) => ( +
+ + +
+ ))} +
+ )} + + {error && ( +

{t('errorLoading', { error })}

+ )} + + {!loading && !error && ( + <> + {allBills.length === 0 ? ( +
+

{t('noResults')}

+
+ ) : ( + <> +
+ {allBills.map((bill) => ( + +

{bill.title}

+
+ + {getStatusLabel(bill.status)} + +
+ + ))} +
+ + {/* Pagination */} +
+ +

+ {t('showing', { count: allBills.length, total: pagination.totalCount })} +

+
+ + )} + + )} +
+ ); +} diff --git a/app/[locale]/chat.backup/page.tsx b/app/[locale]/chat.backup/page.tsx new file mode 100644 index 0000000..5b27352 --- /dev/null +++ b/app/[locale]/chat.backup/page.tsx @@ -0,0 +1,295 @@ +"use client" + +import { useEffect, useRef, useState, Suspense } from "react" +import { useChat } from "ai/react" +import { + Bot, + CopyIcon, + MessageSquare, + RefreshCcw, + SendIcon, + User, + LogOut, + Loader2, +} from "lucide-react" +import Markdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { useRouter, useSearchParams } from "next/navigation" +import { useToast } from "@/hooks/use-toast" +import { trackChatMessage, trackNewChatThread } from "@/lib/analytics" + +import { Button } from "@/components/ui/button" +import { + ChatBubble, + ChatBubbleAction, + ChatBubbleAvatar, + ChatBubbleMessage, +} from "@/components/ui/chat/chat-bubble" +import { ChatInput } from "@/components/ui/chat/chat-input" + +const ChatAiIcons = [ + { icon: CopyIcon, label: "Copy" }, + { icon: RefreshCcw, label: "Refresh" }, +] + +function ChatPageContent() { + const [isGenerating, setIsGenerating] = useState(false) + const [isAuthenticated, setIsAuthenticated] = useState(false) + const router = useRouter() + const { toast } = useToast() + const searchParams = useSearchParams() + const [threadId, setThreadId] = useState(null) + const [isClient, setIsClient] = useState(false) + + useEffect(() => { + setIsClient(true) + }, []) + + useEffect(() => { + const accessToken = localStorage.getItem('access_token') + console.log('Auth check - access_token:', accessToken) + setIsAuthenticated(!!accessToken) + }, []) + + useEffect(() => { + const loadOrCreateThread = async () => { + console.log('loadOrCreateThread called, isAuthenticated:', isAuthenticated) + if (!isAuthenticated) return + + const pehchanId = localStorage.getItem('pehchan_id') + console.log('Pehchan ID:', pehchanId) + if (!pehchanId) return + + const threadIdParam = searchParams.get('thread') + if (threadIdParam) { + console.log('Loading thread:', threadIdParam) + const response = await fetch(`/api/chat/threads/${threadIdParam}?pehchan_id=${pehchanId}`) + const thread = await response.json() + console.log('Loaded thread:', thread) + + if (thread) { + setThreadId(thread.id) + setMessages(thread.messages) + } + } else { + console.log('Creating new thread') + const response = await fetch('/api/chat/threads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pehchanId, + title: 'New Chat' + }) + }) + const thread = await response.json() + console.log('Created thread:', thread) + + if (thread) { + setThreadId(thread.id) + trackNewChatThread() + router.push(`/chat?thread=${thread.id}`) + } + } + } + + loadOrCreateThread() + }, [isAuthenticated, searchParams]) + + const handleLogout = () => { + localStorage.clear() + window.dispatchEvent(new Event('localStorageChange')) + toast({ + title: "Logged out", + description: "You have been successfully logged out" + }) + router.refresh() + } + + const { + messages, + input, + handleInputChange, + handleSubmit, + isLoading, + reload, + setMessages, + } = useChat({ + api: "/api/chat", + initialMessages: [ + { + id: "welcome", + role: "assistant", + content: + "Hello! I am Numainda, your guide to Pakistan's constitutional and electoral information. How may I assist you today?", + }, + ], + onResponse: (response) => { + if (response) { + setIsGenerating(false) + // Scroll to bottom when response starts streaming + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + + if (threadId) { + fetch(`/api/chat/threads/${threadId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages, + title: messages[1]?.content.slice(0, 100) || 'New Chat', + pehchanId: localStorage.getItem('pehchan_id') + }) + }) + } + } + }, + onError: (error) => { + if (error) setIsGenerating(false) + }, + }) + + const messagesEndRef = useRef(null) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [messages]) + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + toast({ + title: "Copied to clipboard", + description: "Message content has been copied to your clipboard", + }) + } catch (err) { + toast({ + title: "Failed to copy", + description: "Could not copy the message to clipboard", + variant: "destructive", + }) + } + } + + return ( +
+
+ {/* Header */} +
+
+ + Numainda Chat +
+
+ + {/* Messages container */} +
+
+ {isClient && messages.map((message) => ( + + + ) : ( + + ) + } + /> + + + {message.content} + + + {message.role === "assistant" && isClient && ( + copyToClipboard(message.content)} + > + + Copy message + + } /> + )} + + ))} + {isGenerating && isClient && ( + + } + /> + +
+ + Numainda is thinking... +
+
+
+ )} +
+
+
+ + {/* Input - now will stay fixed at bottom */} +
+
+
{ + e.preventDefault() + if (!input?.trim() || isLoading) return + setIsGenerating(true) + trackChatMessage(threadId || undefined) + handleSubmit(e) + }} + > + { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + if (!input?.trim() || isLoading) return + setIsGenerating(true) + trackChatMessage(threadId || undefined) + handleSubmit(e) + } + }} + /> + + +
+
+
+
+ ) +} + +export default function ChatPage() { + return ( + + +
}> + + + ) +} diff --git a/app/[locale]/chat/page.tsx b/app/[locale]/chat/page.tsx new file mode 100644 index 0000000..c187cb2 --- /dev/null +++ b/app/[locale]/chat/page.tsx @@ -0,0 +1,263 @@ +'use client' + +import { Suspense, useState, useRef, useEffect } from "react" +import { useSearchParams } from "next/navigation" +import { useChat } from "ai/react" +import { + Bot, + CopyIcon, + SendIcon, + User, + Loader2, + ArrowLeft, + Trash2, +} from "lucide-react" +import { Link } from "@/i18n/navigation" +import { useTranslations } from "next-intl" +import Markdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { useToast } from "@/hooks/use-toast" +import { trackChatMessage } from "@/lib/analytics" +import { Button } from "@/components/ui/button" + +export default function ChatPage() { + return ( + + +
+ }> + + + ) +} + +function ChatContent() { + const t = useTranslations('Chat') + const searchParams = useSearchParams() + const initialQuery = searchParams.get("q") + const [hasSubmittedInitial, setHasSubmittedInitial] = useState(false) + const [isGenerating, setIsGenerating] = useState(false) + const { toast } = useToast() + const messagesEndRef = useRef(null) + const inputRef = useRef(null) + + const { + messages, + input, + handleInputChange, + handleSubmit, + isLoading, + setMessages, + append, + } = useChat({ + api: "/api/chat", + initialMessages: [ + { + id: "welcome", + role: "assistant", + content: + "Hello! I am Numainda, your guide to Pakistan's constitutional and electoral information. How may I assist you today?", + }, + ], + onResponse: () => { + setIsGenerating(false) + }, + onError: () => { + setIsGenerating(false) + }, + }) + + // Auto-submit the initial query from URL + useEffect(() => { + if (initialQuery && !hasSubmittedInitial) { + setHasSubmittedInitial(true) + setIsGenerating(true) + trackChatMessage() + append({ role: "user", content: initialQuery }) + } + }, [initialQuery, hasSubmittedInitial, append]) + + // Auto-scroll to latest message + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }, [messages]) + + // Focus input on mount + useEffect(() => { + if (!initialQuery) { + inputRef.current?.focus() + } + }, [initialQuery]) + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + toast({ + title: t('copiedTitle'), + description: t('copiedDesc'), + }) + } catch { + toast({ + title: t('copyFailedTitle'), + variant: "destructive", + }) + } + } + + const handleClearChat = () => { + setMessages([ + { + id: "welcome", + role: "assistant", + content: t('welcomeMessage'), + }, + ]) + } + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!input?.trim() || isLoading) return + setIsGenerating(true) + trackChatMessage() + handleSubmit(e) + } + + return ( +
+ {/* Chat Header */} +
+
+ + + {t('backToHome')} + +
+ + {t('numainda')} +
+ +
+
+ + {/* Messages Area */} +
+
+
+ {messages.map((message) => ( +
+ {message.role === "assistant" && ( +
+ +
+ )} +
+ + {message.id === "welcome" ? t('welcomeMessage') : message.content} + + {message.role === "assistant" && message.id !== "welcome" && ( + + )} +
+ {message.role === "user" && ( +
+ +
+ )} +
+ ))} + {isGenerating && ( +
+
+ +
+
+
+ + {t('thinking')} +
+
+
+ )} +
+
+
+
+ + {/* Input Area */} +
+
+
+