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.
This commit is contained in:
2026-02-21 00:19:25 +03:30
parent 0eb109725e
commit 2b6538c650
17 changed files with 2448 additions and 0 deletions
@@ -0,0 +1,671 @@
'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>
)
}