Add error handling and new API methods for farm assistant features
- Introduced error messages in Persian for image analysis and chat functionalities in FarmAiAssistantChat and PlantPestDetection components. - Implemented a new postFormData method in ApiClient for handling file uploads. - Enhanced SmartFertilizationRecommendation and SmartIrrigationRecommendation components to fetch configuration data from the API, improving user experience with loading states and error handling. - Refactored components to utilize updated API services for better data management and responsiveness.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { toast } from 'react-toastify'
|
||||
import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Card from '@mui/material/Card'
|
||||
@@ -14,6 +15,7 @@ import classnames from 'classnames'
|
||||
|
||||
// Util Imports
|
||||
import { commonLayoutClasses } from '@layouts/utils/layoutClasses'
|
||||
import { farmAiAssistantService } from '@/libs/api/services/farmAiAssistantService'
|
||||
|
||||
import type { FarmContext, FarmAIMessage, AIResponseSection } from './farmAiAssistantTypes'
|
||||
|
||||
@@ -34,36 +36,6 @@ const SUGGESTION_CHIPS = [
|
||||
{ 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() {
|
||||
@@ -75,11 +47,42 @@ export default function FarmAiAssistantChat() {
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [selectedChip, setSelectedChip] = useState<string | null>(null)
|
||||
const [expandedExplanations, setExpandedExplanations] = useState<Set<string>>(new Set())
|
||||
const [farmContext, setFarmContext] = useState<FarmContext>(DEFAULT_FARM_CONTEXT)
|
||||
const [contextLoading, setContextLoading] = useState(true)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const farmContext = DEFAULT_FARM_CONTEXT
|
||||
const { primary, info, warning } = theme.palette
|
||||
|
||||
// Fetch farm context on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
farmAiAssistantService
|
||||
.getContext()
|
||||
.then(data => {
|
||||
if (!cancelled) {
|
||||
setFarmContext({
|
||||
soilType: data.soilType,
|
||||
waterEC: data.waterEC,
|
||||
selectedCrop: data.selectedCrop,
|
||||
growthStage: data.growthStage,
|
||||
lastIrrigationStatus: data.lastIrrigationStatus
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
toast.error(t('errors.contextLoad'))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setContextLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [t])
|
||||
|
||||
// Scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
@@ -110,18 +113,26 @@ export default function FarmAiAssistantChat() {
|
||||
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
|
||||
try {
|
||||
const res = await farmAiAssistantService.chat({
|
||||
content,
|
||||
farm_context: farmContext,
|
||||
...(conversationId ? { conversation_id: conversationId } : {})
|
||||
})
|
||||
if (res.conversation_id) setConversationId(res.conversation_id)
|
||||
const aiMessage: FarmAIMessage = {
|
||||
id: res.message_id,
|
||||
role: 'assistant',
|
||||
content: res.content ?? '',
|
||||
timestamp: new Date(),
|
||||
sections: res.sections ?? []
|
||||
}
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
} catch {
|
||||
toast.error(t('errors.chatSend'))
|
||||
} finally {
|
||||
setIsTyping(false)
|
||||
}
|
||||
setMessages(prev => [...prev, aiMessage])
|
||||
setIsTyping(false)
|
||||
}
|
||||
|
||||
const toggleExplanation = (id: string) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import UploadBox from './components/UploadBox'
|
||||
import ResultCard from './components/ResultCard'
|
||||
import type { UploadedFile } from './components/UploadBox'
|
||||
import type { PestResult } from './components/ResultCard'
|
||||
import { pestDetectionService } from '@/libs/api/services/pestDetectionService'
|
||||
|
||||
export default function PlantPestDetection() {
|
||||
const t = useTranslations('pestDetection')
|
||||
@@ -36,23 +37,29 @@ export default function PlantPestDetection() {
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const handleAnalyze = useCallback(() => {
|
||||
const handleAnalyze = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
const delay = 1500 + Math.random() * 1000
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file.file)
|
||||
const data = await pestDetectionService.analyze(formData)
|
||||
setResult({
|
||||
pest: t('mockResult.pest'),
|
||||
confidence: 92,
|
||||
description: t('mockResult.description'),
|
||||
treatment: t('mockResult.treatment'),
|
||||
pest: data.pest,
|
||||
confidence: data.confidence,
|
||||
description: data.description,
|
||||
treatment: data.treatment,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : t('analyzeError')
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}, delay)
|
||||
}
|
||||
}, [file, t])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
@@ -8,42 +8,23 @@ import CardContent from '@mui/material/CardContent'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import Button from '@mui/material/Button'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import { useTheme, alpha } from '@mui/material/styles'
|
||||
import type {
|
||||
FarmData,
|
||||
GrowthStage,
|
||||
CropOption,
|
||||
FertilizationPlan,
|
||||
} from '@/libs/api/services/fertilizationRecommendationService'
|
||||
import { fertilizationRecommendationService } from '@/libs/api/services/fertilizationRecommendationService'
|
||||
|
||||
// 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[] = [
|
||||
const DEFAULT_GROWTH_STAGES: GrowthStage[] = [
|
||||
{ id: 'prePlanting', icon: 'tabler-seedling' },
|
||||
{ id: 'earlyGrowth', icon: 'tabler-leaf' },
|
||||
{ id: 'flowering', icon: 'tabler-flower' },
|
||||
@@ -51,7 +32,7 @@ const GROWTH_STAGES: GrowthStage[] = [
|
||||
{ id: 'postHarvest', icon: 'tabler-basket' }
|
||||
]
|
||||
|
||||
const CROP_OPTIONS: CropOption[] = [
|
||||
const DEFAULT_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' },
|
||||
@@ -60,22 +41,6 @@ const CROP_OPTIONS: CropOption[] = [
|
||||
{ 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 theme = useTheme()
|
||||
@@ -83,27 +48,56 @@ export default function SmartFertilizationRecommendation() {
|
||||
const primaryLight = theme.palette.primary.light
|
||||
const primaryDark = theme.palette.primary.dark
|
||||
const paperBg = theme.palette.background.paper
|
||||
const [farmData] = useState<FarmData>(DEFAULT_FARM_DATA)
|
||||
const [growthStage, setGrowthStage] = useState<string>(GROWTH_STAGES[0].id)
|
||||
const [farmData, setFarmData] = useState<FarmData>(DEFAULT_FARM_DATA)
|
||||
const [growthStages, setGrowthStages] = useState<GrowthStage[]>(DEFAULT_GROWTH_STAGES)
|
||||
const [cropOptions, setCropOptions] = useState<CropOption[]>(DEFAULT_CROP_OPTIONS)
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [configError, setConfigError] = useState<string | null>(null)
|
||||
const [growthStage, setGrowthStage] = useState<string>(DEFAULT_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 = () => {
|
||||
useEffect(() => {
|
||||
fertilizationRecommendationService
|
||||
.getConfig()
|
||||
.then(({ farmData: farm, growthStages: stages, cropOptions: crops }) => {
|
||||
if (farm) setFarmData(farm)
|
||||
if (stages?.length) {
|
||||
setGrowthStages(stages)
|
||||
setGrowthStage(stages[0].id)
|
||||
}
|
||||
if (crops?.length) setCropOptions(crops)
|
||||
})
|
||||
.catch((err: { message?: string }) => {
|
||||
setConfigError(err?.message ?? 'Failed to load config')
|
||||
})
|
||||
.finally(() => setConfigLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedCrop) return
|
||||
setLoading(true)
|
||||
setPlan(null)
|
||||
setReasoningExpanded(false)
|
||||
setTimeout(() => {
|
||||
setPlan(
|
||||
generateFertilizationPlan(selectedCrop, growthStage, farmData)
|
||||
)
|
||||
try {
|
||||
const { plan: nextPlan } = await fertilizationRecommendationService.recommend({
|
||||
crop_id: selectedCrop,
|
||||
growth_stage: growthStage,
|
||||
soilType: farmData.soilType,
|
||||
organicMatter: farmData.organicMatter,
|
||||
waterEC: farmData.waterEC,
|
||||
})
|
||||
setPlan(nextPlan)
|
||||
} catch {
|
||||
setPlan(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}, 1400)
|
||||
}
|
||||
}
|
||||
|
||||
const stageIndex = GROWTH_STAGES.findIndex(s => s.id === growthStage)
|
||||
const stageIndex = growthStages.findIndex(s => s.id === growthStage)
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -184,7 +178,7 @@ export default function SmartFertilizationRecommendation() {
|
||||
{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) => {
|
||||
{growthStages.map((stage, idx) => {
|
||||
const isSelected = growthStage === stage.id
|
||||
const isPast = idx < stageIndex
|
||||
return (
|
||||
@@ -245,8 +239,17 @@ export default function SmartFertilizationRecommendation() {
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
||||
{t('plantSelection.title')}
|
||||
</Typography>
|
||||
{configLoading ? (
|
||||
<Box className='flex justify-center py-8 mb-6'>
|
||||
<CircularProgress size={32} sx={{ color: 'primary.main' }} />
|
||||
</Box>
|
||||
) : configError ? (
|
||||
<Typography variant='body2' color='error' className='mb-6'>
|
||||
{configError}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box className='flex flex-wrap gap-3 mb-6'>
|
||||
{CROP_OPTIONS.map(crop => (
|
||||
{cropOptions.map(crop => (
|
||||
<CropCard
|
||||
key={crop.id}
|
||||
crop={crop}
|
||||
@@ -258,13 +261,14 @@ export default function SmartFertilizationRecommendation() {
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 5) Primary CTA Button - End of form */}
|
||||
<Box className='mb-8'>
|
||||
<Button
|
||||
fullWidth
|
||||
variant='contained'
|
||||
disabled={!selectedCrop || loading}
|
||||
disabled={!selectedCrop || loading || configLoading}
|
||||
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]'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
@@ -10,77 +10,57 @@ import Button from '@mui/material/Button'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import { useTheme, alpha } from '@mui/material/styles'
|
||||
import type { FarmInfo, CropOption, IrrigationPlan } from '@/libs/api/services/irrigationRecommendationService'
|
||||
import { irrigationRecommendationService } from '@/libs/api/services/irrigationRecommendationService'
|
||||
|
||||
// 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 theme = useTheme()
|
||||
const [farmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO)
|
||||
const [farmInfo, setFarmInfo] = useState<FarmInfo>(DEFAULT_FARM_INFO)
|
||||
const [cropOptions, setCropOptions] = useState<CropOption[]>([])
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [configError, setConfigError] = useState<string | null>(null)
|
||||
const [selectedCrop, setSelectedCrop] = useState<string | null>(null)
|
||||
const [plan, setPlan] = useState<IrrigationPlan | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const primaryMain = theme.palette.primary.main
|
||||
const primaryLight = theme.palette.primary.light
|
||||
const primaryDark = theme.palette.primary.dark
|
||||
const bgDefault = theme.palette.background.default
|
||||
const paperBg = theme.palette.background.paper
|
||||
|
||||
const handleGenerate = () => {
|
||||
useEffect(() => {
|
||||
irrigationRecommendationService
|
||||
.getConfig()
|
||||
.then(({ farmInfo: info, cropOptions: crops }) => {
|
||||
setFarmInfo(info)
|
||||
setCropOptions(crops.length > 0 ? crops : [])
|
||||
})
|
||||
.catch((err: { message?: string }) => {
|
||||
setConfigError(err?.message ?? 'Failed to load config')
|
||||
})
|
||||
.finally(() => setConfigLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedCrop) return
|
||||
setLoading(true)
|
||||
setPlan(null)
|
||||
// Simulate API delay
|
||||
setTimeout(() => {
|
||||
setPlan(generateIrrigationPlan(selectedCrop, farmInfo))
|
||||
try {
|
||||
const { plan: nextPlan } = await irrigationRecommendationService.recommend({
|
||||
crop_id: selectedCrop,
|
||||
})
|
||||
setPlan(nextPlan)
|
||||
} catch {
|
||||
setPlan(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}, 1200)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -160,8 +140,17 @@ export default function SmartIrrigationRecommendation() {
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary' className='mbe-3'>
|
||||
{t('plantSelection.title')}
|
||||
</Typography>
|
||||
{configLoading ? (
|
||||
<Box className='flex justify-center py-8'>
|
||||
<CircularProgress size={32} sx={{ color: 'primary.main' }} />
|
||||
</Box>
|
||||
) : configError ? (
|
||||
<Typography variant='body2' color='error' className='mb-6'>
|
||||
{configError}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box className='flex flex-wrap gap-3 mb-6'>
|
||||
{CROP_OPTIONS.map(crop => (
|
||||
{(cropOptions.length > 0 ? cropOptions : []).map(crop => (
|
||||
<CropCard
|
||||
key={crop.id}
|
||||
crop={crop}
|
||||
@@ -171,13 +160,14 @@ export default function SmartIrrigationRecommendation() {
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 4) Primary CTA Button - End of form */}
|
||||
<Box className='mb-8'>
|
||||
<Button
|
||||
fullWidth
|
||||
variant='contained'
|
||||
disabled={!selectedCrop || loading}
|
||||
disabled={!selectedCrop || loading || configLoading}
|
||||
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]'
|
||||
|
||||
Reference in New Issue
Block a user