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:
@@ -38,6 +38,10 @@
|
||||
"soilData": "اطلاعات خاک",
|
||||
"cropZoning": "زونبندی کشت",
|
||||
"plantSimulator": "شبیهساز رشد گیاه",
|
||||
"irrigationRecommendation": "توصیه آبیاری",
|
||||
"fertilizationRecommendation": "توصیه کوددهی",
|
||||
"farmAiAssistant": "دستیار هوشمند مزرعه",
|
||||
"pestDetection": "تشخیص آفات گیاهی",
|
||||
"dataSection": "بخش دادهها",
|
||||
"crm": "مدیریت ارتباط با مشتری",
|
||||
"analytics": "تحلیلها",
|
||||
@@ -592,5 +596,139 @@
|
||||
"waterStatus": "وضعیت آب",
|
||||
"yieldStatus": "محصول دهی",
|
||||
"description": "این شبیهساز رشد گیاه را بر اساس سرعت پایه، میزان نور خورشید و آب دریافتی محاسبه میکند. هر برگ به صورت تدریجی روی ساقه ظاهر شده و با حرکت طبیعی در باد نمایش داده میشود. محصولدهی (g) پس از ۲۰٪ رشد شروع شده و با تعداد برگ، نور و آب شتاب میگیرد. سرعت محصول (g/s) نشاندهنده نرخ لحظهای تولید است. نمودار تغییرات همه شاخصها را در طول زمان ثبت میکند."
|
||||
},
|
||||
"irrigation": {
|
||||
"title": "توصیه هوشمند آبیاری",
|
||||
"subtitle": "بر اساس دادههای ثبت شده مزرعه شما",
|
||||
"farmInfo": {
|
||||
"title": "اطلاعات مزرعه",
|
||||
"soilType": "نوع خاک",
|
||||
"waterQuality": "کیفیت آب",
|
||||
"climateZone": "منطقه اقلیمی"
|
||||
},
|
||||
"verifiedBadge": "داده تأیید شده",
|
||||
"editFarmInfo": "ویرایش اطلاعات مزرعه",
|
||||
"plantSelection": {
|
||||
"title": "انتخاب محصول"
|
||||
},
|
||||
"crops": {
|
||||
"wheat": "گندم",
|
||||
"corn": "ذرت",
|
||||
"cotton": "پنبه",
|
||||
"saffron": "زعفران",
|
||||
"canola": "کلزا",
|
||||
"vegetables": "سبزیجات"
|
||||
},
|
||||
"generateCta": "تولید برنامه آبیاری",
|
||||
"generating": "در حال تحلیل و تولید برنامه آبیاری...",
|
||||
"result": {
|
||||
"moistureLevel": "سطح رطوبت هدف",
|
||||
"frequency": "تناوب آبیاری",
|
||||
"timesPerWeek": "بار در هفته",
|
||||
"duration": "مدت هر نوبت",
|
||||
"minutes": "دقیقه",
|
||||
"bestTime": "بهترین زمان آبیاری",
|
||||
"smartWarning": "هشدار هوشمند"
|
||||
}
|
||||
},
|
||||
"fertilization": {
|
||||
"title": "برنامه هوشمند کوددهی",
|
||||
"subtitle": "شخصیسازی شده برای پروفایل خاک شما",
|
||||
"farmData": {
|
||||
"title": "خلاصه دادههای مزرعه",
|
||||
"soilType": "نوع خاک",
|
||||
"organicMatter": "میزان ماده آلی",
|
||||
"waterEC": "هدایت الکتریکی آب"
|
||||
},
|
||||
"verifiedBadge": "داده تأیید کارشناس",
|
||||
"growthStage": {
|
||||
"title": "مرحله رشد",
|
||||
"prePlanting": "قبل از کشت",
|
||||
"earlyGrowth": "رشد اولیه",
|
||||
"flowering": "گلدهی",
|
||||
"fruiting": "میوهدهی",
|
||||
"postHarvest": "پس از برداشت"
|
||||
},
|
||||
"plantSelection": {
|
||||
"title": "انتخاب محصول"
|
||||
},
|
||||
"crops": {
|
||||
"wheat": "گندم",
|
||||
"corn": "ذرت",
|
||||
"cotton": "پنبه",
|
||||
"saffron": "زعفران",
|
||||
"canola": "کلزا",
|
||||
"vegetables": "سبزیجات"
|
||||
},
|
||||
"generateCta": "تولید برنامه کوددهی",
|
||||
"generating": "در حال تحلیل و تولید نسخه تغذیهای...",
|
||||
"result": {
|
||||
"title": "نسخه تغذیه گیاه",
|
||||
"fertilizerType": "نوع کود توصیهشده",
|
||||
"amountPerHectare": "مقدار در هکتار",
|
||||
"applicationMethod": "روش مصرف",
|
||||
"applicationInterval": "فواصل مصرف",
|
||||
"whyRecommendation": "چرا این توصیه؟"
|
||||
}
|
||||
},
|
||||
"pestDetection": {
|
||||
"title": "تشخیص آفات گیاهی با هوش مصنوعی",
|
||||
"subtitle": "تصویر گیاه را آپلود کرده و آفات را فوراً تشخیص دهید",
|
||||
"upload": {
|
||||
"dragDrop": "کشیدن و رها کردن یا کلیک برای آپلود",
|
||||
"dropHere": "تصویر را اینجا رها کنید",
|
||||
"fileFormats": "PNG، JPG، GIF یا WebP (حداکثر ۱۰ مگابایت)",
|
||||
"remove": "حذف",
|
||||
"ariaLabel": "آپلود تصویر گیاه",
|
||||
"invalidFile": "فایل نامعتبر. لطفاً یک تصویر (PNG، JPG، GIF، WebP، حداکثر ۱۰ مگابایت) آپلود کنید."
|
||||
},
|
||||
"analyze": "تحلیل",
|
||||
"analyzing": "در حال تحلیل...",
|
||||
"reset": "بازنشانی",
|
||||
"resultTitle": "نتیجه تشخیص",
|
||||
"resultCard": {
|
||||
"detectedPest": "آفت تشخیص داده شده",
|
||||
"confidence": "اعتماد",
|
||||
"description": "توضیحات",
|
||||
"recommendedTreatment": "درمان پیشنهادی"
|
||||
},
|
||||
"mockResult": {
|
||||
"pest": "شپشک",
|
||||
"description": "حشرات کوچک مکنده شیره که باعث پیچ خوردگی برگ میشوند.",
|
||||
"treatment": "یک بار در هفته از اسپری روغن نیم استفاده کنید."
|
||||
}
|
||||
},
|
||||
"farmAiAssistant": {
|
||||
"header": {
|
||||
"title": "دستیار هوشمند مزرعه",
|
||||
"subtitle": "پاسخ بر اساس دادههای ثبتشده مزرعه شما"
|
||||
},
|
||||
"context": {
|
||||
"title": "زمینه مزرعه",
|
||||
"soilType": "نوع خاک",
|
||||
"waterEC": "EC آب / کیفیت",
|
||||
"crop": "محصول انتخابی",
|
||||
"growthStage": "مرحله رشد",
|
||||
"lastIrrigation": "وضعیت آخرین آبیاری"
|
||||
},
|
||||
"emptyState": {
|
||||
"title": "سوال خود را بپرسید",
|
||||
"subtitle": "دستیار بر اساس خاک، آب و محصول شما پاسخ میدهد"
|
||||
},
|
||||
"suggestions": {
|
||||
"yellowLeaves": "چرا برگهای گوجه زرد میشوند؟",
|
||||
"irrigationPlan": "برنامه آبیاری امروز چیست؟",
|
||||
"fertilizerFlowering": "در گلدهی چه کودی بدهم؟",
|
||||
"plantDisease": "این بیماری گیاهی را تشخیص بده"
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "سوال مزرعهای خود را بنویسید..."
|
||||
},
|
||||
"recommendation": {
|
||||
"frequency": "تناوب",
|
||||
"amount": "مقدار",
|
||||
"timing": "زمانبندی",
|
||||
"whyThis": "چرا این توصیه؟"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+53
@@ -56,11 +56,13 @@
|
||||
"cmdk": "1.0.4",
|
||||
"date-fns": "4.1.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"framer-motion": "^12.34.3",
|
||||
"fs-extra": "11.2.0",
|
||||
"input-otp": "1.4.1",
|
||||
"keen-slider": "6.8.6",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-draw": "^1.0.4",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mapbox-gl": "3.9.0",
|
||||
"negotiator": "1.0.0",
|
||||
"next": "15.1.2",
|
||||
@@ -6323,6 +6325,33 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.34.3",
|
||||
"resolved": "https://mirror-npm.runflare.com/framer-motion/-/framer-motion-12.34.3.tgz",
|
||||
"integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.34.3",
|
||||
"motion-utils": "^12.29.2",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.2.0",
|
||||
"license": "MIT",
|
||||
@@ -7633,6 +7662,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.575.0",
|
||||
"resolved": "https://mirror-npm.runflare.com/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.9.0",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
@@ -7839,6 +7877,21 @@
|
||||
"ufo": "^1.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.34.3",
|
||||
"resolved": "https://mirror-npm.runflare.com/motion-dom/-/motion-dom-12.34.3.tgz",
|
||||
"integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.29.2",
|
||||
"resolved": "https://mirror-npm.runflare.com/motion-utils/-/motion-utils-12.29.2.tgz",
|
||||
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"license": "MIT"
|
||||
|
||||
@@ -61,11 +61,13 @@
|
||||
"cmdk": "1.0.4",
|
||||
"date-fns": "4.1.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"framer-motion": "^12.34.3",
|
||||
"fs-extra": "11.2.0",
|
||||
"input-otp": "1.4.1",
|
||||
"keen-slider": "6.8.6",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-draw": "^1.0.4",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mapbox-gl": "3.9.0",
|
||||
"negotiator": "1.0.0",
|
||||
"next": "15.1.2",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Components Imports
|
||||
import { FarmAiAssistantChat } from '@views/dashboards/farm/farmAiAssistant'
|
||||
|
||||
const FarmAiAssistantPage = () => {
|
||||
return <FarmAiAssistantChat />
|
||||
}
|
||||
|
||||
export default FarmAiAssistantPage
|
||||
@@ -0,0 +1,8 @@
|
||||
// Components Imports
|
||||
import SmartFertilizationRecommendation from '@views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation'
|
||||
|
||||
const FertilizationRecommendationPage = async () => {
|
||||
return <SmartFertilizationRecommendation />
|
||||
}
|
||||
|
||||
export default FertilizationRecommendationPage
|
||||
@@ -0,0 +1,8 @@
|
||||
// Components Imports
|
||||
import SmartIrrigationRecommendation from '@views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation'
|
||||
|
||||
const IrrigationRecommendationPage = async () => {
|
||||
return <SmartIrrigationRecommendation />
|
||||
}
|
||||
|
||||
export default IrrigationRecommendationPage
|
||||
@@ -0,0 +1,8 @@
|
||||
// Components Imports
|
||||
import PlantPestDetection from '@views/dashboards/farm/pestDetection/PlantPestDetection'
|
||||
|
||||
const PestDetectionPage = async () => {
|
||||
return <PlantPestDetection />
|
||||
}
|
||||
|
||||
export default PestDetectionPage
|
||||
@@ -146,3 +146,40 @@ code {
|
||||
background-color: rgb(var(--mui-palette-info-mainChannel) / 0.08);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Smart Irrigation - fade-in animation */
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Hide horizontal scrollbar for growth stage stepper */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Farm AI Assistant - typing indicator bounce */
|
||||
@keyframes typing-bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,18 @@ const VerticalMenu = ({ scrollMenu }: Props) => {
|
||||
<MenuItem href="/dashboard/plant-simulator" icon={<i className="tabler-flower" />}>
|
||||
{t('plantSimulator')}
|
||||
</MenuItem>
|
||||
<MenuItem href="/dashboard/irrigation-recommendation" icon={<i className="tabler-droplet-half-2" />}>
|
||||
{t('irrigationRecommendation')}
|
||||
</MenuItem>
|
||||
<MenuItem href="/dashboard/fertilization-recommendation" icon={<i className="tabler-atom-2" />}>
|
||||
{t('fertilizationRecommendation')}
|
||||
</MenuItem>
|
||||
<MenuItem href="/dashboard/farm-ai-assistant" icon={<i className="tabler-robot" />}>
|
||||
{t('farmAiAssistant')}
|
||||
</MenuItem>
|
||||
<MenuItem href="/dashboard/pest-detection" icon={<i className="tabler-bug" />}>
|
||||
{t('pestDetection')}
|
||||
</MenuItem>
|
||||
</MenuSection>
|
||||
|
||||
</Menu>
|
||||
|
||||
@@ -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: '15–20 L per plant',
|
||||
timing: 'Early morning (05:00–07: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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface FarmContext {
|
||||
soilType: string
|
||||
waterEC: string
|
||||
selectedCrop: string
|
||||
growthStage: string
|
||||
lastIrrigationStatus: string
|
||||
}
|
||||
|
||||
export interface SuggestionChip {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// Structured AI response sections for card-based rendering
|
||||
export interface AIResponseSection {
|
||||
type: 'text' | 'list' | 'recommendation' | 'warning'
|
||||
title?: string
|
||||
content?: string
|
||||
items?: string[]
|
||||
icon?: 'droplet' | 'leaf' | 'warning' | 'fertilizer' | 'calendar'
|
||||
// Recommendation-specific
|
||||
frequency?: string
|
||||
amount?: string
|
||||
timing?: string
|
||||
expandableExplanation?: string
|
||||
}
|
||||
|
||||
export interface FarmAIMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
images?: string[]
|
||||
// For structured AI responses
|
||||
sections?: AIResponseSection[]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as FarmAiAssistantChat } from './FarmAiAssistantChat'
|
||||
export * from './farmAiAssistantTypes'
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Button from '@mui/material/Button'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
import UploadBox from './components/UploadBox'
|
||||
import ResultCard from './components/ResultCard'
|
||||
import type { UploadedFile } from './components/UploadBox'
|
||||
import type { PestResult } from './components/ResultCard'
|
||||
|
||||
export default function PlantPestDetection() {
|
||||
const t = useTranslations('pestDetection')
|
||||
const [file, setFile] = useState<UploadedFile | null>(null)
|
||||
const [result, setResult] = useState<PestResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleFileSelect = useCallback((newFile: UploadedFile | null) => {
|
||||
setFile(newFile)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const handleAnalyze = useCallback(() => {
|
||||
if (!file) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
const delay = 1500 + Math.random() * 1000
|
||||
setTimeout(() => {
|
||||
setResult({
|
||||
pest: t('mockResult.pest'),
|
||||
confidence: 92,
|
||||
description: t('mockResult.description'),
|
||||
treatment: t('mockResult.treatment'),
|
||||
})
|
||||
setLoading(false)
|
||||
}, delay)
|
||||
}, [file, t])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (file) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
setFile(null)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
}, [file])
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="min-bs-screen pb-24"
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<Box className="max-w-2xl mx-auto px-4 py-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<Box className="mb-8">
|
||||
<Typography
|
||||
variant="h4"
|
||||
className="font-bold tracking-tight"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 50%, #15803d 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
fontSize: { xs: '1.5rem', sm: '1.75rem' },
|
||||
}}
|
||||
>
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Upload card */}
|
||||
<Card
|
||||
elevation={0}
|
||||
className="mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg"
|
||||
sx={{
|
||||
borderRadius: '24px',
|
||||
background: 'linear-gradient(145deg, #ffffff 0%, #f8fcf8 100%)',
|
||||
boxShadow: '0 4px 24px rgba(34, 197, 94, 0.08), 0 1px 3px rgba(0,0,0,0.04)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.12)',
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-5 sm:p-6">
|
||||
<UploadBox
|
||||
file={file}
|
||||
onFileSelect={handleFileSelect}
|
||||
onError={setError}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<Box className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!file || loading}
|
||||
onClick={handleAnalyze}
|
||||
startIcon={
|
||||
loading ? (
|
||||
<CircularProgress size={20} color="inherit" sx={{ color: 'white' }} />
|
||||
) : (
|
||||
<i className="tabler-scan text-xl" />
|
||||
)
|
||||
}
|
||||
className="rounded-xl py-3 px-8 font-semibold shadow-md transition-all duration-300 hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 50%, #15803d 100%)',
|
||||
boxShadow: '0 4px 20px rgba(34, 197, 94, 0.35)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #4ade80 0%, #22c55e 50%, #16a34a 100%)',
|
||||
boxShadow: '0 6px 24px rgba(34, 197, 94, 0.45)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'action.disabledBackground',
|
||||
color: 'action.disabled',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? t('analyzing') : t('analyze')}
|
||||
</Button>
|
||||
|
||||
{file && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleReset}
|
||||
disabled={loading}
|
||||
startIcon={<i className="tabler-rotate-2 text-lg" />}
|
||||
className="rounded-xl py-3 px-8 font-semibold"
|
||||
sx={{
|
||||
borderColor: 'divider',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('reset')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Loading state */}
|
||||
<Collapse in={loading}>
|
||||
<Card
|
||||
elevation={0}
|
||||
className="mb-6"
|
||||
sx={{
|
||||
borderRadius: '24px',
|
||||
background: 'linear-gradient(160deg, #ffffff 0%, #f0fdf4 100%)',
|
||||
boxShadow: '0 8px 32px rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.12)',
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-12 flex flex-col items-center gap-4">
|
||||
<CircularProgress size={48} sx={{ color: '#22c55e' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('analyzing')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Collapse>
|
||||
|
||||
{/* Result card */}
|
||||
<Collapse in={!!result && !loading}>
|
||||
{result && !loading && (
|
||||
<Box className="mb-6">
|
||||
<Typography variant="subtitle2" fontWeight={600} color="text.secondary" className="mbe-3">
|
||||
{t('resultTitle')}
|
||||
</Typography>
|
||||
<ResultCard result={result} />
|
||||
</Box>
|
||||
)}
|
||||
</Collapse>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import LinearProgress from '@mui/material/LinearProgress'
|
||||
import CustomAvatar from '@core/components/mui/Avatar'
|
||||
|
||||
export interface PestResult {
|
||||
pest: string
|
||||
confidence: number
|
||||
description: string
|
||||
treatment: string
|
||||
}
|
||||
|
||||
interface ResultCardProps {
|
||||
result: PestResult
|
||||
}
|
||||
|
||||
function getConfidenceColor(confidence: number): 'success' | 'warning' | 'error' {
|
||||
if (confidence > 80) return 'success'
|
||||
if (confidence >= 50) return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
export default function ResultCard({ result }: ResultCardProps) {
|
||||
const t = useTranslations('pestDetection.resultCard')
|
||||
const color = getConfidenceColor(result.confidence)
|
||||
|
||||
return (
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: '24px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: `${color}.main`,
|
||||
background: (theme) =>
|
||||
`linear-gradient(160deg, ${theme.palette.background.paper} 0%, ${theme.palette[color].lighterOpacity} 100%)`,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-5 sm:p-6">
|
||||
{/* Pest name & icon */}
|
||||
<Box className="flex items-center gap-3 mbe-4">
|
||||
<CustomAvatar
|
||||
variant="rounded"
|
||||
skin="filled"
|
||||
color={color}
|
||||
sx={{ width: 48, height: 48 }}
|
||||
>
|
||||
<i className="tabler-bug text-2xl" />
|
||||
</CustomAvatar>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={700} color="text.primary">
|
||||
{result.pest}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t('detectedPest')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Confidence bar */}
|
||||
<Box className="mbe-5">
|
||||
<Box className="flex justify-between mbe-2">
|
||||
<Typography variant="caption" fontWeight={600} color="text.secondary">
|
||||
{t('confidence')}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={700} color={color}>
|
||||
{result.confidence}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={result.confidence}
|
||||
color={color}
|
||||
sx={{
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 5,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<Box className="mbe-5">
|
||||
<Box className="flex items-center gap-2 mbe-2">
|
||||
<i className="tabler-leaf text-lg text-success" />
|
||||
<Typography variant="caption" fontWeight={600} color="text.secondary">
|
||||
{t('description')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.primary">
|
||||
{result.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Treatment */}
|
||||
<Box>
|
||||
<Box className="flex items-center gap-2 mbe-2">
|
||||
<i className="tabler-heart-rate-monitor text-lg text-primary" />
|
||||
<Typography variant="caption" fontWeight={600} color="text.secondary">
|
||||
{t('recommendedTreatment')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.primary">
|
||||
{result.treatment}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Button from '@mui/material/Button'
|
||||
import { styled } from '@mui/material/styles'
|
||||
import type { BoxProps } from '@mui/material/Box'
|
||||
import CustomAvatar from '@core/components/mui/Avatar'
|
||||
import AppReactDropzone from '@/libs/styles/AppReactDropzone'
|
||||
|
||||
export interface UploadedFile {
|
||||
file: File
|
||||
preview: string
|
||||
}
|
||||
|
||||
interface UploadBoxProps {
|
||||
file: UploadedFile | null
|
||||
onFileSelect: (file: UploadedFile | null) => void
|
||||
onError?: (message: string | null) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
const DropzoneWrapper = styled(AppReactDropzone)<BoxProps>(({ theme }) => ({
|
||||
'& .dropzone': {
|
||||
minHeight: 200,
|
||||
padding: theme.spacing(4, 3),
|
||||
borderRadius: '16px',
|
||||
border: '2px dashed',
|
||||
borderColor: 'var(--mui-palette-divider)',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
borderColor: 'var(--mui-palette-primary-main)',
|
||||
backgroundColor: 'var(--mui-palette-primary-lighterOpacity)',
|
||||
},
|
||||
},
|
||||
'& .dropzone.active': {
|
||||
borderColor: 'var(--mui-palette-primary-main)',
|
||||
backgroundColor: 'var(--mui-palette-primary-lighterOpacity)',
|
||||
},
|
||||
'& .dropzone.hasFile': {
|
||||
borderColor: 'var(--mui-palette-primary-main)',
|
||||
minHeight: 'unset',
|
||||
padding: theme.spacing(3),
|
||||
},
|
||||
'& .dropzone.error': {
|
||||
borderColor: 'var(--mui-palette-error-main)',
|
||||
backgroundColor: 'var(--mui-palette-error-lighterOpacity)',
|
||||
},
|
||||
}))
|
||||
|
||||
export default function UploadBox({ file, onFileSelect, onError, error }: UploadBoxProps) {
|
||||
const t = useTranslations('pestDetection.upload')
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop: (acceptedFiles) => {
|
||||
onError?.(null)
|
||||
const validFile = acceptedFiles[0]
|
||||
if (validFile) {
|
||||
onFileSelect({
|
||||
file: validFile,
|
||||
preview: URL.createObjectURL(validFile),
|
||||
})
|
||||
} else {
|
||||
onFileSelect(null)
|
||||
}
|
||||
},
|
||||
onDropRejected: () => {
|
||||
onError?.(t('invalidFile'))
|
||||
onFileSelect(null)
|
||||
},
|
||||
accept: {
|
||||
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'],
|
||||
},
|
||||
maxFiles: 1,
|
||||
maxSize: 10 * 1024 * 1024, // 10MB
|
||||
})
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (file) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
onFileSelect(null)
|
||||
}
|
||||
}
|
||||
|
||||
const dropzoneClass = [
|
||||
'dropzone',
|
||||
isDragActive && 'active',
|
||||
file && 'hasFile',
|
||||
error && 'error',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<DropzoneWrapper>
|
||||
<div {...getRootProps({ className: dropzoneClass })}>
|
||||
<input {...getInputProps()} aria-label={t('ariaLabel')} />
|
||||
{file ? (
|
||||
<Box className="flex flex-col items-center gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
<Box
|
||||
className="relative shrink-0 overflow-hidden rounded-xl"
|
||||
sx={{
|
||||
width: { xs: 140, sm: 160 },
|
||||
height: { xs: 140, sm: 160 },
|
||||
borderRadius: '12px',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={file.preview}
|
||||
alt={file.file.name}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box className="flex flex-1 flex-col items-center sm:items-start text-center sm:text-start min-w-0">
|
||||
<Typography variant="body2" fontWeight={600} color="text.primary" noWrap sx={{ maxWidth: '100%' }}>
|
||||
{file.file.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{(file.file.size / 1024).toFixed(1)} KB
|
||||
</Typography>
|
||||
<Button
|
||||
variant="tonal"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleRemove}
|
||||
startIcon={<i className="tabler-trash text-base" />}
|
||||
className="mt-2"
|
||||
sx={{ alignSelf: { sm: 'flex-start' } }}
|
||||
>
|
||||
{t('remove')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="flex flex-col items-center gap-3 text-center">
|
||||
<CustomAvatar
|
||||
variant="rounded"
|
||||
skin="light"
|
||||
color="success"
|
||||
className="w-16 h-16"
|
||||
sx={{ width: 64, height: 64 }}
|
||||
>
|
||||
<i className={`${isDragActive ? 'tabler-photo-down' : 'tabler-upload'} text-3xl`} />
|
||||
</CustomAvatar>
|
||||
<Box>
|
||||
<Typography variant="body1" fontWeight={600} color="text.primary">
|
||||
{isDragActive ? t('dropHere') : t('dragDrop')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" className="mt-1 block">
|
||||
{t('fileFormats')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="tonal" size="small" color="success">
|
||||
{t('dragDrop')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<Typography variant="caption" color="error" className="mt-2 block" role="alert">
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</DropzoneWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Button from '@mui/material/Button'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
|
||||
// Types
|
||||
interface FarmData {
|
||||
soilType: string
|
||||
organicMatter: string
|
||||
waterEC: string
|
||||
}
|
||||
|
||||
interface GrowthStage {
|
||||
id: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface CropOption {
|
||||
id: string
|
||||
labelKey: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface FertilizationPlan {
|
||||
npkRatio: string
|
||||
amountPerHectare: string
|
||||
applicationMethod: string
|
||||
applicationInterval: string
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
// Mock farm data (from stored soil/water data - no inputs)
|
||||
const DEFAULT_FARM_DATA: FarmData = {
|
||||
soilType: 'Loamy',
|
||||
organicMatter: 'Medium (2.5%)',
|
||||
waterEC: '1.2 dS/m'
|
||||
}
|
||||
|
||||
const GROWTH_STAGES: GrowthStage[] = [
|
||||
{ id: 'prePlanting', icon: 'tabler-seedling' },
|
||||
{ id: 'earlyGrowth', icon: 'tabler-leaf' },
|
||||
{ id: 'flowering', icon: 'tabler-flower' },
|
||||
{ id: 'fruiting', icon: 'tabler-apple' },
|
||||
{ id: 'postHarvest', icon: 'tabler-basket' }
|
||||
]
|
||||
|
||||
const CROP_OPTIONS: CropOption[] = [
|
||||
{ id: 'wheat', labelKey: 'wheat', icon: 'tabler-wheat' },
|
||||
{ id: 'corn', labelKey: 'corn', icon: 'tabler-plant-2' },
|
||||
{ id: 'cotton', labelKey: 'cotton', icon: 'tabler-flower' },
|
||||
{ id: 'saffron', labelKey: 'saffron', icon: 'tabler-flower-2' },
|
||||
{ id: 'canola', labelKey: 'canola', icon: 'tabler-leaf' },
|
||||
{ id: 'vegetables', labelKey: 'vegetables', icon: 'tabler-carrot' }
|
||||
]
|
||||
|
||||
// Mock plan generator (replace with API in production)
|
||||
function generateFertilizationPlan(
|
||||
_cropId: string,
|
||||
_growthStageId: string,
|
||||
_farmData: FarmData
|
||||
): FertilizationPlan {
|
||||
return {
|
||||
npkRatio: '20-20-20 (NPK)',
|
||||
amountPerHectare: '150 kg/ha',
|
||||
applicationMethod: 'Foliar spray + soil broadcast',
|
||||
applicationInterval: 'Every 14 days',
|
||||
reasoning:
|
||||
'Your loamy soil with medium organic matter (2.5%) provides good nutrient retention. Water EC of 1.2 dS/m indicates low salinity—suitable for most crops. At the flowering stage, increased phosphorus supports bloom development. We recommend a balanced NPK to maintain nitrogen for vegetative growth while boosting phosphorous for flowering.'
|
||||
}
|
||||
}
|
||||
|
||||
export default function SmartFertilizationRecommendation() {
|
||||
const t = useTranslations('fertilization')
|
||||
const [farmData] = useState<FarmData>(DEFAULT_FARM_DATA)
|
||||
const [growthStage, setGrowthStage] = useState<string>(GROWTH_STAGES[0].id)
|
||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null)
|
||||
const [plan, setPlan] = useState<FertilizationPlan | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [reasoningExpanded, setReasoningExpanded] = useState(false)
|
||||
|
||||
const handleGenerate = () => {
|
||||
if (!selectedCrop) return
|
||||
setLoading(true)
|
||||
setPlan(null)
|
||||
setReasoningExpanded(false)
|
||||
setTimeout(() => {
|
||||
setPlan(
|
||||
generateFertilizationPlan(selectedCrop, growthStage, farmData)
|
||||
)
|
||||
setLoading(false)
|
||||
}, 1400)
|
||||
}
|
||||
|
||||
const stageIndex = GROWTH_STAGES.findIndex(s => s.id === growthStage)
|
||||
|
||||
return (
|
||||
<Box
|
||||
className='min-bs-screen pb-28'
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(165deg, #f0fdf4 0%, #ecfdf5 25%, #faf5ff 60%, var(--mui-palette-background-default) 100%)',
|
||||
minHeight: '100vh'
|
||||
}}
|
||||
>
|
||||
<Box className='max-w-lg mx-auto px-4 py-6 sm:py-8'>
|
||||
{/* 1) Header */}
|
||||
<Box className='mb-8'>
|
||||
<Typography
|
||||
variant='h4'
|
||||
className='font-bold tracking-tight'
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(135deg, #16a34a 0%, #22c55e 40%, #7c3aed 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
fontSize: { xs: '1.5rem', sm: '1.75rem' }
|
||||
}}
|
||||
>
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
className='mt-1 transition-colors duration-300'
|
||||
>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 2) Farm Data Card */}
|
||||
<Card
|
||||
elevation={0}
|
||||
className='mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg animate-fade-in'
|
||||
sx={{
|
||||
borderRadius: '28px',
|
||||
background:
|
||||
'linear-gradient(145deg, #ffffff 0%, #faf5ff 50%, #f0fdf4 100%)',
|
||||
boxShadow:
|
||||
'0 4px 24px rgba(34, 197, 94, 0.08), 0 4px 12px rgba(124, 58, 237, 0.04), 0 1px 3px rgba(0,0,0,0.04)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.12)'
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-5'>
|
||||
<Box className='flex items-center justify-between mbe-4'>
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary'>
|
||||
{t('farmData.title')}
|
||||
</Typography>
|
||||
<Box
|
||||
className='px-2.5 py-1 rounded-full text-xs font-medium flex items-center gap-1.5'
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
||||
color: 'white',
|
||||
boxShadow: '0 2px 8px rgba(34, 197, 94, 0.3)'
|
||||
}}
|
||||
>
|
||||
<i className='tabler-circle-check text-sm' />
|
||||
{t('verifiedBadge')}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className='flex flex-wrap gap-3'>
|
||||
<FarmBadge icon='tabler-seedling' label={t('farmData.soilType')} value={farmData.soilType} />
|
||||
<FarmBadge
|
||||
icon='tabler-atom-2'
|
||||
label={t('farmData.organicMatter')}
|
||||
value={farmData.organicMatter}
|
||||
/>
|
||||
<FarmBadge icon='tabler-droplet' label={t('farmData.waterEC')} value={farmData.waterEC} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3) Growth Stage Selector */}
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
||||
{t('growthStage.title')}
|
||||
</Typography>
|
||||
<Box className='flex gap-2 mb-6 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-hide'>
|
||||
{GROWTH_STAGES.map((stage, idx) => {
|
||||
const isSelected = growthStage === stage.id
|
||||
const isPast = idx < stageIndex
|
||||
return (
|
||||
<Box
|
||||
key={stage.id}
|
||||
component='button'
|
||||
type='button'
|
||||
onClick={() => setGrowthStage(stage.id)}
|
||||
className='flex flex-col items-center gap-1.5 px-4 py-3 rounded-2xl shrink-0 transition-all duration-300 ease-out border-2 cursor-pointer min-w-[72px]'
|
||||
sx={{
|
||||
borderColor: isSelected ? '#22c55e' : 'transparent',
|
||||
background: isSelected
|
||||
? 'linear-gradient(145deg, rgba(34, 197, 94, 0.15) 0%, rgba(124, 58, 237, 0.06) 100%)'
|
||||
: 'linear-gradient(145deg, #ffffff 0%, #faf5ff 100%)',
|
||||
boxShadow: isSelected
|
||||
? '0 4px 20px rgba(34, 197, 94, 0.2), inset 0 1px 0 rgba(255,255,255,0.8)'
|
||||
: '0 2px 8px rgba(0,0,0,0.04)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: isSelected
|
||||
? '0 6px 24px rgba(34, 197, 94, 0.25)'
|
||||
: '0 4px 16px rgba(124, 58, 237, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className='w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300'
|
||||
sx={{
|
||||
background: isSelected
|
||||
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
|
||||
: isPast
|
||||
? 'linear-gradient(145deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.1) 100%)'
|
||||
: 'rgba(124, 58, 237, 0.08)'
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className={`${stage.icon} text-xl transition-colors duration-300 ${
|
||||
isSelected ? 'text-white' : isPast ? 'text-emerald-600' : 'text-violet-500'
|
||||
}`}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant='caption'
|
||||
fontWeight={600}
|
||||
sx={{
|
||||
color: isSelected ? '#16a34a' : 'text.secondary',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2
|
||||
}}
|
||||
>
|
||||
{t(`growthStage.${stage.id}`)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* 4) Plant Selection */}
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
||||
{t('plantSelection.title')}
|
||||
</Typography>
|
||||
<Box className='flex flex-wrap gap-3 mb-8'>
|
||||
{CROP_OPTIONS.map(crop => (
|
||||
<CropCard
|
||||
key={crop.id}
|
||||
crop={crop}
|
||||
label={t(`crops.${crop.labelKey}`)}
|
||||
selected={selectedCrop === crop.id}
|
||||
onClick={() =>
|
||||
setSelectedCrop(prev => (prev === crop.id ? null : crop.id))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 6) Result Section - Prescription style */}
|
||||
{plan && (
|
||||
<Box className='mb-6 animate-fade-in'>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: '28px',
|
||||
background:
|
||||
'linear-gradient(160deg, #ffffff 0%, #faf5ff 40%, #f0fdf4 100%)',
|
||||
boxShadow:
|
||||
'0 8px 32px rgba(34, 197, 94, 0.12), 0 4px 16px rgba(124, 58, 237, 0.06), 0 2px 8px rgba(0,0,0,0.04)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.15)',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-6'>
|
||||
<Box className='flex items-center gap-2 mbe-5'>
|
||||
<i className='tabler-prescription text-2xl text-emerald-600' />
|
||||
<Typography variant='h6' fontWeight={700} color='text.primary'>
|
||||
{t('result.title')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box className='space-y-3'>
|
||||
<PrescriptionRow
|
||||
icon='tabler-atom-2'
|
||||
label={t('result.fertilizerType')}
|
||||
value={plan.npkRatio}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon='tabler-scale'
|
||||
label={t('result.amountPerHectare')}
|
||||
value={plan.amountPerHectare}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon='tabler-spray'
|
||||
label={t('result.applicationMethod')}
|
||||
value={plan.applicationMethod}
|
||||
/>
|
||||
<PrescriptionRow
|
||||
icon='tabler-calendar-repeat'
|
||||
label={t('result.applicationInterval')}
|
||||
value={plan.applicationInterval}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Expandable "Why this recommendation?" */}
|
||||
<Box
|
||||
className='mt-5 rounded-2xl overflow-hidden transition-all duration-300'
|
||||
sx={{
|
||||
border: '1px solid rgba(34, 197, 94, 0.15)',
|
||||
background: 'rgba(34, 197, 94, 0.04)'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='button'
|
||||
type='button'
|
||||
onClick={() => setReasoningExpanded(!reasoningExpanded)}
|
||||
className='w-full flex items-center justify-between px-4 py-3 text-start cursor-pointer'
|
||||
sx={{ '&:hover': { bgcolor: 'rgba(34, 197, 94, 0.06)' } }}
|
||||
>
|
||||
<Box className='flex items-center gap-2'>
|
||||
<i className='tabler-brain text-lg text-emerald-600' />
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.primary'>
|
||||
{t('result.whyRecommendation')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<i
|
||||
className={`tabler-chevron-down text-xl text-emerald-600 transition-transform duration-300 ${
|
||||
reasoningExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</Box>
|
||||
<Collapse in={reasoningExpanded}>
|
||||
<Box className='px-4 pb-4'>
|
||||
<Typography
|
||||
variant='body2'
|
||||
color='text.secondary'
|
||||
sx={{ lineHeight: 1.7 }}
|
||||
>
|
||||
{plan.reasoning}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Card
|
||||
elevation={0}
|
||||
className='mb-6 animate-fade-in'
|
||||
sx={{
|
||||
borderRadius: '28px',
|
||||
background:
|
||||
'linear-gradient(160deg, #ffffff 0%, #f0fdf4 100%)',
|
||||
boxShadow: '0 8px 32px rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.12)'
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-12 flex flex-col items-center gap-4'>
|
||||
<Box
|
||||
className='w-14 h-14 rounded-2xl flex items-center justify-center animate-pulse'
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(34, 197, 94, 0.15) 0%, rgba(124, 58, 237, 0.08) 100%)'
|
||||
}}
|
||||
>
|
||||
<i className='tabler-sparkles text-2xl text-emerald-600' />
|
||||
</Box>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{t('generating')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 5) Primary CTA Button - Sticky */}
|
||||
<Box
|
||||
className='fixed bottom-0 start-0 end-0 p-4 z-10'
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(to top, rgba(255,255,255,0.98) 0%, rgba(255,255,255,0.9) 70%, transparent 100%)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
variant='contained'
|
||||
disabled={!selectedCrop || loading}
|
||||
onClick={handleGenerate}
|
||||
startIcon={<i className='tabler-sparkles text-xl' />}
|
||||
className='rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]'
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(135deg, #22c55e 0%, #16a34a 50%, #15803d 100%)',
|
||||
boxShadow: '0 4px 20px rgba(34, 197, 94, 0.4)',
|
||||
'&:hover': {
|
||||
background:
|
||||
'linear-gradient(135deg, #4ade80 0%, #22c55e 50%, #16a34a 100%)',
|
||||
boxShadow: '0 6px 28px rgba(34, 197, 94, 0.5)'
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'action.disabledBackground',
|
||||
color: 'action.disabled'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('generateCta')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function FarmBadge({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
className='flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-all duration-200 hover:scale-[1.02] hover:shadow-md'
|
||||
sx={{
|
||||
background:
|
||||
'linear-gradient(145deg, rgba(34, 197, 94, 0.1) 0%, rgba(124, 58, 237, 0.04) 100%)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.15)',
|
||||
boxShadow: 'inset 0 1px 2px rgba(255,255,255,0.5)'
|
||||
}}
|
||||
>
|
||||
<i className={`${icon} text-xl text-emerald-600`} />
|
||||
<Box>
|
||||
<Typography variant='caption' color='text.secondary' display='block' lineHeight={1.2}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant='body2' fontWeight={600} color='text.primary'>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function CropCard({
|
||||
crop,
|
||||
label,
|
||||
selected,
|
||||
onClick
|
||||
}: {
|
||||
crop: CropOption
|
||||
label: string
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
component='button'
|
||||
type='button'
|
||||
elevation={0}
|
||||
onClick={onClick}
|
||||
className='flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer transition-all duration-300 border-2 text-start'
|
||||
sx={{
|
||||
borderColor: selected ? '#22c55e' : 'transparent',
|
||||
background: selected
|
||||
? 'linear-gradient(145deg, rgba(34, 197, 94, 0.15) 0%, rgba(124, 58, 237, 0.06) 100%)'
|
||||
: 'linear-gradient(145deg, #ffffff 0%, #faf5ff 100%)',
|
||||
boxShadow: selected
|
||||
? '0 4px 20px rgba(34, 197, 94, 0.2), inset 0 1px 0 rgba(255,255,255,0.8)'
|
||||
: '0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: selected
|
||||
? '0 6px 24px rgba(34, 197, 94, 0.25)'
|
||||
: '0 4px 16px rgba(34, 197, 94, 0.12)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className='w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-all duration-300'
|
||||
sx={{
|
||||
background: selected
|
||||
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
|
||||
: 'linear-gradient(145deg, rgba(34, 197, 94, 0.1) 0%, rgba(124, 58, 237, 0.05) 100%)'
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className={`${crop.icon} text-xl ${selected ? 'text-white' : 'text-emerald-600'}`}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant='body2'
|
||||
fontWeight={600}
|
||||
color={selected ? '#16a34a' : 'text.primary'}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
{selected && (
|
||||
<i className='tabler-circle-check-filled text-xl text-emerald-600 ms-auto' />
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function PrescriptionRow({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
className='flex items-center gap-4 p-3 rounded-2xl transition-colors duration-200'
|
||||
sx={{
|
||||
background: 'rgba(34, 197, 94, 0.06)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.08)'
|
||||
}}
|
||||
>
|
||||
<i className={`${icon} text-2xl text-emerald-600 shrink-0`} />
|
||||
<Box className='flex-1 min-w-0'>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant='body1' fontWeight={600} color='text.primary'>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Button from '@mui/material/Button'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
|
||||
// Types
|
||||
interface FarmInfo {
|
||||
soilType: string
|
||||
waterQuality: string
|
||||
climateZone: string
|
||||
}
|
||||
|
||||
interface CropOption {
|
||||
id: string
|
||||
labelKey: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface IrrigationPlan {
|
||||
frequencyPerWeek: number
|
||||
durationMinutes: number
|
||||
bestTimeOfDay: string
|
||||
moistureLevel: number // 0-100
|
||||
warning?: string
|
||||
}
|
||||
|
||||
// Mock farm data (replace with API/store in production)
|
||||
const DEFAULT_FARM_INFO: FarmInfo = {
|
||||
soilType: 'Loamy',
|
||||
waterQuality: 'Medium EC',
|
||||
climateZone: 'Temperate'
|
||||
}
|
||||
|
||||
const CROP_OPTIONS: CropOption[] = [
|
||||
{ id: 'wheat', labelKey: 'wheat', icon: 'tabler-wheat' },
|
||||
{ id: 'corn', labelKey: 'corn', icon: 'tabler-plant-2' },
|
||||
{ id: 'cotton', labelKey: 'cotton', icon: 'tabler-flower' },
|
||||
{ id: 'saffron', labelKey: 'saffron', icon: 'tabler-flower-2' },
|
||||
{ id: 'canola', labelKey: 'canola', icon: 'tabler-leaf' },
|
||||
{ id: 'vegetables', labelKey: 'vegetables', icon: 'tabler-carrot' }
|
||||
]
|
||||
|
||||
// Mock plan generator (replace with API in production)
|
||||
function generateIrrigationPlan(_cropId: string, _farmInfo: FarmInfo): IrrigationPlan {
|
||||
return {
|
||||
frequencyPerWeek: 4,
|
||||
durationMinutes: 45,
|
||||
bestTimeOfDay: '05:00 - 07:00',
|
||||
moistureLevel: 72,
|
||||
warning: 'Avoid irrigation during midday hours in the coming week due to forecasted high temperatures.'
|
||||
}
|
||||
}
|
||||
|
||||
export default function SmartIrrigationRecommendation() {
|
||||
const t = useTranslations('irrigation')
|
||||
const [farmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO)
|
||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null)
|
||||
const [plan, setPlan] = useState<IrrigationPlan | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleGenerate = () => {
|
||||
if (!selectedCrop) return
|
||||
setLoading(true)
|
||||
setPlan(null)
|
||||
// Simulate API delay
|
||||
setTimeout(() => {
|
||||
setPlan(generateIrrigationPlan(selectedCrop, farmInfo))
|
||||
setLoading(false)
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className='min-bs-screen pb-24'
|
||||
sx={{
|
||||
background: 'linear-gradient(165deg, #e0f2fe 0%, #f0f9ff 35%, #f8fcff 70%, var(--mui-palette-background-default) 100%)',
|
||||
minHeight: '100vh'
|
||||
}}
|
||||
>
|
||||
<Box className='max-w-lg mx-auto px-4 py-6 sm:py-8'>
|
||||
{/* 1) Dynamic Header */}
|
||||
<Box className='mb-8'>
|
||||
<Typography
|
||||
variant='h4'
|
||||
className='font-bold tracking-tight'
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 50%, #0369a1 100%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
fontSize: { xs: '1.5rem', sm: '1.75rem' }
|
||||
}}
|
||||
>
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary' className='mt-1'>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 2) Farm Info Card */}
|
||||
<Card
|
||||
elevation={0}
|
||||
className='mb-6 overflow-hidden transition-all duration-300 hover:shadow-lg'
|
||||
sx={{
|
||||
borderRadius: '24px',
|
||||
background: 'linear-gradient(145deg, #ffffff 0%, #f8fcff 100%)',
|
||||
boxShadow: '0 4px 24px rgba(14, 165, 233, 0.08), 0 1px 3px rgba(0,0,0,0.04)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.12)'
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-5'>
|
||||
<Box className='flex items-center justify-between mbe-4'>
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary'>
|
||||
{t('farmInfo.title')}
|
||||
</Typography>
|
||||
<Box className='flex items-center gap-2'>
|
||||
<Box
|
||||
className='px-2.5 py-1 rounded-full text-xs font-medium'
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
<i className='tabler-circle-check text-sm' />
|
||||
{t('verifiedBadge')}
|
||||
</Box>
|
||||
<IconButton size='small' sx={{ color: 'text.secondary' }} aria-label={t('editFarmInfo')}>
|
||||
<i className='tabler-pencil text-lg' />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className='flex flex-wrap gap-3'>
|
||||
<FarmBadge icon='tabler-seedling' label={t('farmInfo.soilType')} value={farmInfo.soilType} />
|
||||
<FarmBadge icon='tabler-droplet' label={t('farmInfo.waterQuality')} value={farmInfo.waterQuality} />
|
||||
<FarmBadge icon='tabler-temperature' label={t('farmInfo.climateZone')} value={farmInfo.climateZone} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3) Plant Selection Section */}
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
||||
{t('plantSelection.title')}
|
||||
</Typography>
|
||||
<Box className='flex flex-wrap gap-3 mb-8'>
|
||||
{CROP_OPTIONS.map(crop => (
|
||||
<CropCard
|
||||
key={crop.id}
|
||||
crop={crop}
|
||||
label={t(`crops.${crop.labelKey}`)}
|
||||
selected={selectedCrop === crop.id}
|
||||
onClick={() => setSelectedCrop(prev => (prev === crop.id ? null : crop.id))}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 5) Result Card (after click) */}
|
||||
{plan && (
|
||||
<Box className='mb-6 animate-fade-in'>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: '24px',
|
||||
background: 'linear-gradient(160deg, #ffffff 0%, #f0f9ff 100%)',
|
||||
boxShadow: '0 8px 32px rgba(14, 165, 233, 0.15), 0 2px 8px rgba(0,0,0,0.06)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.18)',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-6'>
|
||||
{/* Circular moisture indicator */}
|
||||
<Box className='flex justify-center mbe-6'>
|
||||
<Box className='relative'>
|
||||
<svg width={120} height={120} className='-rotate-90'>
|
||||
<circle
|
||||
cx={60}
|
||||
cy={60}
|
||||
r={52}
|
||||
fill='none'
|
||||
stroke='rgba(14, 165, 233, 0.12)'
|
||||
strokeWidth={10}
|
||||
/>
|
||||
<circle
|
||||
cx={60}
|
||||
cy={60}
|
||||
r={52}
|
||||
fill='none'
|
||||
stroke='url(#moistureGradient)'
|
||||
strokeWidth={10}
|
||||
strokeLinecap='round'
|
||||
strokeDasharray={`${(plan.moistureLevel / 100) * 327} 327`}
|
||||
className='transition-all duration-1000 ease-out'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id='moistureGradient' x1='0%' y1='0%' x2='100%' y2='100%'>
|
||||
<stop offset='0%' stopColor='#0ea5e9' />
|
||||
<stop offset='100%' stopColor='#0284c7' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<Box
|
||||
className='absolute inset-0 flex flex-col items-center justify-center'
|
||||
sx={{ top: 0, left: 0, right: 0, bottom: 0 }}
|
||||
>
|
||||
<i className='tabler-droplet text-3xl text-[#0ea5e9] mbe-0.5' />
|
||||
<Typography variant='h4' fontWeight={700} color='primary.main'>
|
||||
{plan.moistureLevel}%
|
||||
</Typography>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{t('result.moistureLevel')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className='space-y-4'>
|
||||
<ResultRow
|
||||
icon='tabler-calendar-week'
|
||||
label={t('result.frequency')}
|
||||
value={`${plan.frequencyPerWeek} ${t('result.timesPerWeek')}`}
|
||||
/>
|
||||
<ResultRow
|
||||
icon='tabler-clock'
|
||||
label={t('result.duration')}
|
||||
value={`${plan.durationMinutes} ${t('result.minutes')}`}
|
||||
/>
|
||||
<ResultRow
|
||||
icon='tabler-sunrise'
|
||||
label={t('result.bestTime')}
|
||||
value={plan.bestTimeOfDay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{plan.warning && (
|
||||
<Box
|
||||
className='mt-4 p-4 rounded-2xl'
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.12) 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 mt-0.5 shrink-0' />
|
||||
<Box>
|
||||
<Typography variant='subtitle2' fontWeight={600} color='warning.dark' className='mbe-1'>
|
||||
{t('result.smartWarning')}
|
||||
</Typography>
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{plan.warning}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Card
|
||||
elevation={0}
|
||||
className='mb-6'
|
||||
sx={{
|
||||
borderRadius: '24px',
|
||||
background: 'linear-gradient(160deg, #ffffff 0%, #f0f9ff 100%)',
|
||||
boxShadow: '0 8px 32px rgba(14, 165, 233, 0.1)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.12)'
|
||||
}}
|
||||
>
|
||||
<CardContent className='p-12 flex flex-col items-center gap-4'>
|
||||
<CircularProgress size={48} sx={{ color: '#0ea5e9' }} />
|
||||
<Typography variant='body2' color='text.secondary'>
|
||||
{t('generating')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 4) Primary CTA Button - Sticky */}
|
||||
<Box
|
||||
className='fixed bottom-0 start-0 end-0 p-4 z-10'
|
||||
sx={{
|
||||
background: 'linear-gradient(to top, rgba(255,255,255,0.98) 0%, rgba(255,255,255,0.9) 70%, transparent 100%)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
variant='contained'
|
||||
disabled={!selectedCrop || loading}
|
||||
onClick={handleGenerate}
|
||||
startIcon={<i className='tabler-sparkles text-xl' />}
|
||||
className='rounded-2xl py-3.5 text-base font-semibold shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-[1.01] active:scale-[0.99]'
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 50%, #0369a1 100%)',
|
||||
boxShadow: '0 4px 20px rgba(14, 165, 233, 0.4)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #38bdf8 0%, #0ea5e9 50%, #0284c7 100%)',
|
||||
boxShadow: '0 6px 28px rgba(14, 165, 233, 0.5)'
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'action.disabledBackground',
|
||||
color: 'action.disabled'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('generateCta')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function FarmBadge({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
className='flex items-center gap-2 px-4 py-2.5 rounded-2xl transition-transform duration-200 hover:scale-[1.02]'
|
||||
sx={{
|
||||
background: 'linear-gradient(145deg, rgba(14, 165, 233, 0.08) 0%, rgba(14, 165, 233, 0.04) 100%)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
boxShadow: 'inset 0 1px 2px rgba(255,255,255,0.5)'
|
||||
}}
|
||||
>
|
||||
<i className={`${icon} text-xl text-sky-600`} />
|
||||
<Box>
|
||||
<Typography variant='caption' color='text.secondary' display='block' lineHeight={1.2}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant='body2' fontWeight={600} color='text.primary'>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function CropCard({
|
||||
crop,
|
||||
label,
|
||||
selected,
|
||||
onClick
|
||||
}: {
|
||||
crop: CropOption
|
||||
label: string
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
component='button'
|
||||
type='button'
|
||||
elevation={0}
|
||||
onClick={onClick}
|
||||
className='flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer transition-all duration-300 border-2 text-start'
|
||||
sx={{
|
||||
borderColor: selected ? '#0ea5e9' : 'transparent',
|
||||
background: selected
|
||||
? 'linear-gradient(145deg, rgba(14, 165, 233, 0.12) 0%, rgba(14, 165, 233, 0.06) 100%)'
|
||||
: 'linear-gradient(145deg, #ffffff 0%, #f8fcff 100%)',
|
||||
boxShadow: selected
|
||||
? '0 4px 20px rgba(14, 165, 233, 0.2), inset 0 1px 0 rgba(255,255,255,0.8)'
|
||||
: '0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 rgba(255,255,255,0.9)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: selected
|
||||
? '0 6px 24px rgba(14, 165, 233, 0.25)'
|
||||
: '0 4px 16px rgba(14, 165, 233, 0.12)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className='w-11 h-11 rounded-xl flex items-center justify-center shrink-0'
|
||||
sx={{
|
||||
background: selected
|
||||
? 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%)'
|
||||
: 'linear-gradient(145deg, rgba(14, 165, 233, 0.1) 0%, rgba(14, 165, 233, 0.05) 100%)'
|
||||
}}
|
||||
>
|
||||
<i className={`${crop.icon} text-xl ${selected ? 'text-white' : 'text-sky-600'}`} />
|
||||
</Box>
|
||||
<Typography variant='body2' fontWeight={600} color={selected ? 'primary.main' : 'text.primary'}>
|
||||
{label}
|
||||
</Typography>
|
||||
{selected && (
|
||||
<i className='tabler-circle-check-filled text-xl text-sky-600 ms-auto' />
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<Box className='flex items-center gap-4 p-3 rounded-2xl' sx={{ bgcolor: 'rgba(14, 165, 233, 0.06)' }}>
|
||||
<i className={`${icon} text-2xl text-sky-600 shrink-0`} />
|
||||
<Box className='flex-1 min-w-0'>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant='body1' fontWeight={600} color='text.primary'>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user