Files
Frontend/src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx
T
sajad-dev 2b6538c650 Add irrigation and fertilization recommendations, farm AI assistant, and pest detection features with Persian localization
- Introduced new sections in the dashboard for irrigation recommendations, fertilization recommendations, farm AI assistant, and pest detection.
- Added Persian translations for new features to enhance user experience.
- Updated the vertical menu to include links to the new sections.
- Enhanced global styles with animations for improved UI interactions.
2026-02-21 00:19:25 +03:30

672 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useRef, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import Box from '@mui/material/Box'
import Typography from '@mui/material/Typography'
import Card from '@mui/material/Card'
import IconButton from '@mui/material/IconButton'
import TextField from '@mui/material/TextField'
import Collapse from '@mui/material/Collapse'
import classnames from 'classnames'
import type { FarmContext, FarmAIMessage, AIResponseSection } from './farmAiAssistantTypes'
// ─── Constants ─────────────────────────────────────────────────────────────
const DEFAULT_FARM_CONTEXT: FarmContext = {
soilType: 'Loamy',
waterEC: '1.2 dS/m',
selectedCrop: 'Tomato',
growthStage: 'Flowering',
lastIrrigationStatus: '2 days ago'
}
const SUGGESTION_CHIPS = [
{ id: 'yellow-leaves', labelKey: 'suggestions.yellowLeaves' },
{ id: 'irrigation-plan', labelKey: 'suggestions.irrigationPlan' },
{ id: 'fertilizer-flowering', labelKey: 'suggestions.fertilizerFlowering' },
{ id: 'plant-disease', labelKey: 'suggestions.plantDisease' }
]
// Demo structured AI response for display
const DEMO_AI_RESPONSE_SECTIONS: AIResponseSection[] = [
{
type: 'recommendation',
title: 'Irrigation recommendation',
icon: 'droplet',
frequency: '3 times per week',
amount: '1520 L per plant',
timing: 'Early morning (05:0007:00)',
expandableExplanation:
'Your loamy soil holds moisture well, but tomatoes during flowering need consistent moisture. Water EC of 1.2 dS/m is suitable. Last irrigation was 2 days ago—avoid overwatering to prevent blossom-end rot.'
},
{
type: 'list',
title: 'Key points',
icon: 'leaf',
items: [
'Avoid midday watering to reduce evaporation',
'Drip irrigation preferred for root zone targeting',
'Monitor soil moisture before each session'
]
},
{
type: 'warning',
title: 'Weather advisory',
icon: 'warning',
content: 'High temps forecasted next week. Consider increasing frequency to 4x/week temporarily.'
}
]
// ─── Main Component ────────────────────────────────────────────────────────
export default function FarmAiAssistantChat() {
const t = useTranslations('farmAiAssistant')
const [messages, setMessages] = useState<FarmAIMessage[]>([])
const [inputValue, setInputValue] = useState('')
const [isContextExpanded, setIsContextExpanded] = useState(true)
const [isTyping, setIsTyping] = useState(false)
const [selectedChip, setSelectedChip] = useState<string | null>(null)
const [expandedExplanations, setExpandedExplanations] = useState<Set<string>>(new Set())
const scrollRef = useRef<HTMLDivElement>(null)
const farmContext = DEFAULT_FARM_CONTEXT
// Scroll to bottom on new messages
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth'
})
}, [messages, isTyping])
const handleSuggestionClick = (chipId: string, labelKey: string) => {
const label = t(labelKey)
setSelectedChip(prev => (prev === chipId ? null : chipId))
handleSend(label)
}
const handleSend = async (text?: string) => {
const content = (text || inputValue).trim()
if (!content) return
setInputValue('')
setSelectedChip(null)
const userMessage: FarmAIMessage = {
id: `u-${Date.now()}`,
role: 'user',
content,
timestamp: new Date()
}
setMessages(prev => [...prev, userMessage])
setIsTyping(true)
// Simulate AI response (replace with actual API call)
await new Promise(resolve => setTimeout(resolve, 1500))
const aiMessage: FarmAIMessage = {
id: `a-${Date.now()}`,
role: 'assistant',
content: '',
timestamp: new Date(),
sections: DEMO_AI_RESPONSE_SECTIONS
}
setMessages(prev => [...prev, aiMessage])
setIsTyping(false)
}
const toggleExplanation = (id: string) => {
setExpandedExplanations(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<Box
className='flex flex-col min-bs-screen max-w-md mx-auto'
sx={{
background: 'linear-gradient(180deg, #f8fcf8 0%, #f0f7f4 30%, #e8f4ef 100%)',
minHeight: '100dvh'
}}
>
{/* 1) Smart Header */}
<Box
className='flex items-center gap-3 px-4 pt-4 pb-3 flex-shrink-0'
sx={{
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.06) 0%, rgba(14, 165, 233, 0.06) 100%)',
borderBottom: '1px solid rgba(34, 197, 94, 0.12)'
}}
>
<Box
className='w-12 h-12 rounded-2xl flex items-center justify-center shrink-0'
sx={{
background: 'linear-gradient(145deg, #22c55e 0%, #0ea5e9 100%)',
boxShadow: '0 4px 12px rgba(34, 197, 94, 0.3)'
}}
>
<i className='tabler-leaf text-2xl text-white' />
</Box>
<Box className='min-w-0 flex-1'>
<Typography
variant='h6'
className='font-bold truncate'
sx={{
background: 'linear-gradient(135deg, #16a34a 0%, #0ea5e9 100%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
fontSize: '1.1rem'
}}
>
{t('header.title')}
</Typography>
<Typography variant='caption' color='text.secondary' className='block truncate'>
{t('header.subtitle')}
</Typography>
</Box>
</Box>
{/* 2) Expandable Farm Context Bar */}
<Box
className='mx-4 mt-3 flex-shrink-0 rounded-2xl overflow-hidden'
sx={{
background: 'linear-gradient(145deg, #ffffff 0%, rgba(34, 197, 94, 0.04) 100%)',
border: '1px solid rgba(34, 197, 94, 0.15)',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)'
}}
>
<Box
component='button'
type='button'
onClick={() => setIsContextExpanded(!isContextExpanded)}
className='w-full flex items-center justify-between px-4 py-3 text-start'
sx={{
'&:hover': { bgcolor: 'rgba(34, 197, 94, 0.04)' },
transition: 'background 0.2s'
}}
>
<Typography variant='subtitle2' fontWeight={600} color='text.secondary'>
{t('context.title')}
</Typography>
<i
className={classnames('tabler-chevron-down text-xl text-[#22c55e] transition-transform duration-300', {
'rotate-180': isContextExpanded
})}
/>
</Box>
<Collapse in={isContextExpanded}>
<Box className='px-4 pb-4 grid grid-cols-2 gap-2'>
<ContextBadge icon='tabler-seedling' label={t('context.soilType')} value={farmContext.soilType} />
<ContextBadge icon='tabler-droplet' label={t('context.waterEC')} value={farmContext.waterEC} />
<ContextBadge icon='tabler-plant-2' label={t('context.crop')} value={farmContext.selectedCrop} />
<ContextBadge icon='tabler-flower' label={t('context.growthStage')} value={farmContext.growthStage} />
<ContextBadge
icon='tabler-calendar'
label={t('context.lastIrrigation')}
value={farmContext.lastIrrigationStatus}
colSpan={2}
/>
</Box>
</Collapse>
</Box>
{/* 3) Chat Area */}
<Box
ref={scrollRef}
className='flex-1 overflow-y-auto overflow-x-hidden px-4 py-4 space-y-4'
sx={{ scrollBehavior: 'smooth' }}
>
{messages.length === 0 && !isTyping && (
<Box className='flex flex-col items-center justify-center py-16 text-center'>
<Box
className='w-20 h-20 rounded-3xl flex items-center justify-center mb-4'
sx={{
background: 'linear-gradient(145deg, rgba(34, 197, 94, 0.1) 0%, rgba(14, 165, 233, 0.1) 100%)'
}}
>
<i className='tabler-message-circle text-4xl text-[#22c55e]' />
</Box>
<Typography variant='body1' color='text.secondary' className='mb-1'>
{t('emptyState.title')}
</Typography>
<Typography variant='caption' color='text.secondary'>
{t('emptyState.subtitle')}
</Typography>
</Box>
)}
{messages.map(msg => (
<MessageBubble
key={msg.id}
message={msg}
expandedExplanations={expandedExplanations}
onToggleExplanation={toggleExplanation}
t={t}
/>
))}
{/* Typing indicator */}
{isTyping && (
<Box className='animate-fade-in flex items-start gap-3'>
<Box
className='w-9 h-9 rounded-xl flex items-center justify-center shrink-0'
sx={{
background: 'linear-gradient(145deg, #22c55e 0%, #0ea5e9 100%)'
}}
>
<i className='tabler-leaf text-lg text-white' />
</Box>
<Box
className='px-4 py-3 rounded-2xl rounded-tl-md'
sx={{
background: 'linear-gradient(145deg, #ffffff 0%, rgba(34, 197, 94, 0.05) 100%)',
border: '1px solid rgba(34, 197, 94, 0.15)',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)'
}}
>
<TypingIndicator />
</Box>
</Box>
)}
</Box>
{/* 4) Suggestion Chips */}
<Box className='px-4 py-2 flex-shrink-0 overflow-x-auto scrollbar-hide'>
<Box className='flex gap-2 pb-2'>
{SUGGESTION_CHIPS.map(chip => (
<Box
key={chip.id}
component='button'
type='button'
onClick={() => handleSuggestionClick(chip.id, chip.labelKey)}
className={classnames(
'px-4 py-2 rounded-2xl text-sm font-medium whitespace-nowrap transition-all duration-200 shrink-0',
'hover:scale-[1.02] active:scale-[0.98]'
)}
sx={{
background:
selectedChip === chip.id
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
: 'linear-gradient(145deg, #ffffff 0%, rgba(34, 197, 94, 0.08) 100%)',
color: selectedChip === chip.id ? '#ffffff' : 'text.primary',
border: selectedChip === chip.id ? 'none' : '1px solid rgba(34, 197, 94, 0.2)',
boxShadow:
selectedChip === chip.id
? '0 4px 12px rgba(34, 197, 94, 0.35)'
: '0 2px 8px rgba(0,0,0,0.04)'
}}
>
{t(chip.labelKey)}
</Box>
))}
</Box>
</Box>
{/* 5) Input Area - Sticky Bottom */}
<Box
className='px-4 py-3 pb-6 flex-shrink-0'
sx={{
background: 'linear-gradient(to top, rgba(248,252,248,0.98) 0%, rgba(248,252,248,0.9) 70%, transparent 100%)',
backdropFilter: 'blur(12px)'
}}
>
<Box
className='flex items-end gap-2'
sx={{
background: '#ffffff',
borderRadius: '24px',
border: '1px solid rgba(34, 197, 94, 0.2)',
boxShadow: '0 4px 20px rgba(34, 197, 94, 0.08)',
p: 1
}}
>
<IconButton
component='label'
htmlFor='farm-ai-camera'
size='small'
className='shrink-0'
sx={{
color: 'text.secondary',
'&:hover': { color: '#22c55e', bgcolor: 'rgba(34, 197, 94, 0.08)' }
}}
>
<i className='tabler-camera text-xl' />
<input id='farm-ai-camera' type='file' accept='image/*' hidden />
</IconButton>
<IconButton
size='small'
className='shrink-0'
sx={{
color: 'text.secondary',
'&:hover': { color: '#0ea5e9', bgcolor: 'rgba(14, 165, 233, 0.08)' }
}}
>
<i className='tabler-microphone text-xl' />
</IconButton>
<TextField
fullWidth
multiline
maxRows={4}
placeholder={t('input.placeholder')}
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
variant='standard'
InputProps={{
disableUnderline: true,
sx: {
px: 1.5,
py: 1,
fontSize: '0.95rem'
}
}}
sx={{ flex: 1, minWidth: 0 }}
/>
<IconButton
onClick={() => handleSend()}
disabled={!inputValue.trim()}
className='shrink-0'
sx={{
background: inputValue.trim()
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
: 'action.disabledBackground',
color: inputValue.trim() ? '#ffffff' : 'action.disabled',
'&:hover': inputValue.trim()
? {
background: 'linear-gradient(135deg, #16a34a 0%, #15803d 100%)',
boxShadow: '0 4px 12px rgba(34, 197, 94, 0.4)'
}
: {},
'&.Mui-disabled': { background: 'action.disabledBackground', color: 'action.disabled' }
}}
>
<i className='tabler-send text-xl' />
</IconButton>
</Box>
</Box>
</Box>
)
}
// ─── Sub-components ────────────────────────────────────────────────────────
function ContextBadge({
icon,
label,
value,
colSpan = 1
}: {
icon: string
label: string
value: string
colSpan?: number
}) {
return (
<Box
className={classnames('flex items-center gap-2 px-3 py-2 rounded-xl', colSpan === 2 ? 'col-span-2' : '')}
sx={{
background: 'rgba(34, 197, 94, 0.06)',
border: '1px solid rgba(34, 197, 94, 0.12)'
}}
>
<i className={`${icon} text-lg text-[#22c55e] shrink-0`} />
<Box className='min-w-0'>
<Typography variant='caption' color='text.secondary' display='block' lineHeight={1.2}>
{label}
</Typography>
<Typography variant='body2' fontWeight={600} color='text.primary' noWrap>
{value}
</Typography>
</Box>
</Box>
)
}
function MessageBubble({
message,
expandedExplanations,
onToggleExplanation,
t
}: {
message: FarmAIMessage
expandedExplanations: Set<string>
onToggleExplanation: (id: string) => void
t: (key: string) => string
}) {
const isUser = message.role === 'user'
if (isUser) {
return (
<Box className='flex justify-end animate-fade-in'>
<Box
className='max-w-[85%] px-4 py-2.5 rounded-2xl rounded-tr-md'
sx={{
background: 'linear-gradient(145deg, rgba(34, 197, 94, 0.15) 0%, rgba(34, 197, 94, 0.08) 100%)',
border: '1px solid rgba(34, 197, 94, 0.2)'
}}
>
<Typography variant='body2' color='text.primary'>
{message.content}
</Typography>
</Box>
</Box>
)
}
// AI message - structured cards
return (
<Box className='flex items-start gap-3 animate-fade-in'>
<Box
className='w-9 h-9 rounded-xl flex items-center justify-center shrink-0'
sx={{
background: 'linear-gradient(145deg, #22c55e 0%, #0ea5e9 100%)'
}}
>
<i className='tabler-leaf text-lg text-white' />
</Box>
<Box className='flex-1 min-w-0 space-y-3'>
{message.sections?.map((section, idx) => (
<AISectionCard
key={`${message.id}-${idx}`}
section={section}
expandedExplanations={expandedExplanations}
onToggleExplanation={onToggleExplanation}
messageId={message.id}
idx={idx}
t={t}
/>
))}
</Box>
</Box>
)
}
function AISectionCard({
section,
expandedExplanations,
onToggleExplanation,
messageId,
idx,
t
}: {
section: AIResponseSection
expandedExplanations: Set<string>
onToggleExplanation: (id: string) => void
messageId: string
idx: number
t: (key: string) => string
}) {
const expId = `${messageId}-exp-${idx}`
const iconMap = {
droplet: 'tabler-droplet',
leaf: 'tabler-leaf',
warning: 'tabler-alert-triangle',
fertilizer: 'tabler-atom-2',
calendar: 'tabler-calendar'
}
const iconClass = section.icon ? iconMap[section.icon] : 'tabler-leaf'
if (section.type === 'recommendation') {
return (
<Card
elevation={0}
className='overflow-hidden'
sx={{
borderRadius: '20px',
background: 'linear-gradient(160deg, #ffffff 0%, rgba(34, 197, 94, 0.06) 100%)',
border: '1px solid rgba(34, 197, 94, 0.2)',
boxShadow: '0 4px 20px rgba(34, 197, 94, 0.12)'
}}
>
<Box className='p-4'>
<Box className='flex items-center gap-2 mb-3'>
<i className={`${iconClass} text-xl text-[#22c55e]`} />
<Typography variant='subtitle2' fontWeight={700} color='primary'>
{section.title}
</Typography>
</Box>
<Box className='space-y-2'>
{section.frequency && (
<Box className='flex justify-between'>
<Typography variant='caption' color='text.secondary'>
{t('recommendation.frequency')}
</Typography>
<Typography variant='body2' fontWeight={600}>
{section.frequency}
</Typography>
</Box>
)}
{section.amount && (
<Box className='flex justify-between'>
<Typography variant='caption' color='text.secondary'>
{t('recommendation.amount')}
</Typography>
<Typography variant='body2' fontWeight={600}>
{section.amount}
</Typography>
</Box>
)}
{section.timing && (
<Box className='flex justify-between'>
<Typography variant='caption' color='text.secondary'>
{t('recommendation.timing')}
</Typography>
<Typography variant='body2' fontWeight={600}>
{section.timing}
</Typography>
</Box>
)}
</Box>
{section.expandableExplanation && (
<Box className='mt-3'>
<Box
component='button'
type='button'
onClick={() => onToggleExplanation(expId)}
className='flex items-center gap-1 text-sm font-medium'
sx={{ color: '#22c55e', '&:hover': { color: '#16a34a' } }}
>
{t('recommendation.whyThis')}
<i
className={classnames('tabler-chevron-down text-base transition-transform', {
'rotate-180': expandedExplanations.has(expId)
})}
/>
</Box>
<Collapse in={expandedExplanations.has(expId)}>
<Typography variant='body2' color='text.secondary' className='mt-2'>
{section.expandableExplanation}
</Typography>
</Collapse>
</Box>
)}
</Box>
</Card>
)
}
if (section.type === 'warning') {
return (
<Box
className='p-4 rounded-2xl'
sx={{
background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.15) 0%, rgba(245, 158, 11, 0.08) 100%)',
border: '1px solid rgba(251, 191, 36, 0.35)'
}}
>
<Box className='flex gap-2'>
<i className='tabler-alert-triangle text-xl text-amber-600 shrink-0 mt-0.5' />
<Box>
{section.title && (
<Typography variant='subtitle2' fontWeight={600} color='warning.dark' className='mb-1'>
{section.title}
</Typography>
)}
<Typography variant='body2' color='text.secondary'>
{section.content}
</Typography>
</Box>
</Box>
</Box>
)
}
if (section.type === 'list') {
return (
<Box
className='p-4 rounded-2xl'
sx={{
background: 'linear-gradient(145deg, #ffffff 0%, rgba(34, 197, 94, 0.04) 100%)',
border: '1px solid rgba(34, 197, 94, 0.15)'
}}
>
{section.title && (
<Box className='flex items-center gap-2 mb-2'>
<i className={`${iconClass} text-lg text-[#22c55e]`} />
<Typography variant='subtitle2' fontWeight={600} color='text.primary'>
{section.title}
</Typography>
</Box>
)}
{section.items && (
<Box component='ul' className='m-0 ps-5 space-y-1'>
{section.items.map((item, i) => (
<Typography key={i} component='li' variant='body2' color='text.secondary'>
{item}
</Typography>
))}
</Box>
)}
</Box>
)
}
return null
}
function TypingIndicator() {
return (
<Box className='flex gap-1'>
{[0, 1, 2].map(i => (
<Box
key={i}
className='w-2 h-2 rounded-full bg-[#22c55e]'
sx={{
animation: `typing-bounce 1.4s ease-in-out ${i * 0.16}s infinite both`
}}
/>
))}
</Box>
)
}